@bearmenu/ui 0.8.3 → 0.8.6

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 (194) hide show
  1. package/dist/chunk-2WDJR6K7.js +76 -0
  2. package/dist/chunk-637L7XQ3.js +85 -0
  3. package/dist/chunk-6XMYEZVR.js +26 -0
  4. package/dist/chunk-DNSXMHYX.js +8 -0
  5. package/dist/chunk-FDITZ2VM.js +16 -0
  6. package/dist/chunk-GPH6L4DX.js +55 -0
  7. package/dist/{chunk-N2KNBPAQ.js → chunk-QXHMPJ35.js} +1 -0
  8. package/dist/chunk-TT3WFS6S.js +379 -0
  9. package/dist/chunk-UXWAOISG.js +170 -0
  10. package/dist/{chunk-UZ3PCIE5.js → chunk-XWMRABZW.js} +2 -2
  11. package/dist/{chunk-VBA45GLP.js → chunk-ZDAHKI3A.js} +12 -2
  12. package/dist/components/alert.d.ts +2 -2
  13. package/dist/components/badge.d.ts +1 -1
  14. package/dist/components/button.d.ts +1 -1
  15. package/dist/components/card.d.ts +2 -2
  16. package/dist/components/card.js +4 -127
  17. package/dist/components/category-deck.d.ts +50 -0
  18. package/dist/components/category-deck.js +128 -0
  19. package/dist/components/command.d.ts +15 -15
  20. package/dist/components/concept-card.d.ts +43 -0
  21. package/dist/components/concept-card.js +81 -0
  22. package/dist/components/coverflow.d.ts +123 -0
  23. package/dist/components/coverflow.js +300 -0
  24. package/dist/components/date-input.d.ts +1 -1
  25. package/dist/components/day-rail-chip.d.ts +82 -0
  26. package/dist/components/day-rail-chip.js +105 -0
  27. package/dist/components/drift-rail.d.ts +28 -0
  28. package/dist/components/drift-rail.js +3 -0
  29. package/dist/components/event-card.d.ts +97 -0
  30. package/dist/components/event-card.js +161 -0
  31. package/dist/components/event-compact-row.d.ts +72 -0
  32. package/dist/components/event-compact-row.js +133 -0
  33. package/dist/components/event-day-header.d.ts +65 -0
  34. package/dist/components/event-day-header.js +95 -0
  35. package/dist/components/event-day-separator.d.ts +46 -0
  36. package/dist/components/event-day-separator.js +50 -0
  37. package/dist/components/event-detail-panel.d.ts +80 -0
  38. package/dist/components/event-detail-panel.js +251 -0
  39. package/dist/components/event-hub-hero.d.ts +34 -0
  40. package/dist/components/event-hub-hero.js +57 -0
  41. package/dist/components/event-index-row.d.ts +68 -0
  42. package/dist/components/event-index-row.js +84 -0
  43. package/dist/components/event-list-row-wide.d.ts +101 -0
  44. package/dist/components/event-list-row-wide.js +218 -0
  45. package/dist/components/event-list-row.d.ts +82 -0
  46. package/dist/components/event-list-row.js +173 -0
  47. package/dist/components/event-poster-fallback.d.ts +20 -0
  48. package/dist/components/event-poster-fallback.js +3 -0
  49. package/dist/components/event-types.d.ts +118 -0
  50. package/dist/components/event-types.js +2 -0
  51. package/dist/components/events-empty-state.d.ts +44 -0
  52. package/dist/components/events-empty-state.js +73 -0
  53. package/dist/components/facet-group.d.ts +93 -0
  54. package/dist/components/facet-group.js +222 -0
  55. package/dist/components/featured-rail-item.d.ts +42 -0
  56. package/dist/components/featured-rail-item.js +88 -0
  57. package/dist/components/hour-band.d.ts +68 -0
  58. package/dist/components/hour-band.js +53 -0
  59. package/dist/components/hour-group.d.ts +67 -0
  60. package/dist/components/hour-group.js +59 -0
  61. package/dist/components/icon.d.ts +18 -0
  62. package/dist/components/icon.js +31 -0
  63. package/dist/components/item.d.ts +2 -2
  64. package/dist/components/motion-icon-controls.d.ts +26 -0
  65. package/dist/components/motion-icon-controls.js +4 -0
  66. package/dist/components/motion-icon-set.d.ts +27 -0
  67. package/dist/components/motion-icon-set.js +139 -0
  68. package/dist/components/motion-icon.d.ts +44 -0
  69. package/dist/components/motion-icon.js +3 -0
  70. package/dist/components/multi-select-combobox.js +1 -1
  71. package/dist/components/place-about-tab.js +3 -3
  72. package/dist/components/place-details-mobile.js +3 -3
  73. package/dist/components/poster-stack.d.ts +30 -0
  74. package/dist/components/poster-stack.js +104 -0
  75. package/dist/components/poster-tape.d.ts +36 -0
  76. package/dist/components/poster-tape.js +64 -0
  77. package/dist/components/poster-wall.d.ts +25 -0
  78. package/dist/components/poster-wall.js +183 -0
  79. package/dist/components/quiet-stretch.d.ts +43 -0
  80. package/dist/components/quiet-stretch.js +54 -0
  81. package/dist/components/section-header.d.ts +53 -0
  82. package/dist/components/section-header.js +63 -0
  83. package/dist/components/segmented-nav.d.ts +62 -0
  84. package/dist/components/segmented-nav.js +54 -0
  85. package/dist/components/skeleton-card.js +1 -1
  86. package/dist/components/skeleton.d.ts +17 -0
  87. package/dist/components/skeleton.js +1 -1
  88. package/dist/components/spinner.d.ts +2 -2
  89. package/dist/components/tag-group.js +3 -26
  90. package/dist/components/tag.d.ts +2 -2
  91. package/dist/components/tag.js +1 -1
  92. package/dist/components/text.d.ts +3 -3
  93. package/dist/components/timeline-band.d.ts +46 -0
  94. package/dist/components/timeline-band.js +5 -0
  95. package/dist/components/venue-events-block.d.ts +143 -0
  96. package/dist/components/venue-events-block.js +222 -0
  97. package/dist/components/weekend-panel.d.ts +44 -0
  98. package/dist/components/weekend-panel.js +231 -0
  99. package/dist/lib/icon-registry.d.ts +58 -0
  100. package/dist/lib/icon-registry.js +39 -0
  101. package/package.json +2 -1
  102. package/src/components/accordion.stories.tsx +1 -1
  103. package/src/components/card.tsx +44 -1
  104. package/src/components/category-deck.stories.tsx +96 -0
  105. package/src/components/category-deck.tsx +227 -0
  106. package/src/components/combobox.stories.tsx +1 -1
  107. package/src/components/concept-card.stories.tsx +80 -0
  108. package/src/components/concept-card.tsx +143 -0
  109. package/src/components/coverflow.stories.tsx +240 -0
  110. package/src/components/coverflow.test.tsx +193 -0
  111. package/src/components/coverflow.tsx +550 -0
  112. package/src/components/currency-input.stories.tsx +3 -3
  113. package/src/components/day-rail-chip.stories.tsx +145 -0
  114. package/src/components/day-rail-chip.tsx +201 -0
  115. package/src/components/drift-rail.stories.tsx +92 -0
  116. package/src/components/drift-rail.tsx +86 -0
  117. package/src/components/empty-state.stories.tsx +3 -3
  118. package/src/components/event-card.stories.tsx +248 -0
  119. package/src/components/event-card.test.tsx +177 -0
  120. package/src/components/event-card.tsx +321 -0
  121. package/src/components/event-compact-row.stories.tsx +221 -0
  122. package/src/components/event-compact-row.tsx +268 -0
  123. package/src/components/event-day-header.stories.tsx +124 -0
  124. package/src/components/event-day-header.tsx +180 -0
  125. package/src/components/event-day-separator.stories.tsx +70 -0
  126. package/src/components/event-day-separator.tsx +95 -0
  127. package/src/components/event-detail-panel.stories.tsx +191 -0
  128. package/src/components/event-detail-panel.tsx +360 -0
  129. package/src/components/event-hub-hero.stories.tsx +66 -0
  130. package/src/components/event-hub-hero.tsx +110 -0
  131. package/src/components/event-index-row.stories.tsx +116 -0
  132. package/src/components/event-index-row.tsx +181 -0
  133. package/src/components/event-list-row-wide.stories.tsx +204 -0
  134. package/src/components/event-list-row-wide.tsx +386 -0
  135. package/src/components/event-list-row.stories.tsx +159 -0
  136. package/src/components/event-list-row.tsx +335 -0
  137. package/src/components/event-poster-fallback.stories.tsx +52 -0
  138. package/src/components/event-poster-fallback.tsx +31 -0
  139. package/src/components/event-types.ts +133 -0
  140. package/src/components/events-empty-state.stories.tsx +59 -0
  141. package/src/components/events-empty-state.tsx +131 -0
  142. package/src/components/facet-group.stories.tsx +247 -0
  143. package/src/components/facet-group.test.tsx +225 -0
  144. package/src/components/facet-group.tsx +352 -0
  145. package/src/components/featured-rail-item.stories.tsx +86 -0
  146. package/src/components/featured-rail-item.tsx +153 -0
  147. package/src/components/form.stories.tsx +1 -1
  148. package/src/components/hour-band.stories.tsx +159 -0
  149. package/src/components/hour-band.tsx +126 -0
  150. package/src/components/hour-group.stories.tsx +100 -0
  151. package/src/components/hour-group.tsx +124 -0
  152. package/src/components/icon.tsx +57 -0
  153. package/src/components/input-otp.stories.tsx +1 -1
  154. package/src/components/markdown-renderer.stories.tsx +1 -1
  155. package/src/components/motion-icon-controls.test.tsx +78 -0
  156. package/src/components/motion-icon-controls.tsx +552 -0
  157. package/src/components/motion-icon-set.stories.tsx +185 -0
  158. package/src/components/motion-icon-set.tsx +219 -0
  159. package/src/components/motion-icon.test.tsx +159 -0
  160. package/src/components/motion-icon.tsx +163 -0
  161. package/src/components/multi-select-combobox.stories.tsx +1 -1
  162. package/src/components/phone-frame.stories.tsx +1 -1
  163. package/src/components/poster-stack.stories.tsx +68 -0
  164. package/src/components/poster-stack.tsx +155 -0
  165. package/src/components/poster-tape.stories.tsx +70 -0
  166. package/src/components/poster-tape.tsx +122 -0
  167. package/src/components/poster-wall.stories.tsx +95 -0
  168. package/src/components/poster-wall.tsx +220 -0
  169. package/src/components/quiet-stretch.stories.tsx +62 -0
  170. package/src/components/quiet-stretch.tsx +115 -0
  171. package/src/components/rating.stories.tsx +3 -3
  172. package/src/components/searchable-select.stories.tsx +1 -1
  173. package/src/components/section-header.stories.tsx +70 -0
  174. package/src/components/section-header.tsx +134 -0
  175. package/src/components/segmented-nav.stories.tsx +118 -0
  176. package/src/components/segmented-nav.tsx +113 -0
  177. package/src/components/selection-bar.stories.tsx +1 -1
  178. package/src/components/skeleton-card.stories.tsx +3 -3
  179. package/src/components/skeleton.tsx +18 -0
  180. package/src/components/sliding-panels.stories.tsx +1 -1
  181. package/src/components/stepper.stories.tsx +1 -1
  182. package/src/components/sticky-container.stories.tsx +3 -3
  183. package/src/components/tag.tsx +11 -1
  184. package/src/components/timeline-band.stories.tsx +206 -0
  185. package/src/components/timeline-band.tsx +207 -0
  186. package/src/components/toggle-group.stories.tsx +1 -1
  187. package/src/components/tooltip.stories.tsx +2 -2
  188. package/src/components/venue-events-block.stories.tsx +216 -0
  189. package/src/components/venue-events-block.tsx +408 -0
  190. package/src/components/weekend-panel.stories.tsx +92 -0
  191. package/src/components/weekend-panel.tsx +322 -0
  192. package/src/globals.css +19 -0
  193. package/src/lib/icon-registry.ts +139 -0
  194. package/src/test/setup.ts +5 -1
@@ -0,0 +1,220 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cn } from "../lib/utils";
5
+ import { Skeleton } from "./skeleton";
6
+
7
+ /**
8
+ * PosterWall — the hub's full-bleed masthead: a dark tiled photo grid behind a
9
+ * sweeping light and a veil, with the caller's content laid over it.
10
+ *
11
+ * The wall is deliberately dark and un-themed (fixed dark tokens, not
12
+ * `background`/`foreground`) in both light and dark mode — it's a photo
13
+ * backdrop, the same reasoning `EventCard`'s black overlay pills use over a
14
+ * poster image, not a themed surface.
15
+ *
16
+ * `children` is a slot: PosterWall only owns the tiles/light/veil chrome and
17
+ * the container's size, alignment and padding for the given `variant`. All
18
+ * overlay typography belongs to the caller.
19
+ *
20
+ * The sweep is a genuine `@keyframes` animation (not Framer Motion) because
21
+ * its exact timing is part of the design spec and it needs to keep running
22
+ * indefinitely at zero React cost — the keyframes are scoped to this file via
23
+ * a `<style>` tag since editing the DS's global stylesheet is out of scope
24
+ * for this component.
25
+ */
26
+
27
+ const POSTER_WALL_DESKTOP_TILE_COUNT = 48;
28
+ const POSTER_WALL_COMPACT_TILE_COUNT = 36;
29
+ const POSTER_WALL_SWEEP_KEYFRAMES_ID = "ds-poster-wall-sweep-keyframes";
30
+
31
+ function usePrefersReducedMotion(): boolean {
32
+ const [reduced, setReduced] = React.useState(false);
33
+
34
+ React.useEffect(() => {
35
+ if (typeof window === "undefined" || !window.matchMedia) return;
36
+ const query = window.matchMedia("(prefers-reduced-motion: reduce)");
37
+ setReduced(query.matches);
38
+ const onChange = () => setReduced(query.matches);
39
+ query.addEventListener("change", onChange);
40
+ return () => query.removeEventListener("change", onChange);
41
+ }, []);
42
+
43
+ return reduced;
44
+ }
45
+
46
+ function useInViewport<T extends Element>(): [React.RefObject<T | null>, boolean] {
47
+ const ref = React.useRef<T | null>(null);
48
+ const [inView, setInView] = React.useState(true);
49
+
50
+ React.useEffect(() => {
51
+ const node = ref.current;
52
+ if (!node || typeof IntersectionObserver === "undefined") return;
53
+ const observer = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting), {
54
+ threshold: 0,
55
+ });
56
+ observer.observe(node);
57
+ return () => observer.disconnect();
58
+ }, []);
59
+
60
+ return [ref, inView];
61
+ }
62
+
63
+ export type PosterWallTile = {
64
+ src?: string;
65
+ };
66
+
67
+ export type PosterWallProps = {
68
+ /** Background tiles. Desktop renders 48, `compact` renders 36 — shorter arrays are padded with placeholders. */
69
+ tiles?: PosterWallTile[];
70
+ /** `compact` is the bottom-aligned mobile treatment (fewer, larger tiles, a top-fading veil). */
71
+ variant?: "default" | "compact";
72
+ /** The overlay content — fully caller-composed. */
73
+ children?: React.ReactNode;
74
+ className?: string;
75
+ };
76
+
77
+ export function PosterWall({ tiles, variant = "default", children, className }: PosterWallProps) {
78
+ const isCompact = variant === "compact";
79
+ const tileCount = isCompact ? POSTER_WALL_COMPACT_TILE_COUNT : POSTER_WALL_DESKTOP_TILE_COUNT;
80
+ const slots = Array.from({ length: tileCount }, (_, index): PosterWallTile => tiles?.[index] ?? {});
81
+
82
+ const prefersReducedMotion = usePrefersReducedMotion();
83
+ const [wallRef, inView] = useInViewport<HTMLDivElement>();
84
+ const isSweepPaused = prefersReducedMotion || !inView;
85
+
86
+ return (
87
+ <div
88
+ ref={wallRef}
89
+ className={cn(
90
+ "relative overflow-hidden rounded-[18px] bg-[oklch(0.22_0.012_60)]",
91
+ isCompact ? "h-[330px]" : "h-[420px]",
92
+ className
93
+ )}
94
+ >
95
+ <style>{`
96
+ @keyframes ${POSTER_WALL_SWEEP_KEYFRAMES_ID} {
97
+ 0% { transform: translateX(-30%); }
98
+ 100% { transform: translateX(30%); }
99
+ }
100
+ `}</style>
101
+
102
+ <div
103
+ className={cn(
104
+ "absolute inset-0 grid auto-rows-fr gap-[2px] opacity-50",
105
+ isCompact ? "grid-cols-6" : "grid-cols-12"
106
+ )}
107
+ aria-hidden
108
+ >
109
+ {slots.map((tile, index) =>
110
+ tile.src ? (
111
+ // eslint-disable-next-line @next/next/no-img-element
112
+ <img
113
+ key={index}
114
+ src={tile.src}
115
+ alt=""
116
+ loading="lazy"
117
+ decoding="async"
118
+ className="h-full w-full object-cover"
119
+ />
120
+ ) : (
121
+ <div
122
+ key={index}
123
+ className="h-full w-full bg-[repeating-linear-gradient(45deg,color-mix(in_oklch,white_10%,transparent)_0px,color-mix(in_oklch,white_10%,transparent)_2px,transparent_2px,transparent_8px)]"
124
+ />
125
+ )
126
+ )}
127
+ </div>
128
+
129
+ {/* Reduced motion and off-screen both resolve to "paused" via JS, not a
130
+ `motion-reduce:` class — the animation properties are inline styles
131
+ (so `animation-duration` can stay a plain string), and an inline
132
+ style always outranks a class on the same property. */}
133
+ <div
134
+ className="absolute -inset-[20%]"
135
+ style={{
136
+ background:
137
+ "radial-gradient(circle at 30% 50%, oklch(1 0 0 / 0.34) 0%, transparent 42%)",
138
+ animationName: POSTER_WALL_SWEEP_KEYFRAMES_ID,
139
+ animationDuration: "18s",
140
+ animationTimingFunction: "linear",
141
+ animationIterationCount: "infinite",
142
+ animationPlayState: isSweepPaused ? "paused" : "running",
143
+ }}
144
+ aria-hidden
145
+ />
146
+
147
+ <div
148
+ className="absolute inset-0"
149
+ style={{
150
+ background: isCompact
151
+ ? "linear-gradient(to top, oklch(0.2 0.01 60 / 0.93) 0%, oklch(0.2 0.01 60 / 0.7) 55%, oklch(0.2 0.01 60 / 0.35) 100%)"
152
+ : "linear-gradient(90deg, oklch(0.2 0.01 60 / 0.88) 0%, oklch(0.2 0.01 60 / 0.55) 55%, oklch(0.2 0.01 60 / 0.2) 100%)",
153
+ }}
154
+ aria-hidden
155
+ />
156
+
157
+ <div
158
+ className={cn(
159
+ "relative flex h-full min-w-0 flex-col text-white",
160
+ isCompact ? "justify-end gap-[11px] p-5" : "justify-center gap-[15px] px-12"
161
+ )}
162
+ >
163
+ {children}
164
+ </div>
165
+ </div>
166
+ );
167
+ }
168
+
169
+ export type PosterWallSkeletonProps = {
170
+ /** `compact` is the bottom-aligned mobile treatment (fewer, larger tiles, a top-fading veil). */
171
+ variant?: "default" | "compact";
172
+ className?: string;
173
+ };
174
+
175
+ /** The tile grid at rest — the sweeping light never renders in the loading state. */
176
+ export function PosterWallSkeleton({ variant = "default", className }: PosterWallSkeletonProps) {
177
+ const isCompact = variant === "compact";
178
+ const tileCount = isCompact ? POSTER_WALL_COMPACT_TILE_COUNT : POSTER_WALL_DESKTOP_TILE_COUNT;
179
+
180
+ return (
181
+ <div
182
+ aria-hidden="true"
183
+ className={cn(
184
+ "relative overflow-hidden rounded-[18px] bg-[oklch(0.22_0.012_60)]",
185
+ isCompact ? "h-[330px]" : "h-[420px]",
186
+ className
187
+ )}
188
+ >
189
+ <div
190
+ className={cn(
191
+ "absolute inset-0 grid auto-rows-fr gap-[2px] opacity-50",
192
+ isCompact ? "grid-cols-6" : "grid-cols-12"
193
+ )}
194
+ >
195
+ {Array.from({ length: tileCount }, (_, index) => (
196
+ <Skeleton key={index} className="h-full w-full rounded-none bg-white/10" />
197
+ ))}
198
+ </div>
199
+
200
+ <div
201
+ className="absolute inset-0"
202
+ style={{
203
+ background: isCompact
204
+ ? "linear-gradient(to top, oklch(0.2 0.01 60 / 0.93) 0%, oklch(0.2 0.01 60 / 0.7) 55%, oklch(0.2 0.01 60 / 0.35) 100%)"
205
+ : "linear-gradient(90deg, oklch(0.2 0.01 60 / 0.88) 0%, oklch(0.2 0.01 60 / 0.55) 55%, oklch(0.2 0.01 60 / 0.2) 100%)",
206
+ }}
207
+ />
208
+
209
+ <div
210
+ className={cn(
211
+ "relative flex h-full min-w-0 flex-col",
212
+ isCompact ? "justify-end gap-[11px] p-5" : "justify-center gap-[15px] px-12"
213
+ )}
214
+ >
215
+ <Skeleton className={cn("bg-white/15", isCompact ? "h-[11px] w-40" : "h-3 w-52")} />
216
+ <Skeleton className={cn("bg-white/20", isCompact ? "h-7 w-64" : "h-10 w-96")} />
217
+ </div>
218
+ </div>
219
+ );
220
+ }
@@ -0,0 +1,62 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { QuietStretch, type QuietStretchDay } from "./quiet-stretch";
3
+
4
+ const days: QuietStretchDay[] = [
5
+ { id: "mon", dayLabel: "Mon 7", count: 0 },
6
+ { id: "tue", dayLabel: "Tue 8", count: 0 },
7
+ { id: "wed", dayLabel: "Wed 9", count: 2 },
8
+ { id: "thu", dayLabel: "Thu 10", count: 0 },
9
+ ];
10
+
11
+ const meta: Meta<typeof QuietStretch> = {
12
+ title: "Events/QuietStretch",
13
+ component: QuietStretch,
14
+ tags: ["autodocs"],
15
+ args: {
16
+ label: "QUIET STRETCH",
17
+ days,
18
+ emptyLabel: "nothing yet",
19
+ actionLabel: "Expand",
20
+ },
21
+ decorators: [
22
+ (Story) => (
23
+ <div className="w-[640px]">
24
+ <Story />
25
+ </div>
26
+ ),
27
+ ],
28
+ parameters: {
29
+ docs: {
30
+ description: {
31
+ component:
32
+ "The collapsed run of quiet days on the timeline, summarising a run of near-empty days into one row. Responsive rather than a separate mobile component: past `sm` the label/days/action sit on one row, below it they stack.",
33
+ },
34
+ },
35
+ },
36
+ };
37
+
38
+ export default meta;
39
+ type Story = StoryObj<typeof QuietStretch>;
40
+
41
+ export const Default: Story = {
42
+ args: { onAction: () => {} },
43
+ };
44
+
45
+ /** No link or handler — the action renders as static text, not a control. */
46
+ export const NonInteractive: Story = {};
47
+
48
+ export const AsLink: Story = {
49
+ args: { href: "#quiet-stretch" },
50
+ };
51
+
52
+ /** Narrow viewport — label, days and action stack, days wrap at a tighter gap. */
53
+ export const Narrow: Story = {
54
+ args: { onAction: () => {} },
55
+ decorators: [
56
+ (Story) => (
57
+ <div className="w-[360px]">
58
+ <Story />
59
+ </div>
60
+ ),
61
+ ],
62
+ };
@@ -0,0 +1,115 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { ChevronDown } from "lucide-react";
5
+ import { cn } from "../lib/utils";
6
+
7
+ /**
8
+ * QuietStretch — the collapsed run of quiet days on the timeline, summarising
9
+ * a run of near-empty days into one row so the reader doesn't scroll past
10
+ * dozens of "nothing on" headers to reach the next thing worth seeing.
11
+ *
12
+ * Responsive rather than a separate mobile component: past `sm` the label,
13
+ * days and action sit on one row; below it they stack, and the days list
14
+ * wraps at a tighter gap (14px vs. the desktop 22px).
15
+ *
16
+ * The action renders as a link whenever `href` is given, a button when only
17
+ * `onAction` is — same anchor rule as the rest of the events surface.
18
+ */
19
+
20
+ export type QuietStretchLinkProps = {
21
+ href: string;
22
+ className?: string;
23
+ children: React.ReactNode;
24
+ onClick?: (mouseEvent: React.MouseEvent) => void;
25
+ };
26
+
27
+ function DefaultLink({ href, ...props }: QuietStretchLinkProps) {
28
+ return <a href={href} {...props} />;
29
+ }
30
+
31
+ export type QuietStretchDay = {
32
+ id: string;
33
+ /** Already formatted and localised, e.g. "Mon 7". */
34
+ dayLabel: string;
35
+ count: number;
36
+ };
37
+
38
+ export type QuietStretchProps = {
39
+ /** e.g. "QUIET STRETCH". */
40
+ label: string;
41
+ days: QuietStretchDay[];
42
+ /** Shown in place of a zero count, e.g. "nothing yet". */
43
+ emptyLabel: string;
44
+ /** e.g. "Expand". */
45
+ actionLabel: string;
46
+ href?: string;
47
+ linkComponent?: React.ComponentType<QuietStretchLinkProps>;
48
+ onAction?: (mouseEvent: React.MouseEvent) => void;
49
+ className?: string;
50
+ };
51
+
52
+ export function QuietStretch({
53
+ label,
54
+ days,
55
+ emptyLabel,
56
+ actionLabel,
57
+ href,
58
+ linkComponent: LinkComponent = DefaultLink,
59
+ onAction,
60
+ className,
61
+ }: QuietStretchProps) {
62
+ const isInteractive = !!href || !!onAction;
63
+
64
+ const actionClasses = cn(
65
+ "inline-flex items-center gap-1 whitespace-nowrap rounded-sm text-[13px] font-bold text-primary sm:ml-auto",
66
+ isInteractive &&
67
+ cn(
68
+ "-m-1 cursor-pointer p-1",
69
+ "transition-colors duration-150 hover:text-primary/80 motion-reduce:transition-none",
70
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
71
+ )
72
+ );
73
+
74
+ const action = (
75
+ <>
76
+ {actionLabel}
77
+ <ChevronDown className="h-3.5 w-3.5" aria-hidden />
78
+ </>
79
+ );
80
+
81
+ return (
82
+ <div
83
+ className={cn(
84
+ "my-2 flex flex-col gap-3 rounded-xl bg-muted px-[22px] py-5",
85
+ "sm:flex-row sm:items-center sm:gap-4",
86
+ className
87
+ )}
88
+ >
89
+ <span className="whitespace-nowrap text-xs font-extrabold tracking-widest text-muted-foreground">
90
+ {label}
91
+ </span>
92
+
93
+ <div className="flex flex-wrap gap-3.5 text-[13px] sm:gap-[22px]">
94
+ {days.map((day) => (
95
+ <span key={day.id} className="whitespace-nowrap">
96
+ <span className="font-bold">{day.dayLabel}</span>{" "}
97
+ <span className="text-muted-foreground">{day.count > 0 ? day.count : emptyLabel}</span>
98
+ </span>
99
+ ))}
100
+ </div>
101
+
102
+ {href ? (
103
+ <LinkComponent href={href} className={actionClasses} onClick={onAction}>
104
+ {action}
105
+ </LinkComponent>
106
+ ) : onAction ? (
107
+ <button type="button" className={actionClasses} onClick={onAction}>
108
+ {action}
109
+ </button>
110
+ ) : (
111
+ <span className={actionClasses}>{action}</span>
112
+ )}
113
+ </div>
114
+ );
115
+ }
@@ -1,14 +1,14 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { Rating } from "./rating";
3
3
 
4
- const meta = {
4
+ const meta: Meta<typeof Rating> = {
5
5
  title: "Components/Rating",
6
6
  component: Rating,
7
7
  tags: ["autodocs"],
8
- } satisfies Meta<typeof Rating>;
8
+ };
9
9
 
10
10
  export default meta;
11
- type Story = StoryObj<typeof meta>;
11
+ type Story = StoryObj<typeof Rating>;
12
12
 
13
13
  export const Default: Story = {
14
14
  render: () => <Rating value={4} reviewCount={128} />,
@@ -10,7 +10,7 @@ const meta = {
10
10
  } satisfies Meta<typeof SearchableSelect>;
11
11
 
12
12
  export default meta;
13
- type Story = StoryObj<typeof meta>;
13
+ type Story = StoryObj<typeof SearchableSelect>;
14
14
 
15
15
  const frameworks = [
16
16
  { value: "next", label: "Next.js", description: "The React Framework" },
@@ -0,0 +1,70 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { SectionHeader, SectionHeaderSkeleton } from "./section-header";
3
+
4
+ // Explicitly annotated rather than `satisfies`: the meta-level decorator makes
5
+ // the inferred type reference storybook's internal CSF module, which tsc cannot
6
+ // name portably (TS2742).
7
+ const meta: Meta<typeof SectionHeader> = {
8
+ title: "Events/SectionHeader",
9
+ component: SectionHeader,
10
+ tags: ["autodocs"],
11
+ args: { title: "Tonight" },
12
+ decorators: [
13
+ (Story) => (
14
+ <div className="w-[360px]">
15
+ <Story />
16
+ </div>
17
+ ),
18
+ ],
19
+ };
20
+
21
+ export default meta;
22
+ type Story = StoryObj<typeof SectionHeader>;
23
+
24
+ export const Default: Story = {};
25
+
26
+ export const WithCount: Story = {
27
+ args: { count: 12 },
28
+ };
29
+
30
+ export const WithAction: Story = {
31
+ args: { count: 12, action: "See all", actionHref: "/events" },
32
+ };
33
+
34
+ export const WithSubtitle: Story = {
35
+ args: {
36
+ title: "This weekend",
37
+ subtitle: "Curated by the team, updated every Friday.",
38
+ action: "See all",
39
+ actionHref: "/events/weekend",
40
+ },
41
+ };
42
+
43
+ /** `onAction` renders a button instead of a link — used inside a sheet/drawer. */
44
+ export const ButtonAction: Story = {
45
+ args: { action: "Reset", onAction: () => {} },
46
+ };
47
+
48
+ export const AsH1: Story = {
49
+ args: { as: "h1", title: "All events" },
50
+ };
51
+
52
+ /** Loading state — identical geometry to `Default`, so nothing shifts on data arrival. */
53
+ export const Loading: Story = {
54
+ render: () => <SectionHeaderSkeleton />,
55
+ };
56
+
57
+ /** Reserves space for count, action and subtitle — the richest header shape. */
58
+ export const LoadingFull: Story = {
59
+ render: () => <SectionHeaderSkeleton showCount showAction showSubtitle />,
60
+ };
61
+
62
+ /** Loading above loaded, so any geometry drift is visible at a glance. */
63
+ export const LoadingVsLoaded: Story = {
64
+ render: () => (
65
+ <div>
66
+ <SectionHeaderSkeleton showCount showAction />
67
+ <SectionHeader title="Tonight" count={12} action="See all" actionHref="/events" />
68
+ </div>
69
+ ),
70
+ };
@@ -0,0 +1,134 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { ChevronRight } from "lucide-react";
5
+ import { cn } from "../lib/utils";
6
+ import { Skeleton } from "./skeleton";
7
+
8
+ /**
9
+ * SectionHeader — the title row every rail, list and hub section opens with:
10
+ * title (+ optional count), an optional trailing action, and an optional
11
+ * subtitle line beneath.
12
+ *
13
+ * Renders a link when `actionHref` is given, a button when only `onAction`
14
+ * is, and no action at all otherwise — same href/onClick contract as
15
+ * `EventCard`. No app schema, no i18n, no app hooks.
16
+ */
17
+
18
+ export type SectionHeaderLinkProps = {
19
+ href: string;
20
+ className?: string;
21
+ children: React.ReactNode;
22
+ onClick?: (mouseEvent: React.MouseEvent) => void;
23
+ };
24
+
25
+ function DefaultLink({ href, ...props }: SectionHeaderLinkProps) {
26
+ return <a href={href} {...props} />;
27
+ }
28
+
29
+ export type SectionHeaderProps = {
30
+ title: string;
31
+ /** Renders inline after the title in muted text, e.g. "12". */
32
+ count?: number;
33
+ /** Sub-text below the title row. */
34
+ subtitle?: string;
35
+ /** Trailing action label, e.g. "See all". */
36
+ action?: string;
37
+ actionHref?: string;
38
+ /** Defaults to a plain `<a>`. Pass `next/link` in a Next app. */
39
+ linkComponent?: React.ComponentType<SectionHeaderLinkProps>;
40
+ onAction?: (mouseEvent: React.MouseEvent) => void;
41
+ /**
42
+ * Heading level for `title`, or `"p"` / `"div"` for a section opener that
43
+ * should NOT enter the document outline — e.g. an index whose own group
44
+ * headings are the page's semantic `h2`s, where a generic opener above them
45
+ * would dilute the headings that matter. Defaults to `"h2"`.
46
+ */
47
+ as?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "div";
48
+ className?: string;
49
+ };
50
+
51
+ const ACTION_CLASSES = cn(
52
+ "inline-flex shrink-0 items-center gap-0.5 rounded-sm text-[14px] text-muted-foreground",
53
+ "transition-colors duration-150 hover:text-foreground motion-reduce:transition-none",
54
+ "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
55
+ );
56
+
57
+ export function SectionHeader({
58
+ title,
59
+ count,
60
+ subtitle,
61
+ action,
62
+ actionHref,
63
+ linkComponent: LinkComponent = DefaultLink,
64
+ onAction,
65
+ as: Heading = "h2",
66
+ className,
67
+ }: SectionHeaderProps) {
68
+ const actionIcon = <ChevronRight className="h-3.5 w-3.5" aria-hidden />;
69
+
70
+ return (
71
+ <div className={cn("flex flex-col mb-3.5", className)}>
72
+ <div className="flex items-center justify-between gap-2">
73
+ <div className="flex min-w-0 items-baseline">
74
+ <Heading className="truncate text-[17px] font-bold tracking-[-0.01em] text-foreground md:text-[18px]">
75
+ {title}
76
+ </Heading>
77
+ {count !== undefined && (
78
+ <span className="ml-0.5 shrink-0 text-[12px] text-muted-foreground">{count}</span>
79
+ )}
80
+ </div>
81
+
82
+ {action &&
83
+ (actionHref ? (
84
+ <LinkComponent href={actionHref} className={ACTION_CLASSES} onClick={onAction}>
85
+ {action}
86
+ {actionIcon}
87
+ </LinkComponent>
88
+ ) : onAction ? (
89
+ <button type="button" className={ACTION_CLASSES} onClick={onAction}>
90
+ {action}
91
+ {actionIcon}
92
+ </button>
93
+ ) : null)}
94
+ </div>
95
+
96
+ {subtitle && (
97
+ <p className="mt-0.5 text-[13px] text-muted-foreground md:text-[14px]">{subtitle}</p>
98
+ )}
99
+ </div>
100
+ );
101
+ }
102
+
103
+ export type SectionHeaderSkeletonProps = {
104
+ /** Shows the inline count placeholder, matching `SectionHeaderProps["count"]`. */
105
+ showCount?: boolean;
106
+ /** Shows the trailing action placeholder, matching `SectionHeaderProps["action"]`. */
107
+ showAction?: boolean;
108
+ /** Shows the subtitle placeholder, matching `SectionHeaderProps["subtitle"]`. */
109
+ showSubtitle?: boolean;
110
+ className?: string;
111
+ };
112
+
113
+ /** Mirrors `<SectionHeader>`'s geometry exactly so nothing shifts when data lands. */
114
+ export function SectionHeaderSkeleton({
115
+ showCount,
116
+ showAction,
117
+ showSubtitle,
118
+ className,
119
+ }: SectionHeaderSkeletonProps) {
120
+ return (
121
+ <div aria-hidden="true" className={cn("flex flex-col mb-3.5", className)}>
122
+ <div className="flex items-center justify-between gap-2">
123
+ <div className="flex min-w-0 items-baseline gap-1">
124
+ <Skeleton className="h-[17px] w-32 md:h-[18px]" />
125
+ {showCount && <Skeleton className="h-3 w-4" />}
126
+ </div>
127
+
128
+ {showAction && <Skeleton className="h-3.5 w-16" />}
129
+ </div>
130
+
131
+ {showSubtitle && <Skeleton className="mt-0.5 h-[13px] w-48 md:h-3.5" />}
132
+ </div>
133
+ );
134
+ }