@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,352 @@
1
+ "use client";
2
+
3
+ import { type ComponentPropsWithoutRef, useId, useState } from "react";
4
+ import { Check, ChevronDown, type LucideIcon } from "lucide-react";
5
+ import { cn } from "../lib/utils";
6
+ import { Skeleton } from "./skeleton";
7
+
8
+ /**
9
+ * FacetGroup — one filter group in the events sidebar or the mobile filter sheet.
10
+ *
11
+ * REAL INPUTS, NOT STYLED DIVS: the DS's `<Checkbox>` (`checkbox.tsx`) is
12
+ * a Radix primitive whose check glyph is fixed at 14px inside a 20px box with
13
+ * no prop to shrink either — this design's 16px box with an 11px glyph can't
14
+ * be reached by styling it from the outside, and this file isn't allowed to
15
+ * edit `checkbox.tsx` itself. So each row is a visually-hidden native
16
+ * `<input>` — real semantics, real keyboard/Enter/Space/arrow behaviour, a
17
+ * real accessibility-tree checked state — paired with a decorative box that
18
+ * carries the visual design. `peer`/`peer-focus-visible` puts a visible focus
19
+ * ring on the decorative box when the hidden input has focus.
20
+ *
21
+ * SELECTION MODE IS PART OF THE CONTRACT: `selection="single"` renders real
22
+ * radios sharing one `name`, so the browser and the accessibility tree both
23
+ * know only one value can be live. Rendering checkboxes for a single-valued
24
+ * facet (the events list's WHEN scope, for one) makes the control lie: ticking
25
+ * one option silently unticks another, and a screen reader announces N
26
+ * independent checkboxes one of which is inexplicably always checked.
27
+ *
28
+ * The group itself is a `<fieldset>`/`<legend>`, the correct native pairing
29
+ * for "here is a caption for this set of options" — screen readers announce
30
+ * the group title once per option instead of the title being lost as a bare
31
+ * visual label.
32
+ */
33
+
34
+ export type FacetOption = {
35
+ id: string;
36
+ label: string;
37
+ count: number;
38
+ checked?: boolean;
39
+ };
40
+
41
+ export type FacetGroupSelection = "multiple" | "single";
42
+
43
+ /**
44
+ * Row density. `comfortable` is the default because this component also renders
45
+ * inside the mobile filter sheet, where a row has to clear the 44px WCAG 2.5.5
46
+ * target floor. `compact` is opt-in, for a dense pointer-driven sidebar.
47
+ */
48
+ export type FacetGroupSize = "compact" | "comfortable";
49
+
50
+ /**
51
+ * Same 16px box, same focus ring, same count column in both densities — only
52
+ * the row's own box grows.
53
+ *
54
+ * BOTH carry the hover treatment. `comfortable` shipped without one on the
55
+ * reasoning that it is the touch density, but the two are unrelated: the size
56
+ * exists to meet the 44px target floor, and a pointer can land on a row at any
57
+ * size. A 44px hit area that does not light up reads as disabled — worse than
58
+ * the 20px row it replaced, because it looks like a control that is present but
59
+ * switched off.
60
+ */
61
+ const ROW_SIZE: Record<FacetGroupSize, string> = {
62
+ comfortable: "-mx-2 min-h-11 rounded-lg px-2",
63
+ compact: "-mx-2 rounded-lg px-2 py-1",
64
+ };
65
+
66
+ /**
67
+ * Gap BETWEEN rows, per density — not one shared value.
68
+ *
69
+ * `comfortable` rows are 44px for the touch floor, so 9px between them is a
70
+ * reasonable rhythm. `compact` rows are ~26px, and the same 9px made the gap a
71
+ * third of the row again: a 16px control and a 13.5px label adrift in a pitch
72
+ * far taller than either, which reads as a list that has lost its grouping.
73
+ * 2px keeps the rows distinct without spacing them into separate objects.
74
+ */
75
+ const ROW_GAP: Record<FacetGroupSize, string> = {
76
+ comfortable: "gap-[9px]",
77
+ compact: "gap-[2px]",
78
+ };
79
+
80
+ // `onToggle` is a native DOM handler on every element in React 19's typings —
81
+ // omit it so this component's own `(id, checked)` callback isn't widened into a
82
+ // union with `ToggleEvent`.
83
+ export type FacetGroupProps = Omit<
84
+ ComponentPropsWithoutRef<"fieldset">,
85
+ "title" | "children" | "onToggle"
86
+ > & {
87
+ title: string;
88
+ /** Decorative glyph in the legend, before the title. The title stays the accessible name. */
89
+ icon?: LucideIcon;
90
+ options: FacetOption[];
91
+ /**
92
+ * `"multiple"` (default) renders checkboxes. `"single"` renders radios sharing
93
+ * one `name` — use it for any facet whose model holds exactly one value.
94
+ */
95
+ selection?: FacetGroupSelection;
96
+ /**
97
+ * The radio group's shared `name` when `selection="single"`. Optional: a
98
+ * per-instance name is derived from `useId()` when omitted, so radios can
99
+ * never leak into a neighbouring group. Pass one when the fieldset is
100
+ * submitted as part of a native form and the name has to be stable.
101
+ */
102
+ name?: string;
103
+ size?: FacetGroupSize;
104
+ /**
105
+ * Turns the legend into a disclosure toggle for this group's options.
106
+ *
107
+ * Off by default: a sidebar of permanently-open groups is the right default
108
+ * for a facet panel, because seeing which dimensions exist is most of what
109
+ * the panel is for. Turn it on where several groups are long enough that
110
+ * scanning them costs more than opening one — the events list's five, with
111
+ * venues running to dozens of options.
112
+ */
113
+ collapsible?: boolean;
114
+ /** Only meaningful with `collapsible`. Groups open by default. */
115
+ defaultOpen?: boolean;
116
+ /** Fires with `(id, true)` for the newly picked radio under `selection="single"`. */
117
+ onToggle?: (id: string, checked: boolean) => void;
118
+ /** e.g. "Show all". Omit to hide the trailing action. */
119
+ moreLabel?: string;
120
+ onMore?: () => void;
121
+ className?: string;
122
+ };
123
+
124
+ export function FacetGroup({
125
+ title,
126
+ icon: Icon,
127
+ options,
128
+ selection = "multiple",
129
+ name,
130
+ size = "comfortable",
131
+ collapsible = false,
132
+ defaultOpen = true,
133
+ onToggle,
134
+ moreLabel,
135
+ onMore,
136
+ className,
137
+ ...rest
138
+ }: FacetGroupProps) {
139
+ const isSingle = selection === "single";
140
+ const regionId = useId();
141
+ // A non-collapsible group is permanently open — the state exists but the
142
+ // toggle that would change it is never rendered.
143
+ const [isOpen, setIsOpen] = useState(defaultOpen);
144
+ const open = collapsible ? isOpen : true;
145
+ const setOpen = setIsOpen;
146
+ // Never let an unnamed radio group fall into the page-wide anonymous group.
147
+ const fallbackName = useId();
148
+ const groupName = name ?? fallbackName;
149
+
150
+ return (
151
+ // NO SEPARATOR OF ITS OWN. This drew `<Separator className="absolute
152
+ // inset-x-0 top-0" />`, which looked like a rule above each group and was
153
+ // not: a `<legend>` is laid out in the fieldset's border box, so the
154
+ // padding box — and therefore `top-0` — begins BELOW it. The rule landed
155
+ // between a group's own label and its own options, cutting each group in
156
+ // half rather than dividing one from the next, and the first group got one
157
+ // above nothing.
158
+ //
159
+ // A divider between siblings belongs to whatever owns the order, and that
160
+ // is the caller — it alone knows which group is first and which groups
161
+ // rendered at all. So the caller interleaves `<Separator />` between
162
+ // groups; this component draws none.
163
+ <fieldset className={cn("m-0 border-0 p-0 py-3", className)} {...rest}>
164
+ <legend
165
+ className={cn(
166
+ "block w-full p-0 text-[11.5px] font-extrabold tracking-[0.1em] text-muted-foreground uppercase",
167
+ collapsible ? "mb-0" : "mb-[11px]"
168
+ )}
169
+ >
170
+ {collapsible ? (
171
+ // The legend becomes the toggle. It stays inside `<legend>` so the
172
+ // fieldset keeps its accessible name and screen readers still
173
+ // announce the group per option; `aria-expanded` and the region id
174
+ // carry the disclosure on top of that rather than replacing it.
175
+ <button
176
+ type="button"
177
+ onClick={() => setOpen((previous) => !previous)}
178
+ aria-expanded={open}
179
+ aria-controls={regionId}
180
+ className={cn(
181
+ "-mx-2 flex w-[calc(100%+1rem)] cursor-pointer items-center gap-1.5 rounded-lg px-2 py-1.5 text-left uppercase",
182
+ "transition-[margin,background-color] duration-200 ease-out hover:bg-foreground/5 motion-reduce:transition-none",
183
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
184
+ // The 11px exists to separate this label from ITS OPTIONS, so a
185
+ // collapsed group must not keep it: with the panel at zero
186
+ // height it becomes dead space below the label, and the group
187
+ // reads as `12px | label | 23px` between the caller's dividers.
188
+ // That 11px asymmetry is why an evenly-spaced stack of collapsed
189
+ // groups looked like the rules were mis-centred — they were
190
+ // centred, the labels were not. Transitioned alongside the panel
191
+ // so the two move as one gesture rather than the margin snapping.
192
+ open ? "mb-[11px]" : "mb-0"
193
+ )}
194
+ >
195
+ {Icon && <Icon aria-hidden className="size-3.5 shrink-0" strokeWidth={2.25} />}
196
+ {title}
197
+ <ChevronDown
198
+ aria-hidden
199
+ className={cn(
200
+ "ml-auto size-3.5 shrink-0 transition-transform duration-150 motion-reduce:transition-none",
201
+ open ? "rotate-180" : "rotate-0"
202
+ )}
203
+ />
204
+ </button>
205
+ ) : (
206
+ <span className="flex items-center gap-1.5">
207
+ {Icon && <Icon aria-hidden className="size-3.5 shrink-0" strokeWidth={2.25} />}
208
+ {title}
209
+ </span>
210
+ )}
211
+ </legend>
212
+
213
+ {/* COLLAPSE ANIMATES VIA `grid-template-rows`, not `hidden`.
214
+ `hidden` is a binary — the options vanished on the same frame the
215
+ chevron began rotating, so the one part of the disclosure that moved
216
+ was the arrow and the panel it described just blinked out. Height
217
+ cannot be transitioned to or from `auto`, and this component cannot
218
+ measure its own content without a layout effect, but a grid track
219
+ CAN interpolate `0fr` -> `1fr` with the row sized by its content. So
220
+ the fieldset owns a single-track grid and the inner wrapper clips.
221
+
222
+ The inner `overflow-hidden` is what makes it work AND what makes it
223
+ safe: without it the options would spill out of a zero-height track
224
+ rather than being cut off by it.
225
+
226
+ `inert` + `aria-hidden` when closed, because the panel now stays in
227
+ the DOM at zero height rather than being removed by `hidden` —
228
+ without them every collapsed checkbox would still be tabbable and
229
+ still be announced, which is worse than the instant collapse this
230
+ replaces. `inert` alone is the correct primitive and takes the
231
+ subtree out of the a11y tree per spec, but no test environment
232
+ models that yet, so `aria-hidden` states the same thing in a form
233
+ the suite can actually assert. They are complementary here rather
234
+ than redundant: `aria-hidden` on focusable content is only safe
235
+ BECAUSE `inert` has already made it unfocusable. */}
236
+ <div
237
+ className={cn(
238
+ "grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",
239
+ open ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
240
+ )}
241
+ >
242
+ <div className="overflow-hidden">
243
+ <div
244
+ id={regionId}
245
+ inert={!open}
246
+ aria-hidden={!open || undefined}
247
+ className={cn("flex flex-col", ROW_GAP[size])}
248
+ >
249
+ {options.map((option) => {
250
+ const checked = !!option.checked;
251
+
252
+ return (
253
+ <label
254
+ key={option.id}
255
+ className={cn(
256
+ "flex cursor-pointer items-center gap-[9px] text-[13.5px] select-none",
257
+ "transition-colors duration-150 hover:bg-muted/60 motion-reduce:transition-none",
258
+ ROW_SIZE[size]
259
+ )}
260
+ >
261
+ <input
262
+ type={isSingle ? "radio" : "checkbox"}
263
+ name={isSingle ? groupName : undefined}
264
+ className="peer sr-only"
265
+ checked={checked}
266
+ onChange={(event) => onToggle?.(option.id, event.target.checked)}
267
+ />
268
+ <span
269
+ aria-hidden
270
+ className={cn(
271
+ "flex h-4 w-4 shrink-0 items-center justify-center border-[1.5px] transition-colors duration-150 motion-reduce:transition-none",
272
+ isSingle ? "rounded-full" : "rounded-[5px]",
273
+ "peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2",
274
+ checked ? "border-primary bg-primary" : "border-border bg-background"
275
+ )}
276
+ >
277
+ {checked &&
278
+ (isSingle ? (
279
+ <span className="size-[7px] rounded-full bg-primary-foreground" />
280
+ ) : (
281
+ <Check className="h-[11px] w-[11px] text-primary-foreground" strokeWidth={3} />
282
+ ))}
283
+ </span>
284
+ <span
285
+ title={option.label}
286
+ className={cn(
287
+ "min-w-0 truncate",
288
+ checked ? "font-bold text-primary" : "font-normal text-foreground"
289
+ )}
290
+ >
291
+ {option.label}
292
+ </span>
293
+ <span className="ml-auto shrink-0 font-mono text-[11.5px] tabular-nums text-muted-foreground">
294
+ {option.count}
295
+ </span>
296
+ </label>
297
+ );
298
+ })}
299
+ </div>
300
+ </div>
301
+ </div>
302
+
303
+ {moreLabel && open && (
304
+ <button
305
+ type="button"
306
+ onClick={onMore}
307
+ className="cursor-pointer pt-[11px] text-[12.5px] font-bold text-primary transition-colors duration-150 hover:text-primary/80 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:rounded-sm"
308
+ >
309
+ {moreLabel}
310
+ </button>
311
+ )}
312
+ </fieldset>
313
+ );
314
+ }
315
+
316
+ export type FacetGroupSkeletonProps = {
317
+ /** How many option rows to draw — match the expected/last-known option count. */
318
+ optionCount?: number;
319
+ /** Shows the trailing "more" placeholder, matching `FacetGroupProps["moreLabel"]`. */
320
+ showMore?: boolean;
321
+ /** Must match the `<FacetGroup>` it stands in for, or rows shift when data lands. */
322
+ size?: FacetGroupSize;
323
+ className?: string;
324
+ };
325
+
326
+ /** Mirrors `<FacetGroup>`'s geometry exactly so nothing shifts when data lands. */
327
+ export function FacetGroupSkeleton({
328
+ optionCount = 4,
329
+ showMore,
330
+ size = "comfortable",
331
+ className,
332
+ }: FacetGroupSkeletonProps) {
333
+ return (
334
+ // Matches the live group: no rule of its own, `py-3`, and the row gap keyed
335
+ // to the same density — or the panel shifts on load by the difference.
336
+ <div aria-hidden="true" className={cn("py-3", className)}>
337
+ <Skeleton className="mb-[11px] h-3 w-20" />
338
+
339
+ <div className={cn("flex flex-col", ROW_GAP[size])}>
340
+ {Array.from({ length: optionCount }).map((_, index) => (
341
+ <div key={index} className={cn("flex items-center gap-[9px]", ROW_SIZE[size])}>
342
+ <Skeleton className="h-4 w-4 rounded-[5px]" />
343
+ <Skeleton className="h-3.5 w-24" />
344
+ <Skeleton className="ml-auto h-3 w-5" />
345
+ </div>
346
+ ))}
347
+ </div>
348
+
349
+ {showMore && <Skeleton className="mt-[11px] h-3 w-16" />}
350
+ </div>
351
+ );
352
+ }
@@ -0,0 +1,86 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Flame } from "lucide-react";
3
+ import { FeaturedRailItem, FeaturedRailItemSkeleton } from "./featured-rail-item";
4
+
5
+ // Explicitly annotated rather than `satisfies`: the meta-level decorator makes
6
+ // the inferred type reference storybook's internal CSF module, which tsc cannot
7
+ // name portably (TS2742).
8
+ const meta: Meta<typeof FeaturedRailItem> = {
9
+ title: "Events/FeaturedRailItem",
10
+ component: FeaturedRailItem,
11
+ tags: ["autodocs"],
12
+ args: {
13
+ title: "Psihotrop — From the Beginning to Infinity",
14
+ venue: "Atelier Café",
15
+ note: "Sat 5 · 20:00",
16
+ },
17
+ parameters: {
18
+ docs: {
19
+ description: {
20
+ component:
21
+ "The card inside the featured rail — 196px on desktop, 150px on mobile. Sits inside `<DriftRail>` in the design, but the motion belongs to the rail, not the card: this component only carries the interactive hover lift shared by every DS card.",
22
+ },
23
+ },
24
+ },
25
+ };
26
+
27
+ export default meta;
28
+ type Story = StoryObj<typeof FeaturedRailItem>;
29
+
30
+ export const Default: Story = {};
31
+
32
+ export const WithTag: Story = {
33
+ args: {
34
+ tagLabel: (
35
+ <>
36
+ <Flame className="h-3 w-3" aria-hidden />
37
+ Busiest
38
+ </>
39
+ ),
40
+ },
41
+ };
42
+
43
+ export const Linked: Story = {
44
+ args: { href: "/events/psihotrop" },
45
+ };
46
+
47
+ /**
48
+ * The title WRAPS to as many lines as it needs — it used to truncate to one,
49
+ * which is the defect this story now guards against. `venue` and `note` still
50
+ * truncate: they are secondary lines, and letting every line wrap would leave a
51
+ * rail of cards with no shared baseline at all.
52
+ */
53
+ export const LongTitle: Story = {
54
+ args: {
55
+ title: "The Longest Possible Event Title, Which Wraps Rather Than Losing Its Second Half",
56
+ venue: "A Very Long Venue Name Down by the River",
57
+ },
58
+ };
59
+
60
+ export const Skeleton: Story = {
61
+ render: () => <FeaturedRailItemSkeleton />,
62
+ };
63
+
64
+ export const Rail: Story = {
65
+ render: () => (
66
+ <div className="flex max-w-[420px] gap-3.5 overflow-x-auto pb-1">
67
+ {["Warehouse Rave", "Jazz on the Rooftop", "Open Mic Night", "Street Food Market"].map(
68
+ (title) => (
69
+ <FeaturedRailItem
70
+ key={title}
71
+ title={title}
72
+ venue="Atelier Café"
73
+ note="Sat 5 · 20:00"
74
+ tagLabel={
75
+ <>
76
+ <Flame className="h-3 w-3" aria-hidden />
77
+ Busiest
78
+ </>
79
+ }
80
+ href={`/events/${title.toLowerCase().replace(/\s+/g, "-")}`}
81
+ />
82
+ )
83
+ )}
84
+ </div>
85
+ ),
86
+ };
@@ -0,0 +1,153 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import type { ReactNode } from "react";
5
+ import { cn } from "../lib/utils";
6
+ import { EventPosterFallback } from "./event-poster-fallback";
7
+ import { Skeleton } from "./skeleton";
8
+
9
+ /**
10
+ * FeaturedRailItem — the card inside the featured rail (a `<DriftRail>` or a
11
+ * plain scroll snap row). Narrower than `<EventCard>`'s 4:3 base card and
12
+ * built for a single overlay pill rather than the badge-plus-fee pair, so it
13
+ * stays legible at 150–196px wide.
14
+ *
15
+ * The only motion here is the interactive hover/press lift shared by every DS
16
+ * card (see `card.tsx`) — the source design's continuous drift animation
17
+ * belongs to the rail this card sits inside, not the card itself.
18
+ */
19
+ export type FeaturedRailItemLinkProps = {
20
+ href: string;
21
+ className?: string;
22
+ children: React.ReactNode;
23
+ onClick?: (mouseEvent: React.MouseEvent) => void;
24
+ };
25
+
26
+ function DefaultLink({ href, ...props }: FeaturedRailItemLinkProps) {
27
+ return <a href={href} {...props} />;
28
+ }
29
+
30
+ export type FeaturedRailItemProps = {
31
+ title: string;
32
+ venue: string;
33
+ /** Caller-composed day/time line, e.g. "Sat 5 · 20:00". */
34
+ note: string;
35
+ /** Top-left overlay pill. Include its icon. */
36
+ tagLabel?: React.ReactNode;
37
+ posterUrl?: string;
38
+ href?: string;
39
+ /** Defaults to a plain `<a>`. Pass `next/link` in a Next app. */
40
+ linkComponent?: React.ComponentType<FeaturedRailItemLinkProps>;
41
+ onClick?: (mouseEvent: React.MouseEvent) => void;
42
+ className?: string;
43
+ /** Rendered instead of the poster when it's absent or failed to load. Defaults to `<EventPosterFallback />`. */
44
+ fallback?: ReactNode;
45
+ };
46
+
47
+ export function FeaturedRailItem({
48
+ title,
49
+ venue,
50
+ note,
51
+ tagLabel,
52
+ posterUrl,
53
+ href,
54
+ linkComponent: LinkComponent = DefaultLink,
55
+ onClick,
56
+ className,
57
+ fallback = <EventPosterFallback />,
58
+ }: FeaturedRailItemProps) {
59
+ const [imgError, setImgError] = useState(false);
60
+ const showPoster = !!posterUrl && !imgError;
61
+
62
+ const body = (
63
+ <>
64
+ <div className="relative aspect-[4/3] overflow-hidden rounded-xl bg-muted">
65
+ {showPoster ? (
66
+ // eslint-disable-next-line @next/next/no-img-element
67
+ <img
68
+ src={posterUrl}
69
+ alt={title}
70
+ title={title}
71
+ loading="lazy"
72
+ decoding="async"
73
+ className="h-full w-full object-cover"
74
+ onError={() => setImgError(true)}
75
+ />
76
+ ) : (
77
+ fallback
78
+ )}
79
+
80
+ {tagLabel && (
81
+ <div className="absolute inset-x-2 top-2 flex items-start">
82
+ <span className="inline-flex h-6 max-w-full items-center gap-1 rounded-full bg-black/55 px-2 text-[11px] font-semibold text-white">
83
+ {tagLabel}
84
+ </span>
85
+ </div>
86
+ )}
87
+ </div>
88
+
89
+ <div className="px-0.5 pt-2.5">
90
+ {/* The title wraps in full at this narrow width — it is the one line
91
+ that has to survive intact. `venue` and `note` keep their
92
+ `truncate`: they are secondary, and a rail of cards whose every
93
+ line wraps would have no shared baseline at all. */}
94
+ <h3 className="text-[13.5px] font-bold leading-[1.3]">{title}</h3>
95
+ <div className="mt-0.5 truncate text-xs text-muted-foreground">{venue}</div>
96
+ <div className="mt-[3px] truncate text-[11.5px] font-bold text-primary">{note}</div>
97
+ </div>
98
+ </>
99
+ );
100
+
101
+ const isInteractive = !!(href || onClick);
102
+
103
+ const rootClasses = cn(
104
+ "flex w-[150px] shrink-0 flex-col text-left sm:w-[196px]",
105
+ isInteractive &&
106
+ cn(
107
+ "cursor-pointer rounded-xl transition-transform duration-200",
108
+ "hover:-translate-y-0.5 active:scale-[0.99]",
109
+ "motion-reduce:transition-none motion-reduce:hover:translate-y-0",
110
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
111
+ ),
112
+ className
113
+ );
114
+
115
+ if (href) {
116
+ return (
117
+ <LinkComponent href={href} className={rootClasses} onClick={onClick}>
118
+ {body}
119
+ </LinkComponent>
120
+ );
121
+ }
122
+
123
+ if (onClick) {
124
+ return (
125
+ <button type="button" className={rootClasses} onClick={onClick}>
126
+ {body}
127
+ </button>
128
+ );
129
+ }
130
+
131
+ return <div className={rootClasses}>{body}</div>;
132
+ }
133
+
134
+ export type FeaturedRailItemSkeletonProps = {
135
+ className?: string;
136
+ };
137
+
138
+ export function FeaturedRailItemSkeleton({ className }: FeaturedRailItemSkeletonProps) {
139
+ return (
140
+ <div
141
+ aria-hidden="true"
142
+ className={cn("flex w-[150px] shrink-0 flex-col sm:w-[196px]", className)}
143
+ >
144
+ <Skeleton className="aspect-[4/3] w-full rounded-xl" />
145
+
146
+ <div className="px-0.5 pt-2.5">
147
+ <Skeleton className="h-[13.5px] w-4/5" />
148
+ <Skeleton className="mt-1 h-3 w-3/5" />
149
+ <Skeleton className="mt-1.5 h-[11.5px] w-2/5" />
150
+ </div>
151
+ </div>
152
+ );
153
+ }
@@ -20,7 +20,7 @@ const meta = {
20
20
  } satisfies Meta<typeof Form>;
21
21
 
22
22
  export default meta;
23
- type Story = StoryObj<typeof meta>;
23
+ type Story = StoryObj<typeof Form>;
24
24
 
25
25
  function FormDemo() {
26
26
  const form = useForm({