@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.
- package/dist/chunk-2WDJR6K7.js +76 -0
- 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/icon.d.ts +18 -0
- package/dist/components/icon.js +31 -0
- package/dist/components/item.d.ts +2 -2
- package/dist/components/motion-icon-controls.d.ts +26 -0
- package/dist/components/motion-icon-controls.js +4 -0
- package/dist/components/motion-icon-set.d.ts +27 -0
- package/dist/components/motion-icon-set.js +139 -0
- package/dist/components/motion-icon.d.ts +44 -0
- package/dist/components/motion-icon.js +3 -0
- 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/dist/lib/icon-registry.d.ts +58 -0
- package/dist/lib/icon-registry.js +39 -0
- package/package.json +2 -1
- package/src/components/accordion.stories.tsx +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/combobox.stories.tsx +1 -1
- 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/currency-input.stories.tsx +3 -3
- 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/empty-state.stories.tsx +3 -3
- 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/form.stories.tsx +1 -1
- 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/icon.tsx +57 -0
- package/src/components/input-otp.stories.tsx +1 -1
- package/src/components/markdown-renderer.stories.tsx +1 -1
- package/src/components/motion-icon-controls.test.tsx +78 -0
- package/src/components/motion-icon-controls.tsx +552 -0
- package/src/components/motion-icon-set.stories.tsx +185 -0
- package/src/components/motion-icon-set.tsx +219 -0
- package/src/components/motion-icon.test.tsx +159 -0
- package/src/components/motion-icon.tsx +163 -0
- package/src/components/multi-select-combobox.stories.tsx +1 -1
- package/src/components/phone-frame.stories.tsx +1 -1
- 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/rating.stories.tsx +3 -3
- package/src/components/searchable-select.stories.tsx +1 -1
- 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/selection-bar.stories.tsx +1 -1
- package/src/components/skeleton-card.stories.tsx +3 -3
- package/src/components/skeleton.tsx +18 -0
- package/src/components/sliding-panels.stories.tsx +1 -1
- package/src/components/stepper.stories.tsx +1 -1
- package/src/components/sticky-container.stories.tsx +3 -3
- 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/toggle-group.stories.tsx +1 -1
- package/src/components/tooltip.stories.tsx +2 -2
- 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
- package/src/lib/icon-registry.ts +139 -0
- package/src/test/setup.ts +5 -1
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { memo, useState } from "react";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
import { EventPosterFallback } from "./event-poster-fallback";
|
|
6
|
+
import { Skeleton } from "./skeleton";
|
|
7
|
+
import { Tag } from "./tag";
|
|
8
|
+
import { TagGroup } from "./tag-group";
|
|
9
|
+
import {
|
|
10
|
+
resolveEventEntryFee,
|
|
11
|
+
type EventCardLabels,
|
|
12
|
+
type EventFormatters,
|
|
13
|
+
type EventPreview,
|
|
14
|
+
} from "./event-types";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* EventCompactRow — the dense row used inside `<HourGroup>` once the night
|
|
18
|
+
* thins out and a run of poster cards would spend more space than it earns.
|
|
19
|
+
*
|
|
20
|
+
* NOT `<EventListRow>`: that one carries a date badge and a trailing CTA for
|
|
21
|
+
* the list view. This row has neither — the timeline's `<HourGroup>` already
|
|
22
|
+
* names the hour, and every row here means "open me", same as the poster
|
|
23
|
+
* card. `hideTime` drops the leading time from the sub line too, for the
|
|
24
|
+
* common case where the surrounding heading already names it to the minute.
|
|
25
|
+
*
|
|
26
|
+
* THIS ROW DOES NOT DRAW DIVIDERS; ITS CONTAINER DOES. `<HourGroup>` already
|
|
27
|
+
* draws the rule under the hour heading, so a row-level top separator here
|
|
28
|
+
* doubled it directly beneath every hour. `<EventListRow>` and
|
|
29
|
+
* `<EventListRowWide>` are different: they stack in a plain list with no
|
|
30
|
+
* other rule around them, so their own top separator is the only one there —
|
|
31
|
+
* do not carry this rule over to them.
|
|
32
|
+
*
|
|
33
|
+
* Same contract as the rest: DS-owned view model, injected labels/formatters,
|
|
34
|
+
* plain `<img>`, anchor whenever `href` is given.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
export type EventCompactRowLinkProps = {
|
|
38
|
+
href: string;
|
|
39
|
+
className?: string;
|
|
40
|
+
children: React.ReactNode;
|
|
41
|
+
onClick?: (mouseEvent: React.MouseEvent) => void;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function DefaultLink({ href, ...props }: EventCompactRowLinkProps) {
|
|
45
|
+
return <a href={href} {...props} />;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type EventCompactRowProps = {
|
|
49
|
+
event: EventPreview;
|
|
50
|
+
labels: EventCardLabels;
|
|
51
|
+
formatters: EventFormatters;
|
|
52
|
+
/** Drops the leading time from the sub line. */
|
|
53
|
+
hideTime?: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Replaces the default "time · venue" sub-line entirely. For surfaces where
|
|
56
|
+
* the venue is already named above every row (the hub's venue block) and
|
|
57
|
+
* repeating it on each one is noise — the caller has something more
|
|
58
|
+
* differentiating to say instead (the event's own description). The
|
|
59
|
+
* timeline keeps the default: there the venue IS what tells rows apart
|
|
60
|
+
* across the night. Mirrors `menu-item-row.tsx`'s prop of the same name and
|
|
61
|
+
* intent.
|
|
62
|
+
*/
|
|
63
|
+
subtitle?: string;
|
|
64
|
+
/** Caller-supplied display labels — `EventPreview.tags` is taxonomy
|
|
65
|
+
* coordinates (`slug`/`axis_slug`), not display text, so the DS cannot
|
|
66
|
+
* render them itself. Rendered as small chips below the sub-line; wrap
|
|
67
|
+
* rather than truncate, so a chip's own text is never cut mid-word. Cap
|
|
68
|
+
* the count at the call site — this row does not. */
|
|
69
|
+
tags?: string[];
|
|
70
|
+
size?: "sm" | "default";
|
|
71
|
+
href?: string;
|
|
72
|
+
linkComponent?: React.ComponentType<EventCompactRowLinkProps>;
|
|
73
|
+
onClick?: (event: EventPreview, mouseEvent: React.MouseEvent) => void;
|
|
74
|
+
className?: string;
|
|
75
|
+
/** Forwarded to the poster's `loading` attribute (`"eager"` when true). */
|
|
76
|
+
priority?: boolean;
|
|
77
|
+
/** Rendered in place of the poster when it is absent or fails to load. Defaults to `<EventPosterFallback />`. */
|
|
78
|
+
fallback?: React.ReactNode;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const EventCompactRow = memo(function EventCompactRow({
|
|
82
|
+
event,
|
|
83
|
+
labels,
|
|
84
|
+
formatters,
|
|
85
|
+
hideTime,
|
|
86
|
+
subtitle,
|
|
87
|
+
tags,
|
|
88
|
+
size = "default",
|
|
89
|
+
href,
|
|
90
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
91
|
+
onClick,
|
|
92
|
+
className,
|
|
93
|
+
priority,
|
|
94
|
+
fallback = <EventPosterFallback />,
|
|
95
|
+
}: EventCompactRowProps) {
|
|
96
|
+
const [imgError, setImgError] = useState(false);
|
|
97
|
+
const isSm = size === "sm";
|
|
98
|
+
|
|
99
|
+
const fee = resolveEventEntryFee(event);
|
|
100
|
+
const showPoster = !!event.poster_url && !imgError;
|
|
101
|
+
const venue = event.place_name ?? event.location;
|
|
102
|
+
|
|
103
|
+
const body = (
|
|
104
|
+
<>
|
|
105
|
+
{/* 64x80 AND PORTRAIT, not a 34px square, and both parts matter.
|
|
106
|
+
91% of the posters this renders are portrait and ~57% are Instagram's
|
|
107
|
+
4:5 — so a square crop kept the middle band and threw away the top and
|
|
108
|
+
bottom, which on a flyer is exactly where the act, the venue and the
|
|
109
|
+
date sit. The complaint was that the thumbnail was unreadable, and it
|
|
110
|
+
was unreadable because of what the crop removed as much as the size.
|
|
111
|
+
`4/5` is the least-cropping ratio this DS has, and `event-card.tsx`
|
|
112
|
+
already reserves it for surfaces whose job is to sell the poster.
|
|
113
|
+
|
|
114
|
+
One size for both `sm` and `default`: no consumer passes `sm` today,
|
|
115
|
+
and the failure is identical on a phone, so there is no second number
|
|
116
|
+
to justify. `size` still drives type scale and pill height. */}
|
|
117
|
+
<div className="relative aspect-[4/5] w-16 shrink-0 overflow-hidden rounded-[9px] bg-muted">
|
|
118
|
+
{showPoster ? (
|
|
119
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
120
|
+
<img
|
|
121
|
+
src={event.poster_url}
|
|
122
|
+
alt=""
|
|
123
|
+
loading={priority ? "eager" : "lazy"}
|
|
124
|
+
decoding="async"
|
|
125
|
+
className="h-full w-full object-cover"
|
|
126
|
+
onError={() => setImgError(true)}
|
|
127
|
+
/>
|
|
128
|
+
) : (
|
|
129
|
+
// A bare `bg-muted` rectangle was survivable at 34px. At 64x80 beside
|
|
130
|
+
// real posters it reads as a broken image rather than an absent one —
|
|
131
|
+
// so this row finally joins every sibling in the family in having a
|
|
132
|
+
// designed fallback.
|
|
133
|
+
fallback
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<div className="min-w-0 flex-1">
|
|
138
|
+
{/* The title wraps in full — never truncated, in this row or any of
|
|
139
|
+
its siblings. The sub-line below it keeps its `truncate`: that one
|
|
140
|
+
is "20:00 · Venue", where the leading fact survives the cut and the
|
|
141
|
+
row's own height is what the density buys. */}
|
|
142
|
+
{/* `group-hover`, not `hover`: the whole row is one anchor, so the
|
|
143
|
+
pointer is over the parent and a plain `hover:` here would never
|
|
144
|
+
fire. */}
|
|
145
|
+
<div
|
|
146
|
+
className={cn(
|
|
147
|
+
"font-semibold leading-[1.35]",
|
|
148
|
+
"transition-colors duration-150 motion-reduce:transition-none",
|
|
149
|
+
isSm ? "text-[13.5px]" : "text-[13px]",
|
|
150
|
+
(href || onClick) && "group-hover:text-primary"
|
|
151
|
+
)}
|
|
152
|
+
>
|
|
153
|
+
{event.title}
|
|
154
|
+
</div>
|
|
155
|
+
<div className="truncate text-[11.5px] text-muted-foreground">
|
|
156
|
+
{subtitle ? (
|
|
157
|
+
subtitle
|
|
158
|
+
) : (
|
|
159
|
+
<>
|
|
160
|
+
{!hideTime && (
|
|
161
|
+
<>
|
|
162
|
+
<span className="font-mono tabular-nums">{formatters.time(event.starts_at)}</span>
|
|
163
|
+
<span aria-hidden> · </span>
|
|
164
|
+
</>
|
|
165
|
+
)}
|
|
166
|
+
{venue}
|
|
167
|
+
</>
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
170
|
+
{tags && tags.length > 0 && (
|
|
171
|
+
<TagGroup className="mt-1">
|
|
172
|
+
{tags.map((tag) => (
|
|
173
|
+
<Tag key={tag} size="sm">
|
|
174
|
+
{tag}
|
|
175
|
+
</Tag>
|
|
176
|
+
))}
|
|
177
|
+
</TagGroup>
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
{fee.kind === "free" && (
|
|
182
|
+
<span
|
|
183
|
+
className={cn(
|
|
184
|
+
"inline-flex shrink-0 items-center rounded-full border border-success/40 px-2 font-semibold text-success",
|
|
185
|
+
isSm ? "h-[22px] text-[10.5px]" : "h-6 text-[11px]"
|
|
186
|
+
)}
|
|
187
|
+
>
|
|
188
|
+
{labels.free}
|
|
189
|
+
</span>
|
|
190
|
+
)}
|
|
191
|
+
</>
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
// `items-start`, not `items-center`: an 80px poster is taller than a one-line
|
|
195
|
+
// title over its sub-line, so centring floated the text in empty space.
|
|
196
|
+
// `EventListRow` already made this call at its own 72px poster.
|
|
197
|
+
//
|
|
198
|
+
// THE HOVER WASH IS `bg-foreground/5`, AND THE TOKEN CHOICE IS LOAD-BEARING.
|
|
199
|
+
// This row renders on two different planes: the timeline puts it straight on
|
|
200
|
+
// `--background`, the hub's venue block puts it inside an elevated `Card`, so
|
|
201
|
+
// on `--card`. `hover:bg-card/60` — the obvious thing to copy from the
|
|
202
|
+
// nearest sibling — is a no-op on the second one, card blended onto card. And
|
|
203
|
+
// `bg-muted/50` is nearly invisible on `--card` in dark mode (an L delta of
|
|
204
|
+
// 0.015). A wash relative to the TEXT colour is surface-agnostic by
|
|
205
|
+
// construction, and lands at ~0.04 L on all four surface/mode combinations.
|
|
206
|
+
//
|
|
207
|
+
// The negative margin cancels the padding, so the fill bleeds past the text
|
|
208
|
+
// column and takes a radius without changing the row's content width — the
|
|
209
|
+
// same shape `EventIndexRow` uses. No transform: this is a zero-gap stacked
|
|
210
|
+
// list, so a lift would overlap its neighbours rather than read as elevation.
|
|
211
|
+
const rootClasses = cn(
|
|
212
|
+
"group relative -mx-2.5 flex w-full items-start gap-[11px] rounded-lg px-2.5 py-2 text-left",
|
|
213
|
+
"transition-colors duration-150 motion-reduce:transition-none",
|
|
214
|
+
(href || onClick) &&
|
|
215
|
+
"cursor-pointer hover:bg-foreground/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
216
|
+
className
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
if (href) {
|
|
220
|
+
return (
|
|
221
|
+
<LinkComponent
|
|
222
|
+
href={href}
|
|
223
|
+
className={rootClasses}
|
|
224
|
+
onClick={onClick ? (mouseEvent) => onClick(event, mouseEvent) : undefined}
|
|
225
|
+
>
|
|
226
|
+
{body}
|
|
227
|
+
</LinkComponent>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (onClick) {
|
|
232
|
+
return (
|
|
233
|
+
<button type="button" className={rootClasses} onClick={(e) => onClick(event, e)}>
|
|
234
|
+
{body}
|
|
235
|
+
</button>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return <div className={rootClasses}>{body}</div>;
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
export type EventCompactRowSkeletonProps = {
|
|
243
|
+
/** Matches `EventCompactRowProps["size"]` — keep the poster and title sizes in step. */
|
|
244
|
+
size?: "sm" | "default";
|
|
245
|
+
className?: string;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
/** Mirrors `<EventCompactRow>`'s geometry exactly so nothing shifts when data lands. */
|
|
249
|
+
export function EventCompactRowSkeleton({ size = "default", className }: EventCompactRowSkeletonProps) {
|
|
250
|
+
const isSm = size === "sm";
|
|
251
|
+
|
|
252
|
+
return (
|
|
253
|
+
<div
|
|
254
|
+
aria-hidden="true"
|
|
255
|
+
className={cn(
|
|
256
|
+
"relative -mx-2.5 flex w-full items-start gap-[11px] rounded-lg px-2.5 py-2 text-left",
|
|
257
|
+
className
|
|
258
|
+
)}
|
|
259
|
+
>
|
|
260
|
+
<Skeleton className="aspect-[4/5] w-16 shrink-0 rounded-[9px]" />
|
|
261
|
+
|
|
262
|
+
<div className="min-w-0 flex-1">
|
|
263
|
+
<Skeleton className={cn(isSm ? "h-[13.5px]" : "h-[13px]", "w-2/5")} />
|
|
264
|
+
<Skeleton className="mt-0.5 h-[11.5px] w-1/3" />
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { ArrowRight, PanelRightOpen } from "lucide-react";
|
|
3
|
+
import { EventDayHeader, EventDayHeaderSkeleton } from "./event-day-header";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof EventDayHeader> = {
|
|
6
|
+
title: "Events/EventDayHeader",
|
|
7
|
+
component: EventDayHeader,
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
decorators: [
|
|
10
|
+
(Story) => (
|
|
11
|
+
<div className="w-[358px]">
|
|
12
|
+
<Story />
|
|
13
|
+
</div>
|
|
14
|
+
),
|
|
15
|
+
],
|
|
16
|
+
parameters: {
|
|
17
|
+
docs: {
|
|
18
|
+
description: {
|
|
19
|
+
component:
|
|
20
|
+
"The day divider in the timeline. Its meta line is one caller-composed string — the design's variants don't decompose into counts, and assembling them inside the DS would bake in English clause order.",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export default meta;
|
|
27
|
+
type Story = StoryObj<typeof EventDayHeader>;
|
|
28
|
+
|
|
29
|
+
export const Today: Story = {
|
|
30
|
+
args: { dayNumber: "03", weekday: "Thursday", meta: "September · 15 events · 3 live" },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const BusyDay: Story = {
|
|
34
|
+
args: { dayNumber: "05", weekday: "Saturday", meta: "September · 23 events" },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const OneEvent: Story = {
|
|
38
|
+
args: { dayNumber: "08", weekday: "Tuesday", meta: "September · 1 event" },
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** A day with nothing on still gets a header — the empty state goes under it. */
|
|
42
|
+
export const NothingListed: Story = {
|
|
43
|
+
args: { dayNumber: "11", weekday: "Friday", meta: "September · nothing listed yet" },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Under an active filter the meta line reports the filtered count against the whole. */
|
|
47
|
+
export const Filtered: Story = {
|
|
48
|
+
args: { dayNumber: "05", weekday: "Saturday", meta: "September · 2 of 23 are free" },
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Tappable days get a chevron; static ones don't. */
|
|
52
|
+
export const Tappable: Story = {
|
|
53
|
+
args: {
|
|
54
|
+
dayNumber: "06",
|
|
55
|
+
weekday: "Sunday",
|
|
56
|
+
meta: "September · 10 events",
|
|
57
|
+
onClick: () => {},
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Romanian, to check the meta line and weekday don't assume English width. */
|
|
62
|
+
export const Romanian: Story = {
|
|
63
|
+
args: { dayNumber: "05", weekday: "Sâmbătă", meta: "Septembrie · 23 de evenimente · 3 live" },
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Loading state — identical geometry to `Today`, so nothing shifts on data arrival. */
|
|
67
|
+
export const Loading: Story = {
|
|
68
|
+
render: () => <EventDayHeaderSkeleton />,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** Reserves the chevron's space for a header that will end up tappable. */
|
|
72
|
+
export const LoadingInteractive: Story = {
|
|
73
|
+
render: () => <EventDayHeaderSkeleton interactive />,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Loading above loaded, so any geometry drift is visible at a glance. */
|
|
77
|
+
export const LoadingVsLoaded: Story = {
|
|
78
|
+
render: () => (
|
|
79
|
+
<div>
|
|
80
|
+
<EventDayHeaderSkeleton />
|
|
81
|
+
<EventDayHeader dayNumber="03" weekday="Thursday" meta="September · 15 events · 3 live" />
|
|
82
|
+
</div>
|
|
83
|
+
),
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Desktop scale: 58px day number, 18px weekday, and the "See the whole day"
|
|
88
|
+
* action chip the design puts in the trailing slot. The chip REPLACES the
|
|
89
|
+
* chevron — two affordances for one destination is the duplication this design
|
|
90
|
+
* set out to remove — and it renders outside the header's own link, so it is
|
|
91
|
+
* never an interactive element nested inside an anchor.
|
|
92
|
+
*/
|
|
93
|
+
export const DesktopWithAction: Story = {
|
|
94
|
+
render: () => (
|
|
95
|
+
<div className="w-[860px]">
|
|
96
|
+
<EventDayHeader
|
|
97
|
+
size="default"
|
|
98
|
+
dayNumber="03"
|
|
99
|
+
weekday="Thursday"
|
|
100
|
+
meta="September · 15 events · 3 live right now"
|
|
101
|
+
href="/events/schedule?day=2026-09-03"
|
|
102
|
+
action={
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
className="inline-flex h-9 items-center gap-1.5 rounded-full border border-border bg-card px-4 text-sm font-semibold"
|
|
106
|
+
>
|
|
107
|
+
<PanelRightOpen className="h-3.5 w-3.5" aria-hidden />
|
|
108
|
+
See the whole day
|
|
109
|
+
<ArrowRight className="h-3.5 w-3.5" aria-hidden />
|
|
110
|
+
</button>
|
|
111
|
+
}
|
|
112
|
+
/>
|
|
113
|
+
</div>
|
|
114
|
+
),
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** Desktop scale with no action — a quiet day the reader cannot drill into. */
|
|
118
|
+
export const DesktopPlain: Story = {
|
|
119
|
+
render: () => (
|
|
120
|
+
<div className="w-[860px]">
|
|
121
|
+
<EventDayHeader size="default" dayNumber="11" weekday="Friday" meta="September · nothing listed yet" />
|
|
122
|
+
</div>
|
|
123
|
+
),
|
|
124
|
+
};
|
|
@@ -0,0 +1,180 @@
|
|
|
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
|
+
* EventDayHeader — the day divider in the timeline: an oversized day number,
|
|
10
|
+
* the weekday, and a meta line.
|
|
11
|
+
*
|
|
12
|
+
* The meta line is ONE caller-composed string. The design's variants
|
|
13
|
+
* ("September · 15 events · 3 live", "September · nothing listed yet",
|
|
14
|
+
* "September · 2 of 23 are free") do not decompose into counts, and building
|
|
15
|
+
* them from parts inside the DS would bake in English clause order.
|
|
16
|
+
*
|
|
17
|
+
* A day with nothing on still gets a header — the empty state belongs under it,
|
|
18
|
+
* not instead of it, so the reader can see the date they are looking at.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type EventDayHeaderLinkProps = {
|
|
22
|
+
href: string;
|
|
23
|
+
className?: string;
|
|
24
|
+
children: React.ReactNode;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function DefaultLink({ href, ...props }: EventDayHeaderLinkProps) {
|
|
28
|
+
return <a href={href} {...props} />;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type EventDayHeaderProps = {
|
|
32
|
+
/** Day of month as displayed, e.g. "03". */
|
|
33
|
+
dayNumber: string;
|
|
34
|
+
/** Weekday name, already localised. */
|
|
35
|
+
weekday: string;
|
|
36
|
+
/** Month plus counts, composed by the caller. */
|
|
37
|
+
meta: string;
|
|
38
|
+
/**
|
|
39
|
+
* Trailing slot, right-aligned. The desktop design puts a "See the whole day →"
|
|
40
|
+
* chip here; mobile has no slot and falls back to the chevron below. Passing an
|
|
41
|
+
* action REPLACES the chevron rather than sitting beside it — two affordances
|
|
42
|
+
* for one destination is the duplication this design set out to remove.
|
|
43
|
+
*
|
|
44
|
+
* The action is rendered outside the header's own link/button when the header
|
|
45
|
+
* is itself interactive, so an interactive chip is never nested inside an
|
|
46
|
+
* anchor (invalid HTML, and it steals the row's click target).
|
|
47
|
+
*/
|
|
48
|
+
action?: React.ReactNode;
|
|
49
|
+
/**
|
|
50
|
+
* Larger desktop scale: 58px day number, 18px weekday, 12.5px meta,
|
|
51
|
+
* against the mobile 40/15/11.5.
|
|
52
|
+
*/
|
|
53
|
+
size?: "sm" | "default";
|
|
54
|
+
href?: string;
|
|
55
|
+
linkComponent?: React.ComponentType<EventDayHeaderLinkProps>;
|
|
56
|
+
onClick?: () => void;
|
|
57
|
+
className?: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export function EventDayHeader({
|
|
61
|
+
dayNumber,
|
|
62
|
+
weekday,
|
|
63
|
+
meta,
|
|
64
|
+
action,
|
|
65
|
+
size = "sm",
|
|
66
|
+
href,
|
|
67
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
68
|
+
onClick,
|
|
69
|
+
className,
|
|
70
|
+
}: EventDayHeaderProps) {
|
|
71
|
+
const isInteractive = !!href || !!onClick;
|
|
72
|
+
const large = size === "default";
|
|
73
|
+
|
|
74
|
+
const body = (
|
|
75
|
+
<>
|
|
76
|
+
<div
|
|
77
|
+
className={cn(
|
|
78
|
+
"font-black leading-[0.82] tracking-[-0.05em] tabular-nums",
|
|
79
|
+
large ? "text-[58px]" : "text-[40px]"
|
|
80
|
+
)}
|
|
81
|
+
>
|
|
82
|
+
{dayNumber}
|
|
83
|
+
</div>
|
|
84
|
+
<div className={large ? "pb-1" : "pb-0.5"}>
|
|
85
|
+
<div
|
|
86
|
+
className={cn("font-extrabold tracking-[-0.015em]", large ? "text-lg" : "text-[15px]")}
|
|
87
|
+
>
|
|
88
|
+
{weekday}
|
|
89
|
+
</div>
|
|
90
|
+
<div
|
|
91
|
+
className={cn(
|
|
92
|
+
"mt-0.5 text-muted-foreground",
|
|
93
|
+
large ? "whitespace-nowrap text-[12.5px]" : "text-[11.5px]"
|
|
94
|
+
)}
|
|
95
|
+
>
|
|
96
|
+
{meta}
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
{/* The chevron only stands in when there is no action slot — see `action`. */}
|
|
100
|
+
{!action && isInteractive && (
|
|
101
|
+
<ChevronRight className="ml-auto mb-1 h-[18px] w-[18px] text-muted-foreground" aria-hidden />
|
|
102
|
+
)}
|
|
103
|
+
</>
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// The interactive region covers the date only, never the action slot. Nesting
|
|
107
|
+
// the design's "See the whole day" chip inside the header's own anchor would
|
|
108
|
+
// be invalid HTML and would swallow the chip's click.
|
|
109
|
+
const dateClasses = cn(
|
|
110
|
+
"flex min-w-0 items-end gap-3 text-left",
|
|
111
|
+
!action && "flex-1",
|
|
112
|
+
isInteractive &&
|
|
113
|
+
"cursor-pointer rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const date = href ? (
|
|
117
|
+
<LinkComponent href={href} className={dateClasses}>
|
|
118
|
+
{body}
|
|
119
|
+
</LinkComponent>
|
|
120
|
+
) : onClick ? (
|
|
121
|
+
<button type="button" className={dateClasses} onClick={onClick}>
|
|
122
|
+
{body}
|
|
123
|
+
</button>
|
|
124
|
+
) : (
|
|
125
|
+
<div className={dateClasses}>{body}</div>
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div
|
|
130
|
+
className={cn(
|
|
131
|
+
"relative flex w-full items-end gap-3 pb-3 pt-[18px]",
|
|
132
|
+
large && "gap-[18px] pb-4",
|
|
133
|
+
className
|
|
134
|
+
)}
|
|
135
|
+
>
|
|
136
|
+
{/* NO BOTTOM RULE. A 50px day number above its own month/count line is
|
|
137
|
+
already the loudest thing on the page, and the timeline puts an hour
|
|
138
|
+
puck carrying its own hairline immediately beneath — so this drew a
|
|
139
|
+
second horizontal line a few pixels from a first, dividing a heading
|
|
140
|
+
from the content it introduces rather than separating two things a
|
|
141
|
+
reader could run together. Rules stay where they divide peers: rows
|
|
142
|
+
in a list, an hour heading from its count. */}
|
|
143
|
+
{date}
|
|
144
|
+
{action && <div className="ml-auto mb-1.5 shrink-0">{action}</div>}
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export type EventDayHeaderSkeletonProps = {
|
|
150
|
+
/** Reserves space for the chevron — only render this once you know the header will be tappable. */
|
|
151
|
+
interactive?: boolean;
|
|
152
|
+
className?: string;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Mirrors `<EventDayHeader>`'s geometry. The big day-number block (86×50,
|
|
157
|
+
* 10px radius) and the weekday/meta column (120×16, 200×12) come from the
|
|
158
|
+
* source design's own desktop day-header skeleton — the chevron does not: the
|
|
159
|
+
* design's spec for it (a 170×34 pill pushed right) matches nothing in this
|
|
160
|
+
* component, whose only trailing affordance is an 18×18 chevron icon, so that
|
|
161
|
+
* placeholder is sized from the real component instead.
|
|
162
|
+
*/
|
|
163
|
+
export function EventDayHeaderSkeleton({ interactive, className }: EventDayHeaderSkeletonProps) {
|
|
164
|
+
return (
|
|
165
|
+
<div
|
|
166
|
+
aria-hidden="true"
|
|
167
|
+
className={cn("relative flex w-full items-end gap-3 pb-3 pt-[18px] text-left", className)}
|
|
168
|
+
>
|
|
169
|
+
{/* Mirrors the real header, which no longer draws a bottom rule. */}
|
|
170
|
+
<Skeleton className="h-[50px] w-[86px] rounded-[10px]" />
|
|
171
|
+
|
|
172
|
+
<div className="space-y-0.5 pb-0.5">
|
|
173
|
+
<Skeleton className="h-4 w-[120px]" />
|
|
174
|
+
<Skeleton className="h-3 w-[200px]" />
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
{interactive && <Skeleton className="mb-1 ml-auto h-[18px] w-[18px] shrink-0 rounded-full" />}
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { EventDaySeparator, EventDaySeparatorSkeleton } from "./event-day-separator";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof EventDaySeparator> = {
|
|
5
|
+
title: "Events/EventDaySeparator",
|
|
6
|
+
component: EventDaySeparator,
|
|
7
|
+
tags: ["autodocs"],
|
|
8
|
+
decorators: [
|
|
9
|
+
(Story) => (
|
|
10
|
+
<div className="w-[600px]">
|
|
11
|
+
<Story />
|
|
12
|
+
</div>
|
|
13
|
+
),
|
|
14
|
+
],
|
|
15
|
+
parameters: {
|
|
16
|
+
docs: {
|
|
17
|
+
description: {
|
|
18
|
+
component:
|
|
19
|
+
"The day divider in the list view. Distinct from `<EventDayHeader>` (the timeline's oversized day-number header) — different surface, different density. Both `label` and `count` are caller-composed strings.",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export default meta;
|
|
26
|
+
type Story = StoryObj<typeof EventDaySeparator>;
|
|
27
|
+
|
|
28
|
+
export const Default: Story = {
|
|
29
|
+
args: { label: "Today · Thursday 3 September", count: "15 events" },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const NoCount: Story = {
|
|
33
|
+
args: { label: "Friday 4 September" },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const Small: Story = {
|
|
37
|
+
args: { label: "Today · Thursday 3 September", count: "15 events", size: "sm" },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Loading state — identical geometry to `Default`, so nothing shifts on data arrival. */
|
|
41
|
+
export const Loading: Story = {
|
|
42
|
+
render: () => <EventDaySeparatorSkeleton />,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** The `size="sm"` loading geometry. */
|
|
46
|
+
export const LoadingSmall: Story = {
|
|
47
|
+
render: () => <EventDaySeparatorSkeleton size="sm" />,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Loading above loaded, so any geometry drift is visible at a glance. */
|
|
51
|
+
export const LoadingVsLoaded: Story = {
|
|
52
|
+
render: () => (
|
|
53
|
+
<div>
|
|
54
|
+
<EventDaySeparatorSkeleton />
|
|
55
|
+
<EventDaySeparator label="Today · Thursday 3 September" count="15 events" />
|
|
56
|
+
</div>
|
|
57
|
+
),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** A stack, as the list view actually renders consecutive days. */
|
|
61
|
+
export const Stack: Story = {
|
|
62
|
+
render: () => (
|
|
63
|
+
<div>
|
|
64
|
+
<EventDaySeparator label="Today · Thursday 3 September" count="15 events" />
|
|
65
|
+
<div className="py-2 text-sm text-muted-foreground">…events for today…</div>
|
|
66
|
+
<EventDaySeparator label="Friday 4 September" count="17 events" />
|
|
67
|
+
<div className="py-2 text-sm text-muted-foreground">…events for tomorrow…</div>
|
|
68
|
+
</div>
|
|
69
|
+
),
|
|
70
|
+
};
|