@waveso/ui 0.17.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.
@@ -13,8 +13,21 @@ const KEY_DIRECTIONS = {
13
13
  ArrowUp: "up",
14
14
  ArrowDown: "down"
15
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
+ };
16
23
  /** How far (px) a press moves before it becomes a drag, so taps stay clicks. */
17
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;
18
31
  /** A card leaves when it comes to rest past this share of its width (or height). */
19
32
  const LEAVE_SHARE = .25;
20
33
  /** Only pointer samples this recent (ms) set the release speed. */
@@ -24,7 +37,7 @@ const VELOCITY_WINDOW = 100;
24
37
  * travel to where the card is, to decide whether it leaves and which way.
25
38
  */
26
39
  const THROW_TIME = .25;
27
- /** A leaving card's exit point is this far (px) past the stack's edge, shadow and all. */
40
+ /** A leaving card's exit point is this far (px) past the pile's edge, shadow and all. */
28
41
  const CLEARANCE = 16;
29
42
  const RETURN_SPRING = {
30
43
  type: "spring",
@@ -41,7 +54,7 @@ const EXIT_SPRING = {
41
54
  stiffness: 650,
42
55
  damping: 42
43
56
  };
44
- /** A leaving card fades once clear of the stack and slowed below this (px/s). */
57
+ /** A leaving card fades once clear of the pile and slowed below this (px/s). */
45
58
  const CAUGHT_SPEED = 200;
46
59
  /** Moves a card from one place in the pile to the next. */
47
60
  const PLACE_SPRING = {
@@ -49,6 +62,12 @@ const PLACE_SPRING = {
49
62
  stiffness: 260,
50
63
  damping: 24
51
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
+ };
52
71
  /** How far (in places) the cards behind lift toward the front as it's dragged out. */
53
72
  const LIFT = .4;
54
73
  /** On mount, the cards behind wait this long (s) for the front card to fade in. */
@@ -57,6 +76,36 @@ const ENTER_LEAD = .15;
57
76
  const ENTER_STAGGER = .06;
58
77
  /** And grows in from this size, the zoom recipes' md step (`--scale-md`). */
59
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
+ };
60
109
  /** Dash and gap (px) of `SwipeDeckEmpty`'s outline. */
61
110
  const OUTLINE_DASH = 8;
62
111
  const OUTLINE_GAP = 6;
@@ -64,16 +113,41 @@ const OUTLINE_GAP = 6;
64
113
  const OUTLINE_CORNER_STEPS = 24;
65
114
  /** Presses on these inside a card stay theirs: a slider's thumb, a text box. */
66
115
  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. */
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
+ */
68
121
  function touchAction(directions) {
69
122
  const horizontal = directions.some(isHorizontal);
70
123
  const vertical = directions.some((direction) => !isHorizontal(direction));
71
- if (horizontal && vertical) return "touch-none";
72
- return horizontal ? "touch-pan-y" : "touch-pan-x";
124
+ if (horizontal && vertical) return "touch-pinch-zoom";
125
+ return horizontal ? "touch-pan-y touch-pinch-zoom" : "touch-pan-x touch-pinch-zoom";
73
126
  }
74
127
  function isHorizontal(direction) {
75
128
  return direction === "left" || direction === "right";
76
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
+ }
77
151
  /** Lets the cards behind settle back from a lift. */
78
152
  function releaseLift(lift, isInstant) {
79
153
  if (isInstant) lift.jump(0);
@@ -92,6 +166,29 @@ function transitionsEnd(element) {
92
166
  function within(value, min, max, fallback) {
93
167
  return Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
94
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
+ }
95
192
  /**
96
193
  * Three steady numbers in [-0.5, 0.5] from a card's value: where it lies in a
97
194
  * `pile`, the same on every render and server.
@@ -118,30 +215,51 @@ function sizeOf(element) {
118
215
  height: element.offsetHeight
119
216
  };
120
217
  }
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;
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;
124
244
  }
125
245
  /**
126
246
  * 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.
247
+ * the pile's edge: the nearer of the side edges and the top or bottom edge.
128
248
  */
129
- function exitPoint(x, y, [dx, dy], size) {
249
+ function exitPoint(x, y, [dx, dy], extents) {
130
250
  const length = Math.hypot(dx, dy);
131
251
  const ux = dx / length;
132
252
  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];
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];
134
256
  const step = Math.max(0, Math.min(...steps));
135
257
  return [x + step * ux, y + step * uy];
136
258
  }
137
259
  /** How far a movement goes toward a direction; negative when it goes away. */
138
260
  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
- }
261
+ const [dx, dy] = DIRECTION_VECTORS[direction];
262
+ return x * dx + y * dy;
145
263
  }
146
264
  /**
147
265
  * The allowed direction a movement leans furthest into, and how far it goes
@@ -171,6 +289,9 @@ function releaseVelocity(samples, time) {
171
289
  const elapsed = (last.time - first.time) / 1e3;
172
290
  return [(last.x - first.x) / elapsed, (last.y - first.y) / elapsed];
173
291
  }
292
+ function clampUnit(value) {
293
+ return Math.min(1, Math.max(-1, value));
294
+ }
174
295
  const SwipeDeckContext = React.createContext(null);
175
296
  function useSwipeDeckContext(part) {
176
297
  const context = React.useContext(SwipeDeckContext);
@@ -180,25 +301,43 @@ function useSwipeDeckContext(part) {
180
301
  /**
181
302
  * A pile of cards people swipe away: drag or throw the front card, press an
182
303
  * 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
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
185
307
  * 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.
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.
188
321
  *
189
322
  * 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.
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.
193
328
  *
194
329
  * Give the deck an `aria-label`. Screen readers hear the next card after each
195
330
  * swipe: its `aria-label` when it has one, otherwise all of its text.
196
331
  */
197
- function SwipeDeck({ onSwipe, directions = DEFAULT_DIRECTIONS, disabled = false, layout = "stack", spread = .5, depth = .5, visible = 4, className, style, onKeyDown, children, ref, ...props }) {
332
+ function SwipeDeck({ onSwipe, onSwipeComplete, directions = DEFAULT_DIRECTIONS, disabled = false, layout = "fan", spread = .5, depth = .5, visible = 4, className, style, onKeyDown, children, ref, ...props }) {
198
333
  const rootRef = React.useRef(null);
199
334
  const mergedRef = React.useMemo(() => mergeRefs(rootRef, ref), [ref]);
200
335
  const itemsRef = React.useRef(/* @__PURE__ */ new Map());
201
- const finishedRef = 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());
202
341
  const statusRef = React.useRef(null);
203
342
  const emptyRef = React.useRef(null);
204
343
  const sizeObserverRef = React.useRef(null);
@@ -206,32 +345,60 @@ function SwipeDeck({ onSwipe, directions = DEFAULT_DIRECTIONS, disabled = false,
206
345
  const [order, setOrder] = React.useState([]);
207
346
  const [leavingList, setLeavingList] = React.useState([]);
208
347
  const [isReady, setIsReady] = React.useState(false);
348
+ const visibleCount = Math.round(within(visible, 1, Infinity, 4));
209
349
  const onSwipeRef = React.useRef(onSwipe);
210
- React.useEffect(() => {
350
+ const onSwipeCompleteRef = React.useRef(onSwipeComplete);
351
+ React.useLayoutEffect(() => {
211
352
  onSwipeRef.current = onSwipe;
353
+ onSwipeCompleteRef.current = onSwipeComplete;
212
354
  });
213
- const readOrder = React.useCallback(() => [...itemsRef.current].sort(([, a], [, b]) => a.element.compareDocumentPosition(b.element) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1).map(([value]) => value), []);
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
+ }, []);
214
377
  const syncOrder = React.useCallback(() => {
215
- const items = itemsRef.current;
378
+ if (!rootRef.current) return;
216
379
  const next = readOrder();
380
+ noteMovedBack(next);
217
381
  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
- }
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);
229
387
  }
230
388
  setLeavingList((previous) => {
231
- const kept = previous.filter((value) => items.has(value) && !answered.has(value));
389
+ const kept = previous.filter((value) => itemsRef.current.has(value));
232
390
  return kept.length === previous.length ? previous : kept;
233
391
  });
234
- }, [readOrder]);
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]);
235
402
  React.useLayoutEffect(() => {
236
403
  syncOrder();
237
404
  });
@@ -259,50 +426,143 @@ function SwipeDeck({ onSwipe, directions = DEFAULT_DIRECTIONS, disabled = false,
259
426
  }, []);
260
427
  React.useEffect(() => () => sizeObserverRef.current?.disconnect(), []);
261
428
  const register = React.useCallback((value, record) => {
262
- itemsRef.current.set(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);
263
434
  sizeObserver().observe(record.element);
264
- syncOrder();
435
+ syncSoon();
265
436
  return () => {
266
- if (itemsRef.current.get(value) === record) {
267
- itemsRef.current.delete(value);
437
+ valuesRef.current.delete(record.element);
438
+ if (items.get(value) === record) {
439
+ items.delete(value);
268
440
  sizeObserverRef.current?.unobserve(record.element);
269
- syncOrder();
270
441
  }
442
+ syncSoon();
271
443
  };
272
- }, [sizeObserver, syncOrder]);
444
+ }, [sizeObserver, syncSoon]);
273
445
  const focusDeck = React.useCallback(() => rootRef.current?.focus(), []);
274
446
  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) => {
447
+ const send = React.useCallback((details, cause) => {
448
+ if (sentRef.current.has(details.value)) return false;
282
449
  const current = readOrder();
283
- finishedRef.current.set(details.value, current.slice(current.indexOf(details.value) + 1));
284
- onSwipeRef.current?.(details);
285
- }, [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]);
286
510
  const leaving = React.useMemo(() => new Set(leavingList), [leavingList]);
287
511
  const positions = React.useMemo(() => new Map(order.filter((value) => !leaving.has(value)).map((value, index) => [value, index])), [order, leaving]);
288
512
  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);
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);
292
551
  }, [
293
552
  directions,
294
553
  disabled,
295
- front
554
+ readOrder
296
555
  ]);
297
- const announcedRef = React.useRef(void 0);
556
+ const announcedRef = React.useRef(null);
298
557
  React.useEffect(() => {
558
+ if (!isReady) return;
299
559
  const previous = announcedRef.current;
300
- announcedRef.current = front;
560
+ announcedRef.current = { front };
301
561
  const status = statusRef.current;
302
- if (previous === void 0 || previous === front || !status) return;
562
+ if (!previous || previous.front === front || !status) return;
303
563
  const element = front === void 0 ? emptyRef.current : itemsRef.current.get(front)?.element;
304
564
  status.textContent = element ? element.getAttribute("aria-label") ?? (element.innerText || element.textContent || "") : "";
305
- }, [front]);
565
+ }, [front, isReady]);
306
566
  const handleKeyDown = (event) => {
307
567
  onKeyDown?.(event);
308
568
  if (event.defaultPrevented || event.target !== event.currentTarget) return;
@@ -310,7 +570,11 @@ function SwipeDeck({ onSwipe, directions = DEFAULT_DIRECTIONS, disabled = false,
310
570
  const direction = KEY_DIRECTIONS[event.key];
311
571
  if (!direction || disabled || !directions.includes(direction)) return;
312
572
  event.preventDefault();
313
- swipeFront(direction);
573
+ if (event.repeat) return;
574
+ swipeFront(direction, {
575
+ reason: "keyboard",
576
+ event: event.nativeEvent
577
+ });
314
578
  };
315
579
  const context = React.useMemo(() => ({
316
580
  directions,
@@ -319,11 +583,16 @@ function SwipeDeck({ onSwipe, directions = DEFAULT_DIRECTIONS, disabled = false,
319
583
  front,
320
584
  positions,
321
585
  leaving,
586
+ leavingOrder: leavingList,
322
587
  count: order.length,
588
+ visible: visibleCount,
323
589
  lift,
324
590
  register,
325
- leave,
326
- finish,
591
+ send,
592
+ isMovedBack,
593
+ rejoin,
594
+ complete,
595
+ exitExtents,
327
596
  swipeFront,
328
597
  isEmpty: isReady && front === void 0,
329
598
  emptyRef,
@@ -337,11 +606,16 @@ function SwipeDeck({ onSwipe, directions = DEFAULT_DIRECTIONS, disabled = false,
337
606
  front,
338
607
  positions,
339
608
  leaving,
609
+ leavingList,
340
610
  order.length,
611
+ visibleCount,
341
612
  lift,
342
613
  register,
343
- leave,
344
- finish,
614
+ send,
615
+ isMovedBack,
616
+ rejoin,
617
+ complete,
618
+ exitExtents,
345
619
  swipeFront,
346
620
  focusDeck,
347
621
  settledFront
@@ -357,11 +631,11 @@ function SwipeDeck({ onSwipe, directions = DEFAULT_DIRECTIONS, disabled = false,
357
631
  role: "group",
358
632
  tabIndex: 0,
359
633
  "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),
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),
361
635
  style: {
362
636
  "--swipe-deck-spread": within(spread, 0, 2, .5),
363
637
  "--swipe-deck-depth": within(depth, 0, 1, .5),
364
- "--swipe-deck-visible": Math.round(within(visible, 1, Infinity, 4)),
638
+ "--swipe-deck-visible": visibleCount,
365
639
  ...style
366
640
  },
367
641
  onKeyDown: handleKeyDown,
@@ -376,37 +650,46 @@ function SwipeDeck({ onSwipe, directions = DEFAULT_DIRECTIONS, disabled = false,
376
650
  });
377
651
  }
378
652
  /**
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.
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.
381
655
  *
382
656
  * 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`),
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),
389
663
  * `--swipe-deck-movement-x` / `-y` in px, and `--swipe-deck-progress` from 0
390
664
  * at rest to 1 where letting go makes the card leave.
391
665
  *
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.
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.
395
669
  */
396
670
  function SwipeDeckItem({ value, className, style, ref, onPointerDown, onClickCapture, onDragStart, ...props }) {
397
671
  const deck = useSwipeDeckContext("SwipeDeckItem");
398
- const { directions, register, leave, finish } = deck;
672
+ const { directions, register, send, complete, rejoin, isMovedBack, exitExtents, focusDeck, settle } = deck;
399
673
  const prefersReducedMotion = useReducedMotion();
674
+ const isHydrating = !useIsClientRender();
675
+ const wasHydratedRef = React.useRef(isHydrating);
400
676
  const elementRef = React.useRef(null);
401
677
  const mergedRef = React.useMemo(() => mergeRefs(elementRef, ref), [ref]);
402
678
  const x = useMotionValue(0);
403
679
  const y = useMotionValue(0);
404
680
  const place = useMotionValue(0);
405
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]);
406
687
  const drivesLiftRef = React.useRef(false);
407
688
  const scatter = React.useMemo(() => scatterOf(value), [value]);
408
689
  const leftTowardRef = React.useRef(null);
409
690
  const stopWatchingRef = React.useRef(null);
691
+ const exitRef = React.useRef(null);
692
+ const isTuckingRef = React.useRef(false);
410
693
  const dragRef = React.useRef(null);
411
694
  const endDragRef = React.useRef(null);
412
695
  const sizeRef = React.useRef({
@@ -416,15 +699,16 @@ function SwipeDeckItem({ value, className, style, ref, onPointerDown, onClickCap
416
699
  const draggedRef = React.useRef(false);
417
700
  const [isSwiping, setIsSwiping] = React.useState(false);
418
701
  const [isFading, setIsFading] = React.useState(false);
419
- const [isStarting, setIsStarting] = React.useState(true);
702
+ const [isStarting, setIsStarting] = React.useState(!isHydrating);
420
703
  const [direction, setDirection] = React.useState();
421
704
  const position = deck.positions.get(value);
422
705
  const isLeaving = deck.leaving.has(value);
423
706
  const isFront = deck.front === value;
707
+ const isInert = isLeaving || position !== void 0 && position > 0;
424
708
  const directionsRef = React.useRef(directions);
425
709
  const disabledRef = React.useRef(deck.disabled);
426
710
  const reducedMotionRef = React.useRef(prefersReducedMotion);
427
- React.useEffect(() => {
711
+ React.useLayoutEffect(() => {
428
712
  directionsRef.current = directions;
429
713
  disabledRef.current = deck.disabled;
430
714
  reducedMotionRef.current = prefersReducedMotion;
@@ -440,34 +724,74 @@ function SwipeDeckItem({ value, className, style, ref, onPointerDown, onClickCap
440
724
  element.style.setProperty("--swipe-deck-progress", String(Math.min(share, 1)));
441
725
  if (drivesLiftRef.current) deck.lift.set(Math.min(share, 1) * LIFT);
442
726
  };
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
- });
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
+ }
451
753
  return () => {
452
- stopX();
453
- stopY();
454
- stopPlace();
455
- stopEnterScale();
754
+ for (const stop of stops) stop();
456
755
  if (dragRef.current) releaseLift(deck.lift, reducedMotionRef.current);
457
756
  endDragRef.current?.();
458
757
  stopWatchingRef.current?.();
459
- x.stop();
460
- y.stop();
461
- place.stop();
462
- enterScale.stop();
758
+ for (const motionValue of [
759
+ x,
760
+ y,
761
+ place,
762
+ enterScale,
763
+ press,
764
+ spin,
765
+ tiltX,
766
+ tiltY
767
+ ]) motionValue.stop();
463
768
  };
464
769
  }, [
465
770
  deck.lift,
466
771
  enterScale,
467
772
  place,
773
+ press,
774
+ spin,
775
+ tiltX,
776
+ tiltY,
468
777
  x,
469
778
  y
470
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
+ ]);
471
795
  const snapBack = React.useCallback(([vx, vy] = [0, 0]) => {
472
796
  if (prefersReducedMotion) {
473
797
  x.set(0);
@@ -487,35 +811,72 @@ function SwipeDeckItem({ value, className, style, ref, onPointerDown, onClickCap
487
811
  x,
488
812
  y
489
813
  ]);
490
- const fling = React.useCallback((to, [vx, vy] = [0, 0]) => {
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];
491
844
  endDragRef.current?.();
492
845
  setIsSwiping(false);
493
846
  leftTowardRef.current = to;
494
- leave(value);
495
847
  setDirection(to);
496
848
  drivesLiftRef.current = false;
497
849
  releaseLift(deck.lift, prefersReducedMotion);
850
+ settleFace();
498
851
  setIsStarting(false);
499
- if (prefersReducedMotion) {
852
+ const element = elementRef.current;
853
+ if (prefersReducedMotion || !element) {
500
854
  setIsFading(true);
501
855
  return;
502
856
  }
503
- const size = sizeRef.current;
857
+ const extents = exitExtents(value, element, x.get(), y.get());
504
858
  const aheadX = x.get() + vx * THROW_TIME;
505
859
  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);
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];
509
863
  const headingLength = Math.hypot(...heading);
510
864
  const [ux, uy] = [heading[0] / headingLength, heading[1] / headingLength];
511
- const fade = () => {
865
+ let isOut = false;
866
+ const leaveSight = () => {
867
+ if (isOut) return;
868
+ isOut = true;
869
+ exitRef.current = null;
512
870
  stopWatchingRef.current?.();
513
871
  stopWatchingRef.current = null;
514
- setIsFading(true);
872
+ if (isMovedBack(value)) {
873
+ isTuckingRef.current = true;
874
+ rejoin(value);
875
+ } else setIsFading(true);
515
876
  };
516
877
  const check = () => {
517
878
  const outward = x.getVelocity() * ux + y.getVelocity() * uy;
518
- if (isClear(x.get(), y.get(), size) && outward <= CAUGHT_SPEED) fade();
879
+ if (isClear(x.get(), y.get(), extents, [ux, uy]) && outward <= CAUGHT_SPEED) leaveSight();
519
880
  };
520
881
  stopWatchingRef.current?.();
521
882
  const stopX = x.on("change", check);
@@ -528,36 +889,47 @@ function SwipeDeckItem({ value, className, style, ref, onPointerDown, onClickCap
528
889
  animate(x, targetX, {
529
890
  ...EXIT_SPRING,
530
891
  velocity: vx,
531
- onComplete: isMainX ? fade : void 0
892
+ onComplete: isMainX ? leaveSight : void 0
532
893
  });
533
894
  animate(y, targetY, {
534
895
  ...EXIT_SPRING,
535
896
  velocity: vy,
536
- onComplete: isMainX ? void 0 : fade
897
+ onComplete: isMainX ? void 0 : leaveSight
537
898
  });
538
899
  }, [
539
900
  deck.lift,
540
- leave,
901
+ exitExtents,
902
+ isMovedBack,
541
903
  prefersReducedMotion,
904
+ rejoin,
905
+ settleFace,
542
906
  value,
543
907
  x,
544
908
  y
545
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
+ });
546
922
  React.useLayoutEffect(() => {
547
923
  const element = elementRef.current;
548
924
  if (!element) return;
549
925
  return register(value, {
550
926
  element,
551
- swipe: (to) => {
927
+ swipe: (to, cause) => {
552
928
  sizeRef.current = sizeOf(element);
553
- fling(to);
929
+ swipeRef.current(to, cause);
554
930
  }
555
931
  });
556
- }, [
557
- fling,
558
- register,
559
- value
560
- ]);
932
+ }, [register, value]);
561
933
  React.useEffect(() => {
562
934
  const element = elementRef.current;
563
935
  const to = leftTowardRef.current;
@@ -568,7 +940,7 @@ function SwipeDeckItem({ value, className, style, ref, onPointerDown, onClickCap
568
940
  if (isCancelled) return;
569
941
  x.stop();
570
942
  y.stop();
571
- finish({
943
+ complete({
572
944
  value,
573
945
  direction: to
574
946
  });
@@ -579,7 +951,7 @@ function SwipeDeckItem({ value, className, style, ref, onPointerDown, onClickCap
579
951
  cancelAnimationFrame(frame);
580
952
  };
581
953
  }, [
582
- finish,
954
+ complete,
583
955
  isFading,
584
956
  value,
585
957
  x,
@@ -587,33 +959,68 @@ function SwipeDeckItem({ value, className, style, ref, onPointerDown, onClickCap
587
959
  ]);
588
960
  const wasLeavingRef = React.useRef(false);
589
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);
590
971
  React.useEffect(() => {
591
972
  const target = isLeaving ? 0 : position;
592
973
  if (target === void 0) return;
593
974
  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);
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);
600
999
  isPlacedRef.current = true;
601
1000
  wasLeavingRef.current = isLeaving;
602
1001
  }, [
1002
+ complete,
1003
+ deck.visible,
603
1004
  isLeaving,
604
1005
  place,
605
1006
  position,
606
1007
  prefersReducedMotion,
1008
+ springPlace,
1009
+ value,
607
1010
  x,
608
1011
  y
609
1012
  ]);
610
- const enterAtRef = React.useRef(null);
1013
+ React.useLayoutEffect(() => {
1014
+ if (wasHydratedRef.current && elementRef.current?.matches(`${ITEM_SELECTOR} ~ *`)) setIsStarting(true);
1015
+ }, []);
611
1016
  React.useEffect(() => {
612
1017
  if (!isStarting || position === void 0) return;
613
1018
  if (enterAtRef.current === null) {
614
- const delay = prefersReducedMotion || position === 0 ? 0 : ENTER_LEAD + (position - 1) * ENTER_STAGGER;
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;
615
1022
  enterAtRef.current = performance.now() + delay * 1e3;
616
- if (delay > 0) {
1023
+ if (isBehind) {
617
1024
  enterScale.jump(ENTER_SCALE);
618
1025
  animate(enterScale, 1, {
619
1026
  ...PLACE_SPRING,
@@ -631,12 +1038,12 @@ function SwipeDeckItem({ value, className, style, ref, onPointerDown, onClickCap
631
1038
  cancelAnimationFrame(frame);
632
1039
  };
633
1040
  }, [
1041
+ deck.visible,
634
1042
  enterScale,
635
1043
  isStarting,
636
1044
  position,
637
1045
  prefersReducedMotion
638
1046
  ]);
639
- const { settle } = deck;
640
1047
  React.useEffect(() => {
641
1048
  const element = elementRef.current;
642
1049
  if (!isFront || isStarting || !element) return;
@@ -659,10 +1066,14 @@ function SwipeDeckItem({ value, className, style, ref, onPointerDown, onClickCap
659
1066
  settle,
660
1067
  value
661
1068
  ]);
1069
+ React.useLayoutEffect(() => {
1070
+ if (isInert && elementRef.current?.contains(document.activeElement)) focusDeck();
1071
+ }, [focusDeck, isInert]);
662
1072
  React.useEffect(() => {
663
1073
  if (isFront && !deck.disabled || !dragRef.current) return;
664
1074
  endDragRef.current?.();
665
1075
  setIsSwiping(false);
1076
+ settleFace();
666
1077
  snapBack();
667
1078
  if (!isFront) releaseLift(deck.lift, prefersReducedMotion);
668
1079
  }, [
@@ -670,9 +1081,11 @@ function SwipeDeckItem({ value, className, style, ref, onPointerDown, onClickCap
670
1081
  deck.lift,
671
1082
  isFront,
672
1083
  prefersReducedMotion,
1084
+ settleFace,
673
1085
  snapBack
674
1086
  ]);
675
1087
  const release = (drag, event) => {
1088
+ settleFace();
676
1089
  if (!drag.moved) {
677
1090
  snapBack();
678
1091
  return;
@@ -687,13 +1100,14 @@ function SwipeDeckItem({ value, className, style, ref, onPointerDown, onClickCap
687
1100
  y: event.clientY,
688
1101
  time: event.timeStamp
689
1102
  });
690
- const velocity = releaseVelocity(drag.samples, event.timeStamp);
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];
691
1105
  if (event.type === "pointerup" && !disabledRef.current) {
692
1106
  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
- }
1107
+ if (ahead.direction && ahead.share >= 1 && swipe(ahead.direction, {
1108
+ reason: "swipe",
1109
+ event
1110
+ }, velocity)) return;
697
1111
  }
698
1112
  snapBack(velocity);
699
1113
  };
@@ -706,40 +1120,60 @@ function SwipeDeckItem({ value, className, style, ref, onPointerDown, onClickCap
706
1120
  deck.lift.stop();
707
1121
  x.stop();
708
1122
  y.stop();
709
- sizeRef.current = sizeOf(element);
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;
710
1128
  const drag = {
711
1129
  pointerId: event.pointerId,
712
1130
  startX: event.clientX,
713
1131
  startY: event.clientY,
714
- originX: x.get(),
715
- originY: y.get(),
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),
716
1134
  moved: false,
717
1135
  samples: [{
718
1136
  x: event.clientX,
719
1137
  y: event.clientY,
720
1138
  time: event.timeStamp
721
- }]
1139
+ }],
1140
+ idle: void 0
722
1141
  };
723
1142
  dragRef.current = drag;
724
1143
  const handleMove = (moveEvent) => {
725
1144
  if (moveEvent.pointerId !== drag.pointerId) return;
1145
+ if (moveEvent.buttons === 0) {
1146
+ endDrag();
1147
+ release(drag, moveEvent);
1148
+ return;
1149
+ }
726
1150
  const dx = moveEvent.clientX - drag.startX;
727
1151
  const dy = moveEvent.clientY - drag.startY;
728
1152
  if (!drag.moved) {
729
1153
  if (Math.hypot(dx, dy) < DRAG_SLOP) return;
730
1154
  drag.moved = true;
731
- element.setPointerCapture(drag.pointerId);
1155
+ try {
1156
+ element.setPointerCapture(drag.pointerId);
1157
+ } catch {}
732
1158
  setIsSwiping(true);
733
1159
  }
734
- x.set(drag.originX + dx);
735
- y.set(drag.originY + dy);
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));
736
1164
  drag.samples.push({
737
1165
  x: moveEvent.clientX,
738
1166
  y: moveEvent.clientY,
739
1167
  time: moveEvent.timeStamp
740
1168
  });
741
1169
  if (drag.samples.length > 20) drag.samples.shift();
742
- setDirection(lean(directionsRef.current, x.get(), y.get(), sizeRef.current).direction);
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);
743
1177
  };
744
1178
  const handleEnd = (endEvent) => {
745
1179
  if (endEvent.pointerId !== drag.pointerId) return;
@@ -747,6 +1181,7 @@ function SwipeDeckItem({ value, className, style, ref, onPointerDown, onClickCap
747
1181
  release(drag, endEvent);
748
1182
  };
749
1183
  const endDrag = () => {
1184
+ window.clearTimeout(drag.idle);
750
1185
  window.removeEventListener("pointermove", handleMove);
751
1186
  window.removeEventListener("pointerup", handleEnd);
752
1187
  window.removeEventListener("pointercancel", handleEnd);
@@ -767,7 +1202,7 @@ function SwipeDeckItem({ value, className, style, ref, onPointerDown, onClickCap
767
1202
  }
768
1203
  onClickCapture?.(event);
769
1204
  };
770
- const zIndex = isLeaving ? deck.count + 1 : position === void 0 ? void 0 : deck.count - position;
1205
+ const zIndex = isLeaving ? deck.count + deck.leavingOrder.length - deck.leavingOrder.indexOf(value) : position === void 0 ? void 0 : deck.count - position;
771
1206
  return /* @__PURE__ */ jsx("div", {
772
1207
  ref: mergedRef,
773
1208
  "data-slot": "swipe-deck-item",
@@ -778,8 +1213,8 @@ function SwipeDeckItem({ value, className, style, ref, onPointerDown, onClickCap
778
1213
  "data-starting-style": isStarting ? "" : void 0,
779
1214
  "data-ending-style": isFading ? "" : void 0,
780
1215
  "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),
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),
783
1218
  style: {
784
1219
  zIndex,
785
1220
  "--swipe-deck-index": isLeaving ? 0 : position,
@@ -873,10 +1308,11 @@ function DashedOutline() {
873
1308
  className: "pointer-events-none absolute inset-0 size-full overflow-visible",
874
1309
  "aria-hidden": true,
875
1310
  children: /* @__PURE__ */ jsx("path", {
1311
+ "data-slot": "swipe-deck-empty-outline-path",
876
1312
  d: outline.d,
877
1313
  pathLength,
878
1314
  strokeDasharray: `${OUTLINE_DASH} ${OUTLINE_GAP}`,
879
- className: "stroke-shade fill-none"
1315
+ className: "stroke-contour fill-none"
880
1316
  })
881
1317
  });
882
1318
  }
@@ -886,9 +1322,8 @@ function DashedOutline() {
886
1322
  * their size, inside a dashed outline of their shape (`data-slot`
887
1323
  * `swipe-deck-empty-outline`, to restyle or hide). It is drawn as soon as one
888
1324
  * 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.
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.
892
1327
  */
893
1328
  function SwipeDeckEmpty({ className, children, ref, ...props }) {
894
1329
  const deck = useSwipeDeckContext("SwipeDeckEmpty");
@@ -910,9 +1345,10 @@ function SwipeDeckEmpty({ className, children, ref, ...props }) {
910
1345
  }
911
1346
  /**
912
1347
  * 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.
1348
+ * without dragging. Disabled (and still focusable) when the deck is disabled,
1349
+ * has no card in front, or can't go that way.
914
1350
  */
915
- function SwipeDeckAction({ direction, disabled, onClick, ...props }) {
1351
+ function SwipeDeckAction({ direction, disabled, onClick, onKeyDown, ...props }) {
916
1352
  const deck = useSwipeDeckContext("SwipeDeckAction");
917
1353
  const unavailable = deck.disabled || deck.isReady && deck.front === void 0 || !deck.directions.includes(direction);
918
1354
  return /* @__PURE__ */ jsx(Button, {
@@ -922,7 +1358,14 @@ function SwipeDeckAction({ direction, disabled, onClick, ...props }) {
922
1358
  focusableWhenDisabled: true,
923
1359
  onClick: (event) => {
924
1360
  onClick?.(event);
925
- if (!event.defaultPrevented) deck.swipeFront(direction);
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();
926
1369
  },
927
1370
  ...props
928
1371
  });