@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,26 +1,41 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useEffect,
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import type { PointerEvent as ReactPointerEvent } from "react";
|
|
4
5
|
import { cn } from "../lib/utils";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* DriftRail — an infinite horizontal marquee for logos, tag chips or small
|
|
8
|
-
* poster tiles. The caller passes one set of
|
|
9
|
-
* a second, `aria-hidden` copy right after it
|
|
10
|
-
* seamless. Because the loop depends on the duplicate being
|
|
11
|
-
* to the first pass, keep `children` a fixed, ordered list —
|
|
12
|
-
* randomisation.
|
|
9
|
+
* poster tiles that the reader can take hold of. The caller passes one set of
|
|
10
|
+
* children; the component renders a second, `aria-hidden` copy right after it
|
|
11
|
+
* so the loop is seamless. Because the loop depends on the duplicate being
|
|
12
|
+
* pixel-identical to the first pass, keep `children` a fixed, ordered list —
|
|
13
|
+
* no per-render randomisation.
|
|
13
14
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* IT IS A SCROLLER THAT DRIFTS, NOT AN ANIMATION. It used to be a CSS
|
|
16
|
+
* `translateX` keyframe, which is cheap but cannot be grabbed: a finger on it
|
|
17
|
+
* did nothing, and a marquee that ignores the hand on it reads as a broken
|
|
18
|
+
* scroller. Now the rail is a real horizontal scroll container with the
|
|
19
|
+
* scrollbar hidden, and a `requestAnimationFrame` loop advances `scrollLeft`
|
|
20
|
+
* at `width / durationSeconds` per second. When the scroll position passes
|
|
21
|
+
* the first copy it is wrapped back by exactly one copy's width — the two
|
|
22
|
+
* copies are identical, so the jump is invisible — and the same wrap holds
|
|
23
|
+
* while the reader scrolls, in either direction, so there is no end to hit.
|
|
24
|
+
*
|
|
25
|
+
* THE READER WINS. A touch scrolls natively (momentum and all); a mouse drag
|
|
26
|
+
* scrolls the rail by the drag distance; a wheel works because it is a
|
|
27
|
+
* scroller. Any of those stops the drift, and it resumes a few seconds after
|
|
28
|
+
* the last interaction. Hover pauses it too (`pauseOnHover`), and an
|
|
29
|
+
* `IntersectionObserver` stops the loop off-screen so the page is not doing
|
|
30
|
+
* work nobody can see. `prefers-reduced-motion` removes the drift entirely;
|
|
31
|
+
* the rail stays scrollable by hand, which is more than the static strip it
|
|
32
|
+
* used to collapse to.
|
|
19
33
|
*/
|
|
20
34
|
export type DriftRailProps = {
|
|
21
35
|
children: React.ReactNode;
|
|
22
36
|
/** Runs the loop right-to-left instead of left-to-right. */
|
|
23
37
|
reverse?: boolean;
|
|
38
|
+
/** Seconds for one full pass of the children. */
|
|
24
39
|
durationSeconds?: number;
|
|
25
40
|
/** Pauses the loop while the pointer hovers the rail. */
|
|
26
41
|
pauseOnHover?: boolean;
|
|
@@ -28,6 +43,8 @@ export type DriftRailProps = {
|
|
|
28
43
|
};
|
|
29
44
|
|
|
30
45
|
const DRIFT_RAIL_DEFAULT_DURATION_SECONDS = 42;
|
|
46
|
+
/** How long after the reader lets go before the drift resumes. */
|
|
47
|
+
const DRIFT_RAIL_RESUME_DELAY_MS = 3000;
|
|
31
48
|
|
|
32
49
|
export function DriftRail({
|
|
33
50
|
children,
|
|
@@ -36,46 +53,154 @@ export function DriftRail({
|
|
|
36
53
|
pauseOnHover = true,
|
|
37
54
|
className,
|
|
38
55
|
}: DriftRailProps) {
|
|
39
|
-
const
|
|
40
|
-
const
|
|
41
|
-
const
|
|
56
|
+
const scrollerRef = useRef<HTMLDivElement>(null);
|
|
57
|
+
const trackRef = useRef<HTMLDivElement>(null);
|
|
58
|
+
const [isHovered, setIsHovered] = useState(false);
|
|
59
|
+
const [inView, setInView] = useState(true);
|
|
60
|
+
const [reducedMotion, setReducedMotion] = useState(false);
|
|
61
|
+
// `heldUntil` is a timestamp rather than a boolean so a native touch scroll
|
|
62
|
+
// that keeps going after the finger lifts is not fought by the drift.
|
|
63
|
+
const heldUntil = useRef(0);
|
|
64
|
+
const mouseDrag = useRef<{ startX: number; startScroll: number; pointerId: number } | null>(null);
|
|
65
|
+
// The drift's own position, kept with its fraction. `scrollLeft` is rounded
|
|
66
|
+
// to whole pixels by the browser, and at ~60px/s the per-frame step is under
|
|
67
|
+
// a pixel — read back and added to, it rounded to nothing and the rail never
|
|
68
|
+
// moved. So the loop adds to THIS and writes it out; while the reader holds
|
|
69
|
+
// the rail it follows `scrollLeft` instead, so it never fights the finger.
|
|
70
|
+
const position = useRef(0);
|
|
42
71
|
|
|
43
72
|
useEffect(() => {
|
|
44
|
-
|
|
45
|
-
|
|
73
|
+
if (typeof window === "undefined" || !window.matchMedia) return;
|
|
74
|
+
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
75
|
+
setReducedMotion(query.matches);
|
|
76
|
+
const onChange = () => setReducedMotion(query.matches);
|
|
77
|
+
query.addEventListener("change", onChange);
|
|
78
|
+
return () => query.removeEventListener("change", onChange);
|
|
79
|
+
}, []);
|
|
46
80
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
);
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
const node = scrollerRef.current;
|
|
83
|
+
if (!node || typeof IntersectionObserver === "undefined") return undefined;
|
|
84
|
+
const observer = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting), {
|
|
85
|
+
threshold: 0,
|
|
86
|
+
});
|
|
53
87
|
observer.observe(node);
|
|
54
88
|
return () => observer.disconnect();
|
|
55
89
|
}, []);
|
|
56
90
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
91
|
+
// The drift, and the wrap. Runs while mounted; the wrap has to run even
|
|
92
|
+
// while the reader holds the rail, so the loop never pauses — it only stops
|
|
93
|
+
// ADDING distance when something says it should.
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
const scroller = scrollerRef.current;
|
|
96
|
+
const track = trackRef.current;
|
|
97
|
+
if (!scroller || !track) return undefined;
|
|
98
|
+
|
|
99
|
+
let frame = 0;
|
|
100
|
+
let last = performance.now();
|
|
101
|
+
const drifting = !reducedMotion && inView && !(pauseOnHover && isHovered);
|
|
102
|
+
|
|
103
|
+
// Start the reverse loop inside the second copy so it has room to travel.
|
|
104
|
+
if (reverse && scroller.scrollLeft === 0) scroller.scrollLeft = track.scrollWidth / 2;
|
|
105
|
+
position.current = scroller.scrollLeft;
|
|
106
|
+
|
|
107
|
+
const tick = (now: number) => {
|
|
108
|
+
const half = track.scrollWidth / 2;
|
|
109
|
+
if (half > 0) {
|
|
110
|
+
const driftingNow = drifting && now >= heldUntil.current;
|
|
111
|
+
if (driftingNow) {
|
|
112
|
+
const perSecond = half / durationSeconds;
|
|
113
|
+
const delta = (perSecond * (now - last)) / 1000;
|
|
114
|
+
position.current += reverse ? -delta : delta;
|
|
115
|
+
} else {
|
|
116
|
+
position.current = scroller.scrollLeft;
|
|
70
117
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
118
|
+
// The wrap applies in both modes: the two copies are identical, so
|
|
119
|
+
// jumping by exactly one copy's width is invisible even mid-swipe.
|
|
120
|
+
let wrapped = false;
|
|
121
|
+
if (position.current >= half) {
|
|
122
|
+
position.current -= half;
|
|
123
|
+
wrapped = true;
|
|
124
|
+
} else if (position.current < 0) {
|
|
125
|
+
position.current += half;
|
|
126
|
+
wrapped = true;
|
|
75
127
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
128
|
+
if (driftingNow || wrapped) scroller.scrollLeft = position.current;
|
|
129
|
+
}
|
|
130
|
+
last = now;
|
|
131
|
+
frame = requestAnimationFrame(tick);
|
|
132
|
+
};
|
|
133
|
+
frame = requestAnimationFrame(tick);
|
|
134
|
+
return () => cancelAnimationFrame(frame);
|
|
135
|
+
}, [durationSeconds, inView, isHovered, pauseOnHover, reducedMotion, reverse]);
|
|
136
|
+
|
|
137
|
+
const hold = () => {
|
|
138
|
+
heldUntil.current = Number.POSITIVE_INFINITY;
|
|
139
|
+
};
|
|
140
|
+
const release = () => {
|
|
141
|
+
heldUntil.current = performance.now() + DRIFT_RAIL_RESUME_DELAY_MS;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// Touch and pen scroll natively; only a mouse needs help to drag.
|
|
145
|
+
const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
146
|
+
hold();
|
|
147
|
+
if (event.pointerType !== "mouse" || event.button !== 0 || !scrollerRef.current) return;
|
|
148
|
+
mouseDrag.current = {
|
|
149
|
+
startX: event.clientX,
|
|
150
|
+
startScroll: scrollerRef.current.scrollLeft,
|
|
151
|
+
pointerId: event.pointerId,
|
|
152
|
+
};
|
|
153
|
+
};
|
|
154
|
+
const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
155
|
+
const drag = mouseDrag.current;
|
|
156
|
+
if (!drag || event.pointerId !== drag.pointerId || !scrollerRef.current) return;
|
|
157
|
+
const dx = event.clientX - drag.startX;
|
|
158
|
+
if (Math.abs(dx) > 4 && !event.currentTarget.hasPointerCapture?.(event.pointerId)) {
|
|
159
|
+
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
160
|
+
event.currentTarget.dataset.dragging = "true";
|
|
161
|
+
}
|
|
162
|
+
scrollerRef.current.scrollLeft = drag.startScroll - dx;
|
|
163
|
+
};
|
|
164
|
+
const onPointerEnd = (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
165
|
+
release();
|
|
166
|
+
const drag = mouseDrag.current;
|
|
167
|
+
mouseDrag.current = null;
|
|
168
|
+
if (drag && event.currentTarget.hasPointerCapture?.(event.pointerId)) {
|
|
169
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
170
|
+
}
|
|
171
|
+
delete event.currentTarget.dataset.dragging;
|
|
172
|
+
};
|
|
173
|
+
// A mouse drag must not end as a click on the item under the cursor.
|
|
174
|
+
const onClickCapture = (event: React.MouseEvent<HTMLDivElement>) => {
|
|
175
|
+
if (event.currentTarget.dataset.dragging === "true") {
|
|
176
|
+
event.preventDefault();
|
|
177
|
+
event.stopPropagation();
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div
|
|
183
|
+
ref={scrollerRef}
|
|
184
|
+
onMouseEnter={() => setIsHovered(true)}
|
|
185
|
+
onMouseLeave={() => setIsHovered(false)}
|
|
186
|
+
onPointerDown={onPointerDown}
|
|
187
|
+
onPointerMove={onPointerMove}
|
|
188
|
+
onPointerUp={onPointerEnd}
|
|
189
|
+
onPointerCancel={onPointerEnd}
|
|
190
|
+
onWheel={() => {
|
|
191
|
+
release();
|
|
192
|
+
}}
|
|
193
|
+
// The items are links wrapping images; a mouse drag on one would start a
|
|
194
|
+
// native HTML5 drag and cancel the pointer stream mid-gesture.
|
|
195
|
+
onDragStart={(event) => event.preventDefault()}
|
|
196
|
+
onClickCapture={onClickCapture}
|
|
197
|
+
className={cn(
|
|
198
|
+
"overflow-x-auto overflow-y-hidden py-1.5 scrollbar-hide [touch-action:pan-x]",
|
|
199
|
+
"cursor-grab select-none data-[dragging=true]:cursor-grabbing",
|
|
200
|
+
className
|
|
201
|
+
)}
|
|
202
|
+
>
|
|
203
|
+
<div ref={trackRef} className="flex w-max items-stretch gap-3.5">
|
|
79
204
|
{children}
|
|
80
205
|
<div className="contents" aria-hidden="true">
|
|
81
206
|
{children}
|
|
@@ -79,6 +79,14 @@ export type EventCardProps = {
|
|
|
79
79
|
liveStatus?: string;
|
|
80
80
|
/** Hide the day/time line — the timeline supplies its own hour heading. */
|
|
81
81
|
hideWhen?: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* Already over — dims the poster, mutes the title and shows the `endedLabel`
|
|
84
|
+
* chip. Never row-level opacity, which composites the text below AA. Same
|
|
85
|
+
* contract as `EventCompactRow`.
|
|
86
|
+
*/
|
|
87
|
+
isEnded?: boolean;
|
|
88
|
+
/** Chip text for an ended event, e.g. "Ended". Required to render the chip. */
|
|
89
|
+
endedLabel?: string;
|
|
82
90
|
/** Overrides the day label when the card appears outside its own day section. */
|
|
83
91
|
dayLabel?: string;
|
|
84
92
|
/** Append the entry fee to the meta line. Off by default, matching the design's base card. */
|
|
@@ -108,6 +116,8 @@ export const EventCard = memo(function EventCard({
|
|
|
108
116
|
tags,
|
|
109
117
|
liveStatus,
|
|
110
118
|
hideWhen,
|
|
119
|
+
isEnded,
|
|
120
|
+
endedLabel,
|
|
111
121
|
dayLabel,
|
|
112
122
|
showPrice,
|
|
113
123
|
href,
|
|
@@ -118,6 +128,7 @@ export const EventCard = memo(function EventCard({
|
|
|
118
128
|
fallback = <EventPosterFallback />,
|
|
119
129
|
}: EventCardProps) {
|
|
120
130
|
const [imgError, setImgError] = useState(false);
|
|
131
|
+
const showEnded = isEnded && !!endedLabel;
|
|
121
132
|
const time = hideWhen ? "" : formatters.time(event.starts_at);
|
|
122
133
|
|
|
123
134
|
const fee = resolveEventEntryFee(event);
|
|
@@ -129,11 +140,12 @@ export const EventCard = memo(function EventCard({
|
|
|
129
140
|
<div
|
|
130
141
|
className={cn(
|
|
131
142
|
"relative overflow-hidden rounded-xl bg-muted",
|
|
143
|
+
isEnded && "opacity-45",
|
|
132
144
|
posterAspect === "3/4"
|
|
133
145
|
? "aspect-[3/4]"
|
|
134
146
|
: posterAspect === "4/5"
|
|
135
147
|
? "aspect-[4/5]"
|
|
136
|
-
: "aspect-[4/3]"
|
|
148
|
+
: "aspect-[4/3]",
|
|
137
149
|
)}
|
|
138
150
|
>
|
|
139
151
|
{showPoster ? (
|
|
@@ -154,9 +166,20 @@ export const EventCard = memo(function EventCard({
|
|
|
154
166
|
{/* Top overlay row — hype badge left, entry fee right. The empty span
|
|
155
167
|
keeps the fee pill right-aligned when there is no badge. */}
|
|
156
168
|
<div className="absolute inset-x-2 top-2 flex items-start justify-between gap-2">
|
|
157
|
-
{badge ?
|
|
169
|
+
{badge ? (
|
|
170
|
+
<span className={cn(PILL_BASE, "bg-black/55 text-white")}>
|
|
171
|
+
{badge}
|
|
172
|
+
</span>
|
|
173
|
+
) : (
|
|
174
|
+
<span />
|
|
175
|
+
)}
|
|
158
176
|
{fee.kind === "free" && (
|
|
159
|
-
<span
|
|
177
|
+
<span
|
|
178
|
+
className={cn(
|
|
179
|
+
PILL_BASE,
|
|
180
|
+
"bg-success font-bold text-success-foreground",
|
|
181
|
+
)}
|
|
182
|
+
>
|
|
160
183
|
<Gift className="h-3 w-3" aria-hidden />
|
|
161
184
|
{labels.free}
|
|
162
185
|
</span>
|
|
@@ -165,7 +188,9 @@ export const EventCard = memo(function EventCard({
|
|
|
165
188
|
|
|
166
189
|
{tagLabel && (
|
|
167
190
|
<div className="absolute inset-x-2 bottom-2 flex gap-1">
|
|
168
|
-
<span className={cn(PILL_BASE, "bg-black/55 text-white")}>
|
|
191
|
+
<span className={cn(PILL_BASE, "bg-black/55 text-white")}>
|
|
192
|
+
{tagLabel}
|
|
193
|
+
</span>
|
|
169
194
|
</div>
|
|
170
195
|
)}
|
|
171
196
|
</div>
|
|
@@ -180,7 +205,10 @@ export const EventCard = memo(function EventCard({
|
|
|
180
205
|
<h3
|
|
181
206
|
className={cn(
|
|
182
207
|
"mt-2.5 leading-[1.35]",
|
|
183
|
-
emphasis
|
|
208
|
+
emphasis
|
|
209
|
+
? "text-lg font-bold tracking-[-0.02em]"
|
|
210
|
+
: "text-sm font-semibold",
|
|
211
|
+
isEnded && "text-muted-foreground",
|
|
184
212
|
)}
|
|
185
213
|
>
|
|
186
214
|
{event.title}
|
|
@@ -232,6 +260,14 @@ export const EventCard = memo(function EventCard({
|
|
|
232
260
|
{liveStatus}
|
|
233
261
|
</div>
|
|
234
262
|
)}
|
|
263
|
+
|
|
264
|
+
{showEnded && (
|
|
265
|
+
<div className="mt-1.5">
|
|
266
|
+
<Tag size="sm" variant="default" className="font-extrabold">
|
|
267
|
+
{endedLabel}
|
|
268
|
+
</Tag>
|
|
269
|
+
</div>
|
|
270
|
+
)}
|
|
235
271
|
</>
|
|
236
272
|
);
|
|
237
273
|
|
|
@@ -239,7 +275,7 @@ export const EventCard = memo(function EventCard({
|
|
|
239
275
|
"flex min-w-0 flex-col text-left",
|
|
240
276
|
(href || onClick) &&
|
|
241
277
|
"cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl",
|
|
242
|
-
className
|
|
278
|
+
className,
|
|
243
279
|
);
|
|
244
280
|
|
|
245
281
|
if (href) {
|
|
@@ -247,7 +283,9 @@ export const EventCard = memo(function EventCard({
|
|
|
247
283
|
<LinkComponent
|
|
248
284
|
href={href}
|
|
249
285
|
className={rootClasses}
|
|
250
|
-
onClick={
|
|
286
|
+
onClick={
|
|
287
|
+
onClick ? (mouseEvent) => onClick(event, mouseEvent) : undefined
|
|
288
|
+
}
|
|
251
289
|
>
|
|
252
290
|
{body}
|
|
253
291
|
</LinkComponent>
|
|
@@ -256,7 +294,11 @@ export const EventCard = memo(function EventCard({
|
|
|
256
294
|
|
|
257
295
|
if (onClick) {
|
|
258
296
|
return (
|
|
259
|
-
<button
|
|
297
|
+
<button
|
|
298
|
+
type="button"
|
|
299
|
+
className={rootClasses}
|
|
300
|
+
onClick={(e) => onClick(event, e)}
|
|
301
|
+
>
|
|
260
302
|
{body}
|
|
261
303
|
</button>
|
|
262
304
|
);
|
|
@@ -289,14 +331,17 @@ export function EventCardSkeleton({
|
|
|
289
331
|
className,
|
|
290
332
|
}: EventCardSkeletonProps) {
|
|
291
333
|
return (
|
|
292
|
-
<div
|
|
334
|
+
<div
|
|
335
|
+
aria-hidden="true"
|
|
336
|
+
className={cn("flex min-w-0 flex-col text-left", className)}
|
|
337
|
+
>
|
|
293
338
|
<Skeleton
|
|
294
339
|
className={cn(
|
|
295
340
|
posterAspect === "3/4"
|
|
296
341
|
? "aspect-[3/4]"
|
|
297
342
|
: posterAspect === "4/5"
|
|
298
343
|
? "aspect-[4/5]"
|
|
299
|
-
: "aspect-[4/3]"
|
|
344
|
+
: "aspect-[4/3]",
|
|
300
345
|
)}
|
|
301
346
|
/>
|
|
302
347
|
|
|
@@ -53,6 +53,36 @@ export const FreeEntry: Story = {
|
|
|
53
53
|
args: { event: event("2", "Vinyl Happy Hour", "Zorki Photo Cafe", "2026-09-04T18:00:00", 0) },
|
|
54
54
|
};
|
|
55
55
|
|
|
56
|
+
/** The timeline row: fee on the meta line, tags below. */
|
|
57
|
+
export const TimelineRow: Story = {
|
|
58
|
+
args: {
|
|
59
|
+
event: event("t1", "Klausen Quiz by GOtrivia", "Klausen Pubhouse", "2026-09-07T20:00:00", 20),
|
|
60
|
+
showPrice: true,
|
|
61
|
+
tags: ["Drinks", "Games"],
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Running now: the on-air chip joins the meta line. */
|
|
66
|
+
export const Live: Story = {
|
|
67
|
+
args: {
|
|
68
|
+
event: event("l1", "International Quiz at Yolka", "Yolka", "2026-09-07T19:00:00", 0),
|
|
69
|
+
isLive: true,
|
|
70
|
+
showPrice: true,
|
|
71
|
+
tags: ["Drinks", "Games"],
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** Over: poster dimmed, title muted, an explicit chip. Never row-level opacity. */
|
|
76
|
+
export const Ended: Story = {
|
|
77
|
+
args: {
|
|
78
|
+
event: event("e1", "Hat Design Workshop with Matira Hats", "Del Mars", "2026-09-07T17:00:00", 795),
|
|
79
|
+
isEnded: true,
|
|
80
|
+
endedLabel: "Ended",
|
|
81
|
+
showPrice: true,
|
|
82
|
+
tags: ["Workshop"],
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
|
|
56
86
|
/**
|
|
57
87
|
* No poster — the gradient fallback, which this row did not have until the
|
|
58
88
|
* thumbnail grew. A blank `bg-muted` box passed for "no image" at 34px; at
|
|
@@ -49,3 +49,52 @@ describe("EventCompactRow sub line", () => {
|
|
|
49
49
|
expect(container.textContent).not.toContain("·");
|
|
50
50
|
});
|
|
51
51
|
});
|
|
52
|
+
|
|
53
|
+
describe("EventCompactRow state and price", () => {
|
|
54
|
+
it("adds the live chip to the meta line when isLive", () => {
|
|
55
|
+
render(<EventCompactRow event={event} labels={labels} formatters={formatters} isLive />);
|
|
56
|
+
expect(screen.getByText("Live now")).toBeInTheDocument();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("renders the ended chip only when both isEnded and endedLabel are given", () => {
|
|
60
|
+
const { rerender } = render(
|
|
61
|
+
<EventCompactRow event={event} labels={labels} formatters={formatters} isEnded />
|
|
62
|
+
);
|
|
63
|
+
expect(screen.queryByText("Ended")).not.toBeInTheDocument();
|
|
64
|
+
|
|
65
|
+
rerender(
|
|
66
|
+
<EventCompactRow event={event} labels={labels} formatters={formatters} isEnded endedLabel="Ended" />
|
|
67
|
+
);
|
|
68
|
+
expect(screen.getByText("Ended")).toBeInTheDocument();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("prints a paid fee on the meta line with showPrice, and nothing without it", () => {
|
|
72
|
+
const paid = { ...event, entry_fee: 20 };
|
|
73
|
+
const { container, rerender } = render(
|
|
74
|
+
<EventCompactRow event={paid} labels={labels} formatters={formatters} />
|
|
75
|
+
);
|
|
76
|
+
expect(container.textContent).not.toContain("20 RON");
|
|
77
|
+
|
|
78
|
+
rerender(<EventCompactRow event={paid} labels={labels} formatters={formatters} showPrice />);
|
|
79
|
+
expect(screen.getByText("20 RON")).toBeInTheDocument();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("prints the free label once — on the meta line with showPrice, as the trailing pill without", () => {
|
|
83
|
+
const free = { ...event, entry_fee: 0 };
|
|
84
|
+
const { rerender } = render(
|
|
85
|
+
<EventCompactRow event={free} labels={labels} formatters={formatters} />
|
|
86
|
+
);
|
|
87
|
+
expect(screen.getAllByText("Free")).toHaveLength(1);
|
|
88
|
+
|
|
89
|
+
rerender(<EventCompactRow event={free} labels={labels} formatters={formatters} showPrice />);
|
|
90
|
+
expect(screen.getAllByText("Free")).toHaveLength(1);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("prints nothing for an unknown fee even with showPrice", () => {
|
|
94
|
+
const { container } = render(
|
|
95
|
+
<EventCompactRow event={event} labels={labels} formatters={formatters} showPrice />
|
|
96
|
+
);
|
|
97
|
+
expect(container.textContent).not.toContain("RON");
|
|
98
|
+
expect(container.textContent).not.toContain("Free");
|
|
99
|
+
});
|
|
100
|
+
});
|