@bearmenu/ui 0.8.7 → 0.8.8
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 → chunk-3TNPSN4D.js} +28 -9
- package/dist/chunk-AHL45DD2.js +145 -0
- package/dist/chunk-IYDH5IU6.js +66 -0
- package/dist/chunk-OPVKL4VS.js +18 -0
- package/dist/components/alert.d.ts +2 -2
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/category-deck.js +10 -1
- package/dist/components/coverflow.d.ts +11 -3
- package/dist/components/coverflow.js +34 -51
- package/dist/components/date-input.d.ts +1 -1
- package/dist/components/drift-rail.d.ts +24 -10
- package/dist/components/drift-rail.js +1 -1
- package/dist/components/event-card.d.ts +8 -0
- package/dist/components/event-card.js +63 -33
- package/dist/components/event-compact-row.d.ts +32 -5
- package/dist/components/event-compact-row.js +119 -38
- package/dist/components/event-day-header.js +3 -1
- package/dist/components/event-day-separator.d.ts +7 -1
- package/dist/components/event-day-separator.js +3 -2
- package/dist/components/event-hub-hero.d.ts +25 -4
- package/dist/components/event-hub-hero.js +89 -19
- package/dist/components/event-list-row.d.ts +19 -6
- package/dist/components/event-list-row.js +78 -34
- package/dist/components/hour-band.d.ts +38 -40
- package/dist/components/hour-band.js +55 -27
- package/dist/components/hour-group.js +1 -1
- package/dist/components/poster-tape.js +1 -1
- package/dist/components/poster-wall.d.ts +3 -3
- package/dist/components/poster-wall.js +73 -17
- package/dist/components/section-header.d.ts +16 -1
- package/dist/components/section-header.js +26 -7
- package/dist/components/snap-rail.d.ts +34 -0
- package/dist/components/snap-rail.js +51 -0
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/text.d.ts +1 -1
- package/dist/components/timeline-band.d.ts +20 -2
- package/dist/components/timeline-band.js +1 -1
- package/dist/components/timeline-playhead.d.ts +26 -0
- package/dist/components/timeline-playhead.js +25 -0
- package/dist/components/weekend-panel.d.ts +5 -4
- package/dist/components/weekend-panel.js +85 -71
- package/dist/hooks/use-drag-swipe.d.ts +44 -0
- package/dist/hooks/use-drag-swipe.js +2 -0
- package/dist/hooks/use-media-query.d.ts +17 -0
- package/dist/hooks/use-media-query.js +2 -0
- package/package.json +1 -1
- package/src/components/category-deck.tsx +19 -8
- package/src/components/coverflow.test.tsx +28 -1
- package/src/components/coverflow.tsx +37 -43
- package/src/components/drift-rail.tsx +167 -42
- package/src/components/event-card.tsx +55 -10
- package/src/components/event-compact-row.stories.tsx +30 -0
- package/src/components/event-compact-row.test.tsx +49 -0
- package/src/components/event-compact-row.tsx +161 -37
- package/src/components/event-day-header.tsx +3 -1
- package/src/components/event-day-separator.tsx +8 -1
- package/src/components/event-hub-hero.stories.tsx +30 -0
- package/src/components/event-hub-hero.tsx +102 -30
- package/src/components/event-list-row.stories.tsx +34 -0
- package/src/components/event-list-row.test.tsx +53 -0
- package/src/components/event-list-row.tsx +135 -87
- package/src/components/hour-band.stories.tsx +28 -5
- package/src/components/hour-band.tsx +90 -70
- package/src/components/poster-wall.test.tsx +28 -0
- package/src/components/poster-wall.tsx +96 -23
- package/src/components/section-header.stories.tsx +24 -0
- package/src/components/section-header.test.tsx +28 -0
- package/src/components/section-header.tsx +45 -7
- package/src/components/snap-rail.stories.tsx +79 -0
- package/src/components/snap-rail.test.tsx +18 -0
- package/src/components/snap-rail.tsx +67 -0
- package/src/components/timeline-band.tsx +52 -12
- package/src/components/timeline-playhead.stories.tsx +30 -0
- package/src/components/timeline-playhead.test.tsx +11 -0
- package/src/components/timeline-playhead.tsx +46 -0
- package/src/components/weekend-panel.test.tsx +43 -0
- package/src/components/weekend-panel.tsx +142 -108
- package/src/hooks/use-drag-swipe.test.tsx +66 -0
- package/src/hooks/use-drag-swipe.ts +105 -0
- package/src/hooks/use-media-query.ts +31 -0
- package/dist/chunk-GPH6L4DX.js +0 -55
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { memo, useState } from "react";
|
|
4
|
+
import { Radio } from "lucide-react";
|
|
4
5
|
import { cn } from "../lib/utils";
|
|
5
6
|
import { EventPosterFallback } from "./event-poster-fallback";
|
|
6
7
|
import { Skeleton } from "./skeleton";
|
|
@@ -25,10 +26,25 @@ import {
|
|
|
25
26
|
*
|
|
26
27
|
* THIS ROW DOES NOT DRAW DIVIDERS; ITS CONTAINER DOES. `<HourGroup>` already
|
|
27
28
|
* draws the rule under the hour heading, so a row-level top separator here
|
|
28
|
-
* doubled it directly beneath every hour
|
|
29
|
-
* `<EventListRowWide>` are different:
|
|
30
|
-
* other rule around them, so their own top
|
|
31
|
-
* do not carry this rule over to them.
|
|
29
|
+
* doubled it directly beneath every hour; `<HourBand>` rules between its rows
|
|
30
|
+
* with a wrapper. `<EventListRow>` and `<EventListRowWide>` are different:
|
|
31
|
+
* they stack in a plain list with no other rule around them, so their own top
|
|
32
|
+
* separator is the only one there — do not carry this rule over to them.
|
|
33
|
+
*
|
|
34
|
+
* STATE LIVES ON THE META LINE. `isLive` adds the on-air chip and `isEnded`
|
|
35
|
+
* the ended chip beside the time and venue, so a row's state is read in the
|
|
36
|
+
* same glance as its facts. Ended dims the POSTER and moves the title to
|
|
37
|
+
* `--muted-foreground` — never row-level opacity, which composites the text
|
|
38
|
+
* below AA.
|
|
39
|
+
*
|
|
40
|
+
* THE PRICE IS ON THE LAST ROW, right-aligned beside the tags, not on the
|
|
41
|
+
* title line. Two bold things on one line compete — the title and a price
|
|
42
|
+
* both in 700 read as one shout — and the title is the thing the reader is
|
|
43
|
+
* scanning for. `showPrice` renders it (free in `--success`, paid in
|
|
44
|
+
* `--foreground`); without it the row keeps its trailing free pill for the
|
|
45
|
+
* hub's venue block. This is ONE row for the timeline and the list view:
|
|
46
|
+
* the list used to have its own, and the reader met two anatomies for the
|
|
47
|
+
* same object.
|
|
32
48
|
*
|
|
33
49
|
* Same contract as the rest: DS-owned view model, injected labels/formatters,
|
|
34
50
|
* plain `<img>`, anchor whenever `href` is given.
|
|
@@ -68,6 +84,18 @@ export type EventCompactRowProps = {
|
|
|
68
84
|
* rather than truncate, so a chip's own text is never cut mid-word. Cap
|
|
69
85
|
* the count at the call site — this row does not. */
|
|
70
86
|
tags?: string[];
|
|
87
|
+
/** Currently running — adds the `labels.liveNow` chip to the meta line. */
|
|
88
|
+
isLive?: boolean;
|
|
89
|
+
/** Already over — dims the poster, mutes the title, adds the `endedLabel` chip. */
|
|
90
|
+
isEnded?: boolean;
|
|
91
|
+
/** Chip text for an ended event, e.g. "Ended". Required to render the chip. */
|
|
92
|
+
endedLabel?: string;
|
|
93
|
+
/**
|
|
94
|
+
* Prints the fee on the meta line — `labels.free` in `--success`, a paid
|
|
95
|
+
* amount via `formatters.price` — instead of the trailing free pill. An
|
|
96
|
+
* unknown fee prints nothing, same as every sibling row.
|
|
97
|
+
*/
|
|
98
|
+
showPrice?: boolean;
|
|
71
99
|
size?: "sm" | "default";
|
|
72
100
|
href?: string;
|
|
73
101
|
linkComponent?: React.ComponentType<EventCompactRowLinkProps>;
|
|
@@ -86,6 +114,10 @@ export const EventCompactRow = memo(function EventCompactRow({
|
|
|
86
114
|
hideTime,
|
|
87
115
|
subtitle,
|
|
88
116
|
tags,
|
|
117
|
+
isLive,
|
|
118
|
+
isEnded,
|
|
119
|
+
endedLabel,
|
|
120
|
+
showPrice,
|
|
89
121
|
size = "default",
|
|
90
122
|
href,
|
|
91
123
|
linkComponent: LinkComponent = DefaultLink,
|
|
@@ -101,10 +133,22 @@ export const EventCompactRow = memo(function EventCompactRow({
|
|
|
101
133
|
const showPoster = !!event.poster_url && !imgError;
|
|
102
134
|
const venue = event.place_name ?? event.location;
|
|
103
135
|
const time = formatters.time(event.starts_at);
|
|
136
|
+
const priceText =
|
|
137
|
+
fee.kind === "free"
|
|
138
|
+
? labels.free
|
|
139
|
+
: fee.kind === "paid"
|
|
140
|
+
? formatters.price(fee.amountRon)
|
|
141
|
+
: undefined;
|
|
142
|
+
const showEnded = isEnded && !!endedLabel;
|
|
143
|
+
const separator = (
|
|
144
|
+
<span aria-hidden className="text-border">
|
|
145
|
+
·
|
|
146
|
+
</span>
|
|
147
|
+
);
|
|
104
148
|
|
|
105
149
|
const body = (
|
|
106
150
|
<>
|
|
107
|
-
{/*
|
|
151
|
+
{/* 58px WIDE AND PORTRAIT, not a 34px square, and both parts matter.
|
|
108
152
|
91% of the posters this renders are portrait and ~57% are Instagram's
|
|
109
153
|
4:5 — so a square crop kept the middle band and threw away the top and
|
|
110
154
|
bottom, which on a flyer is exactly where the act, the venue and the
|
|
@@ -115,8 +159,22 @@ export const EventCompactRow = memo(function EventCompactRow({
|
|
|
115
159
|
|
|
116
160
|
One size for both `sm` and `default`: no consumer passes `sm` today,
|
|
117
161
|
and the failure is identical on a phone, so there is no second number
|
|
118
|
-
to justify. `size` still drives type scale and pill height.
|
|
119
|
-
|
|
162
|
+
to justify. `size` still drives type scale and pill height.
|
|
163
|
+
|
|
164
|
+
Ended dims the poster to ~45%, not the row: the text keeps full ink
|
|
165
|
+
and moves to the muted tier instead, which stays above AA.
|
|
166
|
+
|
|
167
|
+
64px at every width — the phone row briefly went to 58 and it was
|
|
168
|
+
too small to read a flyer by. Only the type scale and the padding are
|
|
169
|
+
phone-scoped: a 14px bold title and 11px of vertical padding below
|
|
170
|
+
`lg`; from `lg` the 13px semibold title and 8px padding the desktop
|
|
171
|
+
timeline and the hub's venue block already had. */}
|
|
172
|
+
<div
|
|
173
|
+
className={cn(
|
|
174
|
+
"relative aspect-[4/5] w-16 shrink-0 overflow-hidden rounded-[10px] bg-muted",
|
|
175
|
+
isEnded && "opacity-45",
|
|
176
|
+
)}
|
|
177
|
+
>
|
|
120
178
|
{showPoster ? (
|
|
121
179
|
// eslint-disable-next-line @next/next/no-img-element
|
|
122
180
|
<img
|
|
@@ -128,7 +186,7 @@ export const EventCompactRow = memo(function EventCompactRow({
|
|
|
128
186
|
onError={() => setImgError(true)}
|
|
129
187
|
/>
|
|
130
188
|
) : (
|
|
131
|
-
// A bare `bg-muted` rectangle was survivable at 34px. At
|
|
189
|
+
// A bare `bg-muted` rectangle was survivable at 34px. At 58x72 beside
|
|
132
190
|
// real posters it reads as a broken image rather than an absent one —
|
|
133
191
|
// so this row finally joins every sibling in the family in having a
|
|
134
192
|
// designed fallback.
|
|
@@ -146,15 +204,28 @@ export const EventCompactRow = memo(function EventCompactRow({
|
|
|
146
204
|
fire. */}
|
|
147
205
|
<div
|
|
148
206
|
className={cn(
|
|
149
|
-
"
|
|
207
|
+
"leading-[1.3]",
|
|
150
208
|
"transition-colors duration-150 motion-reduce:transition-none",
|
|
151
|
-
isSm
|
|
152
|
-
|
|
209
|
+
isSm
|
|
210
|
+
? "text-[14.5px] lg:text-[13.5px]"
|
|
211
|
+
: "text-[14px] lg:text-[13px]",
|
|
212
|
+
isEnded
|
|
213
|
+
? "font-semibold text-muted-foreground"
|
|
214
|
+
: "font-bold text-foreground lg:font-semibold",
|
|
215
|
+
(href || onClick) && "group-hover:text-primary",
|
|
153
216
|
)}
|
|
154
217
|
>
|
|
155
218
|
{event.title}
|
|
156
219
|
</div>
|
|
157
|
-
|
|
220
|
+
{/* The meta line wraps rather than truncates once it carries chips:
|
|
221
|
+
a state chip pushed off the end would be the one thing the reader
|
|
222
|
+
needed. Venue is the only free-text part and it is what may wrap. */}
|
|
223
|
+
<div
|
|
224
|
+
className={cn(
|
|
225
|
+
"mt-1 flex items-center gap-1.5 text-[11.5px] text-muted-foreground",
|
|
226
|
+
isLive || showEnded ? "flex-wrap" : "truncate",
|
|
227
|
+
)}
|
|
228
|
+
>
|
|
158
229
|
{subtitle ? (
|
|
159
230
|
subtitle
|
|
160
231
|
) : (
|
|
@@ -162,29 +233,68 @@ export const EventCompactRow = memo(function EventCompactRow({
|
|
|
162
233
|
{!hideTime && time && (
|
|
163
234
|
<>
|
|
164
235
|
<span className="font-mono tabular-nums">{time}</span>
|
|
165
|
-
|
|
236
|
+
{separator}
|
|
166
237
|
</>
|
|
167
238
|
)}
|
|
168
|
-
{venue}
|
|
239
|
+
<span className="min-w-0 truncate">{venue}</span>
|
|
169
240
|
</>
|
|
170
241
|
)}
|
|
242
|
+
{isLive && (
|
|
243
|
+
<Tag
|
|
244
|
+
size="sm"
|
|
245
|
+
variant="success"
|
|
246
|
+
className="shrink-0 whitespace-nowrap font-extrabold"
|
|
247
|
+
>
|
|
248
|
+
<Radio className="h-2.5 w-2.5" aria-hidden />
|
|
249
|
+
{labels.liveNow}
|
|
250
|
+
</Tag>
|
|
251
|
+
)}
|
|
252
|
+
{showEnded && (
|
|
253
|
+
<Tag
|
|
254
|
+
size="sm"
|
|
255
|
+
variant="default"
|
|
256
|
+
className="shrink-0 whitespace-nowrap font-extrabold"
|
|
257
|
+
>
|
|
258
|
+
{endedLabel}
|
|
259
|
+
</Tag>
|
|
260
|
+
)}
|
|
171
261
|
</div>
|
|
172
|
-
{
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
262
|
+
{/* The last row: tags on the left, the fee on the right. Either half
|
|
263
|
+
may be absent; the row exists when at least one is there. */}
|
|
264
|
+
{((tags && tags.length > 0) || (showPrice && priceText)) && (
|
|
265
|
+
<div className="mt-1.5 flex items-center gap-2">
|
|
266
|
+
{tags && tags.length > 0 ? (
|
|
267
|
+
<TagGroup className="min-w-0 flex-1">
|
|
268
|
+
{tags.map((tag) => (
|
|
269
|
+
<Tag key={tag} size="sm" variant="quiet">
|
|
270
|
+
{tag}
|
|
271
|
+
</Tag>
|
|
272
|
+
))}
|
|
273
|
+
</TagGroup>
|
|
274
|
+
) : (
|
|
275
|
+
<span className="flex-1" />
|
|
276
|
+
)}
|
|
277
|
+
{showPrice && priceText && (
|
|
278
|
+
<span
|
|
279
|
+
className={cn(
|
|
280
|
+
"shrink-0 text-[13px] tabular-nums",
|
|
281
|
+
fee.kind === "free"
|
|
282
|
+
? "font-semibold text-success"
|
|
283
|
+
: "font-bold text-foreground",
|
|
284
|
+
)}
|
|
285
|
+
>
|
|
286
|
+
{priceText}
|
|
287
|
+
</span>
|
|
288
|
+
)}
|
|
289
|
+
</div>
|
|
180
290
|
)}
|
|
181
291
|
</div>
|
|
182
292
|
|
|
183
|
-
{fee.kind === "free" && (
|
|
293
|
+
{!showPrice && fee.kind === "free" && (
|
|
184
294
|
<span
|
|
185
295
|
className={cn(
|
|
186
296
|
"inline-flex shrink-0 items-center rounded-full border border-success/40 px-2 font-semibold text-success",
|
|
187
|
-
isSm ? "h-[22px] text-[10.5px]" : "h-6 text-[11px]"
|
|
297
|
+
isSm ? "h-[22px] text-[10.5px]" : "h-6 text-[11px]",
|
|
188
298
|
)}
|
|
189
299
|
>
|
|
190
300
|
{labels.free}
|
|
@@ -193,9 +303,9 @@ export const EventCompactRow = memo(function EventCompactRow({
|
|
|
193
303
|
</>
|
|
194
304
|
);
|
|
195
305
|
|
|
196
|
-
// `items-start`, not `items-center`:
|
|
197
|
-
// title over its sub-line, so centring floated the text in empty
|
|
198
|
-
// `EventListRow`
|
|
306
|
+
// `items-start`, not `items-center`: a 72px-tall poster is taller than a
|
|
307
|
+
// one-line title over its sub-line, so centring floated the text in empty
|
|
308
|
+
// space. `EventListRow` makes the same call.
|
|
199
309
|
//
|
|
200
310
|
// THE HOVER WASH IS `bg-foreground/5`, AND THE TOKEN CHOICE IS LOAD-BEARING.
|
|
201
311
|
// This row renders on two different planes: the timeline puts it straight on
|
|
@@ -211,11 +321,11 @@ export const EventCompactRow = memo(function EventCompactRow({
|
|
|
211
321
|
// same shape `EventIndexRow` uses. No transform: this is a zero-gap stacked
|
|
212
322
|
// list, so a lift would overlap its neighbours rather than read as elevation.
|
|
213
323
|
const rootClasses = cn(
|
|
214
|
-
"group relative -mx-2.5 flex w-full items-start gap-
|
|
324
|
+
"group relative -mx-2.5 flex w-full items-start gap-3 rounded-lg px-2.5 py-[11px] text-left lg:gap-[11px] lg:py-2",
|
|
215
325
|
"transition-colors duration-150 motion-reduce:transition-none",
|
|
216
326
|
(href || onClick) &&
|
|
217
327
|
"cursor-pointer hover:bg-foreground/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
218
|
-
className
|
|
328
|
+
className,
|
|
219
329
|
);
|
|
220
330
|
|
|
221
331
|
if (href) {
|
|
@@ -223,7 +333,9 @@ export const EventCompactRow = memo(function EventCompactRow({
|
|
|
223
333
|
<LinkComponent
|
|
224
334
|
href={href}
|
|
225
335
|
className={rootClasses}
|
|
226
|
-
onClick={
|
|
336
|
+
onClick={
|
|
337
|
+
onClick ? (mouseEvent) => onClick(event, mouseEvent) : undefined
|
|
338
|
+
}
|
|
227
339
|
>
|
|
228
340
|
{body}
|
|
229
341
|
</LinkComponent>
|
|
@@ -232,7 +344,11 @@ export const EventCompactRow = memo(function EventCompactRow({
|
|
|
232
344
|
|
|
233
345
|
if (onClick) {
|
|
234
346
|
return (
|
|
235
|
-
<button
|
|
347
|
+
<button
|
|
348
|
+
type="button"
|
|
349
|
+
className={rootClasses}
|
|
350
|
+
onClick={(e) => onClick(event, e)}
|
|
351
|
+
>
|
|
236
352
|
{body}
|
|
237
353
|
</button>
|
|
238
354
|
);
|
|
@@ -248,22 +364,30 @@ export type EventCompactRowSkeletonProps = {
|
|
|
248
364
|
};
|
|
249
365
|
|
|
250
366
|
/** Mirrors `<EventCompactRow>`'s geometry exactly so nothing shifts when data lands. */
|
|
251
|
-
export function EventCompactRowSkeleton({
|
|
367
|
+
export function EventCompactRowSkeleton({
|
|
368
|
+
size = "default",
|
|
369
|
+
className,
|
|
370
|
+
}: EventCompactRowSkeletonProps) {
|
|
252
371
|
const isSm = size === "sm";
|
|
253
372
|
|
|
254
373
|
return (
|
|
255
374
|
<div
|
|
256
375
|
aria-hidden="true"
|
|
257
376
|
className={cn(
|
|
258
|
-
"relative -mx-2.5 flex w-full items-start gap-
|
|
259
|
-
className
|
|
377
|
+
"relative -mx-2.5 flex w-full items-start gap-3 rounded-lg px-2.5 py-[11px] text-left lg:gap-[11px] lg:py-2",
|
|
378
|
+
className,
|
|
260
379
|
)}
|
|
261
380
|
>
|
|
262
|
-
<Skeleton className="aspect-[4/5] w-16 shrink-0 rounded-[
|
|
381
|
+
<Skeleton className="aspect-[4/5] w-16 shrink-0 rounded-[10px]" />
|
|
263
382
|
|
|
264
383
|
<div className="min-w-0 flex-1">
|
|
265
|
-
<Skeleton
|
|
266
|
-
|
|
384
|
+
<Skeleton
|
|
385
|
+
className={cn(
|
|
386
|
+
isSm ? "h-[14.5px] lg:h-[13.5px]" : "h-[14px] lg:h-[13px]",
|
|
387
|
+
"w-3/5",
|
|
388
|
+
)}
|
|
389
|
+
/>
|
|
390
|
+
<Skeleton className="mt-1 h-[11.5px] w-1/3" />
|
|
267
391
|
</div>
|
|
268
392
|
</div>
|
|
269
393
|
);
|
|
@@ -76,7 +76,9 @@ export function EventDayHeader({
|
|
|
76
76
|
<div
|
|
77
77
|
className={cn(
|
|
78
78
|
"font-black leading-[0.82] tracking-[-0.05em] tabular-nums",
|
|
79
|
-
|
|
79
|
+
// `sm` is the phone scale: 30px, the design's app day head. 40px
|
|
80
|
+
// was a page-scale number that took a third of a 390px row.
|
|
81
|
+
large ? "text-[58px]" : "text-[30px] leading-[0.86]"
|
|
80
82
|
)}
|
|
81
83
|
>
|
|
82
84
|
{dayNumber}
|
|
@@ -28,6 +28,12 @@ import { Skeleton } from "./skeleton";
|
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
30
|
export type EventDaySeparatorProps = {
|
|
31
|
+
/**
|
|
32
|
+
* `always` (default) draws the trailing rule at every width. `lg` drops it
|
|
33
|
+
* below `lg`, where the list is an app sheet whose day heads are bold
|
|
34
|
+
* labels alone — the rows below rule themselves — and keeps it from `lg`.
|
|
35
|
+
*/
|
|
36
|
+
rule?: "always" | "lg";
|
|
31
37
|
label: string;
|
|
32
38
|
count?: string;
|
|
33
39
|
size?: "sm" | "default";
|
|
@@ -42,6 +48,7 @@ export function EventDaySeparator({
|
|
|
42
48
|
size = "default",
|
|
43
49
|
as: Tag = "h2",
|
|
44
50
|
className,
|
|
51
|
+
rule = "always",
|
|
45
52
|
}: EventDaySeparatorProps) {
|
|
46
53
|
return (
|
|
47
54
|
<div
|
|
@@ -61,7 +68,7 @@ export function EventDaySeparator({
|
|
|
61
68
|
</span>
|
|
62
69
|
)}
|
|
63
70
|
|
|
64
|
-
<Separator className="flex-1" />
|
|
71
|
+
<Separator className={cn("flex-1", rule === "lg" && "hidden lg:block")} />
|
|
65
72
|
</div>
|
|
66
73
|
);
|
|
67
74
|
}
|
|
@@ -19,6 +19,7 @@ const meta: Meta<typeof EventHubHero> = {
|
|
|
19
19
|
component: EventHubHero,
|
|
20
20
|
tags: ["autodocs"],
|
|
21
21
|
args: {
|
|
22
|
+
statsLabel: "Key figures",
|
|
22
23
|
eyebrow: "Updated today",
|
|
23
24
|
title: "Everything happening in Bucharest",
|
|
24
25
|
intro:
|
|
@@ -64,3 +65,32 @@ export const NoAside: Story = {
|
|
|
64
65
|
export const Skeleton: Story = {
|
|
65
66
|
render: () => <EventHubHeroSkeleton aside={<div className="aspect-[3/2] rounded-xl bg-muted" />} />,
|
|
66
67
|
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Below `lg` the hero is a masthead: headline, one sub-line, and the figures
|
|
71
|
+
* as a chip rail bleeding to the screen edge. The `live` figure takes the
|
|
72
|
+
* success border and value. View at 390px.
|
|
73
|
+
*/
|
|
74
|
+
export const Masthead: Story = {
|
|
75
|
+
args: {
|
|
76
|
+
statsLabel: "Key figures",
|
|
77
|
+
eyebrow: "Updated today · next 14 days",
|
|
78
|
+
title: "Events in Cluj-Napoca",
|
|
79
|
+
intro:
|
|
80
|
+
"Everything on across the city for the next two weeks, from tonight's quizzes to the weekend's big rooms.",
|
|
81
|
+
stats: [
|
|
82
|
+
{ value: "3", label: "on right now", tone: "live" },
|
|
83
|
+
{ value: "76", label: "this fortnight" },
|
|
84
|
+
{ value: "34", label: "venues" },
|
|
85
|
+
{ value: "12", label: "free entry" },
|
|
86
|
+
{ value: "18", label: "past midnight" },
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
decorators: [
|
|
90
|
+
(Story) => (
|
|
91
|
+
<div className="w-[390px] bg-surface-2 px-4 pt-4">
|
|
92
|
+
<Story />
|
|
93
|
+
</div>
|
|
94
|
+
),
|
|
95
|
+
],
|
|
96
|
+
};
|
|
@@ -5,9 +5,20 @@ import { cn } from "../lib/utils";
|
|
|
5
5
|
import { Skeleton } from "./skeleton";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* EventHubHero — the hub's opening block: an eyebrow, a big
|
|
9
|
-
* intro
|
|
10
|
-
* stack, in the source design) on the right.
|
|
8
|
+
* EventHubHero — the hub's opening block. On desktop: an eyebrow, a big
|
|
9
|
+
* headline, an intro paragraph and a stats row on the left; an arbitrary slot
|
|
10
|
+
* (the poster stack, in the source design) on the right.
|
|
11
|
+
*
|
|
12
|
+
* BELOW `lg` IT IS A MASTHEAD, NOT A HERO. One headline line, the eyebrow
|
|
13
|
+
* demoted to a sub-line beneath it, no intro, and the figures as a swipeable
|
|
14
|
+
* rail of chips that bleeds to the screen edge. The first real content lands
|
|
15
|
+
* roughly 250px sooner than under the web-shaped hero — on a phone the page
|
|
16
|
+
* should show, not narrate. Same markup at every width; only the classes
|
|
17
|
+
* change, so the copy is authored once.
|
|
18
|
+
*
|
|
19
|
+
* A stat with `tone: "live"` is the one that is happening right now: its
|
|
20
|
+
* chip takes a `--success` border and value, the same live colour every
|
|
21
|
+
* events surface uses.
|
|
11
22
|
*
|
|
12
23
|
* All copy and stat values arrive pre-formatted — the DS holds no `Intl`
|
|
13
24
|
* locale and does not format numbers.
|
|
@@ -16,6 +27,8 @@ import { Skeleton } from "./skeleton";
|
|
|
16
27
|
export type EventHubHeroStat = {
|
|
17
28
|
value: string;
|
|
18
29
|
label: string;
|
|
30
|
+
/** `live` marks the figure that is happening right now. */
|
|
31
|
+
tone?: "default" | "live";
|
|
19
32
|
};
|
|
20
33
|
|
|
21
34
|
export type EventHubHeroProps = {
|
|
@@ -24,12 +37,28 @@ export type EventHubHeroProps = {
|
|
|
24
37
|
title: string;
|
|
25
38
|
intro: string;
|
|
26
39
|
stats: EventHubHeroStat[];
|
|
40
|
+
/**
|
|
41
|
+
* Accessible name for the stats rail, e.g. "Key figures". When given, the
|
|
42
|
+
* rail is a named, focusable region so a keyboard can reach chips clipped
|
|
43
|
+
* by the phone's edge. Optional so existing consumers keep compiling — but
|
|
44
|
+
* pass it: without a name the rail cannot be focusable, and a phone reader
|
|
45
|
+
* on a keyboard cannot reach the last figures.
|
|
46
|
+
*/
|
|
47
|
+
statsLabel?: string;
|
|
27
48
|
/** The right-column slot — the poster stack in the source design. */
|
|
28
49
|
aside?: React.ReactNode;
|
|
29
50
|
className?: string;
|
|
30
51
|
};
|
|
31
52
|
|
|
32
|
-
export function EventHubHero({
|
|
53
|
+
export function EventHubHero({
|
|
54
|
+
eyebrow,
|
|
55
|
+
title,
|
|
56
|
+
intro,
|
|
57
|
+
stats,
|
|
58
|
+
statsLabel,
|
|
59
|
+
aside,
|
|
60
|
+
className,
|
|
61
|
+
}: EventHubHeroProps) {
|
|
33
62
|
return (
|
|
34
63
|
<div
|
|
35
64
|
className={cn(
|
|
@@ -37,30 +66,70 @@ export function EventHubHero({ eyebrow, title, intro, stats, aside, className }:
|
|
|
37
66
|
className
|
|
38
67
|
)}
|
|
39
68
|
>
|
|
40
|
-
<div className="flex flex-col gap-4">
|
|
41
|
-
|
|
69
|
+
<div className="flex flex-col gap-1.5 lg:gap-4">
|
|
70
|
+
{/* Eyebrow above the title on desktop; the sub-line beneath it on a
|
|
71
|
+
phone. `order` swaps the position, the classes swap the voice. */}
|
|
72
|
+
<p
|
|
73
|
+
className={cn(
|
|
74
|
+
"order-2 text-[13px] text-muted-foreground",
|
|
75
|
+
"lg:order-none lg:text-[11.5px] lg:font-extrabold lg:tracking-[0.12em]"
|
|
76
|
+
)}
|
|
77
|
+
>
|
|
42
78
|
{eyebrow}
|
|
43
79
|
</p>
|
|
44
80
|
|
|
45
|
-
<h1
|
|
81
|
+
<h1
|
|
82
|
+
className={cn(
|
|
83
|
+
"order-1 text-[26px] font-black leading-[1.1] tracking-[-0.035em] text-foreground",
|
|
84
|
+
"lg:order-none lg:text-[42px] lg:leading-[1.05] lg:tracking-[-0.04em]"
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
46
87
|
{title}
|
|
47
88
|
</h1>
|
|
48
89
|
|
|
49
|
-
<p className="max-w-[520px] text-[
|
|
90
|
+
<p className="hidden max-w-[520px] text-[15px] leading-[1.65] text-muted-foreground lg:order-none lg:block">
|
|
50
91
|
{intro}
|
|
51
92
|
</p>
|
|
52
93
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
94
|
+
{/* Figures. A chip rail below `lg` — `-mx-4 px-4` runs it under the
|
|
95
|
+
page gutter so the last chip is cut by the screen edge, which is
|
|
96
|
+
what says "swipe"; inline figures from `lg`. */}
|
|
97
|
+
<div
|
|
98
|
+
role={statsLabel ? "region" : undefined}
|
|
99
|
+
aria-label={statsLabel}
|
|
100
|
+
tabIndex={statsLabel ? 0 : undefined}
|
|
101
|
+
className={cn(
|
|
102
|
+
"order-3 -mx-4 flex gap-2 overflow-x-auto px-4 pt-2 scrollbar-hide",
|
|
103
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
104
|
+
"lg:order-none lg:mx-0 lg:gap-[30px] lg:overflow-visible lg:px-0 lg:pt-1.5"
|
|
105
|
+
)}
|
|
106
|
+
>
|
|
107
|
+
{stats.map((stat) => {
|
|
108
|
+
const isLive = stat.tone === "live";
|
|
109
|
+
return (
|
|
110
|
+
<div
|
|
111
|
+
key={stat.label}
|
|
112
|
+
className={cn(
|
|
113
|
+
"flex min-w-[106px] shrink-0 flex-col rounded-[14px] border bg-card px-[13px] py-2.5",
|
|
114
|
+
isLive ? "border-success/40" : "border-border",
|
|
115
|
+
"lg:min-w-0 lg:rounded-none lg:border-0 lg:bg-transparent lg:p-0"
|
|
116
|
+
)}
|
|
117
|
+
>
|
|
118
|
+
<span
|
|
119
|
+
className={cn(
|
|
120
|
+
"text-[19px] font-black leading-none tracking-[-0.02em] tabular-nums",
|
|
121
|
+
isLive ? "text-success" : "text-foreground",
|
|
122
|
+
"lg:text-[30px] lg:tracking-[-0.03em]"
|
|
123
|
+
)}
|
|
124
|
+
>
|
|
125
|
+
{stat.value}
|
|
126
|
+
</span>
|
|
127
|
+
<span className="mt-0.5 text-[11px] text-muted-foreground lg:mt-[5px] lg:text-[12.5px]">
|
|
128
|
+
{stat.label}
|
|
129
|
+
</span>
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
})}
|
|
64
133
|
</div>
|
|
65
134
|
</div>
|
|
66
135
|
|
|
@@ -84,21 +153,24 @@ export function EventHubHeroSkeleton({ aside, className }: EventHubHeroSkeletonP
|
|
|
84
153
|
className
|
|
85
154
|
)}
|
|
86
155
|
>
|
|
87
|
-
<div className="flex flex-col gap-4">
|
|
88
|
-
<Skeleton className="h-[11.5px] w-28" />
|
|
156
|
+
<div className="flex flex-col gap-1.5 lg:gap-4">
|
|
157
|
+
<Skeleton className="order-2 h-[13px] w-40 lg:order-none lg:h-[11.5px] lg:w-28" />
|
|
89
158
|
|
|
90
|
-
<Skeleton className="h-[
|
|
159
|
+
<Skeleton className="order-1 h-[26px] w-full max-w-[420px] lg:order-none lg:h-[42px]" />
|
|
91
160
|
|
|
92
|
-
<div className="
|
|
93
|
-
<Skeleton className="h-[
|
|
94
|
-
<Skeleton className="h-[
|
|
161
|
+
<div className="hidden max-w-[520px] flex-col gap-1.5 lg:flex">
|
|
162
|
+
<Skeleton className="h-[15px] w-full" />
|
|
163
|
+
<Skeleton className="h-[15px] w-4/5" />
|
|
95
164
|
</div>
|
|
96
165
|
|
|
97
|
-
<div className="flex gap-
|
|
98
|
-
{Array.from({ length:
|
|
99
|
-
<div
|
|
100
|
-
|
|
101
|
-
|
|
166
|
+
<div className="order-3 -mx-4 flex gap-2 overflow-hidden px-4 pt-2 lg:order-none lg:mx-0 lg:gap-[30px] lg:px-0 lg:pt-1.5">
|
|
167
|
+
{Array.from({ length: 4 }, (_, index) => (
|
|
168
|
+
<div
|
|
169
|
+
key={index}
|
|
170
|
+
className="flex min-w-[106px] shrink-0 flex-col rounded-[14px] border border-border bg-card px-[13px] py-2.5 lg:min-w-0 lg:rounded-none lg:border-0 lg:bg-transparent lg:p-0"
|
|
171
|
+
>
|
|
172
|
+
<Skeleton className="h-[19px] w-8 lg:h-[30px] lg:w-10" />
|
|
173
|
+
<Skeleton className="mt-0.5 h-[11px] w-16 lg:mt-[5px] lg:h-[12.5px]" />
|
|
102
174
|
</div>
|
|
103
175
|
))}
|
|
104
176
|
</div>
|
|
@@ -157,3 +157,37 @@ export const ADay: Story = {
|
|
|
157
157
|
</div>
|
|
158
158
|
),
|
|
159
159
|
};
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Inside a dated group the heading names the day, so the row drops its badge
|
|
163
|
+
* and leads with the poster. This is the list view's shipped shape.
|
|
164
|
+
*/
|
|
165
|
+
export const InDatedGroup: Story = {
|
|
166
|
+
render: () => (
|
|
167
|
+
<div className="flex flex-col">
|
|
168
|
+
<EventListRow
|
|
169
|
+
event={event("g1", "Hat Design Workshop with Matira Hats", "Del Mars", "2026-09-07T17:00:00", 795)}
|
|
170
|
+
labels={labels}
|
|
171
|
+
formatters={formatters}
|
|
172
|
+
tags={["Workshop"]}
|
|
173
|
+
hideDate
|
|
174
|
+
hideRule
|
|
175
|
+
/>
|
|
176
|
+
<EventListRow
|
|
177
|
+
event={event("g2", "International Quiz at Yolka", "Yolka", "2026-09-07T19:00:00", 0)}
|
|
178
|
+
labels={labels}
|
|
179
|
+
formatters={formatters}
|
|
180
|
+
tags={["Drinks", "Games"]}
|
|
181
|
+
isLive
|
|
182
|
+
hideDate
|
|
183
|
+
/>
|
|
184
|
+
<EventListRow
|
|
185
|
+
event={event("g3", "Klausen Quiz by GOtrivia", "Klausen Pubhouse", "2026-09-07T20:00:00", 20)}
|
|
186
|
+
labels={labels}
|
|
187
|
+
formatters={formatters}
|
|
188
|
+
tags={["Drinks", "Games"]}
|
|
189
|
+
hideDate
|
|
190
|
+
/>
|
|
191
|
+
</div>
|
|
192
|
+
),
|
|
193
|
+
};
|