@bearmenu/ui 0.8.4 → 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.
- package/dist/chunk-637L7XQ3.js +85 -0
- package/dist/chunk-6XMYEZVR.js +26 -0
- package/dist/chunk-DNSXMHYX.js +8 -0
- package/dist/chunk-FDITZ2VM.js +16 -0
- package/dist/chunk-GPH6L4DX.js +55 -0
- package/dist/{chunk-N2KNBPAQ.js → chunk-QXHMPJ35.js} +1 -0
- package/dist/chunk-TT3WFS6S.js +379 -0
- package/dist/chunk-UXWAOISG.js +170 -0
- package/dist/{chunk-UZ3PCIE5.js → chunk-XWMRABZW.js} +2 -2
- package/dist/{chunk-VBA45GLP.js → chunk-ZDAHKI3A.js} +12 -2
- package/dist/components/alert.d.ts +2 -2
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/card.d.ts +2 -2
- package/dist/components/card.js +4 -127
- package/dist/components/category-deck.d.ts +50 -0
- package/dist/components/category-deck.js +128 -0
- package/dist/components/command.d.ts +15 -15
- package/dist/components/concept-card.d.ts +43 -0
- package/dist/components/concept-card.js +81 -0
- package/dist/components/coverflow.d.ts +123 -0
- package/dist/components/coverflow.js +300 -0
- package/dist/components/date-input.d.ts +1 -1
- package/dist/components/day-rail-chip.d.ts +82 -0
- package/dist/components/day-rail-chip.js +105 -0
- package/dist/components/drift-rail.d.ts +28 -0
- package/dist/components/drift-rail.js +3 -0
- package/dist/components/event-card.d.ts +97 -0
- package/dist/components/event-card.js +161 -0
- package/dist/components/event-compact-row.d.ts +72 -0
- package/dist/components/event-compact-row.js +133 -0
- package/dist/components/event-day-header.d.ts +65 -0
- package/dist/components/event-day-header.js +95 -0
- package/dist/components/event-day-separator.d.ts +46 -0
- package/dist/components/event-day-separator.js +50 -0
- package/dist/components/event-detail-panel.d.ts +80 -0
- package/dist/components/event-detail-panel.js +251 -0
- package/dist/components/event-hub-hero.d.ts +34 -0
- package/dist/components/event-hub-hero.js +57 -0
- package/dist/components/event-index-row.d.ts +68 -0
- package/dist/components/event-index-row.js +84 -0
- package/dist/components/event-list-row-wide.d.ts +101 -0
- package/dist/components/event-list-row-wide.js +218 -0
- package/dist/components/event-list-row.d.ts +82 -0
- package/dist/components/event-list-row.js +173 -0
- package/dist/components/event-poster-fallback.d.ts +20 -0
- package/dist/components/event-poster-fallback.js +3 -0
- package/dist/components/event-types.d.ts +118 -0
- package/dist/components/event-types.js +2 -0
- package/dist/components/events-empty-state.d.ts +44 -0
- package/dist/components/events-empty-state.js +73 -0
- package/dist/components/facet-group.d.ts +93 -0
- package/dist/components/facet-group.js +222 -0
- package/dist/components/featured-rail-item.d.ts +42 -0
- package/dist/components/featured-rail-item.js +88 -0
- package/dist/components/hour-band.d.ts +68 -0
- package/dist/components/hour-band.js +53 -0
- package/dist/components/hour-group.d.ts +67 -0
- package/dist/components/hour-group.js +59 -0
- package/dist/components/item.d.ts +2 -2
- package/dist/components/motion-icon-controls.js +3 -379
- package/dist/components/multi-select-combobox.js +1 -1
- package/dist/components/place-about-tab.js +3 -3
- package/dist/components/place-details-mobile.js +3 -3
- package/dist/components/poster-stack.d.ts +30 -0
- package/dist/components/poster-stack.js +104 -0
- package/dist/components/poster-tape.d.ts +36 -0
- package/dist/components/poster-tape.js +64 -0
- package/dist/components/poster-wall.d.ts +25 -0
- package/dist/components/poster-wall.js +183 -0
- package/dist/components/quiet-stretch.d.ts +43 -0
- package/dist/components/quiet-stretch.js +54 -0
- package/dist/components/section-header.d.ts +53 -0
- package/dist/components/section-header.js +63 -0
- package/dist/components/segmented-nav.d.ts +62 -0
- package/dist/components/segmented-nav.js +54 -0
- package/dist/components/skeleton-card.js +1 -1
- package/dist/components/skeleton.d.ts +17 -0
- package/dist/components/skeleton.js +1 -1
- package/dist/components/spinner.d.ts +2 -2
- package/dist/components/tag-group.js +3 -26
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/tag.js +1 -1
- package/dist/components/text.d.ts +3 -3
- package/dist/components/timeline-band.d.ts +46 -0
- package/dist/components/timeline-band.js +5 -0
- package/dist/components/venue-events-block.d.ts +143 -0
- package/dist/components/venue-events-block.js +222 -0
- package/dist/components/weekend-panel.d.ts +44 -0
- package/dist/components/weekend-panel.js +231 -0
- package/package.json +1 -1
- package/src/components/card.tsx +44 -1
- package/src/components/category-deck.stories.tsx +96 -0
- package/src/components/category-deck.tsx +227 -0
- package/src/components/concept-card.stories.tsx +80 -0
- package/src/components/concept-card.tsx +143 -0
- package/src/components/coverflow.stories.tsx +240 -0
- package/src/components/coverflow.test.tsx +193 -0
- package/src/components/coverflow.tsx +550 -0
- package/src/components/day-rail-chip.stories.tsx +145 -0
- package/src/components/day-rail-chip.tsx +201 -0
- package/src/components/drift-rail.stories.tsx +92 -0
- package/src/components/drift-rail.tsx +86 -0
- package/src/components/event-card.stories.tsx +248 -0
- package/src/components/event-card.test.tsx +177 -0
- package/src/components/event-card.tsx +321 -0
- package/src/components/event-compact-row.stories.tsx +221 -0
- package/src/components/event-compact-row.tsx +268 -0
- package/src/components/event-day-header.stories.tsx +124 -0
- package/src/components/event-day-header.tsx +180 -0
- package/src/components/event-day-separator.stories.tsx +70 -0
- package/src/components/event-day-separator.tsx +95 -0
- package/src/components/event-detail-panel.stories.tsx +191 -0
- package/src/components/event-detail-panel.tsx +360 -0
- package/src/components/event-hub-hero.stories.tsx +66 -0
- package/src/components/event-hub-hero.tsx +110 -0
- package/src/components/event-index-row.stories.tsx +116 -0
- package/src/components/event-index-row.tsx +181 -0
- package/src/components/event-list-row-wide.stories.tsx +204 -0
- package/src/components/event-list-row-wide.tsx +386 -0
- package/src/components/event-list-row.stories.tsx +159 -0
- package/src/components/event-list-row.tsx +335 -0
- package/src/components/event-poster-fallback.stories.tsx +52 -0
- package/src/components/event-poster-fallback.tsx +31 -0
- package/src/components/event-types.ts +133 -0
- package/src/components/events-empty-state.stories.tsx +59 -0
- package/src/components/events-empty-state.tsx +131 -0
- package/src/components/facet-group.stories.tsx +247 -0
- package/src/components/facet-group.test.tsx +225 -0
- package/src/components/facet-group.tsx +352 -0
- package/src/components/featured-rail-item.stories.tsx +86 -0
- package/src/components/featured-rail-item.tsx +153 -0
- package/src/components/hour-band.stories.tsx +159 -0
- package/src/components/hour-band.tsx +126 -0
- package/src/components/hour-group.stories.tsx +100 -0
- package/src/components/hour-group.tsx +124 -0
- package/src/components/poster-stack.stories.tsx +68 -0
- package/src/components/poster-stack.tsx +155 -0
- package/src/components/poster-tape.stories.tsx +70 -0
- package/src/components/poster-tape.tsx +122 -0
- package/src/components/poster-wall.stories.tsx +95 -0
- package/src/components/poster-wall.tsx +220 -0
- package/src/components/quiet-stretch.stories.tsx +62 -0
- package/src/components/quiet-stretch.tsx +115 -0
- package/src/components/section-header.stories.tsx +70 -0
- package/src/components/section-header.tsx +134 -0
- package/src/components/segmented-nav.stories.tsx +118 -0
- package/src/components/segmented-nav.tsx +113 -0
- package/src/components/skeleton.tsx +18 -0
- package/src/components/tag.tsx +11 -1
- package/src/components/timeline-band.stories.tsx +206 -0
- package/src/components/timeline-band.tsx +207 -0
- package/src/components/venue-events-block.stories.tsx +216 -0
- package/src/components/venue-events-block.tsx +408 -0
- package/src/components/weekend-panel.stories.tsx +92 -0
- package/src/components/weekend-panel.tsx +322 -0
- package/src/globals.css +19 -0
|
@@ -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
|
+
}
|