@bearmenu/ui 0.8.16 → 0.8.18

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 (151) hide show
  1. package/dist/{chunk-WYTJR2YU.js → chunk-5CPBSNWQ.js} +6 -1
  2. package/dist/{chunk-36KEHCET.js → chunk-5LTILG3L.js} +15 -4
  3. package/dist/{chunk-P43B3ONY.js → chunk-5XREWOBB.js} +3 -1
  4. package/dist/{chunk-CLLY6XB3.js → chunk-6VOWT4YR.js} +6 -3
  5. package/dist/{chunk-S33K4RC5.js → chunk-BKKQ7GCZ.js} +1 -1
  6. package/dist/{chunk-ZLPWEHZO.js → chunk-EQYB4Q77.js} +2 -2
  7. package/dist/chunk-LYREUZH6.js +156 -0
  8. package/dist/chunk-PW247TG2.js +81 -0
  9. package/dist/chunk-QY66ME5B.js +207 -0
  10. package/dist/{chunk-T6S2ZBV6.js → chunk-SDWN6L4O.js} +1 -1
  11. package/dist/{chunk-CNJQUHCU.js → chunk-SWD6CJ35.js} +2 -1
  12. package/dist/{chunk-HDR7V6IZ.js → chunk-TIQNM4XD.js} +8 -1
  13. package/dist/components/alert-dialog.js +1 -1
  14. package/dist/components/alert.d.ts +2 -2
  15. package/dist/components/badge.d.ts +1 -1
  16. package/dist/components/badge.js +1 -1
  17. package/dist/components/button.d.ts +1 -1
  18. package/dist/components/button.js +1 -1
  19. package/dist/components/calendar.js +1 -1
  20. package/dist/components/card.d.ts +2 -2
  21. package/dist/components/carousel.js +1 -1
  22. package/dist/components/coach-marks.d.ts +63 -0
  23. package/dist/components/coach-marks.js +162 -0
  24. package/dist/components/combobox.js +1 -1
  25. package/dist/components/command.d.ts +15 -15
  26. package/dist/components/concept-compare-rail.js +3 -3
  27. package/dist/components/concept-compare.js +3 -3
  28. package/dist/components/concept-convergence.js +2 -2
  29. package/dist/components/doorway-tiles.d.ts +11 -0
  30. package/dist/components/doorway-tiles.js +5 -3
  31. package/dist/components/empty-state.js +1 -1
  32. package/dist/components/event-card.d.ts +22 -4
  33. package/dist/components/event-card.js +95 -50
  34. package/dist/components/event-day-header.d.ts +5 -3
  35. package/dist/components/event-day-header.js +12 -4
  36. package/dist/components/event-detail-panel.js +2 -2
  37. package/dist/components/event-door.d.ts +41 -0
  38. package/dist/components/event-door.js +65 -0
  39. package/dist/components/event-hub-hero.d.ts +10 -2
  40. package/dist/components/event-hub-hero.js +11 -5
  41. package/dist/components/event-index-row.js +1 -1
  42. package/dist/components/event-list-row-wide.js +2 -2
  43. package/dist/components/event-list-row.js +1 -1
  44. package/dist/components/event-tonight-strip.d.ts +50 -0
  45. package/dist/components/event-tonight-strip.js +67 -0
  46. package/dist/components/facet-group.d.ts +8 -1
  47. package/dist/components/facet-group.js +3 -0
  48. package/dist/components/filter-category-row.js +1 -1
  49. package/dist/components/hue-chip.js +1 -1
  50. package/dist/components/item.d.ts +2 -2
  51. package/dist/components/item.js +4 -1
  52. package/dist/components/kitchen-dossier.js +1 -1
  53. package/dist/components/link-button.js +1 -1
  54. package/dist/components/menu-family-block.d.ts +8 -1
  55. package/dist/components/menu-family-block.js +101 -68
  56. package/dist/components/menu-family-card.js +2 -2
  57. package/dist/components/menu-filter-chips.d.ts +12 -3
  58. package/dist/components/menu-filter-chips.js +22 -4
  59. package/dist/components/menu-hub-hero.d.ts +7 -1
  60. package/dist/components/menu-hub-hero.js +2 -1
  61. package/dist/components/menu-index-list.js +1 -1
  62. package/dist/components/menu-item.js +3 -3
  63. package/dist/components/menu-kitchen-list.d.ts +14 -2
  64. package/dist/components/menu-kitchen-list.js +1 -1
  65. package/dist/components/multi-select-combobox.js +2 -2
  66. package/dist/components/pagination.js +1 -1
  67. package/dist/components/photo-ground.d.ts +7 -1
  68. package/dist/components/photo-ground.js +1 -1
  69. package/dist/components/place-card.js +2 -2
  70. package/dist/components/place-details-mobile.js +1 -1
  71. package/dist/components/poster-stack.d.ts +36 -3
  72. package/dist/components/poster-stack.js +49 -18
  73. package/dist/components/searchable-select.js +1 -1
  74. package/dist/components/section-header.d.ts +7 -1
  75. package/dist/components/section-header.js +11 -7
  76. package/dist/components/signature-chip.js +2 -2
  77. package/dist/components/sliding-panels.js +1 -1
  78. package/dist/components/snap-rail.d.ts +2 -2
  79. package/dist/components/snap-rail.js +1 -1
  80. package/dist/components/spinner.d.ts +2 -2
  81. package/dist/components/tag.d.ts +2 -2
  82. package/dist/components/text.d.ts +3 -3
  83. package/dist/components/timeline-day.d.ts +54 -0
  84. package/dist/components/timeline-day.js +65 -0
  85. package/dist/components/timeline-hour-puck.d.ts +47 -0
  86. package/dist/components/timeline-hour-puck.js +3 -0
  87. package/dist/components/timeline-hour.d.ts +96 -0
  88. package/dist/components/{timeline-band.js → timeline-hour.js} +2 -1
  89. package/dist/components/timeline-playhead.js +1 -3
  90. package/dist/components/toggle-group.js +9 -4
  91. package/dist/components/toggle.d.ts +1 -1
  92. package/dist/components/toggle.js +1 -1
  93. package/dist/components/top-ranking-entry.js +3 -3
  94. package/package.json +1 -1
  95. package/src/components/badge.stories.tsx +31 -1
  96. package/src/components/badge.tsx +2 -0
  97. package/src/components/button.stories.tsx +5 -4
  98. package/src/components/button.test.tsx +11 -1
  99. package/src/components/button.tsx +16 -5
  100. package/src/components/coach-marks.stories.tsx +79 -0
  101. package/src/components/coach-marks.test.tsx +140 -0
  102. package/src/components/coach-marks.tsx +252 -0
  103. package/src/components/doorway-tiles.stories.tsx +34 -0
  104. package/src/components/doorway-tiles.test.tsx +27 -0
  105. package/src/components/doorway-tiles.tsx +30 -2
  106. package/src/components/event-card.tsx +70 -13
  107. package/src/components/event-day-header.tsx +19 -7
  108. package/src/components/event-door.stories.tsx +88 -0
  109. package/src/components/event-door.tsx +97 -0
  110. package/src/components/event-hub-hero.tsx +21 -3
  111. package/src/components/event-tonight-strip.stories.tsx +78 -0
  112. package/src/components/event-tonight-strip.test.tsx +59 -0
  113. package/src/components/event-tonight-strip.tsx +134 -0
  114. package/src/components/facet-group.tsx +9 -0
  115. package/src/components/hue-chip.test.tsx +6 -3
  116. package/src/components/hue-chip.tsx +5 -2
  117. package/src/components/item.tsx +4 -1
  118. package/src/components/menu-family-block.stories.tsx +12 -0
  119. package/src/components/menu-family-block.tsx +53 -20
  120. package/src/components/menu-filter-chips.tsx +32 -4
  121. package/src/components/menu-hub-hero.test.tsx +13 -0
  122. package/src/components/menu-hub-hero.tsx +9 -2
  123. package/src/components/menu-kitchen-list.tsx +45 -6
  124. package/src/components/photo-ground.tsx +7 -0
  125. package/src/components/poster-stack.stories.tsx +43 -1
  126. package/src/components/poster-stack.tsx +98 -19
  127. package/src/components/section-header.test.tsx +15 -0
  128. package/src/components/section-header.tsx +26 -12
  129. package/src/components/snap-rail.tsx +6 -1
  130. package/src/components/timeline-day.stories.tsx +201 -0
  131. package/src/components/timeline-day.test.tsx +78 -0
  132. package/src/components/timeline-day.tsx +114 -0
  133. package/src/components/timeline-hour-puck.tsx +139 -0
  134. package/src/components/timeline-hour.tsx +266 -0
  135. package/src/components/timeline-playhead.tsx +1 -1
  136. package/src/components/toggle-group.stories.tsx +37 -1
  137. package/src/components/toggle-group.tsx +11 -6
  138. package/src/components/toggle.tsx +8 -0
  139. package/dist/chunk-NSNEANOQ.js +0 -104
  140. package/dist/chunk-SXQZ7DLH.js +0 -167
  141. package/dist/components/hour-band.d.ts +0 -66
  142. package/dist/components/hour-band.js +0 -81
  143. package/dist/components/hour-group.d.ts +0 -67
  144. package/dist/components/hour-group.js +0 -59
  145. package/dist/components/timeline-band.d.ts +0 -64
  146. package/src/components/hour-band.stories.tsx +0 -182
  147. package/src/components/hour-band.tsx +0 -146
  148. package/src/components/hour-group.stories.tsx +0 -100
  149. package/src/components/hour-group.tsx +0 -124
  150. package/src/components/timeline-band.stories.tsx +0 -206
  151. package/src/components/timeline-band.tsx +0 -247
@@ -3,11 +3,29 @@
3
3
  import * as React from "react";
4
4
  import { useEffect, useRef, useState } from "react";
5
5
  import { cn } from "../lib/utils";
6
+ import { EventPosterFallback } from "./event-poster-fallback";
6
7
 
7
8
  /**
8
9
  * PosterStack — three overlapping, gently rotated posters that float, the
9
10
  * hub hero's aside slot in the source design.
10
11
  *
12
+ * TAPPABLE SINCE 0.8.18. The stack was three decorative images: the loudest
13
+ * thing on the hub's first screen, and the one thing on it a reader could not
14
+ * tap. A poster with `href` is an anchor now — the event's detail page for
15
+ * the crawler and a modified click, whatever the app's `linkComponent` does
16
+ * for a plain tap (the hub opens its preview drawer). `caption` prints the
17
+ * title at the poster's foot: it gives the tap a visible reason on touch,
18
+ * where there is no hover, and it is the anchor's accessible name, so the
19
+ * `<img>` goes `alt=""` whenever a caption is set. Without `href` a poster is
20
+ * decorative, as before.
21
+ *
22
+ * The float and the press live on DIFFERENT elements. The wrapper carries
23
+ * the keyframe `transform` (rotation + drift); the anchor inside it carries
24
+ * the press scale and the focus ring. One element cannot animate one
25
+ * `transform` and transition another. The pointed poster comes to the front
26
+ * (`hover:z-[4]`, `focus-within:z-[4]`) — z-index is not animated, it is the
27
+ * "pick this one" cue.
28
+ *
11
29
  * Two motion rules, both mandatory:
12
30
  * 1. Stops entirely under `prefers-reduced-motion: reduce` — enforced via
13
31
  * an injected `<style>` media query rather than relying on Tailwind's
@@ -20,15 +38,35 @@ import { cn } from "../lib/utils";
20
38
  * reduced motion is on, since nothing is animating to pause.
21
39
  */
22
40
 
41
+ export type PosterStackLinkProps = {
42
+ href: string;
43
+ className?: string;
44
+ children: React.ReactNode;
45
+ onClick?: (mouseEvent: React.MouseEvent) => void;
46
+ };
47
+
48
+ function DefaultLink({ href, ...props }: PosterStackLinkProps) {
49
+ return <a href={href} {...props} />;
50
+ }
51
+
23
52
  export type PosterStackPoster = {
24
- /** Absent renders a neutral placeholder block instead of an image. */
53
+ /** Absent renders `fallback` instead of an image. */
25
54
  src?: string;
26
55
  alt: string;
56
+ /** The event's detail page. With it the poster is an anchor; without it, decorative. */
57
+ href?: string;
58
+ /** Caption at the poster's foot — the event title. Becomes the anchor's
59
+ * accessible name; the `<img>` then gets `alt=""`. */
60
+ caption?: string;
61
+ /** Rendered when `src` is absent or fails. Defaults to `<EventPosterFallback />`. */
62
+ fallback?: React.ReactNode;
27
63
  };
28
64
 
29
65
  export type PosterStackProps = {
30
66
  /** Rendered in order; only the first three are shown. */
31
67
  posters: PosterStackPoster[];
68
+ /** Defaults to a plain `<a>`. Pass `next/link`, or an anchor that opens the app's preview. */
69
+ linkComponent?: React.ComponentType<PosterStackLinkProps>;
32
70
  className?: string;
33
71
  };
34
72
 
@@ -51,22 +89,28 @@ type PosterSlotConfig = {
51
89
  zIndexClassName?: string;
52
90
  };
53
91
 
92
+ /**
93
+ * Phone geometry fits a 343px column (375 minus the 16px gutters): the third
94
+ * poster used to reach 358px and ran into the right gutter. 112 / 128 / 112
95
+ * wide, 212px tall — 38px shorter than before, which is what lets the door
96
+ * row land in the first screen. Touch targets are 112×149 and 128×171.
97
+ */
54
98
  const POSTER_SLOTS: PosterSlotConfig[] = [
55
99
  {
56
- positionClassName: "left-[6px] top-[34px] w-[120px] md:left-0 md:top-[52px] md:w-[196px]",
100
+ positionClassName: "left-0 top-[28px] w-[112px] md:left-0 md:top-[52px] md:w-[196px]",
57
101
  restTransform: "rotate(-5deg)",
58
102
  animationName: "ds-poster-stack-float-1",
59
103
  animationDurationMs: 9000,
60
104
  },
61
105
  {
62
- positionClassName: "left-[110px] top-0 w-[140px] md:left-[168px] md:w-[232px]",
106
+ positionClassName: "left-[104px] top-0 w-[128px] md:left-[168px] md:w-[232px]",
63
107
  restTransform: "rotate(3deg)",
64
108
  animationName: "ds-poster-stack-float-2",
65
109
  animationDurationMs: 11000,
66
110
  zIndexClassName: "z-[3]",
67
111
  },
68
112
  {
69
- positionClassName: "left-[238px] top-[58px] w-[120px] md:left-[378px] md:top-24 md:w-[196px]",
113
+ positionClassName: "left-[224px] top-[44px] w-[112px] md:left-[378px] md:top-24 md:w-[196px]",
70
114
  restTransform: "rotate(6deg)",
71
115
  animationName: "ds-poster-stack-float-3",
72
116
  animationDurationMs: 10000,
@@ -77,7 +121,11 @@ const POSTER_SLOTS: PosterSlotConfig[] = [
77
121
  * shadow is an art-directed literal rather than a `--shadow-card` token. */
78
122
  const POSTER_SHADOW = "0 22px 50px oklch(0.3 0.02 60 / 0.24)";
79
123
 
80
- export function PosterStack({ posters, className }: PosterStackProps) {
124
+ export function PosterStack({
125
+ posters,
126
+ linkComponent: LinkComponent = DefaultLink,
127
+ className,
128
+ }: PosterStackProps) {
81
129
  const containerRef = useRef<HTMLDivElement>(null);
82
130
  const posterRefs = useRef<Array<HTMLDivElement | null>>([]);
83
131
  const [failedIndices, setFailedIndices] = useState<Set<number>>(new Set());
@@ -106,12 +154,41 @@ export function PosterStack({ posters, className }: PosterStackProps) {
106
154
  const visiblePosters = posters.slice(0, POSTER_SLOTS.length);
107
155
 
108
156
  return (
109
- <div ref={containerRef} className={cn("relative h-[250px] md:h-[400px]", className)}>
157
+ <div ref={containerRef} className={cn("relative h-[212px] md:h-[400px]", className)}>
110
158
  <style>{POSTER_STACK_ANIMATION_STYLE}</style>
111
159
 
112
160
  {visiblePosters.map((poster, index) => {
113
161
  const slot = POSTER_SLOTS[index];
114
162
  const showPoster = !!poster.src && !failedIndices.has(index);
163
+ const face = (
164
+ <>
165
+ {showPoster ? (
166
+ // eslint-disable-next-line @next/next/no-img-element
167
+ <img
168
+ src={poster.src}
169
+ alt={poster.caption ? "" : poster.alt}
170
+ loading="lazy"
171
+ decoding="async"
172
+ className={cn(
173
+ "h-full w-full object-cover",
174
+ poster.href &&
175
+ "transition-transform duration-300 group-hover/poster:scale-[1.03] motion-reduce:transition-none"
176
+ )}
177
+ onError={() => setFailedIndices((current) => new Set(current).add(index))}
178
+ />
179
+ ) : (
180
+ (poster.fallback ?? <EventPosterFallback />)
181
+ )}
182
+ {poster.caption && (
183
+ <span className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent px-2 pb-2 pt-6">
184
+ <span className="line-clamp-2 text-[11px] font-semibold leading-[1.25] text-white md:text-[12.5px]">
185
+ {poster.caption}
186
+ </span>
187
+ </span>
188
+ )}
189
+ </>
190
+ );
191
+
115
192
  return (
116
193
  <div
117
194
  key={poster.alt || index}
@@ -122,7 +199,8 @@ export function PosterStack({ posters, className }: PosterStackProps) {
122
199
  className={cn(
123
200
  "absolute aspect-[3/4] overflow-hidden rounded-xl bg-muted",
124
201
  slot.positionClassName,
125
- slot.zIndexClassName
202
+ slot.zIndexClassName,
203
+ poster.href && "hover:z-[4] focus-within:z-[4]"
126
204
  )}
127
205
  style={{
128
206
  transform: slot.restTransform,
@@ -134,18 +212,19 @@ export function PosterStack({ posters, className }: PosterStackProps) {
134
212
  animationDirection: "alternate",
135
213
  }}
136
214
  >
137
- {showPoster && (
138
- // eslint-disable-next-line @next/next/no-img-element
139
- <img
140
- src={poster.src}
141
- alt={poster.alt}
142
- loading="lazy"
143
- decoding="async"
144
- className="h-full w-full object-cover"
145
- onError={() =>
146
- setFailedIndices((current) => new Set(current).add(index))
147
- }
148
- />
215
+ {poster.href ? (
216
+ <LinkComponent
217
+ href={poster.href}
218
+ className={cn(
219
+ "group/poster relative block h-full w-full overflow-hidden rounded-xl",
220
+ "transition-transform duration-150 active:scale-[0.98] motion-reduce:transition-none",
221
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
222
+ )}
223
+ >
224
+ {face}
225
+ </LinkComponent>
226
+ ) : (
227
+ <div className="relative h-full w-full">{face}</div>
149
228
  )}
150
229
  </div>
151
230
  );
@@ -25,4 +25,19 @@ describe("SectionHeader", () => {
25
25
  expect(screen.getByText("Still to start").className).toContain("hidden");
26
26
  expect(screen.getByText("Still to start").className).toContain("lg:block");
27
27
  });
28
+
29
+ it("holds trailing controls at the row's end, beside the action", () => {
30
+ render(
31
+ <SectionHeader
32
+ title="Concepts"
33
+ action="See all"
34
+ actionHref="/concepts"
35
+ trailing={<button type="button">Next</button>}
36
+ />
37
+ );
38
+ const next = screen.getByRole("button", { name: "Next" });
39
+ const action = screen.getByRole("link", { name: "See all" });
40
+ expect(next.parentElement).toBe(action.parentElement);
41
+ expect(next.parentElement).not.toContainElement(screen.getByText("Concepts"));
42
+ });
28
43
  });
@@ -60,6 +60,12 @@ export type SectionHeaderProps = {
60
60
  * would dilute the headings that matter. Defaults to `"h2"`.
61
61
  */
62
62
  as?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "div";
63
+ /**
64
+ * Controls at the end of the title row, after `action` — a rail's
65
+ * previous / next pair on desktop. The header owns none of their
66
+ * behaviour; it only holds the slot.
67
+ */
68
+ trailing?: React.ReactNode;
63
69
  className?: string;
64
70
  };
65
71
 
@@ -91,6 +97,7 @@ export function SectionHeader({
91
97
  linkComponent: LinkComponent = DefaultLink,
92
98
  onAction,
93
99
  as: Heading = "h2",
100
+ trailing,
94
101
  className,
95
102
  }: SectionHeaderProps) {
96
103
  const actionIcon = <ChevronRight className="h-3.5 w-3.5" aria-hidden />;
@@ -110,18 +117,25 @@ export function SectionHeader({
110
117
  )}
111
118
  </div>
112
119
 
113
- {action &&
114
- (actionHref ? (
115
- <LinkComponent href={actionHref} className={actionClasses} onClick={onAction}>
116
- {action}
117
- {actionIcon}
118
- </LinkComponent>
119
- ) : onAction ? (
120
- <button type="button" className={actionClasses} onClick={onAction}>
121
- {action}
122
- {actionIcon}
123
- </button>
124
- ) : null)}
120
+ {/* The action and the trailing controls share the row's end, so the
121
+ action never floats to the middle when both are present. */}
122
+ {(action || trailing) && (
123
+ <div className="flex shrink-0 items-center gap-3">
124
+ {action &&
125
+ (actionHref ? (
126
+ <LinkComponent href={actionHref} className={actionClasses} onClick={onAction}>
127
+ {action}
128
+ {actionIcon}
129
+ </LinkComponent>
130
+ ) : onAction ? (
131
+ <button type="button" className={actionClasses} onClick={onAction}>
132
+ {action}
133
+ {actionIcon}
134
+ </button>
135
+ ) : null)}
136
+ {trailing}
137
+ </div>
138
+ )}
125
139
  </div>
126
140
 
127
141
  {subtitle && (
@@ -29,6 +29,9 @@ const snapRailVariants = cva(
29
29
  {
30
30
  variants: {
31
31
  itemWidth: {
32
+ /** Square door tiles — three and a third per phone screen, so the
33
+ * cut fourth says "swipe"; more as the viewport widens. */
34
+ xs: "[&>*]:w-[30%] sm:[&>*]:w-[22%] md:[&>*]:w-[18%]",
32
35
  sm: "[&>*]:w-[46%]",
33
36
  md: "[&>*]:w-[60%]",
34
37
  lg: "[&>*]:w-[80%]",
@@ -42,7 +45,9 @@ const snapRailVariants = cva(
42
45
  bleed: {
43
46
  none: "",
44
47
  card: "-mx-3.5 px-3.5 scroll-px-3.5",
45
- page: "-mx-4 px-4 scroll-px-4",
48
+ /** The consumer's page gutter, read from `--page-gutter` (16px on a
49
+ * phone, 24px from md) — a fixed `-mx-4` was 8px short at md. */
50
+ page: "-mx-[var(--page-gutter,1rem)] px-[var(--page-gutter,1rem)] scroll-px-[var(--page-gutter,1rem)]",
46
51
  },
47
52
  },
48
53
  defaultVariants: {
@@ -0,0 +1,201 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { TimelineDay, TimelineDaySkeleton } from "./timeline-day";
3
+ import { TimelineHour } from "./timeline-hour";
4
+ import { EventCard } from "./event-card";
5
+ import { EventDayHeader } from "./event-day-header";
6
+ import type { EventCardLabels, EventFormatters, EventPreview } from "./event-types";
7
+
8
+ const labels: EventCardLabels = { free: "Free", liveNow: "Live now" };
9
+
10
+ const formatters: EventFormatters = {
11
+ time: (iso) => iso.slice(11, 16),
12
+ day: () => "Thu, Sep 3",
13
+ price: (amount) => `${amount} RON`,
14
+ };
15
+
16
+ const POSTER =
17
+ "https://images.unsplash.com/photo-1514525253161-7a46d19cd819?w=640&q=70&auto=format&fit=crop";
18
+
19
+ const event = (
20
+ id: string,
21
+ title: string,
22
+ place: string,
23
+ hour: string,
24
+ fee?: number,
25
+ poster = POSTER
26
+ ): EventPreview => ({
27
+ id,
28
+ title,
29
+ location: place,
30
+ place_name: place,
31
+ starts_at: `2026-09-03T${hour}:00`,
32
+ entry_fee: fee,
33
+ poster_url: poster,
34
+ });
35
+
36
+ const meta: Meta<typeof TimelineDay> = {
37
+ title: "Events/TimelineDay",
38
+ component: TimelineDay,
39
+ tags: ["autodocs"],
40
+ decorators: [
41
+ (Story) => (
42
+ <div className="mx-auto max-w-[1180px] bg-background px-4">
43
+ <Story />
44
+ </div>
45
+ ),
46
+ ],
47
+ parameters: {
48
+ layout: "fullscreen",
49
+ docs: {
50
+ description: {
51
+ component:
52
+ "One day of the timeline, ONE DOM for both viewports. Below `lg` it is a column of hours, each a `TimelineHour` with a gutter chip and the hour's cards. From `lg` the same nodes become lane │ spine │ content: the hour with `role=\"hero\"` is placed in the lane by the grid and pins, every other hour stations itself on the spine, and cards past the cap paint as rows (`EventCard density=\"row-lg\"`). Resize the canvas across 1024px to watch one tree change shape. `rows` counts the column-3 children.",
53
+ },
54
+ },
55
+ },
56
+ };
57
+
58
+ export default meta;
59
+ type Story = StoryObj<typeof TimelineDay>;
60
+
61
+ function Card(props: {
62
+ ev: EventPreview;
63
+ emphasis?: boolean | "lg";
64
+ hideWhen?: boolean | "below-lg";
65
+ density?: "card" | "row-lg";
66
+ liveStatus?: string;
67
+ priority?: boolean;
68
+ }) {
69
+ return (
70
+ <EventCard
71
+ event={props.ev}
72
+ labels={labels}
73
+ formatters={formatters}
74
+ emphasis={props.emphasis}
75
+ hideWhen={props.hideWhen ?? true}
76
+ density={props.density}
77
+ liveStatus={props.liveStatus}
78
+ showPrice
79
+ tags={["Live music", "Jazz"]}
80
+ href={`#${props.ev.id}`}
81
+ priority={props.priority}
82
+ />
83
+ );
84
+ }
85
+
86
+ /**
87
+ * A six-hour Friday with the hero in the middle: 19:00 opens, the 20:00 hero
88
+ * is lifted into the lane on desktop while its hour's other event stays as a
89
+ * continuation, later hours demote to rows past the cap, and a group of
90
+ * events the ingest never timed trails the day off the axis.
91
+ */
92
+ export const FullDay: Story = {
93
+ render: () => (
94
+ <div className="flex flex-col gap-3 py-6">
95
+ <EventDayHeader
96
+ dayNumber="03"
97
+ weekday="Thursday"
98
+ meta="September · 9 events · 1 live"
99
+ size="responsive"
100
+ />
101
+ <TimelineDay rows={6} laneTop="16px">
102
+ <TimelineHour time="19:00" countLabel="2 events">
103
+ <Card ev={event("a", "Vinyl night at the bar", "Joben Bistro", "19:00", 0)} priority />
104
+ <Card ev={event("b", "Pub quiz: nineties", "Klausen", "19:00", 20)} />
105
+ </TimelineHour>
106
+ <TimelineHour time="20:00" role="hero" isLive liveLabel="NOW ON" countLabel="1 event">
107
+ <Card
108
+ ev={event("c", "Subcarpați live — sold-out tour warm-up", "Form Space", "20:00", 60)}
109
+ emphasis="lg"
110
+ hideWhen="below-lg"
111
+ liveStatus="Live now · ends around 23:00"
112
+ />
113
+ </TimelineHour>
114
+ <TimelineHour time="20:00" role="continuation" isLive liveLabel="NOW ON" countLabel="1 event">
115
+ <Card ev={event("d", "Wine tasting: Dealu Mare", "Enoteca", "20:00", 90)} />
116
+ </TimelineHour>
117
+ <TimelineHour time="21:00" countLabel="3 events" layout="rows">
118
+ <Card ev={event("e", "Comedy open mic", "Flying Circus", "21:00", 25)} density="row-lg" />
119
+ <Card ev={event("f", "Techno: Berlin calling", "Mansion", "21:00", 40)} density="row-lg" />
120
+ <Card ev={event("g", "Salsa social", "Casa Tranzit", "21:00", 0)} density="row-lg" />
121
+ </TimelineHour>
122
+ <TimelineHour time="23:00" countLabel="1 event" layout="rows">
123
+ <Card ev={event("h", "Late-night jazz jam", "Jazz Club", "23:00")} density="row-lg" />
124
+ </TimelineHour>
125
+ <TimelineHour
126
+ time="Time to be confirmed"
127
+ role="unscheduled"
128
+ countLabel="1 event"
129
+ note="The venue has not published a start time."
130
+ >
131
+ <Card ev={event("i", "Board games afternoon", "Insomnia Café", "00:00", 0)} />
132
+ </TimelineHour>
133
+ </TimelineDay>
134
+ </div>
135
+ ),
136
+ };
137
+
138
+ /** A quiet Tuesday: one hour, one event, no hero — no lane on desktop either. */
139
+ export const OneHour: Story = {
140
+ render: () => (
141
+ <div className="flex flex-col gap-3 py-6">
142
+ <EventDayHeader dayNumber="08" weekday="Tuesday" meta="September · 1 event" size="responsive" />
143
+ <TimelineDay rows={1}>
144
+ <TimelineHour time="19:30" countLabel="1 event">
145
+ <Card ev={event("a", "Book club: Cărtărescu", "Cărturești", "19:30", 0)} priority />
146
+ </TimelineHour>
147
+ </TimelineDay>
148
+ </div>
149
+ ),
150
+ };
151
+
152
+ /**
153
+ * Two full days stacked, so the lane's pin and release can be watched at
154
+ * 1024 and 1440: scroll the canvas and the first day's poster holds until
155
+ * its column runs out, then the second day's takes over.
156
+ */
157
+ export const StickyLane: Story = {
158
+ render: () => (
159
+ <div className="flex flex-col gap-10 py-6">
160
+ {["03", "04"].map((day) => (
161
+ <div key={day} className="flex flex-col gap-3">
162
+ <EventDayHeader
163
+ dayNumber={day}
164
+ weekday={day === "03" ? "Thursday" : "Friday"}
165
+ meta="September · 7 events"
166
+ size="responsive"
167
+ />
168
+ <TimelineDay rows={4} laneTop="16px">
169
+ <TimelineHour time="18:00" role="hero" countLabel="1 event">
170
+ <Card
171
+ ev={event(`${day}-hero`, "Headliner of the night", "Form Space", "18:00", 50)}
172
+ emphasis="lg"
173
+ hideWhen="below-lg"
174
+ />
175
+ </TimelineHour>
176
+ {["19:00", "20:00", "21:00", "22:00"].map((hour) => (
177
+ <TimelineHour key={hour} time={hour} countLabel="2 events" layout="rows">
178
+ <Card
179
+ ev={event(`${day}-${hour}-1`, `Something at ${hour}`, "Klausen", hour, 20)}
180
+ density="row-lg"
181
+ />
182
+ <Card
183
+ ev={event(`${day}-${hour}-2`, `Something else at ${hour}`, "Joben", hour, 0)}
184
+ density="row-lg"
185
+ />
186
+ </TimelineHour>
187
+ ))}
188
+ </TimelineDay>
189
+ </div>
190
+ ))}
191
+ </div>
192
+ ),
193
+ };
194
+
195
+ export const Skeleton: Story = {
196
+ render: () => (
197
+ <div className="py-6">
198
+ <TimelineDaySkeleton hourCount={3} />
199
+ </div>
200
+ ),
201
+ };
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { TimelineDay } from "./timeline-day";
4
+ import { TimelineHour } from "./timeline-hour";
5
+
6
+ describe("TimelineDay", () => {
7
+ it("renders every hour in source order, hero included, with one puck per stationed hour", () => {
8
+ const { container } = render(
9
+ <TimelineDay rows={2}>
10
+ <TimelineHour time="19:00" countLabel="1 event">
11
+ <p>Early</p>
12
+ </TimelineHour>
13
+ <TimelineHour time="20:00" role="hero">
14
+ <p>Hero</p>
15
+ </TimelineHour>
16
+ <TimelineHour time="21:00" countLabel="1 event">
17
+ <p>Late</p>
18
+ </TimelineHour>
19
+ </TimelineDay>
20
+ );
21
+
22
+ const hours = [...container.querySelectorAll("[data-timeline-hour]")];
23
+ expect(hours.map((hour) => hour.getAttribute("data-timeline-hour"))).toEqual([
24
+ "hour",
25
+ "hero",
26
+ "hour",
27
+ ]);
28
+ // Chronological in the DOM — the phone's visual order and the reading order.
29
+ expect(container.textContent).toMatch(/19:00[^]*Early[^]*20:00[^]*Hero[^]*21:00[^]*Late/);
30
+ expect(screen.getAllByText(/^\d\d:00$/)).toHaveLength(3);
31
+ });
32
+
33
+ it("sets the row count the lane and spine span", () => {
34
+ const { container } = render(
35
+ <TimelineDay rows={5} laneTop="120px">
36
+ <TimelineHour time="19:00">
37
+ <p>x</p>
38
+ </TimelineHour>
39
+ </TimelineDay>
40
+ );
41
+ const root = container.firstElementChild as HTMLElement;
42
+ expect(root.style.getPropertyValue("--timeline-rows")).toBe("5");
43
+ expect(root.style.getPropertyValue("--timeline-lane-top")).toBe("120px");
44
+ });
45
+
46
+ it("gives an unscheduled group an inline heading and no station", () => {
47
+ const { container } = render(
48
+ <TimelineDay rows={1}>
49
+ <TimelineHour time="Time to be confirmed" role="unscheduled" countLabel="2 events" note="No hour">
50
+ <p>a</p>
51
+ <p>b</p>
52
+ </TimelineHour>
53
+ </TimelineDay>
54
+ );
55
+ expect(screen.getByText("Time to be confirmed")).toBeInTheDocument();
56
+ expect(screen.getByText("2 events")).toBeInTheDocument();
57
+ expect(screen.getByText("No hour")).toBeInTheDocument();
58
+ expect(container.querySelector(".font-mono.tabular-nums.whitespace-nowrap")).toBeNull();
59
+ });
60
+
61
+ it("rules between rows but not between cards", () => {
62
+ const { container: rows } = render(
63
+ <TimelineHour time="21:00" layout="rows">
64
+ <p>a</p>
65
+ <p>b</p>
66
+ </TimelineHour>
67
+ );
68
+ const { container: cards } = render(
69
+ <TimelineHour time="21:00" layout="cards">
70
+ <p>a</p>
71
+ <p>b</p>
72
+ </TimelineHour>
73
+ );
74
+ // The hour's own hairline (vertical) plus the heading rule plus one between the two rows.
75
+ expect(rows.querySelectorAll('[data-orientation="horizontal"]').length).toBe(2);
76
+ expect(cards.querySelectorAll('[data-orientation="horizontal"]').length).toBe(1);
77
+ });
78
+ });