@waveso/ui 0.16.0 → 0.17.0

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