@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.
- package/dist/{chunk-637L7XQ3.js → chunk-3TNPSN4D.js} +28 -9
- package/dist/chunk-AHL45DD2.js +145 -0
- package/dist/chunk-IYDH5IU6.js +66 -0
- package/dist/chunk-OPVKL4VS.js +18 -0
- package/dist/components/alert.d.ts +2 -2
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/category-deck.js +10 -1
- package/dist/components/coverflow.d.ts +11 -3
- package/dist/components/coverflow.js +34 -51
- package/dist/components/date-input.d.ts +1 -1
- package/dist/components/drift-rail.d.ts +24 -10
- package/dist/components/drift-rail.js +1 -1
- package/dist/components/event-card.d.ts +8 -0
- package/dist/components/event-card.js +63 -33
- package/dist/components/event-compact-row.d.ts +32 -5
- package/dist/components/event-compact-row.js +119 -38
- package/dist/components/event-day-header.js +3 -1
- package/dist/components/event-day-separator.d.ts +7 -1
- package/dist/components/event-day-separator.js +3 -2
- package/dist/components/event-hub-hero.d.ts +25 -4
- package/dist/components/event-hub-hero.js +89 -19
- package/dist/components/event-list-row.d.ts +19 -6
- package/dist/components/event-list-row.js +78 -34
- package/dist/components/hour-band.d.ts +38 -40
- package/dist/components/hour-band.js +55 -27
- package/dist/components/hour-group.js +1 -1
- package/dist/components/poster-tape.js +1 -1
- package/dist/components/poster-wall.d.ts +3 -3
- package/dist/components/poster-wall.js +73 -17
- package/dist/components/section-header.d.ts +16 -1
- package/dist/components/section-header.js +26 -7
- package/dist/components/snap-rail.d.ts +34 -0
- package/dist/components/snap-rail.js +51 -0
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/text.d.ts +1 -1
- package/dist/components/timeline-band.d.ts +20 -2
- package/dist/components/timeline-band.js +1 -1
- package/dist/components/timeline-playhead.d.ts +26 -0
- package/dist/components/timeline-playhead.js +25 -0
- package/dist/components/weekend-panel.d.ts +5 -4
- package/dist/components/weekend-panel.js +85 -71
- package/dist/hooks/use-drag-swipe.d.ts +44 -0
- package/dist/hooks/use-drag-swipe.js +2 -0
- package/dist/hooks/use-media-query.d.ts +17 -0
- package/dist/hooks/use-media-query.js +2 -0
- package/package.json +1 -1
- package/src/components/category-deck.tsx +19 -8
- package/src/components/coverflow.test.tsx +28 -1
- package/src/components/coverflow.tsx +37 -43
- package/src/components/drift-rail.tsx +167 -42
- package/src/components/event-card.tsx +55 -10
- package/src/components/event-compact-row.stories.tsx +30 -0
- package/src/components/event-compact-row.test.tsx +49 -0
- package/src/components/event-compact-row.tsx +161 -37
- package/src/components/event-day-header.tsx +3 -1
- package/src/components/event-day-separator.tsx +8 -1
- package/src/components/event-hub-hero.stories.tsx +30 -0
- package/src/components/event-hub-hero.tsx +102 -30
- package/src/components/event-list-row.stories.tsx +34 -0
- package/src/components/event-list-row.test.tsx +53 -0
- package/src/components/event-list-row.tsx +135 -87
- package/src/components/hour-band.stories.tsx +28 -5
- package/src/components/hour-band.tsx +90 -70
- package/src/components/poster-wall.test.tsx +28 -0
- package/src/components/poster-wall.tsx +96 -23
- package/src/components/section-header.stories.tsx +24 -0
- package/src/components/section-header.test.tsx +28 -0
- package/src/components/section-header.tsx +45 -7
- package/src/components/snap-rail.stories.tsx +79 -0
- package/src/components/snap-rail.test.tsx +18 -0
- package/src/components/snap-rail.tsx +67 -0
- package/src/components/timeline-band.tsx +52 -12
- package/src/components/timeline-playhead.stories.tsx +30 -0
- package/src/components/timeline-playhead.test.tsx +11 -0
- package/src/components/timeline-playhead.tsx +46 -0
- package/src/components/weekend-panel.test.tsx +43 -0
- package/src/components/weekend-panel.tsx +142 -108
- package/src/hooks/use-drag-swipe.test.tsx +66 -0
- package/src/hooks/use-drag-swipe.ts +105 -0
- package/src/hooks/use-media-query.ts +31 -0
- package/dist/chunk-GPH6L4DX.js +0 -55
|
@@ -1,17 +1,18 @@
|
|
|
1
|
+
import { useMediaQuery, DESKTOP_MEDIA_QUERY } from '../chunk-OPVKL4VS.js';
|
|
1
2
|
import { MotionPlayPause } from '../chunk-TT3WFS6S.js';
|
|
2
3
|
import '../chunk-2WDJR6K7.js';
|
|
4
|
+
import { useDragSwipe } from '../chunk-IYDH5IU6.js';
|
|
3
5
|
import { Skeleton } from '../chunk-QXHMPJ35.js';
|
|
4
6
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
5
|
-
import {
|
|
7
|
+
import { __spreadProps, __spreadValues, __objRest } from '../chunk-3GWWZKX7.js';
|
|
6
8
|
import * as React from 'react';
|
|
7
9
|
import { ChevronRight } from 'lucide-react';
|
|
8
10
|
import { jsxs, jsx } from 'react/jsx-runtime';
|
|
9
11
|
|
|
10
12
|
var WEEKEND_PANEL_TRACK_GAP_PX = 14;
|
|
11
13
|
var WEEKEND_PANEL_DEFAULT_DURATION_SECONDS = 7;
|
|
12
|
-
var
|
|
13
|
-
var
|
|
14
|
-
var WEEKEND_PANEL_DOT_B_KEYFRAMES_ID = "ds-weekend-panel-dot-b";
|
|
14
|
+
var WEEKEND_PANEL_PAGE_COUNT = 2;
|
|
15
|
+
var WEEKEND_PANEL_CARDS_PER_PAGE = 2;
|
|
15
16
|
function usePrefersReducedMotion() {
|
|
16
17
|
const [reduced, setReduced] = React.useState(false);
|
|
17
18
|
React.useEffect(() => {
|
|
@@ -30,9 +31,12 @@ function useInViewport() {
|
|
|
30
31
|
React.useEffect(() => {
|
|
31
32
|
const node = ref.current;
|
|
32
33
|
if (!node || typeof IntersectionObserver === "undefined") return;
|
|
33
|
-
const observer = new IntersectionObserver(
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
const observer = new IntersectionObserver(
|
|
35
|
+
([entry]) => setInView(entry.isIntersecting),
|
|
36
|
+
{
|
|
37
|
+
threshold: 0
|
|
38
|
+
}
|
|
39
|
+
);
|
|
36
40
|
observer.observe(node);
|
|
37
41
|
return () => observer.disconnect();
|
|
38
42
|
}, []);
|
|
@@ -56,16 +60,36 @@ function WeekendPanel({
|
|
|
56
60
|
className
|
|
57
61
|
}) {
|
|
58
62
|
const prefersReducedMotion = usePrefersReducedMotion();
|
|
63
|
+
const isDesktop = useMediaQuery(DESKTOP_MEDIA_QUERY);
|
|
59
64
|
const [panelRef, inView] = useInViewport();
|
|
60
65
|
const [isHovered, setIsHovered] = React.useState(false);
|
|
61
66
|
const [isFocused, setIsFocused] = React.useState(false);
|
|
62
67
|
const [isManuallyPaused, setIsManuallyPaused] = React.useState(false);
|
|
63
68
|
const [playPauseReplayToken, setPlayPauseReplayToken] = React.useState(0);
|
|
64
|
-
const
|
|
69
|
+
const [page, setPage] = React.useState(0);
|
|
70
|
+
const autoplays = isDesktop && !prefersReducedMotion;
|
|
71
|
+
const isPaused = !autoplays || isManuallyPaused || isHovered || isFocused || !inView;
|
|
72
|
+
React.useEffect(() => {
|
|
73
|
+
if (isPaused) return void 0;
|
|
74
|
+
const interval = window.setInterval(
|
|
75
|
+
() => setPage((current) => (current + 1) % WEEKEND_PANEL_PAGE_COUNT),
|
|
76
|
+
durationSeconds * 1e3 / WEEKEND_PANEL_PAGE_COUNT
|
|
77
|
+
);
|
|
78
|
+
return () => window.clearInterval(interval);
|
|
79
|
+
}, [durationSeconds, isPaused]);
|
|
80
|
+
const turnTo = (next) => {
|
|
81
|
+
setPage(Math.max(0, Math.min(WEEKEND_PANEL_PAGE_COUNT - 1, next)));
|
|
82
|
+
setIsManuallyPaused(true);
|
|
83
|
+
};
|
|
84
|
+
const { dragX, isDragging, handlers } = useDragSwipe({
|
|
85
|
+
onSwipe: (direction) => turnTo(page + direction),
|
|
86
|
+
disabled: prefersReducedMotion
|
|
87
|
+
});
|
|
65
88
|
const action = /* @__PURE__ */ jsxs("span", { className: "ml-auto flex items-center gap-0.5 text-[12.5px] text-muted-foreground", children: [
|
|
66
89
|
actionLabel,
|
|
67
90
|
/* @__PURE__ */ jsx(ChevronRight, { className: "h-3.5 w-3.5", "aria-hidden": true })
|
|
68
91
|
] });
|
|
92
|
+
const cards = React.Children.toArray(children);
|
|
69
93
|
return /* @__PURE__ */ jsxs(
|
|
70
94
|
"div",
|
|
71
95
|
{
|
|
@@ -80,25 +104,6 @@ function WeekendPanel({
|
|
|
80
104
|
className
|
|
81
105
|
),
|
|
82
106
|
children: [
|
|
83
|
-
/* @__PURE__ */ jsx("style", { children: `
|
|
84
|
-
@keyframes ${WEEKEND_PANEL_SLIDE_KEYFRAMES_ID} {
|
|
85
|
-
0%, 45% { transform: translateX(0); }
|
|
86
|
-
50%, 95% { transform: translateX(calc(-100% - ${WEEKEND_PANEL_TRACK_GAP_PX}px)); }
|
|
87
|
-
100% { transform: translateX(0); }
|
|
88
|
-
}
|
|
89
|
-
@keyframes ${WEEKEND_PANEL_DOT_A_KEYFRAMES_ID} {
|
|
90
|
-
0% { background: var(--primary); width: 18px; }
|
|
91
|
-
45% { background: var(--primary); width: 18px; }
|
|
92
|
-
50% { background: color-mix(in oklch, var(--muted-foreground) 35%, transparent); width: 6px; }
|
|
93
|
-
100% { background: color-mix(in oklch, var(--muted-foreground) 35%, transparent); width: 6px; }
|
|
94
|
-
}
|
|
95
|
-
@keyframes ${WEEKEND_PANEL_DOT_B_KEYFRAMES_ID} {
|
|
96
|
-
0% { background: color-mix(in oklch, var(--muted-foreground) 35%, transparent); width: 6px; }
|
|
97
|
-
50% { background: color-mix(in oklch, var(--muted-foreground) 35%, transparent); width: 6px; }
|
|
98
|
-
95% { background: var(--primary); width: 18px; }
|
|
99
|
-
100% { background: var(--primary); width: 18px; }
|
|
100
|
-
}
|
|
101
|
-
` }),
|
|
102
107
|
/* @__PURE__ */ jsxs("div", { className: "flex items-baseline gap-[9px] px-4 pb-3", children: [
|
|
103
108
|
/* @__PURE__ */ jsx(
|
|
104
109
|
"span",
|
|
@@ -130,51 +135,57 @@ function WeekendPanel({
|
|
|
130
135
|
}
|
|
131
136
|
) : action
|
|
132
137
|
] }),
|
|
133
|
-
prefersReducedMotion ? /* @__PURE__ */ jsx("div", { className: "grid grid-cols-2 gap-3.5 px-4", children }) :
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
138
|
+
prefersReducedMotion ? /* @__PURE__ */ jsx("div", { className: "grid grid-cols-2 gap-3.5 px-4", children }) : (
|
|
139
|
+
// Padding OUTSIDE the clip. `overflow-hidden` clips at the padding
|
|
140
|
+
// edge, so with the padding on the clipping element the next page's
|
|
141
|
+
// first card showed through the 16px inset — a sliver of the slide
|
|
142
|
+
// cutting in. The reader should see exactly the two cards of a page.
|
|
143
|
+
/* @__PURE__ */ jsx("div", { className: "px-4", children: /* @__PURE__ */ jsx(
|
|
144
|
+
"div",
|
|
145
|
+
__spreadProps(__spreadValues({}, handlers), {
|
|
146
|
+
className: "overflow-hidden select-none [touch-action:pan-y]",
|
|
147
|
+
children: /* @__PURE__ */ jsx(
|
|
148
|
+
"div",
|
|
149
|
+
{
|
|
150
|
+
className: cn(
|
|
151
|
+
"flex gap-3.5",
|
|
152
|
+
!isDragging && "transition-transform duration-[420ms] ease-[cubic-bezier(0.22,1,0.36,1)]"
|
|
153
|
+
),
|
|
154
|
+
style: {
|
|
155
|
+
transform: `translateX(calc(${-page} * (100% + ${WEEKEND_PANEL_TRACK_GAP_PX}px) + ${dragX}px))`
|
|
156
|
+
},
|
|
157
|
+
children: cards.map((child, index) => /* @__PURE__ */ jsx(
|
|
158
|
+
"div",
|
|
159
|
+
{
|
|
160
|
+
className: "w-[calc((100%-14px)/2)] flex-none",
|
|
161
|
+
onFocus: () => {
|
|
162
|
+
const cardPage = Math.floor(
|
|
163
|
+
index / WEEKEND_PANEL_CARDS_PER_PAGE
|
|
164
|
+
);
|
|
165
|
+
if (cardPage !== page) turnTo(cardPage);
|
|
166
|
+
},
|
|
167
|
+
children: child
|
|
168
|
+
},
|
|
169
|
+
index
|
|
170
|
+
))
|
|
171
|
+
}
|
|
172
|
+
)
|
|
173
|
+
})
|
|
174
|
+
) })
|
|
175
|
+
),
|
|
147
176
|
!prefersReducedMotion && /* @__PURE__ */ jsxs("div", { className: "mt-3.5 grid grid-cols-[1fr_auto_1fr] items-center px-4", children: [
|
|
148
177
|
/* @__PURE__ */ jsx("span", { "aria-hidden": true }),
|
|
149
|
-
/* @__PURE__ */
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
),
|
|
163
|
-
/* @__PURE__ */ jsx(
|
|
164
|
-
"span",
|
|
165
|
-
{
|
|
166
|
-
className: "block h-1.5 w-1.5 rounded-full bg-muted-foreground/35",
|
|
167
|
-
style: {
|
|
168
|
-
animationName: WEEKEND_PANEL_DOT_B_KEYFRAMES_ID,
|
|
169
|
-
animationDuration: `${durationSeconds}s`,
|
|
170
|
-
animationTimingFunction: "ease-in-out",
|
|
171
|
-
animationIterationCount: "infinite",
|
|
172
|
-
animationPlayState: isPaused ? "paused" : "running"
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
)
|
|
176
|
-
] }),
|
|
177
|
-
/* @__PURE__ */ jsx("div", { className: "flex justify-end", children: /* @__PURE__ */ jsx(
|
|
178
|
+
/* @__PURE__ */ jsx("div", { className: "flex items-center justify-center gap-1.5", "aria-hidden": true, children: Array.from({ length: WEEKEND_PANEL_PAGE_COUNT }, (_, index) => /* @__PURE__ */ jsx(
|
|
179
|
+
"span",
|
|
180
|
+
{
|
|
181
|
+
className: cn(
|
|
182
|
+
"block h-1.5 rounded-full transition-[width,background-color] duration-300 motion-reduce:transition-none",
|
|
183
|
+
index === page ? "w-[18px] bg-primary" : "w-1.5 bg-muted-foreground/35"
|
|
184
|
+
)
|
|
185
|
+
},
|
|
186
|
+
index
|
|
187
|
+
)) }),
|
|
188
|
+
/* @__PURE__ */ jsx("div", { className: "flex justify-end", children: autoplays && /* @__PURE__ */ jsx(
|
|
178
189
|
"button",
|
|
179
190
|
{
|
|
180
191
|
type: "button",
|
|
@@ -198,7 +209,10 @@ function WeekendPanel({
|
|
|
198
209
|
}
|
|
199
210
|
);
|
|
200
211
|
}
|
|
201
|
-
function WeekendPanelSkeleton({
|
|
212
|
+
function WeekendPanelSkeleton({
|
|
213
|
+
isHighlighted,
|
|
214
|
+
className
|
|
215
|
+
}) {
|
|
202
216
|
return /* @__PURE__ */ jsxs(
|
|
203
217
|
"div",
|
|
204
218
|
{
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { PointerEvent } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useDragSwipe — horizontal drag-to-step for a stage that is not a native
|
|
5
|
+
* scroller (a coverflow ring, a paged track). Pointer events, so one code path
|
|
6
|
+
* covers touch, pen and mouse.
|
|
7
|
+
*
|
|
8
|
+
* Returns the live drag distance (`dragX`, for the stage to follow the finger
|
|
9
|
+
* a little while dragging) and the handlers to spread onto the stage. On
|
|
10
|
+
* release past `threshold` it calls `onSwipe(-1 | 1)` — `1` for a leftward
|
|
11
|
+
* drag, i.e. "next". `didDrag` is set for the tick after a real drag so the
|
|
12
|
+
* stage's `onClickCapture` can swallow the click a drag would otherwise
|
|
13
|
+
* become on a card underneath.
|
|
14
|
+
*
|
|
15
|
+
* The stage keeps vertical scrolling: pair these with `touch-action: pan-y`
|
|
16
|
+
* so the browser owns vertical gestures and hands only horizontal ones here.
|
|
17
|
+
* Pointer capture is taken on move, not on down, so a tap still reaches the
|
|
18
|
+
* card underneath as a plain click.
|
|
19
|
+
*
|
|
20
|
+
* `onDragStart` is part of the handlers for a reason: the cards under a stage
|
|
21
|
+
* are usually links wrapping images, and a mouse drag on either starts a
|
|
22
|
+
* NATIVE HTML5 drag, which cancels the pointer stream mid-gesture — the swipe
|
|
23
|
+
* silently never completes. Preventing it keeps the pointer events flowing.
|
|
24
|
+
*/
|
|
25
|
+
type DragSwipeOptions = {
|
|
26
|
+
/** Distance in px that counts as a swipe. Default 40. */
|
|
27
|
+
threshold?: number;
|
|
28
|
+
onSwipe: (direction: -1 | 1) => void;
|
|
29
|
+
disabled?: boolean;
|
|
30
|
+
};
|
|
31
|
+
declare function useDragSwipe({ threshold, onSwipe, disabled }: DragSwipeOptions): {
|
|
32
|
+
dragX: number;
|
|
33
|
+
isDragging: boolean;
|
|
34
|
+
handlers: {
|
|
35
|
+
onPointerDown: (event: PointerEvent<HTMLElement>) => void;
|
|
36
|
+
onPointerMove: (event: PointerEvent<HTMLElement>) => void;
|
|
37
|
+
onPointerUp: (event: PointerEvent<HTMLElement>) => void;
|
|
38
|
+
onPointerCancel: (event: PointerEvent<HTMLElement>) => void;
|
|
39
|
+
onClickCapture: (event: PointerEvent<HTMLElement> | React.MouseEvent) => void;
|
|
40
|
+
onDragStart: (event: React.DragEvent<HTMLElement>) => void;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export { type DragSwipeOptions, useDragSwipe };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useMediaQuery — a boolean for a CSS media query that is hydration-safe.
|
|
3
|
+
*
|
|
4
|
+
* `useSyncExternalStore` with a fixed server snapshot (`false`) means the
|
|
5
|
+
* server and the hydrating client render the same tree; the real value
|
|
6
|
+
* arrives on the next render. Components that switch behaviour per breakpoint
|
|
7
|
+
* (not just classes) need this rather than `window.matchMedia` in an effect,
|
|
8
|
+
* which flashes the wrong variant for a frame.
|
|
9
|
+
*
|
|
10
|
+
* Mobile-first: `false` on the server and until measured, so the phone
|
|
11
|
+
* variant is the one that prerenders.
|
|
12
|
+
*/
|
|
13
|
+
declare function useMediaQuery(query: string): boolean;
|
|
14
|
+
/** The design system's desktop breakpoint, Tailwind's `lg`. */
|
|
15
|
+
declare const DESKTOP_MEDIA_QUERY = "(min-width: 1024px)";
|
|
16
|
+
|
|
17
|
+
export { DESKTOP_MEDIA_QUERY, useMediaQuery };
|
package/package.json
CHANGED
|
@@ -31,7 +31,11 @@ const DECK_POSTER_COUNT = 4;
|
|
|
31
31
|
const DECK_TRANSITION =
|
|
32
32
|
"transition-transform duration-[400ms] ease-[cubic-bezier(0.2,0.8,0.2,1)] motion-reduce:transition-none";
|
|
33
33
|
|
|
34
|
-
const DECK_CARD_TRANSFORMS: ReadonlyArray<{
|
|
34
|
+
const DECK_CARD_TRANSFORMS: ReadonlyArray<{
|
|
35
|
+
rest: string;
|
|
36
|
+
spread: string;
|
|
37
|
+
z: string;
|
|
38
|
+
}> = [
|
|
35
39
|
{
|
|
36
40
|
rest: "[transform:translateX(-50%)_rotate(-9deg)_translateX(-26px)]",
|
|
37
41
|
spread:
|
|
@@ -115,13 +119,12 @@ export function CategoryDeck({
|
|
|
115
119
|
}: CategoryDeckProps) {
|
|
116
120
|
const slots = Array.from(
|
|
117
121
|
{ length: DECK_POSTER_COUNT },
|
|
118
|
-
(_, index): CategoryDeckPoster => posters[index] ?? {}
|
|
122
|
+
(_, index): CategoryDeckPoster => posters[index] ?? {},
|
|
119
123
|
);
|
|
120
124
|
|
|
121
125
|
const body = (
|
|
122
126
|
<>
|
|
123
127
|
<div className="relative h-[150px] sm:h-[186px]" aria-hidden>
|
|
124
|
-
|
|
125
128
|
{slots.map((poster, index) => {
|
|
126
129
|
const transform = DECK_CARD_TRANSFORMS[index];
|
|
127
130
|
return (
|
|
@@ -129,12 +132,15 @@ export function CategoryDeck({
|
|
|
129
132
|
key={index}
|
|
130
133
|
className={cn(
|
|
131
134
|
"absolute left-1/2 top-0 aspect-[4/5] w-24 overflow-hidden rounded-[10px] bg-muted",
|
|
135
|
+
// Keyed to --foreground so the shadow is ink in light mode; in
|
|
136
|
+
// dark mode that same 18% is a light halo, so it drops to 7%.
|
|
132
137
|
"shadow-[0_10px_26px_color-mix(in_oklch,var(--foreground)_18%,transparent)]",
|
|
138
|
+
"dark:shadow-[0_10px_26px_color-mix(in_oklch,var(--foreground)_7%,transparent)]",
|
|
133
139
|
"sm:w-[120px]",
|
|
134
140
|
transform.rest,
|
|
135
141
|
transform.spread,
|
|
136
142
|
transform.z,
|
|
137
|
-
DECK_TRANSITION
|
|
143
|
+
DECK_TRANSITION,
|
|
138
144
|
)}
|
|
139
145
|
>
|
|
140
146
|
{poster.content ? (
|
|
@@ -161,9 +167,14 @@ export function CategoryDeck({
|
|
|
161
167
|
<div className="truncate text-[14.5px] font-extrabold tracking-[-0.015em] sm:text-base">
|
|
162
168
|
{name}
|
|
163
169
|
</div>
|
|
164
|
-
<div className="truncate font-mono text-[11.5px] text-muted-foreground">
|
|
170
|
+
<div className="truncate font-mono text-[11.5px] text-muted-foreground">
|
|
171
|
+
{meta}
|
|
172
|
+
</div>
|
|
165
173
|
</div>
|
|
166
|
-
<ChevronRight
|
|
174
|
+
<ChevronRight
|
|
175
|
+
className="ml-auto h-4 w-4 shrink-0 text-muted-foreground"
|
|
176
|
+
aria-hidden
|
|
177
|
+
/>
|
|
167
178
|
</div>
|
|
168
179
|
</>
|
|
169
180
|
);
|
|
@@ -172,7 +183,7 @@ export function CategoryDeck({
|
|
|
172
183
|
"group flex min-w-0 flex-col text-left",
|
|
173
184
|
(href || onClick) &&
|
|
174
185
|
"cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl",
|
|
175
|
-
className
|
|
186
|
+
className,
|
|
176
187
|
);
|
|
177
188
|
|
|
178
189
|
if (href) {
|
|
@@ -209,7 +220,7 @@ export function CategoryDeckSkeleton({ className }: CategoryDeckSkeletonProps) {
|
|
|
209
220
|
className={cn(
|
|
210
221
|
"absolute left-1/2 top-0 aspect-[4/5] w-24 rounded-[10px] sm:w-[120px]",
|
|
211
222
|
transform.rest,
|
|
212
|
-
transform.z
|
|
223
|
+
transform.z,
|
|
213
224
|
)}
|
|
214
225
|
/>
|
|
215
226
|
))}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from "vitest";
|
|
2
|
-
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
3
3
|
import userEvent from "@testing-library/user-event";
|
|
4
4
|
import { Coverflow, type CoverflowItem } from "./coverflow";
|
|
5
5
|
|
|
@@ -175,6 +175,33 @@ describe("Coverflow", () => {
|
|
|
175
175
|
});
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
+
describe("the drag", () => {
|
|
179
|
+
it("steps forward on a leftward drag and back on a rightward one", () => {
|
|
180
|
+
const onActiveIndexChange = vi.fn();
|
|
181
|
+
renderCoverflow({ activeIndex: 2, onActiveIndexChange });
|
|
182
|
+
const stage = screen.getByRole("button", { name: "Event 2" }).parentElement!
|
|
183
|
+
.parentElement as HTMLElement;
|
|
184
|
+
|
|
185
|
+
fireEvent.pointerDown(stage, { clientX: 200, pointerId: 1, button: 0 });
|
|
186
|
+
fireEvent.pointerMove(stage, { clientX: 150, pointerId: 1 });
|
|
187
|
+
fireEvent.pointerMove(stage, { clientX: 100, pointerId: 1 });
|
|
188
|
+
fireEvent.pointerUp(stage, { clientX: 100, pointerId: 1 });
|
|
189
|
+
expect(onActiveIndexChange).toHaveBeenLastCalledWith(3);
|
|
190
|
+
|
|
191
|
+
fireEvent.pointerDown(stage, { clientX: 100, pointerId: 1, button: 0 });
|
|
192
|
+
fireEvent.pointerMove(stage, { clientX: 150, pointerId: 1 });
|
|
193
|
+
fireEvent.pointerMove(stage, { clientX: 200, pointerId: 1 });
|
|
194
|
+
fireEvent.pointerUp(stage, { clientX: 200, pointerId: 1 });
|
|
195
|
+
expect(onActiveIndexChange).toHaveBeenLastCalledWith(1);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("keeps the arrows for desktop only", () => {
|
|
199
|
+
renderCoverflow();
|
|
200
|
+
expect(screen.getByRole("button", { name: "Next" }).className).toContain("lg:flex");
|
|
201
|
+
expect(screen.getByRole("button", { name: "Next" }).className).toContain("hidden");
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
178
205
|
describe("caption and position", () => {
|
|
179
206
|
it("renders the position on its own, with no separator dot, when there is no caption", () => {
|
|
180
207
|
renderCoverflow({ positionLabel: "1 of 7" });
|
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useId, useState } from "react";
|
|
4
4
|
import { ChevronLeft, ChevronRight, Gift } from "lucide-react";
|
|
5
5
|
import { cn } from "../lib/utils";
|
|
6
|
+
import { useDragSwipe } from "../hooks/use-drag-swipe";
|
|
6
7
|
import { EventPosterFallback } from "./event-poster-fallback";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Coverflow — the busiest-events browser: a 3D perspective lane with a large
|
|
10
11
|
* lead poster flanked by up to three shrinking, desaturating posters on each
|
|
11
|
-
* side.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* side.
|
|
13
|
+
*
|
|
14
|
+
* NOTHING MOVES ON ITS OWN. The lane used to drift ±40px on a loop; it was
|
|
15
|
+
* removed at product request — a stage that sways is read as a carousel that
|
|
16
|
+
* is about to advance, and the reader waits for it instead of touching it.
|
|
17
|
+
* The stage is driven by the reader: a horizontal drag (touch, pen or mouse)
|
|
18
|
+
* steps the ring one card in the drag's direction and the lane follows the
|
|
19
|
+
* finger a little on the way, so the gesture has weight. The prev/next arrows
|
|
20
|
+
* stay for desktop and keyboard; below `lg` they are hidden so nothing sits
|
|
21
|
+
* over the lead card — the swipe is the control there, and every card is
|
|
22
|
+
* still a focusable, named button for keyboard and screen-reader users.
|
|
14
23
|
*
|
|
15
24
|
* IT IS A RING, NOT A LANE. A card's slot comes from `ringOffset` — the
|
|
16
25
|
* SIGNED SHORTEST WAY ROUND the list, not the raw `index - activeIndex`. With
|
|
@@ -178,15 +187,12 @@ const COVERFLOW_LEAD_SHADOW_CLASS = "shadow-[0_24px_56px_oklch(0.28_0.03_45/0.32
|
|
|
178
187
|
const COVERFLOW_PILL_CLASS =
|
|
179
188
|
"inline-flex h-6 max-w-[85%] items-center gap-1 rounded-full px-2 text-[11px] font-semibold";
|
|
180
189
|
|
|
181
|
-
const COVERFLOW_DRIFT_DURATION_SECONDS = 26;
|
|
182
|
-
const COVERFLOW_DRIFT_PIXELS = 40;
|
|
183
|
-
|
|
184
190
|
/** Below the lead's z-index the arrows (z-20) and edge fades (z-10) always
|
|
185
191
|
* win, no matter how many cards are mid-transition and briefly promoted. */
|
|
186
192
|
const COVERFLOW_LEAD_Z_INDEX = 6;
|
|
187
193
|
|
|
188
194
|
const COVERFLOW_ARROW_CLASS =
|
|
189
|
-
"absolute top-1/2 z-20
|
|
195
|
+
"absolute top-1/2 z-20 hidden h-11 w-11 -translate-y-1/2 lg:flex cursor-pointer items-center justify-center rounded-full bg-background/85 text-foreground shadow-[var(--shadow-overlay)] backdrop-blur-sm transition-colors duration-150 hover:bg-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 motion-reduce:transition-none";
|
|
190
196
|
|
|
191
197
|
function wrapIndex(index: number, length: number): number {
|
|
192
198
|
return ((index % length) + length) % length;
|
|
@@ -347,7 +353,6 @@ export function Coverflow({
|
|
|
347
353
|
}: CoverflowProps) {
|
|
348
354
|
const scopeId = useId().replace(/[^a-zA-Z0-9]/g, "");
|
|
349
355
|
const scopeClass = `coverflow-${scopeId}`;
|
|
350
|
-
const containerRef = useRef<HTMLDivElement>(null);
|
|
351
356
|
|
|
352
357
|
const length = items.length;
|
|
353
358
|
const active = length > 0 ? wrapIndex(activeIndex, length) : 0;
|
|
@@ -372,26 +377,21 @@ export function Coverflow({
|
|
|
372
377
|
);
|
|
373
378
|
}
|
|
374
379
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
380
|
+
// No clamp at the ends: prev from 0 reports `length - 1` and next from
|
|
381
|
+
// `length - 1` reports 0, so the index the parent stores matches the ring.
|
|
382
|
+
const goTo = (index: number) => onActiveIndexChange?.(wrapIndex(index, length));
|
|
378
383
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
return () => observer.disconnect();
|
|
387
|
-
}, []);
|
|
384
|
+
// A drag to the left means "next" — the ring turns the way the finger went.
|
|
385
|
+
// The lane follows at a third of the drag distance while the finger is down,
|
|
386
|
+
// then snaps back as the step lands.
|
|
387
|
+
const { dragX, isDragging, handlers } = useDragSwipe({
|
|
388
|
+
onSwipe: (direction) => goTo(active + direction),
|
|
389
|
+
disabled: length < 2,
|
|
390
|
+
});
|
|
388
391
|
|
|
389
392
|
if (length === 0) return null;
|
|
390
393
|
|
|
391
394
|
const activeItem = items[active];
|
|
392
|
-
// No clamp at the ends: prev from 0 reports `length - 1` and next from
|
|
393
|
-
// `length - 1` reports 0, so the index the parent stores matches the ring.
|
|
394
|
-
const goTo = (index: number) => onActiveIndexChange?.(wrapIndex(index, length));
|
|
395
395
|
|
|
396
396
|
/** How far the lead itself moved, the short way round — 0 on a settled render. */
|
|
397
397
|
const activeDelta = ringOffset(step.to - step.from, length);
|
|
@@ -446,26 +446,10 @@ export function Coverflow({
|
|
|
446
446
|
--cf-tier-base: 0.5714;
|
|
447
447
|
}
|
|
448
448
|
}
|
|
449
|
-
.${scopeClass} .coverflow-lane {
|
|
450
|
-
animation: ${scopeClass}-drift ${COVERFLOW_DRIFT_DURATION_SECONDS}s ease-in-out infinite alternate;
|
|
451
|
-
}
|
|
452
|
-
.${scopeClass}[data-offscreen="true"] .coverflow-lane {
|
|
453
|
-
animation-play-state: paused;
|
|
454
|
-
}
|
|
455
|
-
@keyframes ${scopeClass}-drift {
|
|
456
|
-
from { transform: translateX(-${COVERFLOW_DRIFT_PIXELS}px); }
|
|
457
|
-
to { transform: translateX(${COVERFLOW_DRIFT_PIXELS}px); }
|
|
458
|
-
}
|
|
459
|
-
@media (prefers-reduced-motion: reduce) {
|
|
460
|
-
.${scopeClass} .coverflow-lane {
|
|
461
|
-
animation: none;
|
|
462
|
-
transform: none;
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
449
|
`}</style>
|
|
466
450
|
|
|
467
451
|
<div
|
|
468
|
-
|
|
452
|
+
{...handlers}
|
|
469
453
|
// CLIPS HORIZONTALLY ONLY. The clip is what hides a card teleporting
|
|
470
454
|
// round the back of the ring, and that only ever happens off the left
|
|
471
455
|
// and right edges — but `overflow-hidden` clips both axes, so it was
|
|
@@ -477,7 +461,11 @@ export function Coverflow({
|
|
|
477
461
|
// container), so `overflow-x-hidden overflow-y-visible` is not a thing
|
|
478
462
|
// the cascade will give you. `clip` carries no such rule, so the pair
|
|
479
463
|
// below means what it says.
|
|
480
|
-
|
|
464
|
+
//
|
|
465
|
+
// `touch-action: pan-y` hands vertical gestures to the browser and
|
|
466
|
+
// horizontal ones to the drag handlers; `select-none` stops a mouse
|
|
467
|
+
// drag from selecting the captions underneath.
|
|
468
|
+
className="relative flex h-[300px] select-none items-center justify-center overflow-x-clip overflow-y-visible [touch-action:pan-y] [perspective:900px] sm:h-[380px] sm:[perspective:1400px]"
|
|
481
469
|
>
|
|
482
470
|
<div
|
|
483
471
|
aria-hidden="true"
|
|
@@ -511,7 +499,13 @@ export function Coverflow({
|
|
|
511
499
|
</>
|
|
512
500
|
)}
|
|
513
501
|
|
|
514
|
-
<div
|
|
502
|
+
<div
|
|
503
|
+
className={cn(
|
|
504
|
+
"coverflow-lane relative h-full w-full [transform-style:preserve-3d]",
|
|
505
|
+
!isDragging && "transition-transform duration-300 ease-out motion-reduce:transition-none"
|
|
506
|
+
)}
|
|
507
|
+
style={{ transform: `translateX(${dragX / 3}px)` }}
|
|
508
|
+
>
|
|
515
509
|
{visibleCards.map(({ item, index, offset, suppressTransition }) => (
|
|
516
510
|
<CoverflowCard
|
|
517
511
|
key={item.id}
|