@waveso/ui 0.16.0 → 0.18.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/README.md +25 -14
- package/dist/accordion.js +2 -2
- package/dist/action-bar.js +1 -1
- package/dist/alert-dialog.js +1 -1
- package/dist/alert.js +1 -1
- package/dist/autocomplete.js +4 -4
- package/dist/avatar.js +6 -6
- package/dist/badge.js +2 -2
- package/dist/button-group.js +3 -3
- package/dist/button.js +3 -3
- package/dist/card.js +1 -1
- package/dist/checkbox.js +1 -1
- package/dist/combobox.js +6 -6
- package/dist/context-menu.d.ts +1 -1
- package/dist/context-menu.js +27 -10
- package/dist/dialog.js +1 -1
- package/dist/drawer.js +2 -2
- package/dist/gradient-reveal-text.js +1 -1
- package/dist/input-group.js +1 -1
- package/dist/input-otp.js +1 -1
- package/dist/input.js +1 -1
- package/dist/item.js +2 -2
- package/dist/menu.d.ts +1 -1
- package/dist/menu.js +27 -10
- package/dist/menubar.js +2 -2
- package/dist/popover.js +1 -1
- package/dist/preview-card.js +1 -1
- package/dist/radio.js +1 -1
- package/dist/resizable.js +2 -2
- package/dist/scroll-area.js +2 -12
- package/dist/select.js +5 -5
- package/dist/separator.js +1 -1
- package/dist/sidebar.js +11 -11
- package/dist/slider.js +2 -2
- package/dist/styles.css +196 -88
- package/dist/swipe-deck.d.ts +154 -0
- package/dist/swipe-deck.js +1374 -0
- package/dist/switch.js +1 -1
- package/dist/table.js +3 -3
- package/dist/tabs.js +3 -3
- package/dist/textarea.js +1 -1
- package/dist/toast.d.ts +1 -1
- package/dist/toast.js +4 -4
- package/dist/toggle.js +3 -3
- package/package.json +1 -1
|
@@ -0,0 +1,1374 @@
|
|
|
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
|
+
/** One step each way, in screen axes (y grows downward). */
|
|
17
|
+
const DIRECTION_VECTORS = {
|
|
18
|
+
left: [-1, 0],
|
|
19
|
+
right: [1, 0],
|
|
20
|
+
up: [0, -1],
|
|
21
|
+
down: [0, 1]
|
|
22
|
+
};
|
|
23
|
+
/** How far (px) a press moves before it becomes a drag, so taps stay clicks. */
|
|
24
|
+
const DRAG_SLOP = 6;
|
|
25
|
+
/**
|
|
26
|
+
* How hard a card resists being pulled toward a side it can't leave by: the
|
|
27
|
+
* factor in iOS's rubber band, under which a pull moves it less and less,
|
|
28
|
+
* never past its own length.
|
|
29
|
+
*/
|
|
30
|
+
const RUBBER_BAND = .55;
|
|
31
|
+
/** A card leaves when it comes to rest past this share of its width (or height). */
|
|
32
|
+
const LEAVE_SHARE = .25;
|
|
33
|
+
/** Only pointer samples this recent (ms) set the release speed. */
|
|
34
|
+
const VELOCITY_WINDOW = 100;
|
|
35
|
+
/**
|
|
36
|
+
* How much a throw counts: its release speed adds this many seconds' worth of
|
|
37
|
+
* travel to where the card is, to decide whether it leaves and which way.
|
|
38
|
+
*/
|
|
39
|
+
const THROW_TIME = .25;
|
|
40
|
+
/** A leaving card's exit point is this far (px) past the pile's edge, shadow and all. */
|
|
41
|
+
const CLEARANCE = 16;
|
|
42
|
+
const RETURN_SPRING = {
|
|
43
|
+
type: "spring",
|
|
44
|
+
stiffness: 400,
|
|
45
|
+
damping: 32
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Carries a leaving card to its exit point. A slow card slides out on it; a
|
|
49
|
+
* thrown one brings its speed and runs out against it, so the harder the
|
|
50
|
+
* throw, the harder the pull back: a limit, not a coast to anywhere.
|
|
51
|
+
*/
|
|
52
|
+
const EXIT_SPRING = {
|
|
53
|
+
type: "spring",
|
|
54
|
+
stiffness: 650,
|
|
55
|
+
damping: 42
|
|
56
|
+
};
|
|
57
|
+
/** A leaving card fades once clear of the pile and slowed below this (px/s). */
|
|
58
|
+
const CAUGHT_SPEED = 200;
|
|
59
|
+
/** Moves a card from one place in the pile to the next. */
|
|
60
|
+
const PLACE_SPRING = {
|
|
61
|
+
type: "spring",
|
|
62
|
+
stiffness: 260,
|
|
63
|
+
damping: 24
|
|
64
|
+
};
|
|
65
|
+
/** Brings a card your list moved to the back from its exit point under the pile. */
|
|
66
|
+
const TUCK_SPRING = {
|
|
67
|
+
type: "spring",
|
|
68
|
+
stiffness: 240,
|
|
69
|
+
damping: 28
|
|
70
|
+
};
|
|
71
|
+
/** How far (in places) the cards behind lift toward the front as it's dragged out. */
|
|
72
|
+
const LIFT = .4;
|
|
73
|
+
/** On mount, the cards behind wait this long (s) for the front card to fade in. */
|
|
74
|
+
const ENTER_LEAD = .15;
|
|
75
|
+
/** Then each card behind comes this long (s) after the one before. */
|
|
76
|
+
const ENTER_STAGGER = .06;
|
|
77
|
+
/** And grows in from this size, the zoom recipes' md step (`--scale-md`). */
|
|
78
|
+
const ENTER_SCALE = .9;
|
|
79
|
+
/** A pressed card lifts to this size, under the pointer. */
|
|
80
|
+
const PRESS_SCALE = 1.035;
|
|
81
|
+
const PRESS_SPRING = {
|
|
82
|
+
type: "spring",
|
|
83
|
+
stiffness: 420,
|
|
84
|
+
damping: 34
|
|
85
|
+
};
|
|
86
|
+
/** How far (deg) a dragged card tilts toward the viewer at most. */
|
|
87
|
+
const TILT = 30;
|
|
88
|
+
/** And how far (deg) it turns in its own plane, carried sideways. */
|
|
89
|
+
const SPIN = 6;
|
|
90
|
+
/** The pointer speed (px/s) that tilts a card all the way. */
|
|
91
|
+
const LEAN_SPEED = 1300;
|
|
92
|
+
/** The drag (px) that tilts and turns a card all the way. */
|
|
93
|
+
const LEAN_REACH = 260;
|
|
94
|
+
/** A still pointer stops tilting the card after this long (ms). */
|
|
95
|
+
const LEAN_IDLE = 70;
|
|
96
|
+
/** The depth (px) the tilt is seen from. */
|
|
97
|
+
const PERSPECTIVE = 800;
|
|
98
|
+
const LEAN_SPRING = {
|
|
99
|
+
type: "spring",
|
|
100
|
+
stiffness: 240,
|
|
101
|
+
damping: 28
|
|
102
|
+
};
|
|
103
|
+
/** Lays a card flat and lowers it once it's let go. */
|
|
104
|
+
const FACE_SPRING = {
|
|
105
|
+
type: "spring",
|
|
106
|
+
stiffness: 240,
|
|
107
|
+
damping: 30
|
|
108
|
+
};
|
|
109
|
+
/** Dash and gap (px) of `SwipeDeckEmpty`'s outline. */
|
|
110
|
+
const OUTLINE_DASH = 8;
|
|
111
|
+
const OUTLINE_GAP = 6;
|
|
112
|
+
/** Points drawn per corner of that outline. */
|
|
113
|
+
const OUTLINE_CORNER_STEPS = 24;
|
|
114
|
+
/** Presses on these inside a card stay theirs: a slider's thumb, a text box. */
|
|
115
|
+
const FIELD_SELECTOR = "input, textarea, select, [contenteditable]:not([contenteditable='false']), [role=slider]";
|
|
116
|
+
const ITEM_SELECTOR = "[data-slot=swipe-deck-item]";
|
|
117
|
+
/**
|
|
118
|
+
* Keeps the browser's own panning on the axis the deck doesn't swipe along,
|
|
119
|
+
* and its pinch zoom everywhere.
|
|
120
|
+
*/
|
|
121
|
+
function touchAction(directions) {
|
|
122
|
+
const horizontal = directions.some(isHorizontal);
|
|
123
|
+
const vertical = directions.some((direction) => !isHorizontal(direction));
|
|
124
|
+
if (horizontal && vertical) return "touch-pinch-zoom";
|
|
125
|
+
return horizontal ? "touch-pan-y touch-pinch-zoom" : "touch-pan-x touch-pinch-zoom";
|
|
126
|
+
}
|
|
127
|
+
function isHorizontal(direction) {
|
|
128
|
+
return direction === "left" || direction === "right";
|
|
129
|
+
}
|
|
130
|
+
/** Whether `offset` on an axis is on a side the card can leave by. */
|
|
131
|
+
function isOpenSide(directions, horizontal, offset) {
|
|
132
|
+
if (offset === 0) return true;
|
|
133
|
+
const toward = horizontal ? offset > 0 ? "right" : "left" : offset > 0 ? "down" : "up";
|
|
134
|
+
return directions.includes(toward);
|
|
135
|
+
}
|
|
136
|
+
/** Where a pull of `offset` toward a closed side leaves the card. */
|
|
137
|
+
function resist(offset, length) {
|
|
138
|
+
if (length <= 0) return 0;
|
|
139
|
+
return Math.sign(offset) * (1 - 1 / (Math.abs(offset) * RUBBER_BAND / length + 1)) * length;
|
|
140
|
+
}
|
|
141
|
+
/** The pull that `resist` turns into `offset`, to catch a resisted card. */
|
|
142
|
+
function unresist(offset, length) {
|
|
143
|
+
if (length <= 0) return 0;
|
|
144
|
+
const share = Math.min(Math.abs(offset) / length, .99);
|
|
145
|
+
return Math.sign(offset) * (1 / (1 - share) - 1) * length / RUBBER_BAND;
|
|
146
|
+
}
|
|
147
|
+
/** A drag's offset on one axis, resisted when it points to a closed side. */
|
|
148
|
+
function follow(directions, horizontal, pull, length) {
|
|
149
|
+
return isOpenSide(directions, horizontal, pull) ? pull : resist(pull, length);
|
|
150
|
+
}
|
|
151
|
+
/** Lets the cards behind settle back from a lift. */
|
|
152
|
+
function releaseLift(lift, isInstant) {
|
|
153
|
+
if (isInstant) lift.jump(0);
|
|
154
|
+
else animate(lift, 0, PLACE_SPRING);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Resolves once an element's running transitions and animations end. jsdom
|
|
158
|
+
* has no Web Animations, and an endless animation of your own would never
|
|
159
|
+
* end, so neither is waited for.
|
|
160
|
+
*/
|
|
161
|
+
function transitionsEnd(element) {
|
|
162
|
+
const animations = typeof element.getAnimations === "function" ? element.getAnimations().filter((animation) => animation.effect?.getComputedTiming().endTime !== Infinity) : [];
|
|
163
|
+
return Promise.allSettled(animations.map((animation) => animation.finished));
|
|
164
|
+
}
|
|
165
|
+
/** `value` kept within `min`–`max`, or `fallback` when it isn't a number. */
|
|
166
|
+
function within(value, min, max, fallback) {
|
|
167
|
+
return Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
|
|
168
|
+
}
|
|
169
|
+
function isThenable(value) {
|
|
170
|
+
return typeof value === "object" && value !== null && "then" in value && typeof value.then === "function";
|
|
171
|
+
}
|
|
172
|
+
function createSwipeEventDetails(cause) {
|
|
173
|
+
let isCanceled = false;
|
|
174
|
+
return {
|
|
175
|
+
...cause,
|
|
176
|
+
cancel: () => {
|
|
177
|
+
isCanceled = true;
|
|
178
|
+
},
|
|
179
|
+
get isCanceled() {
|
|
180
|
+
return isCanceled;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
const subscribeToNothing = () => () => {};
|
|
185
|
+
/**
|
|
186
|
+
* False while React renders on the server or hydrates what the server sent,
|
|
187
|
+
* true for a render made in the browser.
|
|
188
|
+
*/
|
|
189
|
+
function useIsClientRender() {
|
|
190
|
+
return React.useSyncExternalStore(subscribeToNothing, () => true, () => false);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Three steady numbers in [-0.5, 0.5] from a card's value: where it lies in a
|
|
194
|
+
* `pile`, the same on every render and server.
|
|
195
|
+
*/
|
|
196
|
+
function scatterOf(value) {
|
|
197
|
+
let hash = 2166136261;
|
|
198
|
+
for (let index = 0; index < value.length; index++) hash = Math.imul(hash ^ value.charCodeAt(index), 16777619);
|
|
199
|
+
const next = () => {
|
|
200
|
+
hash ^= hash << 13;
|
|
201
|
+
hash ^= hash >>> 17;
|
|
202
|
+
hash ^= hash << 5;
|
|
203
|
+
return (hash >>> 0) % 1e3 / 1e3 - .5;
|
|
204
|
+
};
|
|
205
|
+
return [
|
|
206
|
+
next(),
|
|
207
|
+
next(),
|
|
208
|
+
next()
|
|
209
|
+
];
|
|
210
|
+
}
|
|
211
|
+
/** Layout size, without the transform a moving card carries. */
|
|
212
|
+
function sizeOf(element) {
|
|
213
|
+
return {
|
|
214
|
+
width: element.offsetWidth,
|
|
215
|
+
height: element.offsetHeight
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Where a pointer at (clientX, clientY) is on a card, in px from its transform
|
|
220
|
+
* origin, the card's own turn and size taken back out: the middle of its
|
|
221
|
+
* bounding box is where its middle is drawn, however it is turned.
|
|
222
|
+
*/
|
|
223
|
+
function grabPoint(element, clientX, clientY, size) {
|
|
224
|
+
const box = element.getBoundingClientRect();
|
|
225
|
+
const style = getComputedStyle(element);
|
|
226
|
+
const angle = (Number.parseFloat(style.rotate) || 0) * Math.PI / 180;
|
|
227
|
+
const [scaleX = 1, scaleY = scaleX] = style.scale && style.scale !== "none" ? style.scale.split(" ").map((part) => Number.parseFloat(part) || 1) : [];
|
|
228
|
+
const dx = clientX - (box.left + box.width / 2);
|
|
229
|
+
const dy = clientY - (box.top + box.height / 2);
|
|
230
|
+
const [cos, sin] = [Math.cos(-angle), Math.sin(-angle)];
|
|
231
|
+
const [originX = 0, originY = 0] = style.transformOrigin.split(" ").map((part) => Number.parseFloat(part) || 0);
|
|
232
|
+
return [within(size.width / 2 + (dx * cos - dy * sin) / scaleX, 0, size.width, 0) - originX, within(size.height / 2 + (dx * sin + dy * cos) / scaleY, 0, size.height, 0) - originY];
|
|
233
|
+
}
|
|
234
|
+
/** Whether a card's press and tilt have settled, its pose drawn as `none`. */
|
|
235
|
+
function isPoseAtRest(press, spin, tiltX, tiltY) {
|
|
236
|
+
return Math.abs(press.get() - 1) < 1e-4 && Math.abs(spin.get()) < .001 && Math.abs(tiltX.get()) < .001 && Math.abs(tiltY.get()) < .001;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Whether a card moved by (x, y) is off the pile on the side it's heading for
|
|
240
|
+
* (ux, uy): one dragged out to the right and sent left still has to cross it.
|
|
241
|
+
*/
|
|
242
|
+
function isClear(x, y, extents, [ux, uy]) {
|
|
243
|
+
return ux > 0 && x >= extents.right || ux < 0 && -x >= extents.left || uy > 0 && y >= extents.down || uy < 0 && -y >= extents.up;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Where a card at (x, y), heading along (dx, dy), first sits `CLEARANCE` past
|
|
247
|
+
* the pile's edge: the nearer of the side edges and the top or bottom edge.
|
|
248
|
+
*/
|
|
249
|
+
function exitPoint(x, y, [dx, dy], extents) {
|
|
250
|
+
const length = Math.hypot(dx, dy);
|
|
251
|
+
const ux = dx / length;
|
|
252
|
+
const uy = dy / length;
|
|
253
|
+
const edgeX = ux > 0 ? extents.right + CLEARANCE : -extents.left - CLEARANCE;
|
|
254
|
+
const edgeY = uy > 0 ? extents.down + CLEARANCE : -extents.up - CLEARANCE;
|
|
255
|
+
const steps = [ux === 0 ? Infinity : (edgeX - x) / ux, uy === 0 ? Infinity : (edgeY - y) / uy];
|
|
256
|
+
const step = Math.max(0, Math.min(...steps));
|
|
257
|
+
return [x + step * ux, y + step * uy];
|
|
258
|
+
}
|
|
259
|
+
/** How far a movement goes toward a direction; negative when it goes away. */
|
|
260
|
+
function reach(direction, x, y) {
|
|
261
|
+
const [dx, dy] = DIRECTION_VECTORS[direction];
|
|
262
|
+
return x * dx + y * dy;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* The allowed direction a movement leans furthest into, and how far it goes
|
|
266
|
+
* there as a share of the distance that makes a card leave (1 or more leaves).
|
|
267
|
+
*/
|
|
268
|
+
function lean(directions, x, y, size) {
|
|
269
|
+
let best = {
|
|
270
|
+
direction: void 0,
|
|
271
|
+
share: 0
|
|
272
|
+
};
|
|
273
|
+
for (const direction of directions) {
|
|
274
|
+
const length = isHorizontal(direction) ? size.width : size.height;
|
|
275
|
+
const share = reach(direction, x, y) / Math.max(length * LEAVE_SHARE, 1);
|
|
276
|
+
if (share > best.share) best = {
|
|
277
|
+
direction,
|
|
278
|
+
share
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
return best;
|
|
282
|
+
}
|
|
283
|
+
/** Pointer speed (px/s) over the samples taken just before `time`. */
|
|
284
|
+
function releaseVelocity(samples, time) {
|
|
285
|
+
const recent = samples.filter((sample) => time - sample.time <= VELOCITY_WINDOW);
|
|
286
|
+
const first = recent[0];
|
|
287
|
+
const last = recent.at(-1);
|
|
288
|
+
if (!first || !last || last.time <= first.time) return [0, 0];
|
|
289
|
+
const elapsed = (last.time - first.time) / 1e3;
|
|
290
|
+
return [(last.x - first.x) / elapsed, (last.y - first.y) / elapsed];
|
|
291
|
+
}
|
|
292
|
+
function clampUnit(value) {
|
|
293
|
+
return Math.min(1, Math.max(-1, value));
|
|
294
|
+
}
|
|
295
|
+
const SwipeDeckContext = React.createContext(null);
|
|
296
|
+
function useSwipeDeckContext(part) {
|
|
297
|
+
const context = React.useContext(SwipeDeckContext);
|
|
298
|
+
if (!context) throw new Error(`${part} must be used within a SwipeDeck.`);
|
|
299
|
+
return context;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* A pile of cards people swipe away: drag or throw the front card, press an
|
|
303
|
+
* arrow key while the deck is focused, or use a `SwipeDeckAction`. A let-go
|
|
304
|
+
* card whose position and throw carry it past a quarter of its width (or of
|
|
305
|
+
* its height, up and down) leaves:
|
|
306
|
+
* it slides out the way it is heading until clear of the pile, held by a
|
|
307
|
+
* spring that a hard throw runs out against, then fades. Short of that, it
|
|
308
|
+
* springs back. Pressed, a card lifts under the pointer; dragged, it tilts
|
|
309
|
+
* with the throw.
|
|
310
|
+
*
|
|
311
|
+
* Your list stays the source of truth. `onSwipe` comes the moment a card is
|
|
312
|
+
* sent off: count it there, or cancel to keep it. `onSwipeComplete` comes once
|
|
313
|
+
* it has gone: remove it from your list then, not in `onSwipe`, where removing
|
|
314
|
+
* it would cut its exit short. To cycle the deck instead, move the card to
|
|
315
|
+
* the end of your list in `onSwipe`, and it slides back under the pile. To
|
|
316
|
+
* undo a swipe, put the card back at the start of your list.
|
|
317
|
+
*
|
|
318
|
+
* The cards are the deck's own children, in your list's order. A long list
|
|
319
|
+
* needn't be rendered whole: the deck shows `visible` cards, so the next few
|
|
320
|
+
* dozen are plenty.
|
|
321
|
+
*
|
|
322
|
+
* End the deck with a `SwipeDeckEmpty`: it lies under the last card, so
|
|
323
|
+
* dragging that card shows what comes next.
|
|
324
|
+
*
|
|
325
|
+
* Styling hooks: `data-layout`, `data-disabled`, and `data-empty` once no
|
|
326
|
+
* card is in front (the last one leaving, or none left). CSS variables:
|
|
327
|
+
* `--swipe-deck-card-width` / `-height`, the size of the largest card.
|
|
328
|
+
*
|
|
329
|
+
* Give the deck an `aria-label`. Screen readers hear the next card after each
|
|
330
|
+
* swipe: its `aria-label` when it has one, otherwise all of its text.
|
|
331
|
+
*/
|
|
332
|
+
function SwipeDeck({ onSwipe, onSwipeComplete, directions = DEFAULT_DIRECTIONS, disabled = false, layout = "fan", spread = .5, depth = .5, visible = 4, className, style, onKeyDown, children, ref, ...props }) {
|
|
333
|
+
const rootRef = React.useRef(null);
|
|
334
|
+
const mergedRef = React.useMemo(() => mergeRefs(rootRef, ref), [ref]);
|
|
335
|
+
const itemsRef = React.useRef(/* @__PURE__ */ new Map());
|
|
336
|
+
const valuesRef = React.useRef(/* @__PURE__ */ new WeakMap());
|
|
337
|
+
const sentRef = React.useRef(/* @__PURE__ */ new Map());
|
|
338
|
+
const leavesRef = React.useRef(0);
|
|
339
|
+
const followersRef = React.useRef(/* @__PURE__ */ new Map());
|
|
340
|
+
const movedBackRef = React.useRef(/* @__PURE__ */ new Set());
|
|
341
|
+
const statusRef = React.useRef(null);
|
|
342
|
+
const emptyRef = React.useRef(null);
|
|
343
|
+
const sizeObserverRef = React.useRef(null);
|
|
344
|
+
const lift = useMotionValue(0);
|
|
345
|
+
const [order, setOrder] = React.useState([]);
|
|
346
|
+
const [leavingList, setLeavingList] = React.useState([]);
|
|
347
|
+
const [isReady, setIsReady] = React.useState(false);
|
|
348
|
+
const visibleCount = Math.round(within(visible, 1, Infinity, 4));
|
|
349
|
+
const onSwipeRef = React.useRef(onSwipe);
|
|
350
|
+
const onSwipeCompleteRef = React.useRef(onSwipeComplete);
|
|
351
|
+
React.useLayoutEffect(() => {
|
|
352
|
+
onSwipeRef.current = onSwipe;
|
|
353
|
+
onSwipeCompleteRef.current = onSwipeComplete;
|
|
354
|
+
});
|
|
355
|
+
const readOrder = React.useCallback(() => {
|
|
356
|
+
const root = rootRef.current;
|
|
357
|
+
if (!root) return [];
|
|
358
|
+
const read = (elements) => {
|
|
359
|
+
const next = [];
|
|
360
|
+
for (const element of elements) {
|
|
361
|
+
const value = valuesRef.current.get(element);
|
|
362
|
+
if (value !== void 0) next.push(value);
|
|
363
|
+
}
|
|
364
|
+
return next;
|
|
365
|
+
};
|
|
366
|
+
const next = read(root.children);
|
|
367
|
+
return next.length === itemsRef.current.size ? next : read(root.querySelectorAll(ITEM_SELECTOR));
|
|
368
|
+
}, []);
|
|
369
|
+
const noteMovedBack = React.useCallback((current) => {
|
|
370
|
+
if (followersRef.current.size === 0) return;
|
|
371
|
+
const indexes = new Map(current.map((card, index) => [card, index]));
|
|
372
|
+
for (const [value, followers] of followersRef.current) {
|
|
373
|
+
const own = indexes.get(value);
|
|
374
|
+
if (own !== void 0 && followers.some((follower) => (indexes.get(follower) ?? Infinity) < own)) movedBackRef.current.add(value);
|
|
375
|
+
}
|
|
376
|
+
}, []);
|
|
377
|
+
const syncOrder = React.useCallback(() => {
|
|
378
|
+
if (!rootRef.current) return;
|
|
379
|
+
const next = readOrder();
|
|
380
|
+
noteMovedBack(next);
|
|
381
|
+
setOrder((previous) => previous.length === next.length && previous.every((value, index) => value === next[index]) ? previous : next);
|
|
382
|
+
for (const value of sentRef.current.keys()) {
|
|
383
|
+
if (itemsRef.current.has(value)) continue;
|
|
384
|
+
sentRef.current.delete(value);
|
|
385
|
+
followersRef.current.delete(value);
|
|
386
|
+
movedBackRef.current.delete(value);
|
|
387
|
+
}
|
|
388
|
+
setLeavingList((previous) => {
|
|
389
|
+
const kept = previous.filter((value) => itemsRef.current.has(value));
|
|
390
|
+
return kept.length === previous.length ? previous : kept;
|
|
391
|
+
});
|
|
392
|
+
}, [noteMovedBack, readOrder]);
|
|
393
|
+
const isSyncQueuedRef = React.useRef(false);
|
|
394
|
+
const syncSoon = React.useCallback(() => {
|
|
395
|
+
if (isSyncQueuedRef.current) return;
|
|
396
|
+
isSyncQueuedRef.current = true;
|
|
397
|
+
queueMicrotask(() => {
|
|
398
|
+
isSyncQueuedRef.current = false;
|
|
399
|
+
syncOrder();
|
|
400
|
+
});
|
|
401
|
+
}, [syncOrder]);
|
|
402
|
+
React.useLayoutEffect(() => {
|
|
403
|
+
syncOrder();
|
|
404
|
+
});
|
|
405
|
+
React.useLayoutEffect(() => {
|
|
406
|
+
setIsReady(true);
|
|
407
|
+
}, []);
|
|
408
|
+
React.useEffect(() => {
|
|
409
|
+
const root = rootRef.current;
|
|
410
|
+
if (!root) return;
|
|
411
|
+
const observer = new MutationObserver(syncOrder);
|
|
412
|
+
observer.observe(root, { childList: true });
|
|
413
|
+
return () => observer.disconnect();
|
|
414
|
+
}, [syncOrder]);
|
|
415
|
+
const sizeObserver = React.useCallback(() => {
|
|
416
|
+
sizeObserverRef.current ??= new ResizeObserver(() => {
|
|
417
|
+
const root = rootRef.current;
|
|
418
|
+
const cards = [...itemsRef.current.values()];
|
|
419
|
+
if (!root || cards.length === 0) return;
|
|
420
|
+
const width = Math.max(...cards.map(({ element }) => element.offsetWidth));
|
|
421
|
+
const height = Math.max(...cards.map(({ element }) => element.offsetHeight));
|
|
422
|
+
root.style.setProperty("--swipe-deck-card-width", `${width}px`);
|
|
423
|
+
root.style.setProperty("--swipe-deck-card-height", `${height}px`);
|
|
424
|
+
});
|
|
425
|
+
return sizeObserverRef.current;
|
|
426
|
+
}, []);
|
|
427
|
+
React.useEffect(() => () => sizeObserverRef.current?.disconnect(), []);
|
|
428
|
+
const register = React.useCallback((value, record) => {
|
|
429
|
+
const items = itemsRef.current;
|
|
430
|
+
const existing = items.get(value);
|
|
431
|
+
if (process.env.NODE_ENV === "development" && existing && existing.element !== record.element) console.warn(`SwipeDeck: more than one card has the value "${value}". Give each SwipeDeckItem its own value, the same as its key.`);
|
|
432
|
+
items.set(value, record);
|
|
433
|
+
valuesRef.current.set(record.element, value);
|
|
434
|
+
sizeObserver().observe(record.element);
|
|
435
|
+
syncSoon();
|
|
436
|
+
return () => {
|
|
437
|
+
valuesRef.current.delete(record.element);
|
|
438
|
+
if (items.get(value) === record) {
|
|
439
|
+
items.delete(value);
|
|
440
|
+
sizeObserverRef.current?.unobserve(record.element);
|
|
441
|
+
}
|
|
442
|
+
syncSoon();
|
|
443
|
+
};
|
|
444
|
+
}, [sizeObserver, syncSoon]);
|
|
445
|
+
const focusDeck = React.useCallback(() => rootRef.current?.focus(), []);
|
|
446
|
+
const [settledFront, setSettledFront] = React.useState();
|
|
447
|
+
const send = React.useCallback((details, cause) => {
|
|
448
|
+
if (sentRef.current.has(details.value)) return false;
|
|
449
|
+
const current = readOrder();
|
|
450
|
+
const followers = current.slice(current.indexOf(details.value) + 1);
|
|
451
|
+
const eventDetails = createSwipeEventDetails(cause);
|
|
452
|
+
onSwipeRef.current?.(details, eventDetails);
|
|
453
|
+
if (eventDetails.isCanceled) return false;
|
|
454
|
+
leavesRef.current += 1;
|
|
455
|
+
sentRef.current.set(details.value, leavesRef.current);
|
|
456
|
+
followersRef.current.set(details.value, followers);
|
|
457
|
+
if ((itemsRef.current.get(details.value)?.element)?.contains(document.activeElement)) focusDeck();
|
|
458
|
+
setLeavingList((previous) => previous.includes(details.value) ? previous : [...previous, details.value]);
|
|
459
|
+
return true;
|
|
460
|
+
}, [focusDeck, readOrder]);
|
|
461
|
+
const isMovedBack = React.useCallback((value) => {
|
|
462
|
+
noteMovedBack(readOrder());
|
|
463
|
+
return movedBackRef.current.has(value);
|
|
464
|
+
}, [noteMovedBack, readOrder]);
|
|
465
|
+
const returnNow = React.useCallback((leaves) => {
|
|
466
|
+
const back = /* @__PURE__ */ new Set();
|
|
467
|
+
for (const [value, leave] of leaves) {
|
|
468
|
+
if (sentRef.current.get(value) !== leave) continue;
|
|
469
|
+
back.add(value);
|
|
470
|
+
sentRef.current.delete(value);
|
|
471
|
+
followersRef.current.delete(value);
|
|
472
|
+
movedBackRef.current.delete(value);
|
|
473
|
+
}
|
|
474
|
+
if (back.size === 0) return;
|
|
475
|
+
setLeavingList((previous) => {
|
|
476
|
+
const kept = previous.filter((value) => !back.has(value));
|
|
477
|
+
return kept.length === previous.length ? previous : kept;
|
|
478
|
+
});
|
|
479
|
+
}, []);
|
|
480
|
+
const rejoin = React.useCallback((value) => {
|
|
481
|
+
const leave = sentRef.current.get(value);
|
|
482
|
+
if (leave !== void 0) returnNow(/* @__PURE__ */ new Map([[value, leave]]));
|
|
483
|
+
}, [returnNow]);
|
|
484
|
+
const answeredRef = React.useRef(/* @__PURE__ */ new Map());
|
|
485
|
+
const [answers, setAnswers] = React.useState(0);
|
|
486
|
+
const endLeave = React.useCallback((value, leave) => {
|
|
487
|
+
answeredRef.current.set(value, leave);
|
|
488
|
+
React.startTransition(() => setAnswers((count) => count + 1));
|
|
489
|
+
}, []);
|
|
490
|
+
React.useLayoutEffect(() => {
|
|
491
|
+
if (answeredRef.current.size === 0) return;
|
|
492
|
+
const answered = answeredRef.current;
|
|
493
|
+
answeredRef.current = /* @__PURE__ */ new Map();
|
|
494
|
+
returnNow(answered);
|
|
495
|
+
}, [answers, returnNow]);
|
|
496
|
+
const complete = React.useCallback((details) => {
|
|
497
|
+
const leave = sentRef.current.get(details.value);
|
|
498
|
+
if (leave === void 0) {
|
|
499
|
+
onSwipeCompleteRef.current?.(details);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
let answer;
|
|
503
|
+
try {
|
|
504
|
+
answer = onSwipeCompleteRef.current?.(details);
|
|
505
|
+
} finally {
|
|
506
|
+
if (isThenable(answer)) Promise.resolve(answer).finally(() => endLeave(details.value, leave));
|
|
507
|
+
else endLeave(details.value, leave);
|
|
508
|
+
}
|
|
509
|
+
}, [endLeave]);
|
|
510
|
+
const leaving = React.useMemo(() => new Set(leavingList), [leavingList]);
|
|
511
|
+
const positions = React.useMemo(() => new Map(order.filter((value) => !leaving.has(value)).map((value, index) => [value, index])), [order, leaving]);
|
|
512
|
+
const front = order.find((value) => !leaving.has(value));
|
|
513
|
+
React.useLayoutEffect(() => {
|
|
514
|
+
setSettledFront(void 0);
|
|
515
|
+
}, [front]);
|
|
516
|
+
const positionsRef = React.useRef(positions);
|
|
517
|
+
const visibleRef = React.useRef(visibleCount);
|
|
518
|
+
React.useLayoutEffect(() => {
|
|
519
|
+
positionsRef.current = positions;
|
|
520
|
+
visibleRef.current = visibleCount;
|
|
521
|
+
});
|
|
522
|
+
const exitExtents = React.useCallback((value, element, x, y) => {
|
|
523
|
+
const own = element.getBoundingClientRect();
|
|
524
|
+
const home = {
|
|
525
|
+
left: own.left - x,
|
|
526
|
+
right: own.right - x,
|
|
527
|
+
top: own.top - y,
|
|
528
|
+
bottom: own.bottom - y
|
|
529
|
+
};
|
|
530
|
+
const pile = { ...home };
|
|
531
|
+
for (const [card, record] of itemsRef.current) {
|
|
532
|
+
const position = positionsRef.current.get(card);
|
|
533
|
+
if (card === value || position === void 0 || position >= visibleRef.current) continue;
|
|
534
|
+
const box = record.element.getBoundingClientRect();
|
|
535
|
+
pile.left = Math.min(pile.left, box.left);
|
|
536
|
+
pile.right = Math.max(pile.right, box.right);
|
|
537
|
+
pile.top = Math.min(pile.top, box.top);
|
|
538
|
+
pile.bottom = Math.max(pile.bottom, box.bottom);
|
|
539
|
+
}
|
|
540
|
+
return {
|
|
541
|
+
right: pile.right - home.left,
|
|
542
|
+
left: home.right - pile.left,
|
|
543
|
+
down: pile.bottom - home.top,
|
|
544
|
+
up: home.bottom - pile.top
|
|
545
|
+
};
|
|
546
|
+
}, []);
|
|
547
|
+
const swipeFront = React.useCallback((direction, cause) => {
|
|
548
|
+
if (disabled || !directions.includes(direction)) return;
|
|
549
|
+
const next = readOrder().find((value) => !sentRef.current.has(value));
|
|
550
|
+
if (next !== void 0) itemsRef.current.get(next)?.swipe(direction, cause);
|
|
551
|
+
}, [
|
|
552
|
+
directions,
|
|
553
|
+
disabled,
|
|
554
|
+
readOrder
|
|
555
|
+
]);
|
|
556
|
+
const announcedRef = React.useRef(null);
|
|
557
|
+
React.useEffect(() => {
|
|
558
|
+
if (!isReady) return;
|
|
559
|
+
const previous = announcedRef.current;
|
|
560
|
+
announcedRef.current = { front };
|
|
561
|
+
const status = statusRef.current;
|
|
562
|
+
if (!previous || previous.front === front || !status) return;
|
|
563
|
+
const element = front === void 0 ? emptyRef.current : itemsRef.current.get(front)?.element;
|
|
564
|
+
status.textContent = element ? element.getAttribute("aria-label") ?? (element.innerText || element.textContent || "") : "";
|
|
565
|
+
}, [front, isReady]);
|
|
566
|
+
const handleKeyDown = (event) => {
|
|
567
|
+
onKeyDown?.(event);
|
|
568
|
+
if (event.defaultPrevented || event.target !== event.currentTarget) return;
|
|
569
|
+
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
|
|
570
|
+
const direction = KEY_DIRECTIONS[event.key];
|
|
571
|
+
if (!direction || disabled || !directions.includes(direction)) return;
|
|
572
|
+
event.preventDefault();
|
|
573
|
+
if (event.repeat) return;
|
|
574
|
+
swipeFront(direction, {
|
|
575
|
+
reason: "keyboard",
|
|
576
|
+
event: event.nativeEvent
|
|
577
|
+
});
|
|
578
|
+
};
|
|
579
|
+
const context = React.useMemo(() => ({
|
|
580
|
+
directions,
|
|
581
|
+
disabled,
|
|
582
|
+
isReady,
|
|
583
|
+
front,
|
|
584
|
+
positions,
|
|
585
|
+
leaving,
|
|
586
|
+
leavingOrder: leavingList,
|
|
587
|
+
count: order.length,
|
|
588
|
+
visible: visibleCount,
|
|
589
|
+
lift,
|
|
590
|
+
register,
|
|
591
|
+
send,
|
|
592
|
+
isMovedBack,
|
|
593
|
+
rejoin,
|
|
594
|
+
complete,
|
|
595
|
+
exitExtents,
|
|
596
|
+
swipeFront,
|
|
597
|
+
isEmpty: isReady && front === void 0,
|
|
598
|
+
emptyRef,
|
|
599
|
+
focusDeck,
|
|
600
|
+
settledFront,
|
|
601
|
+
settle: setSettledFront
|
|
602
|
+
}), [
|
|
603
|
+
directions,
|
|
604
|
+
disabled,
|
|
605
|
+
isReady,
|
|
606
|
+
front,
|
|
607
|
+
positions,
|
|
608
|
+
leaving,
|
|
609
|
+
leavingList,
|
|
610
|
+
order.length,
|
|
611
|
+
visibleCount,
|
|
612
|
+
lift,
|
|
613
|
+
register,
|
|
614
|
+
send,
|
|
615
|
+
isMovedBack,
|
|
616
|
+
rejoin,
|
|
617
|
+
complete,
|
|
618
|
+
exitExtents,
|
|
619
|
+
swipeFront,
|
|
620
|
+
focusDeck,
|
|
621
|
+
settledFront
|
|
622
|
+
]);
|
|
623
|
+
return /* @__PURE__ */ jsx(SwipeDeckContext.Provider, {
|
|
624
|
+
value: context,
|
|
625
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
626
|
+
ref: mergedRef,
|
|
627
|
+
"data-slot": "swipe-deck",
|
|
628
|
+
"data-layout": layout,
|
|
629
|
+
"data-empty": context.isEmpty ? "" : void 0,
|
|
630
|
+
"data-disabled": disabled ? "" : void 0,
|
|
631
|
+
role: "group",
|
|
632
|
+
tabIndex: 0,
|
|
633
|
+
"aria-disabled": disabled || void 0,
|
|
634
|
+
className: cn("group/swipe-deck relative isolate grid focus-visible:outline-hidden", "[--swipe-deck-side:1] rtl:[--swipe-deck-side:-1]", "ring-focus/50 rounded-lg focus-visible:not-has-data-front:ring-3", className),
|
|
635
|
+
style: {
|
|
636
|
+
"--swipe-deck-spread": within(spread, 0, 2, .5),
|
|
637
|
+
"--swipe-deck-depth": within(depth, 0, 1, .5),
|
|
638
|
+
"--swipe-deck-visible": visibleCount,
|
|
639
|
+
...style
|
|
640
|
+
},
|
|
641
|
+
onKeyDown: handleKeyDown,
|
|
642
|
+
...props,
|
|
643
|
+
children: [children, /* @__PURE__ */ jsx("span", {
|
|
644
|
+
ref: statusRef,
|
|
645
|
+
"data-slot": "swipe-deck-status",
|
|
646
|
+
role: "status",
|
|
647
|
+
className: "sr-only"
|
|
648
|
+
})]
|
|
649
|
+
})
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* One card in the deck, a direct child of it. It sits in the deck's first grid
|
|
654
|
+
* cell with every other card, so the deck takes the size of its largest card.
|
|
655
|
+
*
|
|
656
|
+
* Styling hooks: `data-front` on the top card, `data-swiping` while it is
|
|
657
|
+
* dragged, `data-leaving` from the moment it leaves, `data-starting-style`
|
|
658
|
+
* while it comes in and `data-ending-style` once it is clear of the pile and
|
|
659
|
+
* fading (the `motion-fade-lg` recipe), and `data-swipe-direction` for the
|
|
660
|
+
* way it leans or leaves. CSS variables, which what's inside the card can
|
|
661
|
+
* read too: `--swipe-deck-index` (its place in the pile, 0 at the front),
|
|
662
|
+
* `--swipe-deck-place` (that place as it springs from one to the next),
|
|
663
|
+
* `--swipe-deck-movement-x` / `-y` in px, and `--swipe-deck-progress` from 0
|
|
664
|
+
* at rest to 1 where letting go makes the card leave.
|
|
665
|
+
*
|
|
666
|
+
* The card owns its translate, rotate, scale and transform, so its motion
|
|
667
|
+
* recipe can only be a `motion-fade-*` one: a recipe that transitions those
|
|
668
|
+
* would lag every drag.
|
|
669
|
+
*/
|
|
670
|
+
function SwipeDeckItem({ value, className, style, ref, onPointerDown, onClickCapture, onDragStart, ...props }) {
|
|
671
|
+
const deck = useSwipeDeckContext("SwipeDeckItem");
|
|
672
|
+
const { directions, register, send, complete, rejoin, isMovedBack, exitExtents, focusDeck, settle } = deck;
|
|
673
|
+
const prefersReducedMotion = useReducedMotion();
|
|
674
|
+
const isHydrating = !useIsClientRender();
|
|
675
|
+
const wasHydratedRef = React.useRef(isHydrating);
|
|
676
|
+
const elementRef = React.useRef(null);
|
|
677
|
+
const mergedRef = React.useMemo(() => mergeRefs(elementRef, ref), [ref]);
|
|
678
|
+
const x = useMotionValue(0);
|
|
679
|
+
const y = useMotionValue(0);
|
|
680
|
+
const place = useMotionValue(0);
|
|
681
|
+
const enterScale = useMotionValue(1);
|
|
682
|
+
const press = useMotionValue(1);
|
|
683
|
+
const tiltX = useMotionValue(0);
|
|
684
|
+
const tiltY = useMotionValue(0);
|
|
685
|
+
const spin = useMotionValue(0);
|
|
686
|
+
const grabRef = React.useRef([0, 0]);
|
|
687
|
+
const drivesLiftRef = React.useRef(false);
|
|
688
|
+
const scatter = React.useMemo(() => scatterOf(value), [value]);
|
|
689
|
+
const leftTowardRef = React.useRef(null);
|
|
690
|
+
const stopWatchingRef = React.useRef(null);
|
|
691
|
+
const exitRef = React.useRef(null);
|
|
692
|
+
const isTuckingRef = React.useRef(false);
|
|
693
|
+
const dragRef = React.useRef(null);
|
|
694
|
+
const endDragRef = React.useRef(null);
|
|
695
|
+
const sizeRef = React.useRef({
|
|
696
|
+
width: 0,
|
|
697
|
+
height: 0
|
|
698
|
+
});
|
|
699
|
+
const draggedRef = React.useRef(false);
|
|
700
|
+
const [isSwiping, setIsSwiping] = React.useState(false);
|
|
701
|
+
const [isFading, setIsFading] = React.useState(false);
|
|
702
|
+
const [isStarting, setIsStarting] = React.useState(!isHydrating);
|
|
703
|
+
const [direction, setDirection] = React.useState();
|
|
704
|
+
const position = deck.positions.get(value);
|
|
705
|
+
const isLeaving = deck.leaving.has(value);
|
|
706
|
+
const isFront = deck.front === value;
|
|
707
|
+
const isInert = isLeaving || position !== void 0 && position > 0;
|
|
708
|
+
const directionsRef = React.useRef(directions);
|
|
709
|
+
const disabledRef = React.useRef(deck.disabled);
|
|
710
|
+
const reducedMotionRef = React.useRef(prefersReducedMotion);
|
|
711
|
+
React.useLayoutEffect(() => {
|
|
712
|
+
directionsRef.current = directions;
|
|
713
|
+
disabledRef.current = deck.disabled;
|
|
714
|
+
reducedMotionRef.current = prefersReducedMotion;
|
|
715
|
+
drivesLiftRef.current = isFront && !isLeaving;
|
|
716
|
+
});
|
|
717
|
+
React.useEffect(() => {
|
|
718
|
+
const write = () => {
|
|
719
|
+
const element = elementRef.current;
|
|
720
|
+
if (!element) return;
|
|
721
|
+
const { share } = lean(directionsRef.current, x.get(), y.get(), sizeRef.current);
|
|
722
|
+
element.style.setProperty("--swipe-deck-movement-x", `${x.get()}px`);
|
|
723
|
+
element.style.setProperty("--swipe-deck-movement-y", `${y.get()}px`);
|
|
724
|
+
element.style.setProperty("--swipe-deck-progress", String(Math.min(share, 1)));
|
|
725
|
+
if (drivesLiftRef.current) deck.lift.set(Math.min(share, 1) * LIFT);
|
|
726
|
+
};
|
|
727
|
+
const writePose = () => {
|
|
728
|
+
const element = elementRef.current;
|
|
729
|
+
if (!element) return;
|
|
730
|
+
const [grabX, grabY] = grabRef.current;
|
|
731
|
+
element.style.setProperty("--swipe-deck-pose", isPoseAtRest(press, spin, tiltX, tiltY) ? "none" : `translate(${grabX}px, ${grabY}px) perspective(${PERSPECTIVE}px) scale(${press.get()}) rotate(${spin.get()}deg) rotateX(${tiltX.get()}deg) rotateY(${tiltY.get()}deg) translate(${-grabX}px, ${-grabY}px)`);
|
|
732
|
+
};
|
|
733
|
+
const writeNumber = (name) => (latest) => {
|
|
734
|
+
elementRef.current?.style.setProperty(name, String(latest));
|
|
735
|
+
};
|
|
736
|
+
const stops = [
|
|
737
|
+
x.on("change", write),
|
|
738
|
+
y.on("change", write),
|
|
739
|
+
place.on("change", writeNumber("--swipe-deck-place")),
|
|
740
|
+
enterScale.on("change", writeNumber("--swipe-deck-enter-scale")),
|
|
741
|
+
press.on("change", writePose),
|
|
742
|
+
spin.on("change", writePose),
|
|
743
|
+
tiltX.on("change", writePose),
|
|
744
|
+
tiltY.on("change", writePose)
|
|
745
|
+
];
|
|
746
|
+
const exit = exitRef.current;
|
|
747
|
+
if (exit) {
|
|
748
|
+
exitRef.current = null;
|
|
749
|
+
x.jump(exit[0]);
|
|
750
|
+
y.jump(exit[1]);
|
|
751
|
+
setIsFading(true);
|
|
752
|
+
}
|
|
753
|
+
return () => {
|
|
754
|
+
for (const stop of stops) stop();
|
|
755
|
+
if (dragRef.current) releaseLift(deck.lift, reducedMotionRef.current);
|
|
756
|
+
endDragRef.current?.();
|
|
757
|
+
stopWatchingRef.current?.();
|
|
758
|
+
for (const motionValue of [
|
|
759
|
+
x,
|
|
760
|
+
y,
|
|
761
|
+
place,
|
|
762
|
+
enterScale,
|
|
763
|
+
press,
|
|
764
|
+
spin,
|
|
765
|
+
tiltX,
|
|
766
|
+
tiltY
|
|
767
|
+
]) motionValue.stop();
|
|
768
|
+
};
|
|
769
|
+
}, [
|
|
770
|
+
deck.lift,
|
|
771
|
+
enterScale,
|
|
772
|
+
place,
|
|
773
|
+
press,
|
|
774
|
+
spin,
|
|
775
|
+
tiltX,
|
|
776
|
+
tiltY,
|
|
777
|
+
x,
|
|
778
|
+
y
|
|
779
|
+
]);
|
|
780
|
+
React.useEffect(() => {
|
|
781
|
+
const element = elementRef.current;
|
|
782
|
+
if (!element || position === void 0 || position > deck.visible) return;
|
|
783
|
+
const writeLift = (latest) => element.style.setProperty("--swipe-deck-lift", String(latest));
|
|
784
|
+
writeLift(deck.lift.get());
|
|
785
|
+
const stop = deck.lift.on("change", writeLift);
|
|
786
|
+
return () => {
|
|
787
|
+
stop();
|
|
788
|
+
element.style.removeProperty("--swipe-deck-lift");
|
|
789
|
+
};
|
|
790
|
+
}, [
|
|
791
|
+
deck.lift,
|
|
792
|
+
deck.visible,
|
|
793
|
+
position
|
|
794
|
+
]);
|
|
795
|
+
const snapBack = React.useCallback(([vx, vy] = [0, 0]) => {
|
|
796
|
+
if (prefersReducedMotion) {
|
|
797
|
+
x.set(0);
|
|
798
|
+
y.set(0);
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
animate(x, 0, {
|
|
802
|
+
...RETURN_SPRING,
|
|
803
|
+
velocity: vx
|
|
804
|
+
});
|
|
805
|
+
animate(y, 0, {
|
|
806
|
+
...RETURN_SPRING,
|
|
807
|
+
velocity: vy
|
|
808
|
+
});
|
|
809
|
+
}, [
|
|
810
|
+
prefersReducedMotion,
|
|
811
|
+
x,
|
|
812
|
+
y
|
|
813
|
+
]);
|
|
814
|
+
const settleFace = React.useCallback(() => {
|
|
815
|
+
for (const [motionValue, rest] of [
|
|
816
|
+
[press, 1],
|
|
817
|
+
[spin, 0],
|
|
818
|
+
[tiltX, 0],
|
|
819
|
+
[tiltY, 0]
|
|
820
|
+
]) if (reducedMotionRef.current) motionValue.jump(rest);
|
|
821
|
+
else animate(motionValue, rest, FACE_SPRING);
|
|
822
|
+
}, [
|
|
823
|
+
press,
|
|
824
|
+
spin,
|
|
825
|
+
tiltX,
|
|
826
|
+
tiltY
|
|
827
|
+
]);
|
|
828
|
+
const tilt = React.useCallback((vx, vy) => {
|
|
829
|
+
if (reducedMotionRef.current) return;
|
|
830
|
+
const sideways = clampUnit(vx / LEAN_SPEED) * .7 + clampUnit(x.get() / LEAN_REACH) * .3;
|
|
831
|
+
const upward = clampUnit(-vy / LEAN_SPEED) * .7 + clampUnit(-y.get() / LEAN_REACH) * .3;
|
|
832
|
+
animate(tiltY, sideways * TILT, LEAN_SPRING);
|
|
833
|
+
animate(tiltX, upward * TILT, LEAN_SPRING);
|
|
834
|
+
animate(spin, clampUnit(x.get() / LEAN_REACH) * SPIN, LEAN_SPRING);
|
|
835
|
+
}, [
|
|
836
|
+
spin,
|
|
837
|
+
tiltX,
|
|
838
|
+
tiltY,
|
|
839
|
+
x,
|
|
840
|
+
y
|
|
841
|
+
]);
|
|
842
|
+
const fling = React.useCallback((to, velocity) => {
|
|
843
|
+
const [vx, vy] = velocity ?? [0, 0];
|
|
844
|
+
endDragRef.current?.();
|
|
845
|
+
setIsSwiping(false);
|
|
846
|
+
leftTowardRef.current = to;
|
|
847
|
+
setDirection(to);
|
|
848
|
+
drivesLiftRef.current = false;
|
|
849
|
+
releaseLift(deck.lift, prefersReducedMotion);
|
|
850
|
+
settleFace();
|
|
851
|
+
setIsStarting(false);
|
|
852
|
+
const element = elementRef.current;
|
|
853
|
+
if (prefersReducedMotion || !element) {
|
|
854
|
+
setIsFading(true);
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
const extents = exitExtents(value, element, x.get(), y.get());
|
|
858
|
+
const aheadX = x.get() + vx * THROW_TIME;
|
|
859
|
+
const aheadY = y.get() + vy * THROW_TIME;
|
|
860
|
+
const heading = velocity && Math.hypot(aheadX, aheadY) >= 1 ? [aheadX, aheadY] : DIRECTION_VECTORS[to];
|
|
861
|
+
const [targetX, targetY] = exitPoint(x.get(), y.get(), heading, extents);
|
|
862
|
+
exitRef.current = [targetX, targetY];
|
|
863
|
+
const headingLength = Math.hypot(...heading);
|
|
864
|
+
const [ux, uy] = [heading[0] / headingLength, heading[1] / headingLength];
|
|
865
|
+
let isOut = false;
|
|
866
|
+
const leaveSight = () => {
|
|
867
|
+
if (isOut) return;
|
|
868
|
+
isOut = true;
|
|
869
|
+
exitRef.current = null;
|
|
870
|
+
stopWatchingRef.current?.();
|
|
871
|
+
stopWatchingRef.current = null;
|
|
872
|
+
if (isMovedBack(value)) {
|
|
873
|
+
isTuckingRef.current = true;
|
|
874
|
+
rejoin(value);
|
|
875
|
+
} else setIsFading(true);
|
|
876
|
+
};
|
|
877
|
+
const check = () => {
|
|
878
|
+
const outward = x.getVelocity() * ux + y.getVelocity() * uy;
|
|
879
|
+
if (isClear(x.get(), y.get(), extents, [ux, uy]) && outward <= CAUGHT_SPEED) leaveSight();
|
|
880
|
+
};
|
|
881
|
+
stopWatchingRef.current?.();
|
|
882
|
+
const stopX = x.on("change", check);
|
|
883
|
+
const stopY = y.on("change", check);
|
|
884
|
+
stopWatchingRef.current = () => {
|
|
885
|
+
stopX();
|
|
886
|
+
stopY();
|
|
887
|
+
};
|
|
888
|
+
const isMainX = Math.abs(targetX - x.get()) >= Math.abs(targetY - y.get());
|
|
889
|
+
animate(x, targetX, {
|
|
890
|
+
...EXIT_SPRING,
|
|
891
|
+
velocity: vx,
|
|
892
|
+
onComplete: isMainX ? leaveSight : void 0
|
|
893
|
+
});
|
|
894
|
+
animate(y, targetY, {
|
|
895
|
+
...EXIT_SPRING,
|
|
896
|
+
velocity: vy,
|
|
897
|
+
onComplete: isMainX ? void 0 : leaveSight
|
|
898
|
+
});
|
|
899
|
+
}, [
|
|
900
|
+
deck.lift,
|
|
901
|
+
exitExtents,
|
|
902
|
+
isMovedBack,
|
|
903
|
+
prefersReducedMotion,
|
|
904
|
+
rejoin,
|
|
905
|
+
settleFace,
|
|
906
|
+
value,
|
|
907
|
+
x,
|
|
908
|
+
y
|
|
909
|
+
]);
|
|
910
|
+
const swipe = (to, cause, velocity) => {
|
|
911
|
+
if (!send({
|
|
912
|
+
value,
|
|
913
|
+
direction: to
|
|
914
|
+
}, cause)) return false;
|
|
915
|
+
fling(to, velocity);
|
|
916
|
+
return true;
|
|
917
|
+
};
|
|
918
|
+
const swipeRef = React.useRef(swipe);
|
|
919
|
+
React.useEffect(() => {
|
|
920
|
+
swipeRef.current = swipe;
|
|
921
|
+
});
|
|
922
|
+
React.useLayoutEffect(() => {
|
|
923
|
+
const element = elementRef.current;
|
|
924
|
+
if (!element) return;
|
|
925
|
+
return register(value, {
|
|
926
|
+
element,
|
|
927
|
+
swipe: (to, cause) => {
|
|
928
|
+
sizeRef.current = sizeOf(element);
|
|
929
|
+
swipeRef.current(to, cause);
|
|
930
|
+
}
|
|
931
|
+
});
|
|
932
|
+
}, [register, value]);
|
|
933
|
+
React.useEffect(() => {
|
|
934
|
+
const element = elementRef.current;
|
|
935
|
+
const to = leftTowardRef.current;
|
|
936
|
+
if (!isFading || !element || !to) return;
|
|
937
|
+
let isCancelled = false;
|
|
938
|
+
const frame = requestAnimationFrame(() => {
|
|
939
|
+
transitionsEnd(element).then(() => {
|
|
940
|
+
if (isCancelled) return;
|
|
941
|
+
x.stop();
|
|
942
|
+
y.stop();
|
|
943
|
+
complete({
|
|
944
|
+
value,
|
|
945
|
+
direction: to
|
|
946
|
+
});
|
|
947
|
+
});
|
|
948
|
+
});
|
|
949
|
+
return () => {
|
|
950
|
+
isCancelled = true;
|
|
951
|
+
cancelAnimationFrame(frame);
|
|
952
|
+
};
|
|
953
|
+
}, [
|
|
954
|
+
complete,
|
|
955
|
+
isFading,
|
|
956
|
+
value,
|
|
957
|
+
x,
|
|
958
|
+
y
|
|
959
|
+
]);
|
|
960
|
+
const wasLeavingRef = React.useRef(false);
|
|
961
|
+
const isPlacedRef = React.useRef(false);
|
|
962
|
+
const springPlace = React.useCallback((target, spring) => {
|
|
963
|
+
const shown = Math.min(target, deck.visible);
|
|
964
|
+
animate(place, shown, {
|
|
965
|
+
...spring,
|
|
966
|
+
onComplete: shown === target ? void 0 : () => place.jump(target)
|
|
967
|
+
});
|
|
968
|
+
}, [deck.visible, place]);
|
|
969
|
+
const enterAtRef = React.useRef(null);
|
|
970
|
+
const isReenteringRef = React.useRef(false);
|
|
971
|
+
React.useEffect(() => {
|
|
972
|
+
const target = isLeaving ? 0 : position;
|
|
973
|
+
if (target === void 0) return;
|
|
974
|
+
if (wasLeavingRef.current && !isLeaving) {
|
|
975
|
+
stopWatchingRef.current?.();
|
|
976
|
+
stopWatchingRef.current = null;
|
|
977
|
+
exitRef.current = null;
|
|
978
|
+
if (isTuckingRef.current) {
|
|
979
|
+
isTuckingRef.current = false;
|
|
980
|
+
animate(x, 0, TUCK_SPRING);
|
|
981
|
+
animate(y, 0, TUCK_SPRING);
|
|
982
|
+
springPlace(target, TUCK_SPRING);
|
|
983
|
+
const to = leftTowardRef.current;
|
|
984
|
+
if (to) complete({
|
|
985
|
+
value,
|
|
986
|
+
direction: to
|
|
987
|
+
});
|
|
988
|
+
} else {
|
|
989
|
+
x.jump(0);
|
|
990
|
+
y.jump(0);
|
|
991
|
+
place.jump(target);
|
|
992
|
+
setIsFading(false);
|
|
993
|
+
enterAtRef.current = null;
|
|
994
|
+
isReenteringRef.current = true;
|
|
995
|
+
setIsStarting(true);
|
|
996
|
+
}
|
|
997
|
+
} else if (prefersReducedMotion || !isPlacedRef.current || place.get() > deck.visible && target > deck.visible) place.jump(target);
|
|
998
|
+
else springPlace(target, PLACE_SPRING);
|
|
999
|
+
isPlacedRef.current = true;
|
|
1000
|
+
wasLeavingRef.current = isLeaving;
|
|
1001
|
+
}, [
|
|
1002
|
+
complete,
|
|
1003
|
+
deck.visible,
|
|
1004
|
+
isLeaving,
|
|
1005
|
+
place,
|
|
1006
|
+
position,
|
|
1007
|
+
prefersReducedMotion,
|
|
1008
|
+
springPlace,
|
|
1009
|
+
value,
|
|
1010
|
+
x,
|
|
1011
|
+
y
|
|
1012
|
+
]);
|
|
1013
|
+
React.useLayoutEffect(() => {
|
|
1014
|
+
if (wasHydratedRef.current && elementRef.current?.matches(`${ITEM_SELECTOR} ~ *`)) setIsStarting(true);
|
|
1015
|
+
}, []);
|
|
1016
|
+
React.useEffect(() => {
|
|
1017
|
+
if (!isStarting || position === void 0) return;
|
|
1018
|
+
if (enterAtRef.current === null) {
|
|
1019
|
+
const isBehind = !prefersReducedMotion && position > 0 && position < deck.visible;
|
|
1020
|
+
const delay = isBehind && !isReenteringRef.current ? ENTER_LEAD + (position - 1) * ENTER_STAGGER : 0;
|
|
1021
|
+
isReenteringRef.current = false;
|
|
1022
|
+
enterAtRef.current = performance.now() + delay * 1e3;
|
|
1023
|
+
if (isBehind) {
|
|
1024
|
+
enterScale.jump(ENTER_SCALE);
|
|
1025
|
+
animate(enterScale, 1, {
|
|
1026
|
+
...PLACE_SPRING,
|
|
1027
|
+
delay
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
} else if (position === 0) animate(enterScale, 1, PLACE_SPRING);
|
|
1031
|
+
const wait = position === 0 ? 0 : Math.max(0, enterAtRef.current - performance.now());
|
|
1032
|
+
let frame = 0;
|
|
1033
|
+
const timer = window.setTimeout(() => {
|
|
1034
|
+
frame = requestAnimationFrame(() => setIsStarting(false));
|
|
1035
|
+
}, wait);
|
|
1036
|
+
return () => {
|
|
1037
|
+
window.clearTimeout(timer);
|
|
1038
|
+
cancelAnimationFrame(frame);
|
|
1039
|
+
};
|
|
1040
|
+
}, [
|
|
1041
|
+
deck.visible,
|
|
1042
|
+
enterScale,
|
|
1043
|
+
isStarting,
|
|
1044
|
+
position,
|
|
1045
|
+
prefersReducedMotion
|
|
1046
|
+
]);
|
|
1047
|
+
React.useEffect(() => {
|
|
1048
|
+
const element = elementRef.current;
|
|
1049
|
+
if (!isFront || isStarting || !element) return;
|
|
1050
|
+
let isCancelled = false;
|
|
1051
|
+
const report = () => {
|
|
1052
|
+
transitionsEnd(element).then(() => {
|
|
1053
|
+
if (!isCancelled) settle(value);
|
|
1054
|
+
});
|
|
1055
|
+
};
|
|
1056
|
+
const stopListening = place.on("animationComplete", report);
|
|
1057
|
+
if (!place.isAnimating()) report();
|
|
1058
|
+
return () => {
|
|
1059
|
+
isCancelled = true;
|
|
1060
|
+
stopListening();
|
|
1061
|
+
};
|
|
1062
|
+
}, [
|
|
1063
|
+
isFront,
|
|
1064
|
+
isStarting,
|
|
1065
|
+
place,
|
|
1066
|
+
settle,
|
|
1067
|
+
value
|
|
1068
|
+
]);
|
|
1069
|
+
React.useLayoutEffect(() => {
|
|
1070
|
+
if (isInert && elementRef.current?.contains(document.activeElement)) focusDeck();
|
|
1071
|
+
}, [focusDeck, isInert]);
|
|
1072
|
+
React.useEffect(() => {
|
|
1073
|
+
if (isFront && !deck.disabled || !dragRef.current) return;
|
|
1074
|
+
endDragRef.current?.();
|
|
1075
|
+
setIsSwiping(false);
|
|
1076
|
+
settleFace();
|
|
1077
|
+
snapBack();
|
|
1078
|
+
if (!isFront) releaseLift(deck.lift, prefersReducedMotion);
|
|
1079
|
+
}, [
|
|
1080
|
+
deck.disabled,
|
|
1081
|
+
deck.lift,
|
|
1082
|
+
isFront,
|
|
1083
|
+
prefersReducedMotion,
|
|
1084
|
+
settleFace,
|
|
1085
|
+
snapBack
|
|
1086
|
+
]);
|
|
1087
|
+
const release = (drag, event) => {
|
|
1088
|
+
settleFace();
|
|
1089
|
+
if (!drag.moved) {
|
|
1090
|
+
snapBack();
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
setIsSwiping(false);
|
|
1094
|
+
draggedRef.current = true;
|
|
1095
|
+
window.setTimeout(() => {
|
|
1096
|
+
draggedRef.current = false;
|
|
1097
|
+
});
|
|
1098
|
+
if (event.type === "pointerup") drag.samples.push({
|
|
1099
|
+
x: event.clientX,
|
|
1100
|
+
y: event.clientY,
|
|
1101
|
+
time: event.timeStamp
|
|
1102
|
+
});
|
|
1103
|
+
const [pointerVx, pointerVy] = releaseVelocity(drag.samples, event.timeStamp);
|
|
1104
|
+
const velocity = [isOpenSide(directionsRef.current, true, x.get()) ? pointerVx : 0, isOpenSide(directionsRef.current, false, y.get()) ? pointerVy : 0];
|
|
1105
|
+
if (event.type === "pointerup" && !disabledRef.current) {
|
|
1106
|
+
const ahead = lean(directionsRef.current, x.get() + velocity[0] * THROW_TIME, y.get() + velocity[1] * THROW_TIME, sizeRef.current);
|
|
1107
|
+
if (ahead.direction && ahead.share >= 1 && swipe(ahead.direction, {
|
|
1108
|
+
reason: "swipe",
|
|
1109
|
+
event
|
|
1110
|
+
}, velocity)) return;
|
|
1111
|
+
}
|
|
1112
|
+
snapBack(velocity);
|
|
1113
|
+
};
|
|
1114
|
+
const handlePointerDown = (event) => {
|
|
1115
|
+
onPointerDown?.(event);
|
|
1116
|
+
if (event.defaultPrevented || event.button !== 0 || !isFront || deck.disabled || dragRef.current) return;
|
|
1117
|
+
const element = event.currentTarget;
|
|
1118
|
+
const field = event.target instanceof Element ? event.target.closest(FIELD_SELECTOR) : null;
|
|
1119
|
+
if (field && element.contains(field)) return;
|
|
1120
|
+
deck.lift.stop();
|
|
1121
|
+
x.stop();
|
|
1122
|
+
y.stop();
|
|
1123
|
+
const size = sizeOf(element);
|
|
1124
|
+
sizeRef.current = size;
|
|
1125
|
+
if (isPoseAtRest(press, spin, tiltX, tiltY)) grabRef.current = grabPoint(element, event.clientX, event.clientY, size);
|
|
1126
|
+
if (!prefersReducedMotion) animate(press, PRESS_SCALE, PRESS_SPRING);
|
|
1127
|
+
const openAtPress = directionsRef.current;
|
|
1128
|
+
const drag = {
|
|
1129
|
+
pointerId: event.pointerId,
|
|
1130
|
+
startX: event.clientX,
|
|
1131
|
+
startY: event.clientY,
|
|
1132
|
+
originX: isOpenSide(openAtPress, true, x.get()) ? x.get() : unresist(x.get(), size.width),
|
|
1133
|
+
originY: isOpenSide(openAtPress, false, y.get()) ? y.get() : unresist(y.get(), size.height),
|
|
1134
|
+
moved: false,
|
|
1135
|
+
samples: [{
|
|
1136
|
+
x: event.clientX,
|
|
1137
|
+
y: event.clientY,
|
|
1138
|
+
time: event.timeStamp
|
|
1139
|
+
}],
|
|
1140
|
+
idle: void 0
|
|
1141
|
+
};
|
|
1142
|
+
dragRef.current = drag;
|
|
1143
|
+
const handleMove = (moveEvent) => {
|
|
1144
|
+
if (moveEvent.pointerId !== drag.pointerId) return;
|
|
1145
|
+
if (moveEvent.buttons === 0) {
|
|
1146
|
+
endDrag();
|
|
1147
|
+
release(drag, moveEvent);
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
const dx = moveEvent.clientX - drag.startX;
|
|
1151
|
+
const dy = moveEvent.clientY - drag.startY;
|
|
1152
|
+
if (!drag.moved) {
|
|
1153
|
+
if (Math.hypot(dx, dy) < DRAG_SLOP) return;
|
|
1154
|
+
drag.moved = true;
|
|
1155
|
+
try {
|
|
1156
|
+
element.setPointerCapture(drag.pointerId);
|
|
1157
|
+
} catch {}
|
|
1158
|
+
setIsSwiping(true);
|
|
1159
|
+
}
|
|
1160
|
+
const allowed = directionsRef.current;
|
|
1161
|
+
const { width, height } = sizeRef.current;
|
|
1162
|
+
x.set(follow(allowed, true, drag.originX + dx, width));
|
|
1163
|
+
y.set(follow(allowed, false, drag.originY + dy, height));
|
|
1164
|
+
drag.samples.push({
|
|
1165
|
+
x: moveEvent.clientX,
|
|
1166
|
+
y: moveEvent.clientY,
|
|
1167
|
+
time: moveEvent.timeStamp
|
|
1168
|
+
});
|
|
1169
|
+
if (drag.samples.length > 20) drag.samples.shift();
|
|
1170
|
+
setDirection(lean(allowed, x.get(), y.get(), sizeRef.current).direction);
|
|
1171
|
+
const [pointerVx, pointerVy] = releaseVelocity(drag.samples, moveEvent.timeStamp);
|
|
1172
|
+
tilt(isOpenSide(allowed, true, x.get()) ? pointerVx : 0, isOpenSide(allowed, false, y.get()) ? pointerVy : 0);
|
|
1173
|
+
window.clearTimeout(drag.idle);
|
|
1174
|
+
drag.idle = window.setTimeout(() => {
|
|
1175
|
+
if (dragRef.current === drag) tilt(0, 0);
|
|
1176
|
+
}, LEAN_IDLE);
|
|
1177
|
+
};
|
|
1178
|
+
const handleEnd = (endEvent) => {
|
|
1179
|
+
if (endEvent.pointerId !== drag.pointerId) return;
|
|
1180
|
+
endDrag();
|
|
1181
|
+
release(drag, endEvent);
|
|
1182
|
+
};
|
|
1183
|
+
const endDrag = () => {
|
|
1184
|
+
window.clearTimeout(drag.idle);
|
|
1185
|
+
window.removeEventListener("pointermove", handleMove);
|
|
1186
|
+
window.removeEventListener("pointerup", handleEnd);
|
|
1187
|
+
window.removeEventListener("pointercancel", handleEnd);
|
|
1188
|
+
dragRef.current = null;
|
|
1189
|
+
endDragRef.current = null;
|
|
1190
|
+
};
|
|
1191
|
+
window.addEventListener("pointermove", handleMove);
|
|
1192
|
+
window.addEventListener("pointerup", handleEnd);
|
|
1193
|
+
window.addEventListener("pointercancel", handleEnd);
|
|
1194
|
+
endDragRef.current = endDrag;
|
|
1195
|
+
};
|
|
1196
|
+
const handleClickCapture = (event) => {
|
|
1197
|
+
if (draggedRef.current) {
|
|
1198
|
+
draggedRef.current = false;
|
|
1199
|
+
event.preventDefault();
|
|
1200
|
+
event.stopPropagation();
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
onClickCapture?.(event);
|
|
1204
|
+
};
|
|
1205
|
+
const zIndex = isLeaving ? deck.count + deck.leavingOrder.length - deck.leavingOrder.indexOf(value) : position === void 0 ? void 0 : deck.count - position;
|
|
1206
|
+
return /* @__PURE__ */ jsx("div", {
|
|
1207
|
+
ref: mergedRef,
|
|
1208
|
+
"data-slot": "swipe-deck-item",
|
|
1209
|
+
role: "group",
|
|
1210
|
+
"data-front": isFront ? "" : void 0,
|
|
1211
|
+
"data-swiping": isSwiping ? "" : void 0,
|
|
1212
|
+
"data-leaving": isLeaving ? "" : void 0,
|
|
1213
|
+
"data-starting-style": isStarting ? "" : void 0,
|
|
1214
|
+
"data-ending-style": isFading ? "" : void 0,
|
|
1215
|
+
"data-swipe-direction": isSwiping || isLeaving ? direction : void 0,
|
|
1216
|
+
inert: isInert,
|
|
1217
|
+
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))]", "[transform:var(--swipe-deck-pose,none)]", "[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:rtl:origin-[10%_90%]", "group-data-[layout=fan]/swipe-deck:[--swipe-deck-layout-rotate:calc(var(--swipe-deck-layout-slot)_*_var(--swipe-deck-spread,0.5)_*_8deg_*_var(--swipe-deck-side,1))]", "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_*_var(--swipe-deck-side,1))]", "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))]", "group-focus-visible/swipe-deck:data-front: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", "[-webkit-tap-highlight-color:transparent] [-webkit-touch-callout:none]", !deck.isReady && "[&:is([data-slot=swipe-deck-item]~*)]:invisible [&:is([data-slot=swipe-deck-item]~*)]:opacity-0", touchAction(directions), className),
|
|
1218
|
+
style: {
|
|
1219
|
+
zIndex,
|
|
1220
|
+
"--swipe-deck-index": isLeaving ? 0 : position,
|
|
1221
|
+
"--swipe-deck-scatter-x": scatter[0],
|
|
1222
|
+
"--swipe-deck-scatter-y": scatter[1],
|
|
1223
|
+
"--swipe-deck-scatter-rotate": scatter[2],
|
|
1224
|
+
...style
|
|
1225
|
+
},
|
|
1226
|
+
onPointerDown: handlePointerDown,
|
|
1227
|
+
onClickCapture: handleClickCapture,
|
|
1228
|
+
onDragStart: (event) => {
|
|
1229
|
+
onDragStart?.(event);
|
|
1230
|
+
event.preventDefault();
|
|
1231
|
+
},
|
|
1232
|
+
...props
|
|
1233
|
+
});
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* A rounded rectangle, inset half a pixel so a 1px line sits inside the box as
|
|
1237
|
+
* a border would, with corners that are superellipses of `exponent` (2 is a
|
|
1238
|
+
* circular arc, 4 a squircle). Returns the path and its length.
|
|
1239
|
+
*/
|
|
1240
|
+
function outlinePath(width, height, radius, exponent) {
|
|
1241
|
+
const inset = .5;
|
|
1242
|
+
const r = Math.max(0, Math.min(radius, width / 2, height / 2) - inset);
|
|
1243
|
+
const [left, top] = [inset, inset];
|
|
1244
|
+
const [right, bottom] = [width - inset, height - inset];
|
|
1245
|
+
const corners = [
|
|
1246
|
+
[
|
|
1247
|
+
right - r,
|
|
1248
|
+
top + r,
|
|
1249
|
+
-Math.PI / 2
|
|
1250
|
+
],
|
|
1251
|
+
[
|
|
1252
|
+
right - r,
|
|
1253
|
+
bottom - r,
|
|
1254
|
+
0
|
|
1255
|
+
],
|
|
1256
|
+
[
|
|
1257
|
+
left + r,
|
|
1258
|
+
bottom - r,
|
|
1259
|
+
Math.PI / 2
|
|
1260
|
+
],
|
|
1261
|
+
[
|
|
1262
|
+
left + r,
|
|
1263
|
+
top + r,
|
|
1264
|
+
Math.PI
|
|
1265
|
+
]
|
|
1266
|
+
];
|
|
1267
|
+
const points = [];
|
|
1268
|
+
for (const [cx, cy, start] of corners) for (let step = 0; step <= OUTLINE_CORNER_STEPS; step++) {
|
|
1269
|
+
const angle = start + step / OUTLINE_CORNER_STEPS * (Math.PI / 2);
|
|
1270
|
+
const [cos, sin] = [Math.cos(angle), Math.sin(angle)];
|
|
1271
|
+
points.push([cx + r * Math.sign(cos) * Math.abs(cos) ** (2 / exponent), cy + r * Math.sign(sin) * Math.abs(sin) ** (2 / exponent)]);
|
|
1272
|
+
}
|
|
1273
|
+
const length = points.reduce((total, [x, y], index) => {
|
|
1274
|
+
const [px, py] = points[(index + points.length - 1) % points.length];
|
|
1275
|
+
return total + Math.hypot(x - px, y - py);
|
|
1276
|
+
}, 0);
|
|
1277
|
+
return {
|
|
1278
|
+
d: `M ${points.map(([x, y]) => `${x.toFixed(2)} ${y.toFixed(2)}`).join(" L ")} Z`,
|
|
1279
|
+
length
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
/**
|
|
1283
|
+
* A 1px dashed outline with long dashes on the box it sits in. CSS ties a
|
|
1284
|
+
* dashed border's dash length to its width, so this one is drawn, on the same
|
|
1285
|
+
* corners as the cards: squircles where the browser draws them, arcs elsewhere.
|
|
1286
|
+
*/
|
|
1287
|
+
function DashedOutline() {
|
|
1288
|
+
const svgRef = React.useRef(null);
|
|
1289
|
+
const [outline, setOutline] = React.useState({
|
|
1290
|
+
d: "",
|
|
1291
|
+
length: 0
|
|
1292
|
+
});
|
|
1293
|
+
React.useLayoutEffect(() => {
|
|
1294
|
+
const box = svgRef.current?.parentElement;
|
|
1295
|
+
if (!box) return;
|
|
1296
|
+
const exponent = CSS.supports("corner-shape", "squircle") ? 4 : 2;
|
|
1297
|
+
const draw = () => setOutline(outlinePath(box.offsetWidth, box.offsetHeight, parseFloat(getComputedStyle(box).borderTopLeftRadius), exponent));
|
|
1298
|
+
draw();
|
|
1299
|
+
const observer = new ResizeObserver(draw);
|
|
1300
|
+
observer.observe(box);
|
|
1301
|
+
return () => observer.disconnect();
|
|
1302
|
+
}, []);
|
|
1303
|
+
const period = 14;
|
|
1304
|
+
const pathLength = Math.max(1, Math.round(outline.length / period)) * period;
|
|
1305
|
+
return /* @__PURE__ */ jsx("svg", {
|
|
1306
|
+
"data-slot": "swipe-deck-empty-outline",
|
|
1307
|
+
ref: svgRef,
|
|
1308
|
+
className: "pointer-events-none absolute inset-0 size-full overflow-visible",
|
|
1309
|
+
"aria-hidden": true,
|
|
1310
|
+
children: /* @__PURE__ */ jsx("path", {
|
|
1311
|
+
"data-slot": "swipe-deck-empty-outline-path",
|
|
1312
|
+
d: outline.d,
|
|
1313
|
+
pathLength,
|
|
1314
|
+
strokeDasharray: `${OUTLINE_DASH} ${OUTLINE_GAP}`,
|
|
1315
|
+
className: "stroke-contour fill-none"
|
|
1316
|
+
})
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
/**
|
|
1320
|
+
* What the deck shows once its cards run out: a message, an action (start
|
|
1321
|
+
* over, carry on), anything you put in it. It sits where the cards sit, at
|
|
1322
|
+
* their size, inside a dashed outline of their shape (`data-slot`
|
|
1323
|
+
* `swipe-deck-empty-outline`, to restyle or hide). It is drawn as soon as one
|
|
1324
|
+
* card is left and has come to rest, under that card, so dragging it shows
|
|
1325
|
+
* what comes next; it can be reached (Tab, clicks) and screen readers hear it
|
|
1326
|
+
* (its `aria-label`, or its text) once that last card has gone too.
|
|
1327
|
+
*/
|
|
1328
|
+
function SwipeDeckEmpty({ className, children, ref, ...props }) {
|
|
1329
|
+
const deck = useSwipeDeckContext("SwipeDeckEmpty");
|
|
1330
|
+
const { emptyRef, focusDeck, isEmpty } = deck;
|
|
1331
|
+
const isDrawn = deck.isReady && (deck.positions.size === 0 || deck.positions.size === 1 && deck.settledFront === deck.front);
|
|
1332
|
+
const elementRef = React.useRef(null);
|
|
1333
|
+
const mergedRef = React.useMemo(() => mergeRefs(elementRef, emptyRef, ref), [emptyRef, ref]);
|
|
1334
|
+
React.useLayoutEffect(() => {
|
|
1335
|
+
if (!isEmpty && elementRef.current?.contains(document.activeElement)) focusDeck();
|
|
1336
|
+
}, [focusDeck, isEmpty]);
|
|
1337
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1338
|
+
ref: mergedRef,
|
|
1339
|
+
"data-slot": "swipe-deck-empty",
|
|
1340
|
+
inert: !isEmpty,
|
|
1341
|
+
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),
|
|
1342
|
+
...props,
|
|
1343
|
+
children: [/* @__PURE__ */ jsx(DashedOutline, {}), children]
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
/**
|
|
1347
|
+
* A button that sends the front card off in `direction`: the way to swipe
|
|
1348
|
+
* without dragging. Disabled (and still focusable) when the deck is disabled,
|
|
1349
|
+
* has no card in front, or can't go that way.
|
|
1350
|
+
*/
|
|
1351
|
+
function SwipeDeckAction({ direction, disabled, onClick, onKeyDown, ...props }) {
|
|
1352
|
+
const deck = useSwipeDeckContext("SwipeDeckAction");
|
|
1353
|
+
const unavailable = deck.disabled || deck.isReady && deck.front === void 0 || !deck.directions.includes(direction);
|
|
1354
|
+
return /* @__PURE__ */ jsx(Button, {
|
|
1355
|
+
"data-slot": "swipe-deck-action",
|
|
1356
|
+
"data-swipe-direction": direction,
|
|
1357
|
+
disabled: disabled || unavailable,
|
|
1358
|
+
focusableWhenDisabled: true,
|
|
1359
|
+
onClick: (event) => {
|
|
1360
|
+
onClick?.(event);
|
|
1361
|
+
if (!event.defaultPrevented) deck.swipeFront(direction, {
|
|
1362
|
+
reason: "action-press",
|
|
1363
|
+
event: event.nativeEvent
|
|
1364
|
+
});
|
|
1365
|
+
},
|
|
1366
|
+
onKeyDown: (event) => {
|
|
1367
|
+
onKeyDown?.(event);
|
|
1368
|
+
if (event.repeat && event.key === "Enter") event.preventDefault();
|
|
1369
|
+
},
|
|
1370
|
+
...props
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1373
|
+
//#endregion
|
|
1374
|
+
export { SwipeDeck, SwipeDeckAction, SwipeDeckEmpty, SwipeDeckItem };
|