@bearmenu/ui 0.8.7 → 0.8.8

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.
Files changed (81) hide show
  1. package/dist/{chunk-637L7XQ3.js → chunk-3TNPSN4D.js} +28 -9
  2. package/dist/chunk-AHL45DD2.js +145 -0
  3. package/dist/chunk-IYDH5IU6.js +66 -0
  4. package/dist/chunk-OPVKL4VS.js +18 -0
  5. package/dist/components/alert.d.ts +2 -2
  6. package/dist/components/badge.d.ts +1 -1
  7. package/dist/components/category-deck.js +10 -1
  8. package/dist/components/coverflow.d.ts +11 -3
  9. package/dist/components/coverflow.js +34 -51
  10. package/dist/components/date-input.d.ts +1 -1
  11. package/dist/components/drift-rail.d.ts +24 -10
  12. package/dist/components/drift-rail.js +1 -1
  13. package/dist/components/event-card.d.ts +8 -0
  14. package/dist/components/event-card.js +63 -33
  15. package/dist/components/event-compact-row.d.ts +32 -5
  16. package/dist/components/event-compact-row.js +119 -38
  17. package/dist/components/event-day-header.js +3 -1
  18. package/dist/components/event-day-separator.d.ts +7 -1
  19. package/dist/components/event-day-separator.js +3 -2
  20. package/dist/components/event-hub-hero.d.ts +25 -4
  21. package/dist/components/event-hub-hero.js +89 -19
  22. package/dist/components/event-list-row.d.ts +19 -6
  23. package/dist/components/event-list-row.js +78 -34
  24. package/dist/components/hour-band.d.ts +38 -40
  25. package/dist/components/hour-band.js +55 -27
  26. package/dist/components/hour-group.js +1 -1
  27. package/dist/components/poster-tape.js +1 -1
  28. package/dist/components/poster-wall.d.ts +3 -3
  29. package/dist/components/poster-wall.js +73 -17
  30. package/dist/components/section-header.d.ts +16 -1
  31. package/dist/components/section-header.js +26 -7
  32. package/dist/components/snap-rail.d.ts +34 -0
  33. package/dist/components/snap-rail.js +51 -0
  34. package/dist/components/tag.d.ts +2 -2
  35. package/dist/components/text.d.ts +1 -1
  36. package/dist/components/timeline-band.d.ts +20 -2
  37. package/dist/components/timeline-band.js +1 -1
  38. package/dist/components/timeline-playhead.d.ts +26 -0
  39. package/dist/components/timeline-playhead.js +25 -0
  40. package/dist/components/weekend-panel.d.ts +5 -4
  41. package/dist/components/weekend-panel.js +85 -71
  42. package/dist/hooks/use-drag-swipe.d.ts +44 -0
  43. package/dist/hooks/use-drag-swipe.js +2 -0
  44. package/dist/hooks/use-media-query.d.ts +17 -0
  45. package/dist/hooks/use-media-query.js +2 -0
  46. package/package.json +1 -1
  47. package/src/components/category-deck.tsx +19 -8
  48. package/src/components/coverflow.test.tsx +28 -1
  49. package/src/components/coverflow.tsx +37 -43
  50. package/src/components/drift-rail.tsx +167 -42
  51. package/src/components/event-card.tsx +55 -10
  52. package/src/components/event-compact-row.stories.tsx +30 -0
  53. package/src/components/event-compact-row.test.tsx +49 -0
  54. package/src/components/event-compact-row.tsx +161 -37
  55. package/src/components/event-day-header.tsx +3 -1
  56. package/src/components/event-day-separator.tsx +8 -1
  57. package/src/components/event-hub-hero.stories.tsx +30 -0
  58. package/src/components/event-hub-hero.tsx +102 -30
  59. package/src/components/event-list-row.stories.tsx +34 -0
  60. package/src/components/event-list-row.test.tsx +53 -0
  61. package/src/components/event-list-row.tsx +135 -87
  62. package/src/components/hour-band.stories.tsx +28 -5
  63. package/src/components/hour-band.tsx +90 -70
  64. package/src/components/poster-wall.test.tsx +28 -0
  65. package/src/components/poster-wall.tsx +96 -23
  66. package/src/components/section-header.stories.tsx +24 -0
  67. package/src/components/section-header.test.tsx +28 -0
  68. package/src/components/section-header.tsx +45 -7
  69. package/src/components/snap-rail.stories.tsx +79 -0
  70. package/src/components/snap-rail.test.tsx +18 -0
  71. package/src/components/snap-rail.tsx +67 -0
  72. package/src/components/timeline-band.tsx +52 -12
  73. package/src/components/timeline-playhead.stories.tsx +30 -0
  74. package/src/components/timeline-playhead.test.tsx +11 -0
  75. package/src/components/timeline-playhead.tsx +46 -0
  76. package/src/components/weekend-panel.test.tsx +43 -0
  77. package/src/components/weekend-panel.tsx +142 -108
  78. package/src/hooks/use-drag-swipe.test.tsx +66 -0
  79. package/src/hooks/use-drag-swipe.ts +105 -0
  80. package/src/hooks/use-media-query.ts +31 -0
  81. package/dist/chunk-GPH6L4DX.js +0 -55
@@ -4,33 +4,38 @@ import * as React from "react";
4
4
  import { ChevronRight } from "lucide-react";
5
5
  import { MotionPlayPause } from "./motion-icon-controls";
6
6
  import { cn } from "../lib/utils";
7
+ import { useDragSwipe } from "../hooks/use-drag-swipe";
8
+ import { DESKTOP_MEDIA_QUERY, useMediaQuery } from "../hooks/use-media-query";
7
9
  import { Skeleton } from "./skeleton";
8
10
 
9
11
  /**
10
- * WeekendPanel — one day panel in the weekend section: a header row over an
11
- * auto-advancing two-up carousel of the day's event cards.
12
+ * WeekendPanel — one day panel in the weekend section: a header row over a
13
+ * two-up, two-page carousel of the day's event cards.
12
14
  *
13
15
  * The carousel assumes exactly two "pages" of two cards (four `children` in
14
- * total) that's what the design's slide keyframes measure against
15
- * (`translateX(calc(-100% - gap))` moves the track exactly one page). Pass a
16
- * different count and the maths still animate, but the pause points won't
17
- * line up with a full page.
16
+ * total): the track moves by one page width plus the gap. Pass a different
17
+ * count and it still works, but the second page may be half-empty.
18
18
  *
19
- * ACCESSIBILITY: satisfies WCAG 2.2.2 (Pause, Stop, Hide) with a visible
20
- * pause/play toggle next to the progress dots. Hover/focus/off-screen pausing
21
- * remain as convenience behaviour, but the button is the actual mechanism
22
- * it is a latch (`isManuallyPaused`), not a hold, so a touch or low-vision
23
- * user can stop the carousel and keep it stopped while they read. Toggling it
24
- * flips `animationPlayState` for the track AND both dot keyframes together.
25
- * Under `prefers-reduced-motion` the panel already renders a static grid with
26
- * nothing animating, so the control would be meaningless and is not rendered.
19
+ * THE READER TURNS THE PAGE. The track used to be a CSS keyframe loop that
20
+ * slid every few seconds; readers tried to swipe it and nothing happened.
21
+ * The page is now state: a horizontal drag on the track (touch, pen or mouse)
22
+ * turns it, the track follows the finger on the way, and focusing a card on
23
+ * the hidden page (keyboard) turns to that page so focus is never invisible.
24
+ *
25
+ * AUTOPLAY IS DESKTOP-ONLY. Below `lg` the panel never advances by itself
26
+ * on a phone a self-advancing card under the thumb is the thing the reader
27
+ * is trying to swipe, and it moves away. From `lg` the two pages alternate
28
+ * every `durationSeconds / 2`, pausing on hover, focus, off-screen, and for
29
+ * good once the reader touches it. That version keeps its visible pause/play
30
+ * toggle (WCAG 2.2.2): a latch, not a hold, so a low-vision user can stop it
31
+ * and keep it stopped. Under `prefers-reduced-motion` the panel renders a
32
+ * static grid with nothing animating and no control.
27
33
  */
28
34
 
29
35
  const WEEKEND_PANEL_TRACK_GAP_PX = 14;
30
36
  const WEEKEND_PANEL_DEFAULT_DURATION_SECONDS = 7;
31
- const WEEKEND_PANEL_SLIDE_KEYFRAMES_ID = "ds-weekend-panel-slide";
32
- const WEEKEND_PANEL_DOT_A_KEYFRAMES_ID = "ds-weekend-panel-dot-a";
33
- const WEEKEND_PANEL_DOT_B_KEYFRAMES_ID = "ds-weekend-panel-dot-b";
37
+ const WEEKEND_PANEL_PAGE_COUNT = 2;
38
+ const WEEKEND_PANEL_CARDS_PER_PAGE = 2;
34
39
 
35
40
  function usePrefersReducedMotion(): boolean {
36
41
  const [reduced, setReduced] = React.useState(false);
@@ -47,16 +52,22 @@ function usePrefersReducedMotion(): boolean {
47
52
  return reduced;
48
53
  }
49
54
 
50
- function useInViewport<T extends Element>(): [React.RefObject<T | null>, boolean] {
55
+ function useInViewport<T extends Element>(): [
56
+ React.RefObject<T | null>,
57
+ boolean,
58
+ ] {
51
59
  const ref = React.useRef<T | null>(null);
52
60
  const [inView, setInView] = React.useState(true);
53
61
 
54
62
  React.useEffect(() => {
55
63
  const node = ref.current;
56
64
  if (!node || typeof IntersectionObserver === "undefined") return;
57
- const observer = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting), {
58
- threshold: 0,
59
- });
65
+ const observer = new IntersectionObserver(
66
+ ([entry]) => setInView(entry.isIntersecting),
67
+ {
68
+ threshold: 0,
69
+ },
70
+ );
60
71
  observer.observe(node);
61
72
  return () => observer.disconnect();
62
73
  }, []);
@@ -91,14 +102,15 @@ export type WeekendPanelProps = {
91
102
  linkComponent?: React.ComponentType<WeekendPanelLinkProps>;
92
103
  onActionClick?: () => void;
93
104
  isHighlighted?: boolean;
94
- /** Loop duration for the slide/dots animation. Default 7 sibling panels typically stagger at 8.5 and 10. */
105
+ /** Desktop autoplay loop duration the page turns every half of it. Default 7; sibling panels typically stagger at 8.5 and 10. */
95
106
  durationSeconds?: number;
96
107
  /**
97
108
  * Accessible names for the pause/play toggle. Required — the control is
98
- * only omitted under `prefers-reduced-motion`, where the labels go unused.
109
+ * only rendered on desktop and not under `prefers-reduced-motion`, but the
110
+ * DS owns no copy.
99
111
  */
100
112
  labels: WeekendPanelLabels;
101
- /** The event cards. The animation is built around exactly four (two pages of two). */
113
+ /** The event cards. Built around exactly four (two pages of two). */
102
114
  children?: React.ReactNode;
103
115
  className?: string;
104
116
  };
@@ -117,12 +129,37 @@ export function WeekendPanel({
117
129
  className,
118
130
  }: WeekendPanelProps) {
119
131
  const prefersReducedMotion = usePrefersReducedMotion();
132
+ const isDesktop = useMediaQuery(DESKTOP_MEDIA_QUERY);
120
133
  const [panelRef, inView] = useInViewport<HTMLDivElement>();
121
134
  const [isHovered, setIsHovered] = React.useState(false);
122
135
  const [isFocused, setIsFocused] = React.useState(false);
123
136
  const [isManuallyPaused, setIsManuallyPaused] = React.useState(false);
124
137
  const [playPauseReplayToken, setPlayPauseReplayToken] = React.useState(0);
125
- const isPaused = isManuallyPaused || isHovered || isFocused || !inView;
138
+ const [page, setPage] = React.useState(0);
139
+
140
+ const autoplays = isDesktop && !prefersReducedMotion;
141
+ const isPaused =
142
+ !autoplays || isManuallyPaused || isHovered || isFocused || !inView;
143
+
144
+ React.useEffect(() => {
145
+ if (isPaused) return undefined;
146
+ const interval = window.setInterval(
147
+ () => setPage((current) => (current + 1) % WEEKEND_PANEL_PAGE_COUNT),
148
+ (durationSeconds * 1000) / WEEKEND_PANEL_PAGE_COUNT,
149
+ );
150
+ return () => window.clearInterval(interval);
151
+ }, [durationSeconds, isPaused]);
152
+
153
+ const turnTo = (next: number) => {
154
+ setPage(Math.max(0, Math.min(WEEKEND_PANEL_PAGE_COUNT - 1, next)));
155
+ // Once the reader has taken the wheel the panel stops turning itself.
156
+ setIsManuallyPaused(true);
157
+ };
158
+
159
+ const { dragX, isDragging, handlers } = useDragSwipe({
160
+ onSwipe: (direction) => turnTo(page + direction),
161
+ disabled: prefersReducedMotion,
162
+ });
126
163
 
127
164
  const action = (
128
165
  <span className="ml-auto flex items-center gap-0.5 text-[12.5px] text-muted-foreground">
@@ -131,6 +168,8 @@ export function WeekendPanel({
131
168
  </span>
132
169
  );
133
170
 
171
+ const cards = React.Children.toArray(children);
172
+
134
173
  return (
135
174
  <div
136
175
  ref={panelRef}
@@ -141,34 +180,14 @@ export function WeekendPanel({
141
180
  className={cn(
142
181
  "relative overflow-hidden rounded-2xl border bg-card py-4",
143
182
  isHighlighted ? "border-primary/40" : "border-border",
144
- className
183
+ className,
145
184
  )}
146
185
  >
147
- <style>{`
148
- @keyframes ${WEEKEND_PANEL_SLIDE_KEYFRAMES_ID} {
149
- 0%, 45% { transform: translateX(0); }
150
- 50%, 95% { transform: translateX(calc(-100% - ${WEEKEND_PANEL_TRACK_GAP_PX}px)); }
151
- 100% { transform: translateX(0); }
152
- }
153
- @keyframes ${WEEKEND_PANEL_DOT_A_KEYFRAMES_ID} {
154
- 0% { background: var(--primary); width: 18px; }
155
- 45% { background: var(--primary); width: 18px; }
156
- 50% { background: color-mix(in oklch, var(--muted-foreground) 35%, transparent); width: 6px; }
157
- 100% { background: color-mix(in oklch, var(--muted-foreground) 35%, transparent); width: 6px; }
158
- }
159
- @keyframes ${WEEKEND_PANEL_DOT_B_KEYFRAMES_ID} {
160
- 0% { background: color-mix(in oklch, var(--muted-foreground) 35%, transparent); width: 6px; }
161
- 50% { background: color-mix(in oklch, var(--muted-foreground) 35%, transparent); width: 6px; }
162
- 95% { background: var(--primary); width: 18px; }
163
- 100% { background: var(--primary); width: 18px; }
164
- }
165
- `}</style>
166
-
167
186
  <div className="flex items-baseline gap-[9px] px-4 pb-3">
168
187
  <span
169
188
  className={cn(
170
189
  "text-[15.5px] font-extrabold tracking-[-0.02em] sm:text-[17px]",
171
- isHighlighted && "text-primary"
190
+ isHighlighted && "text-primary",
172
191
  )}
173
192
  >
174
193
  {dayLabel}
@@ -176,7 +195,7 @@ export function WeekendPanel({
176
195
  <span
177
196
  className={cn(
178
197
  "font-mono text-xs",
179
- isHighlighted ? "text-primary" : "text-muted-foreground"
198
+ isHighlighted ? "text-primary" : "text-muted-foreground",
180
199
  )}
181
200
  >
182
201
  {countLabel}
@@ -202,20 +221,42 @@ export function WeekendPanel({
202
221
  {prefersReducedMotion ? (
203
222
  <div className="grid grid-cols-2 gap-3.5 px-4">{children}</div>
204
223
  ) : (
205
- <div className="overflow-hidden px-4">
224
+ // Padding OUTSIDE the clip. `overflow-hidden` clips at the padding
225
+ // edge, so with the padding on the clipping element the next page's
226
+ // first card showed through the 16px inset — a sliver of the slide
227
+ // cutting in. The reader should see exactly the two cards of a page.
228
+ <div className="px-4">
206
229
  <div
207
- className="flex gap-3.5"
208
- style={{
209
- animationName: WEEKEND_PANEL_SLIDE_KEYFRAMES_ID,
210
- animationDuration: `${durationSeconds}s`,
211
- animationTimingFunction: "ease-in-out",
212
- animationIterationCount: "infinite",
213
- animationPlayState: isPaused ? "paused" : "running",
214
- }}
230
+ {...handlers}
231
+ className="overflow-hidden select-none [touch-action:pan-y]"
215
232
  >
216
- {React.Children.map(children, (child) => (
217
- <div className="w-[calc((100%-14px)/2)] flex-none">{child}</div>
218
- ))}
233
+ <div
234
+ className={cn(
235
+ "flex gap-3.5",
236
+ !isDragging &&
237
+ "transition-transform duration-[420ms] ease-[cubic-bezier(0.22,1,0.36,1)]",
238
+ )}
239
+ style={{
240
+ transform: `translateX(calc(${-page} * (100% + ${WEEKEND_PANEL_TRACK_GAP_PX}px) + ${dragX}px))`,
241
+ }}
242
+ >
243
+ {cards.map((child, index) => (
244
+ <div
245
+ key={index}
246
+ className="w-[calc((100%-14px)/2)] flex-none"
247
+ // Keyboard: focusing a card on the other page turns to it, so
248
+ // focus is never parked behind the clip.
249
+ onFocus={() => {
250
+ const cardPage = Math.floor(
251
+ index / WEEKEND_PANEL_CARDS_PER_PAGE,
252
+ );
253
+ if (cardPage !== page) turnTo(cardPage);
254
+ }}
255
+ >
256
+ {child}
257
+ </div>
258
+ ))}
259
+ </div>
219
260
  </div>
220
261
  </div>
221
262
  )}
@@ -224,56 +265,46 @@ export function WeekendPanel({
224
265
  <div className="mt-3.5 grid grid-cols-[1fr_auto_1fr] items-center px-4">
225
266
  <span aria-hidden />
226
267
 
268
+ {/* The dots say which page is showing; the width tweens with the
269
+ page. Decorative — the swipe (and, on desktop, the toggle) are
270
+ the controls, and each card is already a named link. */}
227
271
  <div className="flex items-center justify-center gap-1.5" aria-hidden>
228
- <span
229
- className="block h-1.5 w-[18px] rounded-full bg-primary"
230
- style={{
231
- animationName: WEEKEND_PANEL_DOT_A_KEYFRAMES_ID,
232
- animationDuration: `${durationSeconds}s`,
233
- animationTimingFunction: "ease-in-out",
234
- animationIterationCount: "infinite",
235
- animationPlayState: isPaused ? "paused" : "running",
236
- }}
237
- />
238
- <span
239
- className="block h-1.5 w-1.5 rounded-full bg-muted-foreground/35"
240
- style={{
241
- animationName: WEEKEND_PANEL_DOT_B_KEYFRAMES_ID,
242
- animationDuration: `${durationSeconds}s`,
243
- animationTimingFunction: "ease-in-out",
244
- animationIterationCount: "infinite",
245
- animationPlayState: isPaused ? "paused" : "running",
246
- }}
247
- />
272
+ {Array.from({ length: WEEKEND_PANEL_PAGE_COUNT }, (_, index) => (
273
+ <span
274
+ key={index}
275
+ className={cn(
276
+ "block h-1.5 rounded-full transition-[width,background-color] duration-300 motion-reduce:transition-none",
277
+ index === page
278
+ ? "w-[18px] bg-primary"
279
+ : "w-1.5 bg-muted-foreground/35",
280
+ )}
281
+ />
282
+ ))}
248
283
  </div>
249
284
 
250
285
  <div className="flex justify-end">
251
- {/* `MotionPlayPause`, not two swapped lucide glyphs. The triangle
252
- folding into two bars IS the state change, so the control shows
253
- what it did instead of cutting to a different icon — which is
254
- the whole reason that gesture exists in the set.
255
-
256
- `active` drives the HELD state and the bars are the active
257
- variant, so it is `!isManuallyPaused`: while the carousel plays,
258
- the button offers pause. `replayToken` bumps on pointer down so
259
- the gesture answers the touch rather than its consequence.
260
-
261
- `motion-reduce` is handled inside `MotionIcon`; the button keeps
262
- its own for the background transition. */}
263
- <button
264
- type="button"
265
- onClick={() => setIsManuallyPaused((current) => !current)}
266
- onPointerDown={() => setPlayPauseReplayToken((n) => n + 1)}
267
- aria-label={isManuallyPaused ? labels.play : labels.pause}
268
- aria-pressed={isManuallyPaused}
269
- className="flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 motion-reduce:transition-none"
270
- >
271
- <MotionPlayPause
272
- active={!isManuallyPaused}
273
- replayToken={playPauseReplayToken}
274
- className="size-4"
275
- />
276
- </button>
286
+ {/* Desktop only: the toggle exists to stop an autoplay, and below
287
+ `lg` there is none to stop. `MotionPlayPause`, not two swapped
288
+ lucide glyphs: the triangle folding into two bars IS the state
289
+ change. `active` drives the HELD state and the bars are the
290
+ active variant, so it is `!isManuallyPaused`; `replayToken`
291
+ bumps on pointer down so the gesture answers the touch. */}
292
+ {autoplays && (
293
+ <button
294
+ type="button"
295
+ onClick={() => setIsManuallyPaused((current) => !current)}
296
+ onPointerDown={() => setPlayPauseReplayToken((n) => n + 1)}
297
+ aria-label={isManuallyPaused ? labels.play : labels.pause}
298
+ aria-pressed={isManuallyPaused}
299
+ className="flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 motion-reduce:transition-none"
300
+ >
301
+ <MotionPlayPause
302
+ active={!isManuallyPaused}
303
+ replayToken={playPauseReplayToken}
304
+ className="size-4"
305
+ />
306
+ </button>
307
+ )}
277
308
  </div>
278
309
  </div>
279
310
  )}
@@ -287,14 +318,17 @@ export type WeekendPanelSkeletonProps = {
287
318
  };
288
319
 
289
320
  /** The panel at rest: the two visible cards of the two-up carousel, no slide. */
290
- export function WeekendPanelSkeleton({ isHighlighted, className }: WeekendPanelSkeletonProps) {
321
+ export function WeekendPanelSkeleton({
322
+ isHighlighted,
323
+ className,
324
+ }: WeekendPanelSkeletonProps) {
291
325
  return (
292
326
  <div
293
327
  aria-hidden="true"
294
328
  className={cn(
295
329
  "relative overflow-hidden rounded-2xl border bg-card py-4",
296
330
  isHighlighted ? "border-primary/40" : "border-border",
297
- className
331
+ className,
298
332
  )}
299
333
  >
300
334
  <div className="flex items-baseline gap-[9px] px-4 pb-3">
@@ -0,0 +1,66 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { fireEvent, render, screen } from "@testing-library/react";
3
+ import { useDragSwipe } from "./use-drag-swipe";
4
+
5
+ function Stage({ onSwipe }: { onSwipe: (direction: -1 | 1) => void }) {
6
+ const { dragX, handlers } = useDragSwipe({ onSwipe });
7
+ return (
8
+ <div data-testid="stage" data-drag={dragX} {...handlers}>
9
+ <button type="button" onClick={() => onSwipe(0 as never)}>
10
+ card
11
+ </button>
12
+ </div>
13
+ );
14
+ }
15
+
16
+ const drag = (stage: HTMLElement, from: number, to: number) => {
17
+ fireEvent.pointerDown(stage, { clientX: from, pointerId: 1, button: 0 });
18
+ fireEvent.pointerMove(stage, { clientX: (from + to) / 2, pointerId: 1 });
19
+ fireEvent.pointerMove(stage, { clientX: to, pointerId: 1 });
20
+ fireEvent.pointerUp(stage, { clientX: to, pointerId: 1 });
21
+ };
22
+
23
+ describe("useDragSwipe", () => {
24
+ it("reports 1 for a leftward drag past the threshold and -1 for a rightward one", () => {
25
+ const onSwipe = vi.fn();
26
+ render(<Stage onSwipe={onSwipe} />);
27
+ const stage = screen.getByTestId("stage");
28
+
29
+ drag(stage, 200, 100);
30
+ expect(onSwipe).toHaveBeenLastCalledWith(1);
31
+
32
+ drag(stage, 100, 200);
33
+ expect(onSwipe).toHaveBeenLastCalledWith(-1);
34
+ });
35
+
36
+ it("ignores a drag shorter than the threshold", () => {
37
+ const onSwipe = vi.fn();
38
+ render(<Stage onSwipe={onSwipe} />);
39
+ drag(screen.getByTestId("stage"), 200, 180);
40
+ expect(onSwipe).not.toHaveBeenCalled();
41
+ });
42
+
43
+ it("follows the finger while dragging and settles back on release", () => {
44
+ render(<Stage onSwipe={() => {}} />);
45
+ const stage = screen.getByTestId("stage");
46
+ fireEvent.pointerDown(stage, { clientX: 200, pointerId: 1, button: 0 });
47
+ fireEvent.pointerMove(stage, { clientX: 140, pointerId: 1 });
48
+ expect(stage.dataset.drag).toBe("-60");
49
+ fireEvent.pointerUp(stage, { clientX: 140, pointerId: 1 });
50
+ expect(stage.dataset.drag).toBe("0");
51
+ });
52
+
53
+ it("swallows the click a drag would otherwise become", () => {
54
+ const onSwipe = vi.fn();
55
+ render(<Stage onSwipe={onSwipe} />);
56
+ const stage = screen.getByTestId("stage");
57
+ drag(stage, 200, 100);
58
+ onSwipe.mockClear();
59
+ fireEvent.click(screen.getByRole("button", { name: "card" }));
60
+ expect(onSwipe).not.toHaveBeenCalled();
61
+
62
+ // A plain click, with no drag before it, still reaches the card.
63
+ fireEvent.click(screen.getByRole("button", { name: "card" }));
64
+ expect(onSwipe).toHaveBeenCalledTimes(1);
65
+ });
66
+ });
@@ -0,0 +1,105 @@
1
+ "use client";
2
+
3
+ import { useCallback, useRef, useState } from "react";
4
+ import type { PointerEvent as ReactPointerEvent } from "react";
5
+
6
+ /**
7
+ * useDragSwipe — horizontal drag-to-step for a stage that is not a native
8
+ * scroller (a coverflow ring, a paged track). Pointer events, so one code path
9
+ * covers touch, pen and mouse.
10
+ *
11
+ * Returns the live drag distance (`dragX`, for the stage to follow the finger
12
+ * a little while dragging) and the handlers to spread onto the stage. On
13
+ * release past `threshold` it calls `onSwipe(-1 | 1)` — `1` for a leftward
14
+ * drag, i.e. "next". `didDrag` is set for the tick after a real drag so the
15
+ * stage's `onClickCapture` can swallow the click a drag would otherwise
16
+ * become on a card underneath.
17
+ *
18
+ * The stage keeps vertical scrolling: pair these with `touch-action: pan-y`
19
+ * so the browser owns vertical gestures and hands only horizontal ones here.
20
+ * Pointer capture is taken on move, not on down, so a tap still reaches the
21
+ * card underneath as a plain click.
22
+ *
23
+ * `onDragStart` is part of the handlers for a reason: the cards under a stage
24
+ * are usually links wrapping images, and a mouse drag on either starts a
25
+ * NATIVE HTML5 drag, which cancels the pointer stream mid-gesture — the swipe
26
+ * silently never completes. Preventing it keeps the pointer events flowing.
27
+ */
28
+ export type DragSwipeOptions = {
29
+ /** Distance in px that counts as a swipe. Default 40. */
30
+ threshold?: number;
31
+ onSwipe: (direction: -1 | 1) => void;
32
+ disabled?: boolean;
33
+ };
34
+
35
+ export function useDragSwipe({ threshold = 40, onSwipe, disabled }: DragSwipeOptions) {
36
+ const [dragX, setDragX] = useState(0);
37
+ const startX = useRef<number | null>(null);
38
+ const pointerId = useRef<number | null>(null);
39
+ const didDrag = useRef(false);
40
+
41
+ const onPointerDown = useCallback(
42
+ (event: ReactPointerEvent<HTMLElement>) => {
43
+ if (disabled || event.button !== 0) return;
44
+ startX.current = event.clientX;
45
+ pointerId.current = event.pointerId;
46
+ didDrag.current = false;
47
+ },
48
+ [disabled]
49
+ );
50
+
51
+ const onPointerMove = useCallback((event: ReactPointerEvent<HTMLElement>) => {
52
+ if (startX.current === null || event.pointerId !== pointerId.current) return;
53
+ const dx = event.clientX - startX.current;
54
+ if (!didDrag.current && Math.abs(dx) > 6) {
55
+ didDrag.current = true;
56
+ event.currentTarget.setPointerCapture?.(event.pointerId);
57
+ }
58
+ if (didDrag.current) setDragX(dx);
59
+ }, []);
60
+
61
+ const end = useCallback(
62
+ (event: ReactPointerEvent<HTMLElement>, cancelled: boolean) => {
63
+ if (startX.current === null || event.pointerId !== pointerId.current) return;
64
+ const dx = event.clientX - startX.current;
65
+ startX.current = null;
66
+ pointerId.current = null;
67
+ if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
68
+ event.currentTarget.releasePointerCapture(event.pointerId);
69
+ }
70
+ setDragX(0);
71
+ if (!cancelled && didDrag.current && Math.abs(dx) >= threshold) {
72
+ onSwipe(dx < 0 ? 1 : -1);
73
+ }
74
+ // Leave `didDrag` set through the click that follows a release; the
75
+ // stage's click-capture reads it, then clears it.
76
+ },
77
+ [onSwipe, threshold]
78
+ );
79
+
80
+ const onPointerUp = useCallback((event: ReactPointerEvent<HTMLElement>) => end(event, false), [end]);
81
+ const onPointerCancel = useCallback(
82
+ (event: ReactPointerEvent<HTMLElement>) => end(event, true),
83
+ [end]
84
+ );
85
+
86
+ /** Native link/image dragging would cancel the pointer stream — see above. */
87
+ const onDragStart = useCallback((event: React.DragEvent<HTMLElement>) => {
88
+ event.preventDefault();
89
+ }, []);
90
+
91
+ /** Spread on the stage: swallows the click a drag would otherwise become. */
92
+ const onClickCapture = useCallback((event: ReactPointerEvent<HTMLElement> | React.MouseEvent) => {
93
+ if (didDrag.current) {
94
+ event.preventDefault();
95
+ event.stopPropagation();
96
+ didDrag.current = false;
97
+ }
98
+ }, []);
99
+
100
+ return {
101
+ dragX,
102
+ isDragging: dragX !== 0,
103
+ handlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onClickCapture, onDragStart },
104
+ };
105
+ }
@@ -0,0 +1,31 @@
1
+ "use client";
2
+
3
+ import { useSyncExternalStore } from "react";
4
+
5
+ /**
6
+ * useMediaQuery — a boolean for a CSS media query that is hydration-safe.
7
+ *
8
+ * `useSyncExternalStore` with a fixed server snapshot (`false`) means the
9
+ * server and the hydrating client render the same tree; the real value
10
+ * arrives on the next render. Components that switch behaviour per breakpoint
11
+ * (not just classes) need this rather than `window.matchMedia` in an effect,
12
+ * which flashes the wrong variant for a frame.
13
+ *
14
+ * Mobile-first: `false` on the server and until measured, so the phone
15
+ * variant is the one that prerenders.
16
+ */
17
+ export function useMediaQuery(query: string): boolean {
18
+ return useSyncExternalStore(
19
+ (onChange) => {
20
+ if (typeof window.matchMedia !== "function") return () => {};
21
+ const mql = window.matchMedia(query);
22
+ mql.addEventListener("change", onChange);
23
+ return () => mql.removeEventListener("change", onChange);
24
+ },
25
+ () => (typeof window.matchMedia === "function" ? window.matchMedia(query).matches : false),
26
+ () => false
27
+ );
28
+ }
29
+
30
+ /** The design system's desktop breakpoint, Tailwind's `lg`. */
31
+ export const DESKTOP_MEDIA_QUERY = "(min-width: 1024px)";
@@ -1,55 +0,0 @@
1
- import { cn } from './chunk-FEK5XGZW.js';
2
- import { useId, useRef, useEffect } from 'react';
3
- import { jsxs, jsx } from 'react/jsx-runtime';
4
-
5
- var DRIFT_RAIL_DEFAULT_DURATION_SECONDS = 42;
6
- function DriftRail({
7
- children,
8
- reverse = false,
9
- durationSeconds = DRIFT_RAIL_DEFAULT_DURATION_SECONDS,
10
- pauseOnHover = true,
11
- className
12
- }) {
13
- const scopeId = useId().replace(/[^a-zA-Z0-9]/g, "");
14
- const scopeClass = `drift-rail-${scopeId}`;
15
- const containerRef = useRef(null);
16
- useEffect(() => {
17
- const node = containerRef.current;
18
- if (!node || typeof IntersectionObserver === "undefined") return void 0;
19
- const observer = new IntersectionObserver(
20
- ([entry]) => {
21
- node.dataset.offscreen = entry.isIntersecting ? "false" : "true";
22
- },
23
- { threshold: 0 }
24
- );
25
- observer.observe(node);
26
- return () => observer.disconnect();
27
- }, []);
28
- return /* @__PURE__ */ jsxs("div", { ref: containerRef, className: cn(scopeClass, "overflow-hidden py-1.5", className), children: [
29
- /* @__PURE__ */ jsx("style", { children: `
30
- .${scopeClass} .drift-rail-run {
31
- animation: ${scopeClass}-run ${durationSeconds}s linear infinite;
32
- animation-direction: ${reverse ? "reverse" : "normal"};
33
- }
34
- .${scopeClass}[data-offscreen="true"] .drift-rail-run {
35
- animation-play-state: paused;
36
- }
37
- @keyframes ${scopeClass}-run {
38
- from { transform: translateX(0); }
39
- to { transform: translateX(-50%); }
40
- }
41
- @media (prefers-reduced-motion: reduce) {
42
- .${scopeClass} .drift-rail-run {
43
- animation: none;
44
- }
45
- }
46
- ${pauseOnHover ? `.${scopeClass}:hover .drift-rail-run { animation-play-state: paused; }` : ""}
47
- ` }),
48
- /* @__PURE__ */ jsxs("div", { className: "drift-rail-run flex w-max items-stretch gap-3.5 motion-reduce:[animation:none]", children: [
49
- children,
50
- /* @__PURE__ */ jsx("div", { className: "contents", "aria-hidden": "true", children })
51
- ] })
52
- ] });
53
- }
54
-
55
- export { DriftRail };