@bearmenu/ui 0.8.6 → 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 (88) 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/button.d.ts +1 -1
  8. package/dist/components/card.d.ts +2 -2
  9. package/dist/components/category-deck.js +10 -1
  10. package/dist/components/command.d.ts +15 -15
  11. package/dist/components/coverflow.d.ts +11 -3
  12. package/dist/components/coverflow.js +34 -51
  13. package/dist/components/drift-rail.d.ts +24 -10
  14. package/dist/components/drift-rail.js +1 -1
  15. package/dist/components/event-card.d.ts +8 -0
  16. package/dist/components/event-card.js +68 -35
  17. package/dist/components/event-compact-row.d.ts +34 -6
  18. package/dist/components/event-compact-row.js +120 -38
  19. package/dist/components/event-day-header.js +3 -1
  20. package/dist/components/event-day-separator.d.ts +7 -1
  21. package/dist/components/event-day-separator.js +3 -2
  22. package/dist/components/event-hub-hero.d.ts +25 -4
  23. package/dist/components/event-hub-hero.js +89 -19
  24. package/dist/components/event-list-row.d.ts +19 -6
  25. package/dist/components/event-list-row.js +78 -34
  26. package/dist/components/event-types.d.ts +5 -1
  27. package/dist/components/hour-band.d.ts +38 -40
  28. package/dist/components/hour-band.js +55 -27
  29. package/dist/components/hour-group.js +1 -1
  30. package/dist/components/item.d.ts +2 -2
  31. package/dist/components/poster-tape.js +1 -1
  32. package/dist/components/poster-wall.d.ts +3 -3
  33. package/dist/components/poster-wall.js +73 -17
  34. package/dist/components/section-header.d.ts +16 -1
  35. package/dist/components/section-header.js +26 -7
  36. package/dist/components/snap-rail.d.ts +34 -0
  37. package/dist/components/snap-rail.js +51 -0
  38. package/dist/components/spinner.d.ts +2 -2
  39. package/dist/components/tag.d.ts +2 -2
  40. package/dist/components/text.d.ts +3 -3
  41. package/dist/components/timeline-band.d.ts +20 -2
  42. package/dist/components/timeline-band.js +1 -1
  43. package/dist/components/timeline-playhead.d.ts +26 -0
  44. package/dist/components/timeline-playhead.js +25 -0
  45. package/dist/components/weekend-panel.d.ts +5 -4
  46. package/dist/components/weekend-panel.js +85 -71
  47. package/dist/hooks/use-drag-swipe.d.ts +44 -0
  48. package/dist/hooks/use-drag-swipe.js +2 -0
  49. package/dist/hooks/use-media-query.d.ts +17 -0
  50. package/dist/hooks/use-media-query.js +2 -0
  51. package/package.json +1 -1
  52. package/src/components/category-deck.tsx +19 -8
  53. package/src/components/coverflow.test.tsx +28 -1
  54. package/src/components/coverflow.tsx +37 -43
  55. package/src/components/drift-rail.tsx +167 -42
  56. package/src/components/event-card.test.tsx +11 -0
  57. package/src/components/event-card.tsx +67 -14
  58. package/src/components/event-compact-row.stories.tsx +30 -0
  59. package/src/components/event-compact-row.test.tsx +100 -0
  60. package/src/components/event-compact-row.tsx +166 -40
  61. package/src/components/event-day-header.tsx +3 -1
  62. package/src/components/event-day-separator.tsx +8 -1
  63. package/src/components/event-hub-hero.stories.tsx +30 -0
  64. package/src/components/event-hub-hero.tsx +102 -30
  65. package/src/components/event-list-row.stories.tsx +34 -0
  66. package/src/components/event-list-row.test.tsx +53 -0
  67. package/src/components/event-list-row.tsx +135 -87
  68. package/src/components/event-types.ts +5 -1
  69. package/src/components/hour-band.stories.tsx +28 -5
  70. package/src/components/hour-band.tsx +90 -70
  71. package/src/components/poster-wall.test.tsx +28 -0
  72. package/src/components/poster-wall.tsx +96 -23
  73. package/src/components/section-header.stories.tsx +24 -0
  74. package/src/components/section-header.test.tsx +28 -0
  75. package/src/components/section-header.tsx +45 -7
  76. package/src/components/snap-rail.stories.tsx +79 -0
  77. package/src/components/snap-rail.test.tsx +18 -0
  78. package/src/components/snap-rail.tsx +67 -0
  79. package/src/components/timeline-band.tsx +52 -12
  80. package/src/components/timeline-playhead.stories.tsx +30 -0
  81. package/src/components/timeline-playhead.test.tsx +11 -0
  82. package/src/components/timeline-playhead.tsx +46 -0
  83. package/src/components/weekend-panel.test.tsx +43 -0
  84. package/src/components/weekend-panel.tsx +142 -108
  85. package/src/hooks/use-drag-swipe.test.tsx +66 -0
  86. package/src/hooks/use-drag-swipe.ts +105 -0
  87. package/src/hooks/use-media-query.ts +31 -0
  88. package/dist/chunk-GPH6L4DX.js +0 -55
@@ -1,26 +1,41 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useId, useRef } from "react";
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 children; the component renders
9
- * a second, `aria-hidden` copy right after it so a `translateX(-50%)` loop is
10
- * seamless. Because the loop depends on the duplicate being pixel-identical
11
- * to the first pass, keep `children` a fixed, ordered list — no per-render
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
- * Motion is scoped to this instance via a generated id (no shared stylesheet,
15
- * no class collisions between multiple rails on one page) and is paused two
16
- * ways, per the source design: an `IntersectionObserver` stops the loop the
17
- * moment the rail scrolls off-screen, and `prefers-reduced-motion: reduce`
18
- * removes the animation outright rather than merely slowing it.
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 scopeId = useId().replace(/[^a-zA-Z0-9]/g, "");
40
- const scopeClass = `drift-rail-${scopeId}`;
41
- const containerRef = useRef<HTMLDivElement>(null);
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
- const node = containerRef.current;
45
- if (!node || typeof IntersectionObserver === "undefined") return undefined;
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
- const observer = new IntersectionObserver(
48
- ([entry]) => {
49
- node.dataset.offscreen = entry.isIntersecting ? "false" : "true";
50
- },
51
- { threshold: 0 }
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
- return (
58
- <div ref={containerRef} className={cn(scopeClass, "overflow-hidden py-1.5", className)}>
59
- <style>{`
60
- .${scopeClass} .drift-rail-run {
61
- animation: ${scopeClass}-run ${durationSeconds}s linear infinite;
62
- animation-direction: ${reverse ? "reverse" : "normal"};
63
- }
64
- .${scopeClass}[data-offscreen="true"] .drift-rail-run {
65
- animation-play-state: paused;
66
- }
67
- @keyframes ${scopeClass}-run {
68
- from { transform: translateX(0); }
69
- to { transform: translateX(-50%); }
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
- @media (prefers-reduced-motion: reduce) {
72
- .${scopeClass} .drift-rail-run {
73
- animation: none;
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
- ${pauseOnHover ? `.${scopeClass}:hover .drift-rail-run { animation-play-state: paused; }` : ""}
77
- `}</style>
78
- <div className="drift-rail-run flex w-max items-stretch gap-3.5 motion-reduce:[animation:none]">
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}
@@ -108,6 +108,17 @@ describe("EventCard", () => {
108
108
  expect(screen.queryByText("Sat, Sep 5")).not.toBeInTheDocument();
109
109
  });
110
110
 
111
+ it("prints the day alone, with no separator, when the time formatter returns an empty string", () => {
112
+ // "" is the formatter's way of saying the hour is unknown; the card must
113
+ // not print a dangling "·" or invent a placeholder for it.
114
+ const { container } = renderCard({
115
+ formatters: { ...formatters, time: () => "" },
116
+ });
117
+ expect(screen.getByText("Sat, Sep 5")).toBeInTheDocument();
118
+ expect(container.textContent).not.toContain("·");
119
+ expect(container.textContent).not.toContain("20:00");
120
+ });
121
+
111
122
  it("hides the whole meta line when hideWhen is set", () => {
112
123
  renderCard({ hideWhen: true });
113
124
  expect(screen.queryByText("20:00")).not.toBeInTheDocument();
@@ -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,8 @@ 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;
132
+ const time = hideWhen ? "" : formatters.time(event.starts_at);
121
133
 
122
134
  const fee = resolveEventEntryFee(event);
123
135
  const showPoster = !!event.poster_url && !imgError;
@@ -128,11 +140,12 @@ export const EventCard = memo(function EventCard({
128
140
  <div
129
141
  className={cn(
130
142
  "relative overflow-hidden rounded-xl bg-muted",
143
+ isEnded && "opacity-45",
131
144
  posterAspect === "3/4"
132
145
  ? "aspect-[3/4]"
133
146
  : posterAspect === "4/5"
134
147
  ? "aspect-[4/5]"
135
- : "aspect-[4/3]"
148
+ : "aspect-[4/3]",
136
149
  )}
137
150
  >
138
151
  {showPoster ? (
@@ -153,9 +166,20 @@ export const EventCard = memo(function EventCard({
153
166
  {/* Top overlay row — hype badge left, entry fee right. The empty span
154
167
  keeps the fee pill right-aligned when there is no badge. */}
155
168
  <div className="absolute inset-x-2 top-2 flex items-start justify-between gap-2">
156
- {badge ? <span className={cn(PILL_BASE, "bg-black/55 text-white")}>{badge}</span> : <span />}
169
+ {badge ? (
170
+ <span className={cn(PILL_BASE, "bg-black/55 text-white")}>
171
+ {badge}
172
+ </span>
173
+ ) : (
174
+ <span />
175
+ )}
157
176
  {fee.kind === "free" && (
158
- <span className={cn(PILL_BASE, "bg-success font-bold text-success-foreground")}>
177
+ <span
178
+ className={cn(
179
+ PILL_BASE,
180
+ "bg-success font-bold text-success-foreground",
181
+ )}
182
+ >
159
183
  <Gift className="h-3 w-3" aria-hidden />
160
184
  {labels.free}
161
185
  </span>
@@ -164,7 +188,9 @@ export const EventCard = memo(function EventCard({
164
188
 
165
189
  {tagLabel && (
166
190
  <div className="absolute inset-x-2 bottom-2 flex gap-1">
167
- <span className={cn(PILL_BASE, "bg-black/55 text-white")}>{tagLabel}</span>
191
+ <span className={cn(PILL_BASE, "bg-black/55 text-white")}>
192
+ {tagLabel}
193
+ </span>
168
194
  </div>
169
195
  )}
170
196
  </div>
@@ -179,7 +205,10 @@ export const EventCard = memo(function EventCard({
179
205
  <h3
180
206
  className={cn(
181
207
  "mt-2.5 leading-[1.35]",
182
- emphasis ? "text-lg font-bold tracking-[-0.02em]" : "text-sm font-semibold"
208
+ emphasis
209
+ ? "text-lg font-bold tracking-[-0.02em]"
210
+ : "text-sm font-semibold",
211
+ isEnded && "text-muted-foreground",
183
212
  )}
184
213
  >
185
214
  {event.title}
@@ -193,10 +222,17 @@ export const EventCard = memo(function EventCard({
193
222
  {!hideWhen && (
194
223
  <div className="mt-[3px] flex items-center gap-1.5 text-xs text-muted-foreground">
195
224
  <span>{dayLabel ?? formatters.day(event.starts_at)}</span>
196
- <span className="text-border" aria-hidden>
197
- ·
198
- </span>
199
- <span>{formatters.time(event.starts_at)}</span>
225
+ {/* An empty time is "hour unknown" (see `EventFormatters.time`): the
226
+ day stands alone, no dangling separator. Consumers used to have
227
+ to `hideWhen` these cards and lose the day with the time. */}
228
+ {time && (
229
+ <>
230
+ <span className="text-border" aria-hidden>
231
+ ·
232
+ </span>
233
+ <span>{time}</span>
234
+ </>
235
+ )}
200
236
  {showPrice && fee.kind === "paid" && (
201
237
  <>
202
238
  <span className="text-border" aria-hidden>
@@ -224,6 +260,14 @@ export const EventCard = memo(function EventCard({
224
260
  {liveStatus}
225
261
  </div>
226
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
+ )}
227
271
  </>
228
272
  );
229
273
 
@@ -231,7 +275,7 @@ export const EventCard = memo(function EventCard({
231
275
  "flex min-w-0 flex-col text-left",
232
276
  (href || onClick) &&
233
277
  "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl",
234
- className
278
+ className,
235
279
  );
236
280
 
237
281
  if (href) {
@@ -239,7 +283,9 @@ export const EventCard = memo(function EventCard({
239
283
  <LinkComponent
240
284
  href={href}
241
285
  className={rootClasses}
242
- onClick={onClick ? (mouseEvent) => onClick(event, mouseEvent) : undefined}
286
+ onClick={
287
+ onClick ? (mouseEvent) => onClick(event, mouseEvent) : undefined
288
+ }
243
289
  >
244
290
  {body}
245
291
  </LinkComponent>
@@ -248,7 +294,11 @@ export const EventCard = memo(function EventCard({
248
294
 
249
295
  if (onClick) {
250
296
  return (
251
- <button type="button" className={rootClasses} onClick={(e) => onClick(event, e)}>
297
+ <button
298
+ type="button"
299
+ className={rootClasses}
300
+ onClick={(e) => onClick(event, e)}
301
+ >
252
302
  {body}
253
303
  </button>
254
304
  );
@@ -281,14 +331,17 @@ export function EventCardSkeleton({
281
331
  className,
282
332
  }: EventCardSkeletonProps) {
283
333
  return (
284
- <div aria-hidden="true" className={cn("flex min-w-0 flex-col text-left", className)}>
334
+ <div
335
+ aria-hidden="true"
336
+ className={cn("flex min-w-0 flex-col text-left", className)}
337
+ >
285
338
  <Skeleton
286
339
  className={cn(
287
340
  posterAspect === "3/4"
288
341
  ? "aspect-[3/4]"
289
342
  : posterAspect === "4/5"
290
343
  ? "aspect-[4/5]"
291
- : "aspect-[4/3]"
344
+ : "aspect-[4/3]",
292
345
  )}
293
346
  />
294
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
@@ -0,0 +1,100 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { EventCompactRow } from "./event-compact-row";
4
+ import type { EventCardLabels, EventFormatters, EventPreview } from "./event-types";
5
+
6
+ const labels: EventCardLabels = { free: "Free", liveNow: "Live now" };
7
+
8
+ const formatters: EventFormatters = {
9
+ time: () => "20:00",
10
+ day: () => "Sat, Sep 5",
11
+ price: (amount) => `${amount} RON`,
12
+ };
13
+
14
+ const event: EventPreview = {
15
+ id: "e1",
16
+ title: "Psihotrop Live",
17
+ location: "Atelier Cafe",
18
+ starts_at: "2026-09-05T20:00:00Z",
19
+ place_name: "Atelier Cafe",
20
+ };
21
+
22
+ describe("EventCompactRow sub line", () => {
23
+ it("leads with the time and the venue by default", () => {
24
+ const { container } = render(
25
+ <EventCompactRow event={event} labels={labels} formatters={formatters} />
26
+ );
27
+ expect(screen.getByText("20:00")).toBeInTheDocument();
28
+ expect(container.textContent).toContain("·");
29
+ expect(screen.getByText("Atelier Cafe")).toBeInTheDocument();
30
+ });
31
+
32
+ it("prints the venue alone when the time formatter returns an empty string (hour unknown)", () => {
33
+ const { container } = render(
34
+ <EventCompactRow
35
+ event={event}
36
+ labels={labels}
37
+ formatters={{ ...formatters, time: () => "" }}
38
+ />
39
+ );
40
+ expect(container.textContent).not.toContain("·");
41
+ expect(screen.getByText("Atelier Cafe")).toBeInTheDocument();
42
+ });
43
+
44
+ it("still honours hideTime for callers that know the hour is stated elsewhere", () => {
45
+ const { container } = render(
46
+ <EventCompactRow event={event} labels={labels} formatters={formatters} hideTime />
47
+ );
48
+ expect(container.textContent).not.toContain("20:00");
49
+ expect(container.textContent).not.toContain("·");
50
+ });
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
+ });