@waveso/ui 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/styles.css +6 -12
- package/dist/swipe-deck.d.ts +98 -0
- package/dist/swipe-deck.js +931 -0
- package/package.json +1 -1
package/dist/styles.css
CHANGED
|
@@ -842,8 +842,12 @@
|
|
|
842
842
|
* cascade, with NO `!important` (banned — see feedback_security). The
|
|
843
843
|
* resting/enter/exit declarations stay layered so `group-has` still wins.
|
|
844
844
|
*
|
|
845
|
-
*
|
|
846
|
-
*
|
|
845
|
+
* The resting state declares NO `filter`. It stays `none`, and `none` blends
|
|
846
|
+
* with the starting/ending `blur()` as a zero blur (how CSS interpolates
|
|
847
|
+
* `none` with a filter list), so the transitions still run. Don't add a
|
|
848
|
+
* resting `blur()`: Lightning CSS minifies `blur(0)` to the invalid `blur()`,
|
|
849
|
+
* and a stand-in like `blur(0.001px)` is not invisible in WebKit, which draws
|
|
850
|
+
* any non-zero blur at a minimum radius and softens the whole surface. */
|
|
847
851
|
|
|
848
852
|
/* `motion-scrim` — opacity only, no blur. For full-screen backdrops: animating
|
|
849
853
|
* blur across the whole viewport janks (a flat dim shows none anyway). The
|
|
@@ -865,7 +869,6 @@
|
|
|
865
869
|
* tier per surface (don't compose — each declares its own unlayered transition).
|
|
866
870
|
* Companion idioms: `motion-color` (controls) and `motion-scrim` (backdrops). */
|
|
867
871
|
@utility motion-scale-sm {
|
|
868
|
-
filter: blur(0.001px);
|
|
869
872
|
scale: 1;
|
|
870
873
|
&[data-starting-style],
|
|
871
874
|
&[data-ending-style] {
|
|
@@ -875,7 +878,6 @@
|
|
|
875
878
|
}
|
|
876
879
|
}
|
|
877
880
|
@utility motion-scale-md {
|
|
878
|
-
filter: blur(0.001px);
|
|
879
881
|
scale: 1;
|
|
880
882
|
&[data-starting-style],
|
|
881
883
|
&[data-ending-style] {
|
|
@@ -885,7 +887,6 @@
|
|
|
885
887
|
}
|
|
886
888
|
}
|
|
887
889
|
@utility motion-scale-lg {
|
|
888
|
-
filter: blur(0.001px);
|
|
889
890
|
scale: 1;
|
|
890
891
|
&[data-starting-style],
|
|
891
892
|
&[data-ending-style] {
|
|
@@ -899,7 +900,6 @@
|
|
|
899
900
|
* trigger's `data-[side]` (Base UI sets it) and settles toward it; default /
|
|
900
901
|
* side=bottom rises up. Opacity + blur + translate (`--offset-*`), NO scale. */
|
|
901
902
|
@utility motion-slide-sm {
|
|
902
|
-
filter: blur(0.001px);
|
|
903
903
|
translate: 0;
|
|
904
904
|
&[data-starting-style],
|
|
905
905
|
&[data-ending-style] {
|
|
@@ -919,7 +919,6 @@
|
|
|
919
919
|
&[data-side=inline-end][data-ending-style] { translate: var(--offset-sm) 0; }
|
|
920
920
|
}
|
|
921
921
|
@utility motion-slide-md {
|
|
922
|
-
filter: blur(0.001px);
|
|
923
922
|
translate: 0;
|
|
924
923
|
&[data-starting-style],
|
|
925
924
|
&[data-ending-style] {
|
|
@@ -939,7 +938,6 @@
|
|
|
939
938
|
&[data-side=inline-end][data-ending-style] { translate: var(--offset-md) 0; }
|
|
940
939
|
}
|
|
941
940
|
@utility motion-slide-lg {
|
|
942
|
-
filter: blur(0.001px);
|
|
943
941
|
translate: 0;
|
|
944
942
|
&[data-starting-style],
|
|
945
943
|
&[data-ending-style] {
|
|
@@ -962,7 +960,6 @@
|
|
|
962
960
|
/* `motion-fade-*` — fade in place: opacity + blur, no transform. The signature
|
|
963
961
|
* blur-fade for content that should appear without moving or scaling. */
|
|
964
962
|
@utility motion-fade-sm {
|
|
965
|
-
filter: blur(0.001px);
|
|
966
963
|
&[data-starting-style],
|
|
967
964
|
&[data-ending-style] {
|
|
968
965
|
opacity: 0;
|
|
@@ -970,7 +967,6 @@
|
|
|
970
967
|
}
|
|
971
968
|
}
|
|
972
969
|
@utility motion-fade-md {
|
|
973
|
-
filter: blur(0.001px);
|
|
974
970
|
&[data-starting-style],
|
|
975
971
|
&[data-ending-style] {
|
|
976
972
|
opacity: 0;
|
|
@@ -978,7 +974,6 @@
|
|
|
978
974
|
}
|
|
979
975
|
}
|
|
980
976
|
@utility motion-fade-lg {
|
|
981
|
-
filter: blur(0.001px);
|
|
982
977
|
&[data-starting-style],
|
|
983
978
|
&[data-ending-style] {
|
|
984
979
|
opacity: 0;
|
|
@@ -991,7 +986,6 @@
|
|
|
991
986
|
* recipe with one transition. Don't try `motion-scale-md motion-slide-md`: their
|
|
992
987
|
* transitions collide and scale won't animate. md only (no sm/lg consumer). */
|
|
993
988
|
@utility motion-pop-md {
|
|
994
|
-
filter: blur(0.001px);
|
|
995
989
|
scale: 1;
|
|
996
990
|
translate: 0;
|
|
997
991
|
/* Pop from the anchored EDGE, centered along it — symmetric left/right for
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { ButtonProps } from "./button.js";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
//#region src/swipe-deck.d.ts
|
|
4
|
+
type SwipeDirection = "left" | "right" | "up" | "down";
|
|
5
|
+
type SwipeDeckLayout = "stack" | "fan" | "cascade" | "pile";
|
|
6
|
+
interface SwipeDeckSwipeDetails {
|
|
7
|
+
/** The `value` of the card that left. */
|
|
8
|
+
value: string;
|
|
9
|
+
/** The direction it left in. */
|
|
10
|
+
direction: SwipeDirection;
|
|
11
|
+
}
|
|
12
|
+
type SwipeDeckProps = React.ComponentProps<"div"> & {
|
|
13
|
+
/**
|
|
14
|
+
* Called once a card has left the deck. Remove it from your list to discard
|
|
15
|
+
* it, or move it to the end of your list to cycle it to the back.
|
|
16
|
+
*/
|
|
17
|
+
onSwipe?: (details: SwipeDeckSwipeDetails) => void;
|
|
18
|
+
/** The directions a card can leave in. Defaults to left and right. */
|
|
19
|
+
directions?: readonly SwipeDirection[];
|
|
20
|
+
/** Stops cards leaving by pointer, keyboard or action. */
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* How the cards behind the front one sit: `stack` peeks them above it, `fan`
|
|
24
|
+
* turns them out like a hand of cards, `cascade` steps them up and to the
|
|
25
|
+
* side, and `pile` scatters them. Defaults to `stack`.
|
|
26
|
+
*/
|
|
27
|
+
layout?: SwipeDeckLayout;
|
|
28
|
+
/** How far the cards behind spread out, from 0 (not at all) to 2. Defaults to 0.5. */
|
|
29
|
+
spread?: number;
|
|
30
|
+
/** How much each card behind shrinks, from 0 (not at all) to 1. Defaults to 0.5. */
|
|
31
|
+
depth?: number;
|
|
32
|
+
/** How many cards show, the front one included; at least 1. Defaults to 4. */
|
|
33
|
+
visible?: number;
|
|
34
|
+
};
|
|
35
|
+
type SwipeDeckItemProps = React.ComponentProps<"div"> & {
|
|
36
|
+
/** Names the card in `onSwipe`. Use the same id as its `key`. */
|
|
37
|
+
value: string;
|
|
38
|
+
};
|
|
39
|
+
type SwipeDeckEmptyProps = React.ComponentProps<"div">;
|
|
40
|
+
type SwipeDeckActionProps = ButtonProps & {
|
|
41
|
+
/** The direction the front card leaves in when pressed. */
|
|
42
|
+
direction: SwipeDirection;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* A pile of cards people swipe away: drag or throw the front card, press an
|
|
46
|
+
* arrow key while the deck is focused, or use a `SwipeDeckAction`. A let-go
|
|
47
|
+
* card whose position and throw carry it past a quarter of its width leaves:
|
|
48
|
+
* it slides out the way it is heading until clear of the stack, held by a
|
|
49
|
+
* spring that a hard throw runs out against, then fades. Short of that, it
|
|
50
|
+
* springs back. Your list stays the source of truth — `onSwipe` tells you
|
|
51
|
+
* which card left and which way, and you remove it or move it to the back.
|
|
52
|
+
*
|
|
53
|
+
* End the deck with a `SwipeDeckEmpty`: it lies under the last card, so
|
|
54
|
+
* dragging that card shows what comes next. The deck carries `data-empty`
|
|
55
|
+
* once no card is in front (the last one leaving, or none left), for styles
|
|
56
|
+
* of your own.
|
|
57
|
+
*
|
|
58
|
+
* Give the deck an `aria-label`. Screen readers hear the next card after each
|
|
59
|
+
* swipe: its `aria-label` when it has one, otherwise all of its text.
|
|
60
|
+
*/
|
|
61
|
+
declare function SwipeDeck({ onSwipe, directions, disabled, layout, spread, depth, visible, className, style, onKeyDown, children, ref, ...props }: SwipeDeckProps): React.JSX.Element;
|
|
62
|
+
/**
|
|
63
|
+
* One card in the deck. It sits in the deck's first grid cell with every other
|
|
64
|
+
* card, so the deck takes the size of its largest card.
|
|
65
|
+
*
|
|
66
|
+
* Styling hooks: `data-front` on the top card, `data-swiping` while it is
|
|
67
|
+
* dragged, `data-leaving` from the moment it leaves, `data-ending-style` once
|
|
68
|
+
* it is clear of the stack and fading (the `motion-fade-lg` recipe), and
|
|
69
|
+
* `data-swipe-direction` for the way it leans or leaves. CSS variables:
|
|
70
|
+
* `--swipe-deck-index` (its place in the pile, 0 at the front),
|
|
71
|
+
* `--swipe-deck-place` (that place as it springs from one to the next, less
|
|
72
|
+
* the lift while the front card is dragged out, as `--swipe-deck-slot`),
|
|
73
|
+
* `--swipe-deck-movement-x` / `-y` in px, and `--swipe-deck-progress` from 0
|
|
74
|
+
* at rest to 1 where letting go makes the card leave.
|
|
75
|
+
*
|
|
76
|
+
* The card owns its translate, rotate and scale, so its motion recipe can only
|
|
77
|
+
* be a `motion-fade-*` one: a recipe that transitions those would lag every
|
|
78
|
+
* drag.
|
|
79
|
+
*/
|
|
80
|
+
declare function SwipeDeckItem({ value, className, style, ref, onPointerDown, onClickCapture, onDragStart, ...props }: SwipeDeckItemProps): React.JSX.Element;
|
|
81
|
+
/**
|
|
82
|
+
* What the deck shows once its cards run out: a message, an action (start
|
|
83
|
+
* over, carry on), anything you put in it. It sits where the cards sit, at
|
|
84
|
+
* their size, inside a dashed outline of their shape (`data-slot`
|
|
85
|
+
* `swipe-deck-empty-outline`, to restyle or hide). It is drawn as soon as one
|
|
86
|
+
* card is left and has come to rest, under that card, so dragging it shows
|
|
87
|
+
* what comes next; it
|
|
88
|
+
* can be reached (Tab, clicks) and screen readers hear it (its `aria-label`,
|
|
89
|
+
* or its text) once that last card has gone too.
|
|
90
|
+
*/
|
|
91
|
+
declare function SwipeDeckEmpty({ className, children, ref, ...props }: SwipeDeckEmptyProps): React.JSX.Element;
|
|
92
|
+
/**
|
|
93
|
+
* A button that sends the front card off in `direction`: the way to swipe
|
|
94
|
+
* without dragging. Disabled when the deck is empty or can't go that way.
|
|
95
|
+
*/
|
|
96
|
+
declare function SwipeDeckAction({ direction, disabled, onClick, ...props }: SwipeDeckActionProps): React.JSX.Element;
|
|
97
|
+
//#endregion
|
|
98
|
+
export { SwipeDeck, SwipeDeckAction, type SwipeDeckActionProps, SwipeDeckEmpty, type SwipeDeckEmptyProps, SwipeDeckItem, type SwipeDeckItemProps, type SwipeDeckLayout, type SwipeDeckProps, type SwipeDeckSwipeDetails, type SwipeDirection };
|
|
@@ -0,0 +1,931 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { cn } from "./lib/utils.js";
|
|
3
|
+
import { Button } from "./button.js";
|
|
4
|
+
import { mergeRefs } from "./lib/merge-refs.js";
|
|
5
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
import * as React from "react";
|
|
7
|
+
import { animate, useMotionValue, useReducedMotion } from "motion/react";
|
|
8
|
+
//#region src/swipe-deck.tsx
|
|
9
|
+
const DEFAULT_DIRECTIONS = ["left", "right"];
|
|
10
|
+
const KEY_DIRECTIONS = {
|
|
11
|
+
ArrowLeft: "left",
|
|
12
|
+
ArrowRight: "right",
|
|
13
|
+
ArrowUp: "up",
|
|
14
|
+
ArrowDown: "down"
|
|
15
|
+
};
|
|
16
|
+
/** How far (px) a press moves before it becomes a drag, so taps stay clicks. */
|
|
17
|
+
const DRAG_SLOP = 6;
|
|
18
|
+
/** A card leaves when it comes to rest past this share of its width (or height). */
|
|
19
|
+
const LEAVE_SHARE = .25;
|
|
20
|
+
/** Only pointer samples this recent (ms) set the release speed. */
|
|
21
|
+
const VELOCITY_WINDOW = 100;
|
|
22
|
+
/**
|
|
23
|
+
* How much a throw counts: its release speed adds this many seconds' worth of
|
|
24
|
+
* travel to where the card is, to decide whether it leaves and which way.
|
|
25
|
+
*/
|
|
26
|
+
const THROW_TIME = .25;
|
|
27
|
+
/** A leaving card's exit point is this far (px) past the stack's edge, shadow and all. */
|
|
28
|
+
const CLEARANCE = 16;
|
|
29
|
+
const RETURN_SPRING = {
|
|
30
|
+
type: "spring",
|
|
31
|
+
stiffness: 400,
|
|
32
|
+
damping: 32
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Carries a leaving card to its exit point. A slow card slides out on it; a
|
|
36
|
+
* thrown one brings its speed and runs out against it, so the harder the
|
|
37
|
+
* throw, the harder the pull back: a limit, not a coast to anywhere.
|
|
38
|
+
*/
|
|
39
|
+
const EXIT_SPRING = {
|
|
40
|
+
type: "spring",
|
|
41
|
+
stiffness: 650,
|
|
42
|
+
damping: 42
|
|
43
|
+
};
|
|
44
|
+
/** A leaving card fades once clear of the stack and slowed below this (px/s). */
|
|
45
|
+
const CAUGHT_SPEED = 200;
|
|
46
|
+
/** Moves a card from one place in the pile to the next. */
|
|
47
|
+
const PLACE_SPRING = {
|
|
48
|
+
type: "spring",
|
|
49
|
+
stiffness: 260,
|
|
50
|
+
damping: 24
|
|
51
|
+
};
|
|
52
|
+
/** How far (in places) the cards behind lift toward the front as it's dragged out. */
|
|
53
|
+
const LIFT = .4;
|
|
54
|
+
/** On mount, the cards behind wait this long (s) for the front card to fade in. */
|
|
55
|
+
const ENTER_LEAD = .15;
|
|
56
|
+
/** Then each card behind comes this long (s) after the one before. */
|
|
57
|
+
const ENTER_STAGGER = .06;
|
|
58
|
+
/** And grows in from this size, the zoom recipes' md step (`--scale-md`). */
|
|
59
|
+
const ENTER_SCALE = .9;
|
|
60
|
+
/** Dash and gap (px) of `SwipeDeckEmpty`'s outline. */
|
|
61
|
+
const OUTLINE_DASH = 8;
|
|
62
|
+
const OUTLINE_GAP = 6;
|
|
63
|
+
/** Points drawn per corner of that outline. */
|
|
64
|
+
const OUTLINE_CORNER_STEPS = 24;
|
|
65
|
+
/** Presses on these inside a card stay theirs: a slider's thumb, a text box. */
|
|
66
|
+
const FIELD_SELECTOR = "input, textarea, select, [contenteditable]:not([contenteditable='false']), [role=slider]";
|
|
67
|
+
/** Keeps the browser's own panning on the axis the deck doesn't swipe along. */
|
|
68
|
+
function touchAction(directions) {
|
|
69
|
+
const horizontal = directions.some(isHorizontal);
|
|
70
|
+
const vertical = directions.some((direction) => !isHorizontal(direction));
|
|
71
|
+
if (horizontal && vertical) return "touch-none";
|
|
72
|
+
return horizontal ? "touch-pan-y" : "touch-pan-x";
|
|
73
|
+
}
|
|
74
|
+
function isHorizontal(direction) {
|
|
75
|
+
return direction === "left" || direction === "right";
|
|
76
|
+
}
|
|
77
|
+
/** Lets the cards behind settle back from a lift. */
|
|
78
|
+
function releaseLift(lift, isInstant) {
|
|
79
|
+
if (isInstant) lift.jump(0);
|
|
80
|
+
else animate(lift, 0, PLACE_SPRING);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Resolves once an element's running transitions and animations end. jsdom
|
|
84
|
+
* has no Web Animations, and an endless animation of your own would never
|
|
85
|
+
* end, so neither is waited for.
|
|
86
|
+
*/
|
|
87
|
+
function transitionsEnd(element) {
|
|
88
|
+
const animations = typeof element.getAnimations === "function" ? element.getAnimations().filter((animation) => animation.effect?.getComputedTiming().endTime !== Infinity) : [];
|
|
89
|
+
return Promise.allSettled(animations.map((animation) => animation.finished));
|
|
90
|
+
}
|
|
91
|
+
/** `value` kept within `min`–`max`, or `fallback` when it isn't a number. */
|
|
92
|
+
function within(value, min, max, fallback) {
|
|
93
|
+
return Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Three steady numbers in [-0.5, 0.5] from a card's value: where it lies in a
|
|
97
|
+
* `pile`, the same on every render and server.
|
|
98
|
+
*/
|
|
99
|
+
function scatterOf(value) {
|
|
100
|
+
let hash = 2166136261;
|
|
101
|
+
for (let index = 0; index < value.length; index++) hash = Math.imul(hash ^ value.charCodeAt(index), 16777619);
|
|
102
|
+
const next = () => {
|
|
103
|
+
hash ^= hash << 13;
|
|
104
|
+
hash ^= hash >>> 17;
|
|
105
|
+
hash ^= hash << 5;
|
|
106
|
+
return (hash >>> 0) % 1e3 / 1e3 - .5;
|
|
107
|
+
};
|
|
108
|
+
return [
|
|
109
|
+
next(),
|
|
110
|
+
next(),
|
|
111
|
+
next()
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
/** Layout size, without the transform a moving card carries. */
|
|
115
|
+
function sizeOf(element) {
|
|
116
|
+
return {
|
|
117
|
+
width: element.offsetWidth,
|
|
118
|
+
height: element.offsetHeight
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/** Whether a card moved by (x, y) is off the stack. */
|
|
122
|
+
function isClear(x, y, size) {
|
|
123
|
+
return Math.abs(x) >= size.width || Math.abs(y) >= size.height;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Where a card at (x, y), heading along (dx, dy), first sits `CLEARANCE` past
|
|
127
|
+
* the stack's edge: the nearer of the side edges and the top or bottom edge.
|
|
128
|
+
*/
|
|
129
|
+
function exitPoint(x, y, [dx, dy], size) {
|
|
130
|
+
const length = Math.hypot(dx, dy);
|
|
131
|
+
const ux = dx / length;
|
|
132
|
+
const uy = dy / length;
|
|
133
|
+
const steps = [ux === 0 ? Infinity : (Math.sign(ux) * (size.width + CLEARANCE) - x) / ux, uy === 0 ? Infinity : (Math.sign(uy) * (size.height + CLEARANCE) - y) / uy];
|
|
134
|
+
const step = Math.max(0, Math.min(...steps));
|
|
135
|
+
return [x + step * ux, y + step * uy];
|
|
136
|
+
}
|
|
137
|
+
/** How far a movement goes toward a direction; negative when it goes away. */
|
|
138
|
+
function reach(direction, x, y) {
|
|
139
|
+
switch (direction) {
|
|
140
|
+
case "right": return x;
|
|
141
|
+
case "left": return -x;
|
|
142
|
+
case "down": return y;
|
|
143
|
+
case "up": return -y;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The allowed direction a movement leans furthest into, and how far it goes
|
|
148
|
+
* there as a share of the distance that makes a card leave (1 or more leaves).
|
|
149
|
+
*/
|
|
150
|
+
function lean(directions, x, y, size) {
|
|
151
|
+
let best = {
|
|
152
|
+
direction: void 0,
|
|
153
|
+
share: 0
|
|
154
|
+
};
|
|
155
|
+
for (const direction of directions) {
|
|
156
|
+
const length = isHorizontal(direction) ? size.width : size.height;
|
|
157
|
+
const share = reach(direction, x, y) / Math.max(length * LEAVE_SHARE, 1);
|
|
158
|
+
if (share > best.share) best = {
|
|
159
|
+
direction,
|
|
160
|
+
share
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return best;
|
|
164
|
+
}
|
|
165
|
+
/** Pointer speed (px/s) over the samples taken just before `time`. */
|
|
166
|
+
function releaseVelocity(samples, time) {
|
|
167
|
+
const recent = samples.filter((sample) => time - sample.time <= VELOCITY_WINDOW);
|
|
168
|
+
const first = recent[0];
|
|
169
|
+
const last = recent.at(-1);
|
|
170
|
+
if (!first || !last || last.time <= first.time) return [0, 0];
|
|
171
|
+
const elapsed = (last.time - first.time) / 1e3;
|
|
172
|
+
return [(last.x - first.x) / elapsed, (last.y - first.y) / elapsed];
|
|
173
|
+
}
|
|
174
|
+
const SwipeDeckContext = React.createContext(null);
|
|
175
|
+
function useSwipeDeckContext(part) {
|
|
176
|
+
const context = React.useContext(SwipeDeckContext);
|
|
177
|
+
if (!context) throw new Error(`${part} must be used within a SwipeDeck.`);
|
|
178
|
+
return context;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* A pile of cards people swipe away: drag or throw the front card, press an
|
|
182
|
+
* arrow key while the deck is focused, or use a `SwipeDeckAction`. A let-go
|
|
183
|
+
* card whose position and throw carry it past a quarter of its width leaves:
|
|
184
|
+
* it slides out the way it is heading until clear of the stack, held by a
|
|
185
|
+
* spring that a hard throw runs out against, then fades. Short of that, it
|
|
186
|
+
* springs back. Your list stays the source of truth — `onSwipe` tells you
|
|
187
|
+
* which card left and which way, and you remove it or move it to the back.
|
|
188
|
+
*
|
|
189
|
+
* End the deck with a `SwipeDeckEmpty`: it lies under the last card, so
|
|
190
|
+
* dragging that card shows what comes next. The deck carries `data-empty`
|
|
191
|
+
* once no card is in front (the last one leaving, or none left), for styles
|
|
192
|
+
* of your own.
|
|
193
|
+
*
|
|
194
|
+
* Give the deck an `aria-label`. Screen readers hear the next card after each
|
|
195
|
+
* swipe: its `aria-label` when it has one, otherwise all of its text.
|
|
196
|
+
*/
|
|
197
|
+
function SwipeDeck({ onSwipe, directions = DEFAULT_DIRECTIONS, disabled = false, layout = "stack", spread = .5, depth = .5, visible = 4, className, style, onKeyDown, children, ref, ...props }) {
|
|
198
|
+
const rootRef = React.useRef(null);
|
|
199
|
+
const mergedRef = React.useMemo(() => mergeRefs(rootRef, ref), [ref]);
|
|
200
|
+
const itemsRef = React.useRef(/* @__PURE__ */ new Map());
|
|
201
|
+
const finishedRef = React.useRef(/* @__PURE__ */ new Map());
|
|
202
|
+
const statusRef = React.useRef(null);
|
|
203
|
+
const emptyRef = React.useRef(null);
|
|
204
|
+
const sizeObserverRef = React.useRef(null);
|
|
205
|
+
const lift = useMotionValue(0);
|
|
206
|
+
const [order, setOrder] = React.useState([]);
|
|
207
|
+
const [leavingList, setLeavingList] = React.useState([]);
|
|
208
|
+
const [isReady, setIsReady] = React.useState(false);
|
|
209
|
+
const onSwipeRef = React.useRef(onSwipe);
|
|
210
|
+
React.useEffect(() => {
|
|
211
|
+
onSwipeRef.current = onSwipe;
|
|
212
|
+
});
|
|
213
|
+
const readOrder = React.useCallback(() => [...itemsRef.current].sort(([, a], [, b]) => a.element.compareDocumentPosition(b.element) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1).map(([value]) => value), []);
|
|
214
|
+
const syncOrder = React.useCallback(() => {
|
|
215
|
+
const items = itemsRef.current;
|
|
216
|
+
const next = readOrder();
|
|
217
|
+
setOrder((previous) => previous.length === next.length && previous.every((value, index) => value === next[index]) ? previous : next);
|
|
218
|
+
const answered = /* @__PURE__ */ new Set();
|
|
219
|
+
for (const [value, followers] of finishedRef.current) {
|
|
220
|
+
const index = next.indexOf(value);
|
|
221
|
+
const hasMoved = followers.some((follower) => {
|
|
222
|
+
const followerIndex = next.indexOf(follower);
|
|
223
|
+
return followerIndex !== -1 && followerIndex < index;
|
|
224
|
+
});
|
|
225
|
+
if (index === -1 || hasMoved) {
|
|
226
|
+
answered.add(value);
|
|
227
|
+
finishedRef.current.delete(value);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
setLeavingList((previous) => {
|
|
231
|
+
const kept = previous.filter((value) => items.has(value) && !answered.has(value));
|
|
232
|
+
return kept.length === previous.length ? previous : kept;
|
|
233
|
+
});
|
|
234
|
+
}, [readOrder]);
|
|
235
|
+
React.useLayoutEffect(() => {
|
|
236
|
+
syncOrder();
|
|
237
|
+
});
|
|
238
|
+
React.useLayoutEffect(() => {
|
|
239
|
+
setIsReady(true);
|
|
240
|
+
}, []);
|
|
241
|
+
React.useEffect(() => {
|
|
242
|
+
const root = rootRef.current;
|
|
243
|
+
if (!root) return;
|
|
244
|
+
const observer = new MutationObserver(syncOrder);
|
|
245
|
+
observer.observe(root, { childList: true });
|
|
246
|
+
return () => observer.disconnect();
|
|
247
|
+
}, [syncOrder]);
|
|
248
|
+
const sizeObserver = React.useCallback(() => {
|
|
249
|
+
sizeObserverRef.current ??= new ResizeObserver(() => {
|
|
250
|
+
const root = rootRef.current;
|
|
251
|
+
const cards = [...itemsRef.current.values()];
|
|
252
|
+
if (!root || cards.length === 0) return;
|
|
253
|
+
const width = Math.max(...cards.map(({ element }) => element.offsetWidth));
|
|
254
|
+
const height = Math.max(...cards.map(({ element }) => element.offsetHeight));
|
|
255
|
+
root.style.setProperty("--swipe-deck-card-width", `${width}px`);
|
|
256
|
+
root.style.setProperty("--swipe-deck-card-height", `${height}px`);
|
|
257
|
+
});
|
|
258
|
+
return sizeObserverRef.current;
|
|
259
|
+
}, []);
|
|
260
|
+
React.useEffect(() => () => sizeObserverRef.current?.disconnect(), []);
|
|
261
|
+
const register = React.useCallback((value, record) => {
|
|
262
|
+
itemsRef.current.set(value, record);
|
|
263
|
+
sizeObserver().observe(record.element);
|
|
264
|
+
syncOrder();
|
|
265
|
+
return () => {
|
|
266
|
+
if (itemsRef.current.get(value) === record) {
|
|
267
|
+
itemsRef.current.delete(value);
|
|
268
|
+
sizeObserverRef.current?.unobserve(record.element);
|
|
269
|
+
syncOrder();
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
}, [sizeObserver, syncOrder]);
|
|
273
|
+
const focusDeck = React.useCallback(() => rootRef.current?.focus(), []);
|
|
274
|
+
const [settledFront, setSettledFront] = React.useState();
|
|
275
|
+
React.useEffect(() => lift.on("change", (latest) => {
|
|
276
|
+
rootRef.current?.style.setProperty("--swipe-deck-lift", String(latest));
|
|
277
|
+
}), [lift]);
|
|
278
|
+
const leave = React.useCallback((value) => {
|
|
279
|
+
setLeavingList((previous) => previous.includes(value) ? previous : [...previous, value]);
|
|
280
|
+
}, []);
|
|
281
|
+
const finish = React.useCallback((details) => {
|
|
282
|
+
const current = readOrder();
|
|
283
|
+
finishedRef.current.set(details.value, current.slice(current.indexOf(details.value) + 1));
|
|
284
|
+
onSwipeRef.current?.(details);
|
|
285
|
+
}, [readOrder]);
|
|
286
|
+
const leaving = React.useMemo(() => new Set(leavingList), [leavingList]);
|
|
287
|
+
const positions = React.useMemo(() => new Map(order.filter((value) => !leaving.has(value)).map((value, index) => [value, index])), [order, leaving]);
|
|
288
|
+
const front = order.find((value) => !leaving.has(value));
|
|
289
|
+
const swipeFront = React.useCallback((direction) => {
|
|
290
|
+
if (disabled || front === void 0 || !directions.includes(direction)) return;
|
|
291
|
+
itemsRef.current.get(front)?.swipe(direction);
|
|
292
|
+
}, [
|
|
293
|
+
directions,
|
|
294
|
+
disabled,
|
|
295
|
+
front
|
|
296
|
+
]);
|
|
297
|
+
const announcedRef = React.useRef(void 0);
|
|
298
|
+
React.useEffect(() => {
|
|
299
|
+
const previous = announcedRef.current;
|
|
300
|
+
announcedRef.current = front;
|
|
301
|
+
const status = statusRef.current;
|
|
302
|
+
if (previous === void 0 || previous === front || !status) return;
|
|
303
|
+
const element = front === void 0 ? emptyRef.current : itemsRef.current.get(front)?.element;
|
|
304
|
+
status.textContent = element ? element.getAttribute("aria-label") ?? (element.innerText || element.textContent || "") : "";
|
|
305
|
+
}, [front]);
|
|
306
|
+
const handleKeyDown = (event) => {
|
|
307
|
+
onKeyDown?.(event);
|
|
308
|
+
if (event.defaultPrevented || event.target !== event.currentTarget) return;
|
|
309
|
+
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
|
|
310
|
+
const direction = KEY_DIRECTIONS[event.key];
|
|
311
|
+
if (!direction || disabled || !directions.includes(direction)) return;
|
|
312
|
+
event.preventDefault();
|
|
313
|
+
swipeFront(direction);
|
|
314
|
+
};
|
|
315
|
+
const context = React.useMemo(() => ({
|
|
316
|
+
directions,
|
|
317
|
+
disabled,
|
|
318
|
+
isReady,
|
|
319
|
+
front,
|
|
320
|
+
positions,
|
|
321
|
+
leaving,
|
|
322
|
+
count: order.length,
|
|
323
|
+
lift,
|
|
324
|
+
register,
|
|
325
|
+
leave,
|
|
326
|
+
finish,
|
|
327
|
+
swipeFront,
|
|
328
|
+
isEmpty: isReady && front === void 0,
|
|
329
|
+
emptyRef,
|
|
330
|
+
focusDeck,
|
|
331
|
+
settledFront,
|
|
332
|
+
settle: setSettledFront
|
|
333
|
+
}), [
|
|
334
|
+
directions,
|
|
335
|
+
disabled,
|
|
336
|
+
isReady,
|
|
337
|
+
front,
|
|
338
|
+
positions,
|
|
339
|
+
leaving,
|
|
340
|
+
order.length,
|
|
341
|
+
lift,
|
|
342
|
+
register,
|
|
343
|
+
leave,
|
|
344
|
+
finish,
|
|
345
|
+
swipeFront,
|
|
346
|
+
focusDeck,
|
|
347
|
+
settledFront
|
|
348
|
+
]);
|
|
349
|
+
return /* @__PURE__ */ jsx(SwipeDeckContext.Provider, {
|
|
350
|
+
value: context,
|
|
351
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
352
|
+
ref: mergedRef,
|
|
353
|
+
"data-slot": "swipe-deck",
|
|
354
|
+
"data-layout": layout,
|
|
355
|
+
"data-empty": context.isEmpty ? "" : void 0,
|
|
356
|
+
"data-disabled": disabled ? "" : void 0,
|
|
357
|
+
role: "group",
|
|
358
|
+
tabIndex: 0,
|
|
359
|
+
"aria-disabled": disabled || void 0,
|
|
360
|
+
className: cn("group/swipe-deck relative isolate grid outline-hidden", "ring-focus/50 rounded-lg focus-visible:not-has-data-front:ring-3", className),
|
|
361
|
+
style: {
|
|
362
|
+
"--swipe-deck-spread": within(spread, 0, 2, .5),
|
|
363
|
+
"--swipe-deck-depth": within(depth, 0, 1, .5),
|
|
364
|
+
"--swipe-deck-visible": Math.round(within(visible, 1, Infinity, 4)),
|
|
365
|
+
...style
|
|
366
|
+
},
|
|
367
|
+
onKeyDown: handleKeyDown,
|
|
368
|
+
...props,
|
|
369
|
+
children: [children, /* @__PURE__ */ jsx("span", {
|
|
370
|
+
ref: statusRef,
|
|
371
|
+
"data-slot": "swipe-deck-status",
|
|
372
|
+
role: "status",
|
|
373
|
+
className: "sr-only"
|
|
374
|
+
})]
|
|
375
|
+
})
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* One card in the deck. It sits in the deck's first grid cell with every other
|
|
380
|
+
* card, so the deck takes the size of its largest card.
|
|
381
|
+
*
|
|
382
|
+
* Styling hooks: `data-front` on the top card, `data-swiping` while it is
|
|
383
|
+
* dragged, `data-leaving` from the moment it leaves, `data-ending-style` once
|
|
384
|
+
* it is clear of the stack and fading (the `motion-fade-lg` recipe), and
|
|
385
|
+
* `data-swipe-direction` for the way it leans or leaves. CSS variables:
|
|
386
|
+
* `--swipe-deck-index` (its place in the pile, 0 at the front),
|
|
387
|
+
* `--swipe-deck-place` (that place as it springs from one to the next, less
|
|
388
|
+
* the lift while the front card is dragged out, as `--swipe-deck-slot`),
|
|
389
|
+
* `--swipe-deck-movement-x` / `-y` in px, and `--swipe-deck-progress` from 0
|
|
390
|
+
* at rest to 1 where letting go makes the card leave.
|
|
391
|
+
*
|
|
392
|
+
* The card owns its translate, rotate and scale, so its motion recipe can only
|
|
393
|
+
* be a `motion-fade-*` one: a recipe that transitions those would lag every
|
|
394
|
+
* drag.
|
|
395
|
+
*/
|
|
396
|
+
function SwipeDeckItem({ value, className, style, ref, onPointerDown, onClickCapture, onDragStart, ...props }) {
|
|
397
|
+
const deck = useSwipeDeckContext("SwipeDeckItem");
|
|
398
|
+
const { directions, register, leave, finish } = deck;
|
|
399
|
+
const prefersReducedMotion = useReducedMotion();
|
|
400
|
+
const elementRef = React.useRef(null);
|
|
401
|
+
const mergedRef = React.useMemo(() => mergeRefs(elementRef, ref), [ref]);
|
|
402
|
+
const x = useMotionValue(0);
|
|
403
|
+
const y = useMotionValue(0);
|
|
404
|
+
const place = useMotionValue(0);
|
|
405
|
+
const enterScale = useMotionValue(1);
|
|
406
|
+
const drivesLiftRef = React.useRef(false);
|
|
407
|
+
const scatter = React.useMemo(() => scatterOf(value), [value]);
|
|
408
|
+
const leftTowardRef = React.useRef(null);
|
|
409
|
+
const stopWatchingRef = React.useRef(null);
|
|
410
|
+
const dragRef = React.useRef(null);
|
|
411
|
+
const endDragRef = React.useRef(null);
|
|
412
|
+
const sizeRef = React.useRef({
|
|
413
|
+
width: 0,
|
|
414
|
+
height: 0
|
|
415
|
+
});
|
|
416
|
+
const draggedRef = React.useRef(false);
|
|
417
|
+
const [isSwiping, setIsSwiping] = React.useState(false);
|
|
418
|
+
const [isFading, setIsFading] = React.useState(false);
|
|
419
|
+
const [isStarting, setIsStarting] = React.useState(true);
|
|
420
|
+
const [direction, setDirection] = React.useState();
|
|
421
|
+
const position = deck.positions.get(value);
|
|
422
|
+
const isLeaving = deck.leaving.has(value);
|
|
423
|
+
const isFront = deck.front === value;
|
|
424
|
+
const directionsRef = React.useRef(directions);
|
|
425
|
+
const disabledRef = React.useRef(deck.disabled);
|
|
426
|
+
const reducedMotionRef = React.useRef(prefersReducedMotion);
|
|
427
|
+
React.useEffect(() => {
|
|
428
|
+
directionsRef.current = directions;
|
|
429
|
+
disabledRef.current = deck.disabled;
|
|
430
|
+
reducedMotionRef.current = prefersReducedMotion;
|
|
431
|
+
drivesLiftRef.current = isFront && !isLeaving;
|
|
432
|
+
});
|
|
433
|
+
React.useEffect(() => {
|
|
434
|
+
const write = () => {
|
|
435
|
+
const element = elementRef.current;
|
|
436
|
+
if (!element) return;
|
|
437
|
+
const { share } = lean(directionsRef.current, x.get(), y.get(), sizeRef.current);
|
|
438
|
+
element.style.setProperty("--swipe-deck-movement-x", `${x.get()}px`);
|
|
439
|
+
element.style.setProperty("--swipe-deck-movement-y", `${y.get()}px`);
|
|
440
|
+
element.style.setProperty("--swipe-deck-progress", String(Math.min(share, 1)));
|
|
441
|
+
if (drivesLiftRef.current) deck.lift.set(Math.min(share, 1) * LIFT);
|
|
442
|
+
};
|
|
443
|
+
const stopX = x.on("change", write);
|
|
444
|
+
const stopY = y.on("change", write);
|
|
445
|
+
const stopPlace = place.on("change", (latest) => {
|
|
446
|
+
elementRef.current?.style.setProperty("--swipe-deck-place", String(latest));
|
|
447
|
+
});
|
|
448
|
+
const stopEnterScale = enterScale.on("change", (latest) => {
|
|
449
|
+
elementRef.current?.style.setProperty("--swipe-deck-enter-scale", String(latest));
|
|
450
|
+
});
|
|
451
|
+
return () => {
|
|
452
|
+
stopX();
|
|
453
|
+
stopY();
|
|
454
|
+
stopPlace();
|
|
455
|
+
stopEnterScale();
|
|
456
|
+
if (dragRef.current) releaseLift(deck.lift, reducedMotionRef.current);
|
|
457
|
+
endDragRef.current?.();
|
|
458
|
+
stopWatchingRef.current?.();
|
|
459
|
+
x.stop();
|
|
460
|
+
y.stop();
|
|
461
|
+
place.stop();
|
|
462
|
+
enterScale.stop();
|
|
463
|
+
};
|
|
464
|
+
}, [
|
|
465
|
+
deck.lift,
|
|
466
|
+
enterScale,
|
|
467
|
+
place,
|
|
468
|
+
x,
|
|
469
|
+
y
|
|
470
|
+
]);
|
|
471
|
+
const snapBack = React.useCallback(([vx, vy] = [0, 0]) => {
|
|
472
|
+
if (prefersReducedMotion) {
|
|
473
|
+
x.set(0);
|
|
474
|
+
y.set(0);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
animate(x, 0, {
|
|
478
|
+
...RETURN_SPRING,
|
|
479
|
+
velocity: vx
|
|
480
|
+
});
|
|
481
|
+
animate(y, 0, {
|
|
482
|
+
...RETURN_SPRING,
|
|
483
|
+
velocity: vy
|
|
484
|
+
});
|
|
485
|
+
}, [
|
|
486
|
+
prefersReducedMotion,
|
|
487
|
+
x,
|
|
488
|
+
y
|
|
489
|
+
]);
|
|
490
|
+
const fling = React.useCallback((to, [vx, vy] = [0, 0]) => {
|
|
491
|
+
endDragRef.current?.();
|
|
492
|
+
setIsSwiping(false);
|
|
493
|
+
leftTowardRef.current = to;
|
|
494
|
+
leave(value);
|
|
495
|
+
setDirection(to);
|
|
496
|
+
drivesLiftRef.current = false;
|
|
497
|
+
releaseLift(deck.lift, prefersReducedMotion);
|
|
498
|
+
setIsStarting(false);
|
|
499
|
+
if (prefersReducedMotion) {
|
|
500
|
+
setIsFading(true);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
const size = sizeRef.current;
|
|
504
|
+
const aheadX = x.get() + vx * THROW_TIME;
|
|
505
|
+
const aheadY = y.get() + vy * THROW_TIME;
|
|
506
|
+
const sign = to === "right" || to === "down" ? 1 : -1;
|
|
507
|
+
const heading = Math.hypot(aheadX, aheadY) >= 1 ? [aheadX, aheadY] : isHorizontal(to) ? [sign, 0] : [0, sign];
|
|
508
|
+
const [targetX, targetY] = exitPoint(x.get(), y.get(), heading, size);
|
|
509
|
+
const headingLength = Math.hypot(...heading);
|
|
510
|
+
const [ux, uy] = [heading[0] / headingLength, heading[1] / headingLength];
|
|
511
|
+
const fade = () => {
|
|
512
|
+
stopWatchingRef.current?.();
|
|
513
|
+
stopWatchingRef.current = null;
|
|
514
|
+
setIsFading(true);
|
|
515
|
+
};
|
|
516
|
+
const check = () => {
|
|
517
|
+
const outward = x.getVelocity() * ux + y.getVelocity() * uy;
|
|
518
|
+
if (isClear(x.get(), y.get(), size) && outward <= CAUGHT_SPEED) fade();
|
|
519
|
+
};
|
|
520
|
+
stopWatchingRef.current?.();
|
|
521
|
+
const stopX = x.on("change", check);
|
|
522
|
+
const stopY = y.on("change", check);
|
|
523
|
+
stopWatchingRef.current = () => {
|
|
524
|
+
stopX();
|
|
525
|
+
stopY();
|
|
526
|
+
};
|
|
527
|
+
const isMainX = Math.abs(targetX - x.get()) >= Math.abs(targetY - y.get());
|
|
528
|
+
animate(x, targetX, {
|
|
529
|
+
...EXIT_SPRING,
|
|
530
|
+
velocity: vx,
|
|
531
|
+
onComplete: isMainX ? fade : void 0
|
|
532
|
+
});
|
|
533
|
+
animate(y, targetY, {
|
|
534
|
+
...EXIT_SPRING,
|
|
535
|
+
velocity: vy,
|
|
536
|
+
onComplete: isMainX ? void 0 : fade
|
|
537
|
+
});
|
|
538
|
+
}, [
|
|
539
|
+
deck.lift,
|
|
540
|
+
leave,
|
|
541
|
+
prefersReducedMotion,
|
|
542
|
+
value,
|
|
543
|
+
x,
|
|
544
|
+
y
|
|
545
|
+
]);
|
|
546
|
+
React.useLayoutEffect(() => {
|
|
547
|
+
const element = elementRef.current;
|
|
548
|
+
if (!element) return;
|
|
549
|
+
return register(value, {
|
|
550
|
+
element,
|
|
551
|
+
swipe: (to) => {
|
|
552
|
+
sizeRef.current = sizeOf(element);
|
|
553
|
+
fling(to);
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
}, [
|
|
557
|
+
fling,
|
|
558
|
+
register,
|
|
559
|
+
value
|
|
560
|
+
]);
|
|
561
|
+
React.useEffect(() => {
|
|
562
|
+
const element = elementRef.current;
|
|
563
|
+
const to = leftTowardRef.current;
|
|
564
|
+
if (!isFading || !element || !to) return;
|
|
565
|
+
let isCancelled = false;
|
|
566
|
+
const frame = requestAnimationFrame(() => {
|
|
567
|
+
transitionsEnd(element).then(() => {
|
|
568
|
+
if (isCancelled) return;
|
|
569
|
+
x.stop();
|
|
570
|
+
y.stop();
|
|
571
|
+
finish({
|
|
572
|
+
value,
|
|
573
|
+
direction: to
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
return () => {
|
|
578
|
+
isCancelled = true;
|
|
579
|
+
cancelAnimationFrame(frame);
|
|
580
|
+
};
|
|
581
|
+
}, [
|
|
582
|
+
finish,
|
|
583
|
+
isFading,
|
|
584
|
+
value,
|
|
585
|
+
x,
|
|
586
|
+
y
|
|
587
|
+
]);
|
|
588
|
+
const wasLeavingRef = React.useRef(false);
|
|
589
|
+
const isPlacedRef = React.useRef(false);
|
|
590
|
+
React.useEffect(() => {
|
|
591
|
+
const target = isLeaving ? 0 : position;
|
|
592
|
+
if (target === void 0) return;
|
|
593
|
+
if (wasLeavingRef.current && !isLeaving) {
|
|
594
|
+
x.jump(0);
|
|
595
|
+
y.jump(0);
|
|
596
|
+
place.jump(target);
|
|
597
|
+
setIsFading(false);
|
|
598
|
+
} else if (prefersReducedMotion || !isPlacedRef.current) place.jump(target);
|
|
599
|
+
else animate(place, target, PLACE_SPRING);
|
|
600
|
+
isPlacedRef.current = true;
|
|
601
|
+
wasLeavingRef.current = isLeaving;
|
|
602
|
+
}, [
|
|
603
|
+
isLeaving,
|
|
604
|
+
place,
|
|
605
|
+
position,
|
|
606
|
+
prefersReducedMotion,
|
|
607
|
+
x,
|
|
608
|
+
y
|
|
609
|
+
]);
|
|
610
|
+
const enterAtRef = React.useRef(null);
|
|
611
|
+
React.useEffect(() => {
|
|
612
|
+
if (!isStarting || position === void 0) return;
|
|
613
|
+
if (enterAtRef.current === null) {
|
|
614
|
+
const delay = prefersReducedMotion || position === 0 ? 0 : ENTER_LEAD + (position - 1) * ENTER_STAGGER;
|
|
615
|
+
enterAtRef.current = performance.now() + delay * 1e3;
|
|
616
|
+
if (delay > 0) {
|
|
617
|
+
enterScale.jump(ENTER_SCALE);
|
|
618
|
+
animate(enterScale, 1, {
|
|
619
|
+
...PLACE_SPRING,
|
|
620
|
+
delay
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
} else if (position === 0) animate(enterScale, 1, PLACE_SPRING);
|
|
624
|
+
const wait = position === 0 ? 0 : Math.max(0, enterAtRef.current - performance.now());
|
|
625
|
+
let frame = 0;
|
|
626
|
+
const timer = window.setTimeout(() => {
|
|
627
|
+
frame = requestAnimationFrame(() => setIsStarting(false));
|
|
628
|
+
}, wait);
|
|
629
|
+
return () => {
|
|
630
|
+
window.clearTimeout(timer);
|
|
631
|
+
cancelAnimationFrame(frame);
|
|
632
|
+
};
|
|
633
|
+
}, [
|
|
634
|
+
enterScale,
|
|
635
|
+
isStarting,
|
|
636
|
+
position,
|
|
637
|
+
prefersReducedMotion
|
|
638
|
+
]);
|
|
639
|
+
const { settle } = deck;
|
|
640
|
+
React.useEffect(() => {
|
|
641
|
+
const element = elementRef.current;
|
|
642
|
+
if (!isFront || isStarting || !element) return;
|
|
643
|
+
let isCancelled = false;
|
|
644
|
+
const report = () => {
|
|
645
|
+
transitionsEnd(element).then(() => {
|
|
646
|
+
if (!isCancelled) settle(value);
|
|
647
|
+
});
|
|
648
|
+
};
|
|
649
|
+
const stopListening = place.on("animationComplete", report);
|
|
650
|
+
if (!place.isAnimating()) report();
|
|
651
|
+
return () => {
|
|
652
|
+
isCancelled = true;
|
|
653
|
+
stopListening();
|
|
654
|
+
};
|
|
655
|
+
}, [
|
|
656
|
+
isFront,
|
|
657
|
+
isStarting,
|
|
658
|
+
place,
|
|
659
|
+
settle,
|
|
660
|
+
value
|
|
661
|
+
]);
|
|
662
|
+
React.useEffect(() => {
|
|
663
|
+
if (isFront && !deck.disabled || !dragRef.current) return;
|
|
664
|
+
endDragRef.current?.();
|
|
665
|
+
setIsSwiping(false);
|
|
666
|
+
snapBack();
|
|
667
|
+
if (!isFront) releaseLift(deck.lift, prefersReducedMotion);
|
|
668
|
+
}, [
|
|
669
|
+
deck.disabled,
|
|
670
|
+
deck.lift,
|
|
671
|
+
isFront,
|
|
672
|
+
prefersReducedMotion,
|
|
673
|
+
snapBack
|
|
674
|
+
]);
|
|
675
|
+
const release = (drag, event) => {
|
|
676
|
+
if (!drag.moved) {
|
|
677
|
+
snapBack();
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
setIsSwiping(false);
|
|
681
|
+
draggedRef.current = true;
|
|
682
|
+
window.setTimeout(() => {
|
|
683
|
+
draggedRef.current = false;
|
|
684
|
+
});
|
|
685
|
+
if (event.type === "pointerup") drag.samples.push({
|
|
686
|
+
x: event.clientX,
|
|
687
|
+
y: event.clientY,
|
|
688
|
+
time: event.timeStamp
|
|
689
|
+
});
|
|
690
|
+
const velocity = releaseVelocity(drag.samples, event.timeStamp);
|
|
691
|
+
if (event.type === "pointerup" && !disabledRef.current) {
|
|
692
|
+
const ahead = lean(directionsRef.current, x.get() + velocity[0] * THROW_TIME, y.get() + velocity[1] * THROW_TIME, sizeRef.current);
|
|
693
|
+
if (ahead.direction && ahead.share >= 1) {
|
|
694
|
+
fling(ahead.direction, velocity);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
snapBack(velocity);
|
|
699
|
+
};
|
|
700
|
+
const handlePointerDown = (event) => {
|
|
701
|
+
onPointerDown?.(event);
|
|
702
|
+
if (event.defaultPrevented || event.button !== 0 || !isFront || deck.disabled || dragRef.current) return;
|
|
703
|
+
const element = event.currentTarget;
|
|
704
|
+
const field = event.target instanceof Element ? event.target.closest(FIELD_SELECTOR) : null;
|
|
705
|
+
if (field && element.contains(field)) return;
|
|
706
|
+
deck.lift.stop();
|
|
707
|
+
x.stop();
|
|
708
|
+
y.stop();
|
|
709
|
+
sizeRef.current = sizeOf(element);
|
|
710
|
+
const drag = {
|
|
711
|
+
pointerId: event.pointerId,
|
|
712
|
+
startX: event.clientX,
|
|
713
|
+
startY: event.clientY,
|
|
714
|
+
originX: x.get(),
|
|
715
|
+
originY: y.get(),
|
|
716
|
+
moved: false,
|
|
717
|
+
samples: [{
|
|
718
|
+
x: event.clientX,
|
|
719
|
+
y: event.clientY,
|
|
720
|
+
time: event.timeStamp
|
|
721
|
+
}]
|
|
722
|
+
};
|
|
723
|
+
dragRef.current = drag;
|
|
724
|
+
const handleMove = (moveEvent) => {
|
|
725
|
+
if (moveEvent.pointerId !== drag.pointerId) return;
|
|
726
|
+
const dx = moveEvent.clientX - drag.startX;
|
|
727
|
+
const dy = moveEvent.clientY - drag.startY;
|
|
728
|
+
if (!drag.moved) {
|
|
729
|
+
if (Math.hypot(dx, dy) < DRAG_SLOP) return;
|
|
730
|
+
drag.moved = true;
|
|
731
|
+
element.setPointerCapture(drag.pointerId);
|
|
732
|
+
setIsSwiping(true);
|
|
733
|
+
}
|
|
734
|
+
x.set(drag.originX + dx);
|
|
735
|
+
y.set(drag.originY + dy);
|
|
736
|
+
drag.samples.push({
|
|
737
|
+
x: moveEvent.clientX,
|
|
738
|
+
y: moveEvent.clientY,
|
|
739
|
+
time: moveEvent.timeStamp
|
|
740
|
+
});
|
|
741
|
+
if (drag.samples.length > 20) drag.samples.shift();
|
|
742
|
+
setDirection(lean(directionsRef.current, x.get(), y.get(), sizeRef.current).direction);
|
|
743
|
+
};
|
|
744
|
+
const handleEnd = (endEvent) => {
|
|
745
|
+
if (endEvent.pointerId !== drag.pointerId) return;
|
|
746
|
+
endDrag();
|
|
747
|
+
release(drag, endEvent);
|
|
748
|
+
};
|
|
749
|
+
const endDrag = () => {
|
|
750
|
+
window.removeEventListener("pointermove", handleMove);
|
|
751
|
+
window.removeEventListener("pointerup", handleEnd);
|
|
752
|
+
window.removeEventListener("pointercancel", handleEnd);
|
|
753
|
+
dragRef.current = null;
|
|
754
|
+
endDragRef.current = null;
|
|
755
|
+
};
|
|
756
|
+
window.addEventListener("pointermove", handleMove);
|
|
757
|
+
window.addEventListener("pointerup", handleEnd);
|
|
758
|
+
window.addEventListener("pointercancel", handleEnd);
|
|
759
|
+
endDragRef.current = endDrag;
|
|
760
|
+
};
|
|
761
|
+
const handleClickCapture = (event) => {
|
|
762
|
+
if (draggedRef.current) {
|
|
763
|
+
draggedRef.current = false;
|
|
764
|
+
event.preventDefault();
|
|
765
|
+
event.stopPropagation();
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
onClickCapture?.(event);
|
|
769
|
+
};
|
|
770
|
+
const zIndex = isLeaving ? deck.count + 1 : position === void 0 ? void 0 : deck.count - position;
|
|
771
|
+
return /* @__PURE__ */ jsx("div", {
|
|
772
|
+
ref: mergedRef,
|
|
773
|
+
"data-slot": "swipe-deck-item",
|
|
774
|
+
role: "group",
|
|
775
|
+
"data-front": isFront ? "" : void 0,
|
|
776
|
+
"data-swiping": isSwiping ? "" : void 0,
|
|
777
|
+
"data-leaving": isLeaving ? "" : void 0,
|
|
778
|
+
"data-starting-style": isStarting ? "" : void 0,
|
|
779
|
+
"data-ending-style": isFading ? "" : void 0,
|
|
780
|
+
"data-swipe-direction": isSwiping || isLeaving ? direction : void 0,
|
|
781
|
+
inert: isLeaving || position !== void 0 && position > 0,
|
|
782
|
+
className: cn("motion-fade-lg relative col-start-1 row-start-1 rounded-lg select-none", "[--swipe-deck-slot:calc(var(--swipe-deck-place,0)_-_min(var(--swipe-deck-place,0),1)_*_var(--swipe-deck-lift,0))]", "[--swipe-deck-layout-slot:min(var(--swipe-deck-slot),calc(var(--swipe-deck-visible,4)_-_1))]", "[translate:calc(var(--swipe-deck-movement-x,0px)_+_var(--swipe-deck-layout-x,0px))_calc(var(--swipe-deck-movement-y,0px)_+_var(--swipe-deck-layout-y,0px))]", "[rotate:var(--swipe-deck-layout-rotate,0deg)] [scale:calc(var(--swipe-deck-layout-scale,1)_*_var(--swipe-deck-enter-scale,1))]", "[opacity:clamp(0,calc(var(--swipe-deck-visible,4)_-_var(--swipe-deck-slot)),1)]", "group-data-[layout=stack]/swipe-deck:origin-top", "group-data-[layout=stack]/swipe-deck:[--swipe-deck-layout-y:calc(var(--swipe-deck-layout-slot)_*_var(--swipe-deck-spread,0.5)_*_-32px)]", "group-data-[layout=stack]/swipe-deck:[--swipe-deck-layout-scale:max(0,calc(1_-_var(--swipe-deck-layout-slot)_*_var(--swipe-deck-depth,0.5)_*_0.1))]", "group-data-[layout=fan]/swipe-deck:origin-[90%_90%]", "group-data-[layout=fan]/swipe-deck:[--swipe-deck-layout-rotate:calc(var(--swipe-deck-layout-slot)_*_var(--swipe-deck-spread,0.5)_*_8deg)]", "group-data-[layout=fan]/swipe-deck:[--swipe-deck-layout-scale:max(0,calc(1_-_var(--swipe-deck-layout-slot)_*_var(--swipe-deck-depth,0.5)_*_0.12))]", "group-data-[layout=cascade]/swipe-deck:[--swipe-deck-layout-x:calc(var(--swipe-deck-layout-slot)_*_var(--swipe-deck-spread,0.5)_*_-28px)]", "group-data-[layout=cascade]/swipe-deck:[--swipe-deck-layout-y:calc(var(--swipe-deck-layout-slot)_*_var(--swipe-deck-spread,0.5)_*_-28px)]", "group-data-[layout=cascade]/swipe-deck:[--swipe-deck-layout-scale:max(0,calc(1_-_var(--swipe-deck-layout-slot)_*_var(--swipe-deck-depth,0.5)_*_0.07))]", "group-data-[layout=pile]/swipe-deck:[--swipe-deck-layout-x:calc(var(--swipe-deck-scatter-x)_*_var(--swipe-deck-spread,0.5)_*_56px)]", "group-data-[layout=pile]/swipe-deck:[--swipe-deck-layout-y:calc(var(--swipe-deck-scatter-y)_*_var(--swipe-deck-spread,0.5)_*_44px)]", "group-data-[layout=pile]/swipe-deck:[--swipe-deck-layout-rotate:calc(var(--swipe-deck-scatter-rotate)_*_var(--swipe-deck-spread,0.5)_*_40deg)]", "group-data-[layout=pile]/swipe-deck:[--swipe-deck-layout-scale:max(0,calc(1_-_var(--swipe-deck-layout-slot)_*_var(--swipe-deck-depth,0.5)_*_0.03))]", "ring-focus/50 group-focus-visible/swipe-deck:data-front:ring-3", "data-front:cursor-grab data-swiping:cursor-grabbing group-data-disabled/swipe-deck:data-front:cursor-auto", touchAction(directions), className),
|
|
783
|
+
style: {
|
|
784
|
+
zIndex,
|
|
785
|
+
"--swipe-deck-index": isLeaving ? 0 : position,
|
|
786
|
+
"--swipe-deck-scatter-x": scatter[0],
|
|
787
|
+
"--swipe-deck-scatter-y": scatter[1],
|
|
788
|
+
"--swipe-deck-scatter-rotate": scatter[2],
|
|
789
|
+
...style
|
|
790
|
+
},
|
|
791
|
+
onPointerDown: handlePointerDown,
|
|
792
|
+
onClickCapture: handleClickCapture,
|
|
793
|
+
onDragStart: (event) => {
|
|
794
|
+
onDragStart?.(event);
|
|
795
|
+
event.preventDefault();
|
|
796
|
+
},
|
|
797
|
+
...props
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* A rounded rectangle, inset half a pixel so a 1px line sits inside the box as
|
|
802
|
+
* a border would, with corners that are superellipses of `exponent` (2 is a
|
|
803
|
+
* circular arc, 4 a squircle). Returns the path and its length.
|
|
804
|
+
*/
|
|
805
|
+
function outlinePath(width, height, radius, exponent) {
|
|
806
|
+
const inset = .5;
|
|
807
|
+
const r = Math.max(0, Math.min(radius, width / 2, height / 2) - inset);
|
|
808
|
+
const [left, top] = [inset, inset];
|
|
809
|
+
const [right, bottom] = [width - inset, height - inset];
|
|
810
|
+
const corners = [
|
|
811
|
+
[
|
|
812
|
+
right - r,
|
|
813
|
+
top + r,
|
|
814
|
+
-Math.PI / 2
|
|
815
|
+
],
|
|
816
|
+
[
|
|
817
|
+
right - r,
|
|
818
|
+
bottom - r,
|
|
819
|
+
0
|
|
820
|
+
],
|
|
821
|
+
[
|
|
822
|
+
left + r,
|
|
823
|
+
bottom - r,
|
|
824
|
+
Math.PI / 2
|
|
825
|
+
],
|
|
826
|
+
[
|
|
827
|
+
left + r,
|
|
828
|
+
top + r,
|
|
829
|
+
Math.PI
|
|
830
|
+
]
|
|
831
|
+
];
|
|
832
|
+
const points = [];
|
|
833
|
+
for (const [cx, cy, start] of corners) for (let step = 0; step <= OUTLINE_CORNER_STEPS; step++) {
|
|
834
|
+
const angle = start + step / OUTLINE_CORNER_STEPS * (Math.PI / 2);
|
|
835
|
+
const [cos, sin] = [Math.cos(angle), Math.sin(angle)];
|
|
836
|
+
points.push([cx + r * Math.sign(cos) * Math.abs(cos) ** (2 / exponent), cy + r * Math.sign(sin) * Math.abs(sin) ** (2 / exponent)]);
|
|
837
|
+
}
|
|
838
|
+
const length = points.reduce((total, [x, y], index) => {
|
|
839
|
+
const [px, py] = points[(index + points.length - 1) % points.length];
|
|
840
|
+
return total + Math.hypot(x - px, y - py);
|
|
841
|
+
}, 0);
|
|
842
|
+
return {
|
|
843
|
+
d: `M ${points.map(([x, y]) => `${x.toFixed(2)} ${y.toFixed(2)}`).join(" L ")} Z`,
|
|
844
|
+
length
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* A 1px dashed outline with long dashes on the box it sits in. CSS ties a
|
|
849
|
+
* dashed border's dash length to its width, so this one is drawn, on the same
|
|
850
|
+
* corners as the cards: squircles where the browser draws them, arcs elsewhere.
|
|
851
|
+
*/
|
|
852
|
+
function DashedOutline() {
|
|
853
|
+
const svgRef = React.useRef(null);
|
|
854
|
+
const [outline, setOutline] = React.useState({
|
|
855
|
+
d: "",
|
|
856
|
+
length: 0
|
|
857
|
+
});
|
|
858
|
+
React.useLayoutEffect(() => {
|
|
859
|
+
const box = svgRef.current?.parentElement;
|
|
860
|
+
if (!box) return;
|
|
861
|
+
const exponent = CSS.supports("corner-shape", "squircle") ? 4 : 2;
|
|
862
|
+
const draw = () => setOutline(outlinePath(box.offsetWidth, box.offsetHeight, parseFloat(getComputedStyle(box).borderTopLeftRadius), exponent));
|
|
863
|
+
draw();
|
|
864
|
+
const observer = new ResizeObserver(draw);
|
|
865
|
+
observer.observe(box);
|
|
866
|
+
return () => observer.disconnect();
|
|
867
|
+
}, []);
|
|
868
|
+
const period = 14;
|
|
869
|
+
const pathLength = Math.max(1, Math.round(outline.length / period)) * period;
|
|
870
|
+
return /* @__PURE__ */ jsx("svg", {
|
|
871
|
+
"data-slot": "swipe-deck-empty-outline",
|
|
872
|
+
ref: svgRef,
|
|
873
|
+
className: "pointer-events-none absolute inset-0 size-full overflow-visible",
|
|
874
|
+
"aria-hidden": true,
|
|
875
|
+
children: /* @__PURE__ */ jsx("path", {
|
|
876
|
+
d: outline.d,
|
|
877
|
+
pathLength,
|
|
878
|
+
strokeDasharray: `${OUTLINE_DASH} ${OUTLINE_GAP}`,
|
|
879
|
+
className: "stroke-shade fill-none"
|
|
880
|
+
})
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* What the deck shows once its cards run out: a message, an action (start
|
|
885
|
+
* over, carry on), anything you put in it. It sits where the cards sit, at
|
|
886
|
+
* their size, inside a dashed outline of their shape (`data-slot`
|
|
887
|
+
* `swipe-deck-empty-outline`, to restyle or hide). It is drawn as soon as one
|
|
888
|
+
* card is left and has come to rest, under that card, so dragging it shows
|
|
889
|
+
* what comes next; it
|
|
890
|
+
* can be reached (Tab, clicks) and screen readers hear it (its `aria-label`,
|
|
891
|
+
* or its text) once that last card has gone too.
|
|
892
|
+
*/
|
|
893
|
+
function SwipeDeckEmpty({ className, children, ref, ...props }) {
|
|
894
|
+
const deck = useSwipeDeckContext("SwipeDeckEmpty");
|
|
895
|
+
const { emptyRef, focusDeck, isEmpty } = deck;
|
|
896
|
+
const isDrawn = deck.isReady && (deck.positions.size === 0 || deck.positions.size === 1 && deck.settledFront === deck.front);
|
|
897
|
+
const elementRef = React.useRef(null);
|
|
898
|
+
const mergedRef = React.useMemo(() => mergeRefs(elementRef, emptyRef, ref), [emptyRef, ref]);
|
|
899
|
+
React.useLayoutEffect(() => {
|
|
900
|
+
if (!isEmpty && elementRef.current?.contains(document.activeElement)) focusDeck();
|
|
901
|
+
}, [focusDeck, isEmpty]);
|
|
902
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
903
|
+
ref: mergedRef,
|
|
904
|
+
"data-slot": "swipe-deck-empty",
|
|
905
|
+
inert: !isEmpty,
|
|
906
|
+
className: cn("text-muted relative col-start-1 row-start-1 flex min-h-(--swipe-deck-card-height) min-w-(--swipe-deck-card-width) flex-col items-center justify-center gap-3 rounded-lg p-6 text-center text-sm", !isDrawn && "invisible", className),
|
|
907
|
+
...props,
|
|
908
|
+
children: [/* @__PURE__ */ jsx(DashedOutline, {}), children]
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* A button that sends the front card off in `direction`: the way to swipe
|
|
913
|
+
* without dragging. Disabled when the deck is empty or can't go that way.
|
|
914
|
+
*/
|
|
915
|
+
function SwipeDeckAction({ direction, disabled, onClick, ...props }) {
|
|
916
|
+
const deck = useSwipeDeckContext("SwipeDeckAction");
|
|
917
|
+
const unavailable = deck.disabled || deck.isReady && deck.front === void 0 || !deck.directions.includes(direction);
|
|
918
|
+
return /* @__PURE__ */ jsx(Button, {
|
|
919
|
+
"data-slot": "swipe-deck-action",
|
|
920
|
+
"data-swipe-direction": direction,
|
|
921
|
+
disabled: disabled || unavailable,
|
|
922
|
+
focusableWhenDisabled: true,
|
|
923
|
+
onClick: (event) => {
|
|
924
|
+
onClick?.(event);
|
|
925
|
+
if (!event.defaultPrevented) deck.swipeFront(direction);
|
|
926
|
+
},
|
|
927
|
+
...props
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
//#endregion
|
|
931
|
+
export { SwipeDeck, SwipeDeckAction, SwipeDeckEmpty, SwipeDeckItem };
|