@bearmenu/ui 0.8.17 → 0.8.18
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-WYTJR2YU.js → chunk-5CPBSNWQ.js} +6 -1
- package/dist/{chunk-54FQQZZJ.js → chunk-EQYB4Q77.js} +1 -1
- package/dist/chunk-LYREUZH6.js +156 -0
- package/dist/chunk-PW247TG2.js +81 -0
- 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/command.d.ts +15 -15
- package/dist/components/concept-compare-rail.js +2 -2
- package/dist/components/concept-compare.js +2 -2
- package/dist/components/event-card.d.ts +22 -4
- package/dist/components/event-card.js +95 -50
- package/dist/components/event-day-header.d.ts +5 -3
- package/dist/components/event-day-header.js +12 -4
- package/dist/components/event-door.d.ts +41 -0
- package/dist/components/event-door.js +65 -0
- package/dist/components/event-hub-hero.d.ts +10 -2
- package/dist/components/event-hub-hero.js +11 -5
- package/dist/components/event-tonight-strip.d.ts +50 -0
- package/dist/components/event-tonight-strip.js +67 -0
- package/dist/components/item.d.ts +2 -2
- package/dist/components/menu-hub-hero.d.ts +7 -1
- package/dist/components/menu-hub-hero.js +2 -1
- package/dist/components/place-card.js +1 -1
- package/dist/components/poster-stack.d.ts +36 -3
- package/dist/components/poster-stack.js +49 -18
- package/dist/components/snap-rail.d.ts +2 -2
- package/dist/components/snap-rail.js +1 -1
- package/dist/components/spinner.d.ts +2 -2
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/text.d.ts +3 -3
- package/dist/components/timeline-day.d.ts +54 -0
- package/dist/components/timeline-day.js +65 -0
- package/dist/components/timeline-hour-puck.d.ts +47 -0
- package/dist/components/timeline-hour-puck.js +3 -0
- package/dist/components/timeline-hour.d.ts +96 -0
- package/dist/components/{timeline-band.js → timeline-hour.js} +2 -1
- package/dist/components/timeline-playhead.js +1 -3
- package/package.json +1 -1
- package/src/components/event-card.tsx +70 -13
- package/src/components/event-day-header.tsx +19 -7
- package/src/components/event-door.stories.tsx +88 -0
- package/src/components/event-door.tsx +97 -0
- package/src/components/event-hub-hero.tsx +21 -3
- package/src/components/event-tonight-strip.stories.tsx +78 -0
- package/src/components/event-tonight-strip.test.tsx +59 -0
- package/src/components/event-tonight-strip.tsx +134 -0
- package/src/components/menu-hub-hero.test.tsx +13 -0
- package/src/components/menu-hub-hero.tsx +9 -2
- package/src/components/poster-stack.stories.tsx +43 -1
- package/src/components/poster-stack.tsx +98 -19
- package/src/components/snap-rail.tsx +6 -1
- package/src/components/timeline-day.stories.tsx +201 -0
- package/src/components/timeline-day.test.tsx +78 -0
- package/src/components/timeline-day.tsx +114 -0
- package/src/components/timeline-hour-puck.tsx +139 -0
- package/src/components/timeline-hour.tsx +266 -0
- package/src/components/timeline-playhead.tsx +1 -1
- package/dist/chunk-NSNEANOQ.js +0 -104
- package/dist/components/hour-band.d.ts +0 -66
- package/dist/components/hour-band.js +0 -81
- package/dist/components/hour-group.d.ts +0 -67
- package/dist/components/hour-group.js +0 -59
- package/dist/components/timeline-band.d.ts +0 -64
- package/src/components/hour-band.stories.tsx +0 -182
- package/src/components/hour-band.tsx +0 -146
- package/src/components/hour-group.stories.tsx +0 -100
- package/src/components/hour-group.tsx +0 -124
- package/src/components/timeline-band.stories.tsx +0 -206
- package/src/components/timeline-band.tsx +0 -247
|
@@ -55,8 +55,22 @@ export type EventCardProps = {
|
|
|
55
55
|
* `Coverflow`) — it crops less than `3/4` and is not the same as it.
|
|
56
56
|
*/
|
|
57
57
|
posterAspect?: "4/3" | "3/4" | "4/5";
|
|
58
|
-
/**
|
|
59
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Larger title — the featured/highlight slot. Never a clamp: the title
|
|
60
|
+
* wraps in full either way. `"lg"` applies it from the `lg` breakpoint
|
|
61
|
+
* only: the timeline's day hero is an ordinary card in the phone column and
|
|
62
|
+
* the lane's feature poster on desktop, one node.
|
|
63
|
+
*/
|
|
64
|
+
emphasis?: boolean | "lg";
|
|
65
|
+
/**
|
|
66
|
+
* `card` (default) at every width. `row-lg`: the card below `lg`, and from
|
|
67
|
+
* `lg` the compact row `EventCompactRow` draws — a 64px portrait poster
|
|
68
|
+
* beside the text, overlay pills off, the fee as a chip on the tags row.
|
|
69
|
+
* The timeline demotes an hour's later events to rows on desktop only, and
|
|
70
|
+
* a server render cannot swap components per viewport; this is the one
|
|
71
|
+
* node that paints both.
|
|
72
|
+
*/
|
|
73
|
+
density?: "card" | "row-lg";
|
|
60
74
|
/** Top-left overlay pill (the design's "Busy" hype marker). Include its icon. */
|
|
61
75
|
badge?: React.ReactNode;
|
|
62
76
|
/** Bottom-left overlay pill naming the category. Include its icon. */
|
|
@@ -77,8 +91,12 @@ export type EventCardProps = {
|
|
|
77
91
|
* locale, and "is this live" depends on the app's clock, not the DS's.
|
|
78
92
|
*/
|
|
79
93
|
liveStatus?: string;
|
|
80
|
-
/**
|
|
81
|
-
|
|
94
|
+
/**
|
|
95
|
+
* Hide the day/time line — the timeline supplies its own hour heading.
|
|
96
|
+
* `"below-lg"` hides it on the phone only: the desktop lane's hero sits
|
|
97
|
+
* beside whatever hour is passing and has to carry its own start time.
|
|
98
|
+
*/
|
|
99
|
+
hideWhen?: boolean | "below-lg";
|
|
82
100
|
/**
|
|
83
101
|
* Already over — dims the poster, mutes the title and shows the `endedLabel`
|
|
84
102
|
* chip. Never row-level opacity, which composites the text below AA. Same
|
|
@@ -111,6 +129,7 @@ export const EventCard = memo(function EventCard({
|
|
|
111
129
|
formatters,
|
|
112
130
|
posterAspect = "4/3",
|
|
113
131
|
emphasis,
|
|
132
|
+
density = "card",
|
|
114
133
|
badge,
|
|
115
134
|
tagLabel,
|
|
116
135
|
tags,
|
|
@@ -129,7 +148,10 @@ export const EventCard = memo(function EventCard({
|
|
|
129
148
|
}: EventCardProps) {
|
|
130
149
|
const [imgError, setImgError] = useState(false);
|
|
131
150
|
const showEnded = isEnded && !!endedLabel;
|
|
132
|
-
|
|
151
|
+
// `"below-lg"` still renders the line (hidden by CSS on the phone), so the
|
|
152
|
+
// time is formatted whenever the line can show anywhere.
|
|
153
|
+
const time = hideWhen === true ? "" : formatters.time(event.starts_at);
|
|
154
|
+
const isRowLg = density === "row-lg";
|
|
133
155
|
|
|
134
156
|
const fee = resolveEventEntryFee(event);
|
|
135
157
|
const showPoster = !!event.poster_url && !imgError;
|
|
@@ -146,6 +168,8 @@ export const EventCard = memo(function EventCard({
|
|
|
146
168
|
: posterAspect === "4/5"
|
|
147
169
|
? "aspect-[4/5]"
|
|
148
170
|
: "aspect-[4/3]",
|
|
171
|
+
// The compact row's poster from `lg`: 64px, portrait, tighter radius.
|
|
172
|
+
isRowLg && "lg:aspect-[4/5] lg:w-16 lg:shrink-0 lg:rounded-[10px]",
|
|
149
173
|
)}
|
|
150
174
|
>
|
|
151
175
|
{showPoster ? (
|
|
@@ -164,8 +188,14 @@ export const EventCard = memo(function EventCard({
|
|
|
164
188
|
)}
|
|
165
189
|
|
|
166
190
|
{/* Top overlay row — hype badge left, entry fee right. The empty span
|
|
167
|
-
keeps the fee pill right-aligned when there is no badge.
|
|
168
|
-
|
|
191
|
+
keeps the fee pill right-aligned when there is no badge. Off on a
|
|
192
|
+
64px row poster, where the fee moves to the tags row. */}
|
|
193
|
+
<div
|
|
194
|
+
className={cn(
|
|
195
|
+
"absolute inset-x-2 top-2 flex items-start justify-between gap-2",
|
|
196
|
+
isRowLg && "lg:hidden",
|
|
197
|
+
)}
|
|
198
|
+
>
|
|
169
199
|
{badge ? (
|
|
170
200
|
<span className={cn(PILL_BASE, "bg-black/55 text-white")}>
|
|
171
201
|
{badge}
|
|
@@ -187,7 +217,12 @@ export const EventCard = memo(function EventCard({
|
|
|
187
217
|
</div>
|
|
188
218
|
|
|
189
219
|
{tagLabel && (
|
|
190
|
-
<div
|
|
220
|
+
<div
|
|
221
|
+
className={cn(
|
|
222
|
+
"absolute inset-x-2 bottom-2 flex gap-1",
|
|
223
|
+
isRowLg && "lg:hidden",
|
|
224
|
+
)}
|
|
225
|
+
>
|
|
191
226
|
<span className={cn(PILL_BASE, "bg-black/55 text-white")}>
|
|
192
227
|
{tagLabel}
|
|
193
228
|
</span>
|
|
@@ -195,6 +230,8 @@ export const EventCard = memo(function EventCard({
|
|
|
195
230
|
)}
|
|
196
231
|
</div>
|
|
197
232
|
|
|
233
|
+
{/* The text column — a wrapper so `row-lg` can lay it beside the poster. */}
|
|
234
|
+
<div className={cn("flex min-w-0 flex-col", isRowLg && "lg:flex-1")}>
|
|
198
235
|
{/* NEVER CLAMPED, in any variant. The title is the one thing a card
|
|
199
236
|
exists to say, and "Movie Night: Futni m…" says none of it — the
|
|
200
237
|
truncated form was the reported defect. It wraps to as many lines as
|
|
@@ -205,9 +242,11 @@ export const EventCard = memo(function EventCard({
|
|
|
205
242
|
<h3
|
|
206
243
|
className={cn(
|
|
207
244
|
"mt-2.5 leading-[1.35]",
|
|
208
|
-
emphasis
|
|
245
|
+
emphasis === true
|
|
209
246
|
? "text-lg font-bold tracking-[-0.02em]"
|
|
210
247
|
: "text-sm font-semibold",
|
|
248
|
+
emphasis === "lg" && "lg:text-lg lg:font-bold lg:tracking-[-0.02em]",
|
|
249
|
+
isRowLg && "lg:mt-0 lg:text-[13.5px]",
|
|
211
250
|
isEnded && "text-muted-foreground",
|
|
212
251
|
)}
|
|
213
252
|
>
|
|
@@ -219,8 +258,13 @@ export const EventCard = memo(function EventCard({
|
|
|
219
258
|
<span className="truncate">{venue}</span>
|
|
220
259
|
</div>
|
|
221
260
|
|
|
222
|
-
{
|
|
223
|
-
<div
|
|
261
|
+
{hideWhen !== true && (
|
|
262
|
+
<div
|
|
263
|
+
className={cn(
|
|
264
|
+
"mt-[3px] flex items-center gap-1.5 text-xs text-muted-foreground",
|
|
265
|
+
hideWhen === "below-lg" && "hidden lg:flex",
|
|
266
|
+
)}
|
|
267
|
+
>
|
|
224
268
|
<span>{dayLabel ?? formatters.day(event.starts_at)}</span>
|
|
225
269
|
{/* An empty time is "hour unknown" (see `EventFormatters.time`): the
|
|
226
270
|
day stands alone, no dangling separator. Consumers used to have
|
|
@@ -244,13 +288,21 @@ export const EventCard = memo(function EventCard({
|
|
|
244
288
|
</div>
|
|
245
289
|
)}
|
|
246
290
|
|
|
247
|
-
{
|
|
291
|
+
{/* The row look carries the fee as a chip here, where the compact row
|
|
292
|
+
puts it — the poster is too small for an overlay pill. */}
|
|
293
|
+
{((tags && tags.length > 0) || (isRowLg && fee.kind === "free")) && (
|
|
248
294
|
<TagGroup className="mt-1.5">
|
|
249
|
-
{tags
|
|
295
|
+
{tags?.map((tag) => (
|
|
250
296
|
<Tag key={tag} size="sm">
|
|
251
297
|
{tag}
|
|
252
298
|
</Tag>
|
|
253
299
|
))}
|
|
300
|
+
{isRowLg && fee.kind === "free" && (
|
|
301
|
+
<Tag size="sm" variant="success" className="hidden font-bold lg:inline-flex">
|
|
302
|
+
<Gift className="h-3 w-3" aria-hidden />
|
|
303
|
+
{labels.free}
|
|
304
|
+
</Tag>
|
|
305
|
+
)}
|
|
254
306
|
</TagGroup>
|
|
255
307
|
)}
|
|
256
308
|
|
|
@@ -268,13 +320,18 @@ export const EventCard = memo(function EventCard({
|
|
|
268
320
|
</Tag>
|
|
269
321
|
</div>
|
|
270
322
|
)}
|
|
323
|
+
</div>
|
|
271
324
|
</>
|
|
272
325
|
);
|
|
273
326
|
|
|
274
327
|
const rootClasses = cn(
|
|
275
328
|
"flex min-w-0 flex-col text-left",
|
|
329
|
+
// The compact row from `lg`: poster beside text, the row's own padding
|
|
330
|
+
// and negative gutter so the hover wash bleeds like `EventCompactRow`'s.
|
|
331
|
+
isRowLg && "lg:-mx-2.5 lg:flex-row lg:items-start lg:gap-[11px] lg:rounded-lg lg:px-2.5 lg:py-2",
|
|
276
332
|
(href || onClick) &&
|
|
277
333
|
"cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl",
|
|
334
|
+
(href || onClick) && isRowLg && "lg:hover:bg-foreground/5",
|
|
278
335
|
className,
|
|
279
336
|
);
|
|
280
337
|
|
|
@@ -47,10 +47,12 @@ export type EventDayHeaderProps = {
|
|
|
47
47
|
*/
|
|
48
48
|
action?: React.ReactNode;
|
|
49
49
|
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
50
|
+
* `sm`: the phone scale (30px day number, 15px weekday, 11.5px meta).
|
|
51
|
+
* `default`: the desktop scale (58 / 18 / 12.5). `responsive`: `sm` below
|
|
52
|
+
* `lg` and `default` from it, in one node — for a server render that
|
|
53
|
+
* cannot know the viewport.
|
|
52
54
|
*/
|
|
53
|
-
size?: "sm" | "default";
|
|
55
|
+
size?: "sm" | "default" | "responsive";
|
|
54
56
|
href?: string;
|
|
55
57
|
linkComponent?: React.ComponentType<EventDayHeaderLinkProps>;
|
|
56
58
|
onClick?: () => void;
|
|
@@ -70,6 +72,9 @@ export function EventDayHeader({
|
|
|
70
72
|
}: EventDayHeaderProps) {
|
|
71
73
|
const isInteractive = !!href || !!onClick;
|
|
72
74
|
const large = size === "default";
|
|
75
|
+
// `responsive` is the phone scale with every `default` class as an `lg:`
|
|
76
|
+
// twin — one node for a server-rendered timeline.
|
|
77
|
+
const responsive = size === "responsive";
|
|
73
78
|
|
|
74
79
|
const body = (
|
|
75
80
|
<>
|
|
@@ -78,21 +83,27 @@ export function EventDayHeader({
|
|
|
78
83
|
"font-black leading-[0.82] tracking-[-0.05em] tabular-nums",
|
|
79
84
|
// `sm` is the phone scale: 30px, the design's app day head. 40px
|
|
80
85
|
// was a page-scale number that took a third of a 390px row.
|
|
81
|
-
large ? "text-[58px]" : "text-[30px] leading-[0.86]"
|
|
86
|
+
large ? "text-[58px]" : "text-[30px] leading-[0.86]",
|
|
87
|
+
responsive && "lg:text-[58px] lg:leading-[0.82]"
|
|
82
88
|
)}
|
|
83
89
|
>
|
|
84
90
|
{dayNumber}
|
|
85
91
|
</div>
|
|
86
|
-
<div className={large ? "pb-1" : "pb-0.5"}>
|
|
92
|
+
<div className={cn(large ? "pb-1" : "pb-0.5", responsive && "lg:pb-1")}>
|
|
87
93
|
<div
|
|
88
|
-
className={cn(
|
|
94
|
+
className={cn(
|
|
95
|
+
"font-extrabold tracking-[-0.015em]",
|
|
96
|
+
large ? "text-lg" : "text-[15px]",
|
|
97
|
+
responsive && "lg:text-lg"
|
|
98
|
+
)}
|
|
89
99
|
>
|
|
90
100
|
{weekday}
|
|
91
101
|
</div>
|
|
92
102
|
<div
|
|
93
103
|
className={cn(
|
|
94
104
|
"mt-0.5 text-muted-foreground",
|
|
95
|
-
large ? "whitespace-nowrap text-[12.5px]" : "text-[11.5px]"
|
|
105
|
+
large ? "whitespace-nowrap text-[12.5px]" : "text-[11.5px]",
|
|
106
|
+
responsive && "lg:whitespace-nowrap lg:text-[12.5px]"
|
|
96
107
|
)}
|
|
97
108
|
>
|
|
98
109
|
{meta}
|
|
@@ -132,6 +143,7 @@ export function EventDayHeader({
|
|
|
132
143
|
className={cn(
|
|
133
144
|
"relative flex w-full items-end gap-3 pb-3 pt-[18px]",
|
|
134
145
|
large && "gap-[18px] pb-4",
|
|
146
|
+
responsive && "lg:gap-[18px] lg:pb-4",
|
|
135
147
|
className
|
|
136
148
|
)}
|
|
137
149
|
>
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { EventDoor, EventDoorSkeleton } from "./event-door";
|
|
3
|
+
import { SnapRail } from "./snap-rail";
|
|
4
|
+
|
|
5
|
+
function photo(label: string, fill: string) {
|
|
6
|
+
return (
|
|
7
|
+
"data:image/svg+xml;utf8," +
|
|
8
|
+
encodeURIComponent(
|
|
9
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="320" height="320"><rect width="320" height="320" fill="${fill}"/><text x="50%" y="50%" font-family="monospace" font-size="22" fill="#fff" text-anchor="middle">${label}</text></svg>`
|
|
10
|
+
)
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DOORS = [
|
|
15
|
+
{ name: "Concerts", fill: "#7a5c46" },
|
|
16
|
+
{ name: "Parties", fill: "#4a6a5c" },
|
|
17
|
+
{ name: "Live music", fill: "#6a5c8a" },
|
|
18
|
+
{ name: "Stand-up", fill: "#8a6a4a" },
|
|
19
|
+
{ name: "Theatre", fill: "#4a5a7a" },
|
|
20
|
+
{ name: "Workshops", fill: "#5a7a4a" },
|
|
21
|
+
{ name: "Markets", fill: "#7a4a5a" },
|
|
22
|
+
{ name: "Film", fill: "#4a4a4a" },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const meta: Meta<typeof EventDoor> = {
|
|
26
|
+
title: "Events/EventDoor",
|
|
27
|
+
component: EventDoor,
|
|
28
|
+
tags: ["autodocs"],
|
|
29
|
+
args: {
|
|
30
|
+
name: "Concerts",
|
|
31
|
+
href: "#concerts",
|
|
32
|
+
imageSrc: photo("stage", "#7a5c46"),
|
|
33
|
+
},
|
|
34
|
+
decorators: [
|
|
35
|
+
(Story) => (
|
|
36
|
+
<div className="w-[104px]">
|
|
37
|
+
<Story />
|
|
38
|
+
</div>
|
|
39
|
+
),
|
|
40
|
+
],
|
|
41
|
+
parameters: {
|
|
42
|
+
docs: {
|
|
43
|
+
description: {
|
|
44
|
+
component:
|
|
45
|
+
"A category's entry point: a square photograph of the kind of night, a scrim, the name inside the image, no count. The 1:1 shape with the name inside is what says door rather than event. Always an anchor.",
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export default meta;
|
|
52
|
+
type Story = StoryObj<typeof EventDoor>;
|
|
53
|
+
|
|
54
|
+
export const Default: Story = {};
|
|
55
|
+
|
|
56
|
+
/** No image — the poster fallback carries the tile. */
|
|
57
|
+
export const NoImage: Story = {
|
|
58
|
+
args: { imageSrc: undefined },
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** The hub's door row on a phone: a snap rail of squares, three and a third visible. */
|
|
62
|
+
export const Row: Story = {
|
|
63
|
+
decorators: [
|
|
64
|
+
(Story) => (
|
|
65
|
+
<div className="w-[375px] px-4" style={{ "--page-gutter": "1rem" } as React.CSSProperties}>
|
|
66
|
+
<Story />
|
|
67
|
+
</div>
|
|
68
|
+
),
|
|
69
|
+
],
|
|
70
|
+
render: () => (
|
|
71
|
+
<nav aria-label="Browse by kind">
|
|
72
|
+
<SnapRail itemWidth="xs" gap="sm" bleed="page">
|
|
73
|
+
{DOORS.map((door) => (
|
|
74
|
+
<EventDoor
|
|
75
|
+
key={door.name}
|
|
76
|
+
name={door.name}
|
|
77
|
+
href={`#${door.name}`}
|
|
78
|
+
imageSrc={photo(door.name.toLowerCase(), door.fill)}
|
|
79
|
+
/>
|
|
80
|
+
))}
|
|
81
|
+
</SnapRail>
|
|
82
|
+
</nav>
|
|
83
|
+
),
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const Skeleton: Story = {
|
|
87
|
+
render: () => <EventDoorSkeleton />,
|
|
88
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
import { EventPosterFallback } from "./event-poster-fallback";
|
|
7
|
+
import { Skeleton } from "./skeleton";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* EventDoor — a square tile that is a category's entry point: a photograph of
|
|
11
|
+
* the KIND of night (a stage, a dance floor, a terrace), a scrim, the name.
|
|
12
|
+
*
|
|
13
|
+
* A door and an event row must not look alike. `CategoryDeck` fans four event
|
|
14
|
+
* posters and prints a count, which reads as "these four events"; a card with
|
|
15
|
+
* a single event's poster reads as "tap = that event" and changes daily. The
|
|
16
|
+
* 1:1 shape with the name INSIDE the image is what says "door, not event", and
|
|
17
|
+
* it carries no count — a count is a claim about scale the hub's figures
|
|
18
|
+
* already make, and on a door it competes with the name.
|
|
19
|
+
*
|
|
20
|
+
* Always an anchor: a door with no destination is not a door, so `href` is
|
|
21
|
+
* required. The scrim is a gradient over a photograph, which is mode-
|
|
22
|
+
* independent — the same measured treatment `DoorwayTiles` uses.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export type EventDoorLinkProps = {
|
|
26
|
+
href: string;
|
|
27
|
+
className?: string;
|
|
28
|
+
children: React.ReactNode;
|
|
29
|
+
onClick?: (mouseEvent: React.MouseEvent) => void;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function DefaultLink({ href, ...props }: EventDoorLinkProps) {
|
|
33
|
+
return <a href={href} {...props} />;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type EventDoorProps = {
|
|
37
|
+
/** The category's name — the anchor's accessible name and the tile's label. */
|
|
38
|
+
name: string;
|
|
39
|
+
href: string;
|
|
40
|
+
imageSrc?: string;
|
|
41
|
+
/** Rendered when `imageSrc` is absent or fails. Defaults to `<EventPosterFallback />`. */
|
|
42
|
+
fallback?: React.ReactNode;
|
|
43
|
+
linkComponent?: React.ComponentType<EventDoorLinkProps>;
|
|
44
|
+
onClick?: (mouseEvent: React.MouseEvent) => void;
|
|
45
|
+
className?: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export function EventDoor({
|
|
49
|
+
name,
|
|
50
|
+
href,
|
|
51
|
+
imageSrc,
|
|
52
|
+
fallback = <EventPosterFallback />,
|
|
53
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
54
|
+
onClick,
|
|
55
|
+
className,
|
|
56
|
+
}: EventDoorProps) {
|
|
57
|
+
const [failed, setFailed] = useState(false);
|
|
58
|
+
const showImage = !!imageSrc && !failed;
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<LinkComponent
|
|
62
|
+
href={href}
|
|
63
|
+
onClick={onClick}
|
|
64
|
+
className={cn(
|
|
65
|
+
"group relative block aspect-square overflow-hidden rounded-xl bg-muted",
|
|
66
|
+
"transition-transform duration-150 active:scale-[0.98] motion-reduce:transition-none",
|
|
67
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
68
|
+
className
|
|
69
|
+
)}
|
|
70
|
+
>
|
|
71
|
+
{showImage ? (
|
|
72
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
73
|
+
<img
|
|
74
|
+
src={imageSrc}
|
|
75
|
+
alt=""
|
|
76
|
+
loading="lazy"
|
|
77
|
+
decoding="async"
|
|
78
|
+
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.04] motion-reduce:transition-none"
|
|
79
|
+
onError={() => setFailed(true)}
|
|
80
|
+
/>
|
|
81
|
+
) : (
|
|
82
|
+
fallback
|
|
83
|
+
)}
|
|
84
|
+
<span
|
|
85
|
+
aria-hidden
|
|
86
|
+
className="absolute inset-x-0 bottom-0 h-3/4 bg-gradient-to-t from-black/65 via-black/45 to-transparent"
|
|
87
|
+
/>
|
|
88
|
+
<span className="absolute inset-x-2 bottom-2 line-clamp-2 text-[12.5px] font-bold leading-[1.2] tracking-[-0.01em] text-white">
|
|
89
|
+
{name}
|
|
90
|
+
</span>
|
|
91
|
+
</LinkComponent>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function EventDoorSkeleton({ className }: { className?: string }) {
|
|
96
|
+
return <Skeleton className={cn("aspect-square w-full rounded-xl", className)} />;
|
|
97
|
+
}
|
|
@@ -47,6 +47,12 @@ export type EventHubHeroProps = {
|
|
|
47
47
|
statsLabel?: string;
|
|
48
48
|
/** The right-column slot — the poster stack in the source design. */
|
|
49
49
|
aside?: React.ReactNode;
|
|
50
|
+
/**
|
|
51
|
+
* The text column's last row, under the figures — the hub's tonight state
|
|
52
|
+
* strip. The figures say scale; this says what is on right now, and it is
|
|
53
|
+
* the one thing on the first screen a reader can tap into the timeline.
|
|
54
|
+
*/
|
|
55
|
+
footer?: React.ReactNode;
|
|
50
56
|
className?: string;
|
|
51
57
|
};
|
|
52
58
|
|
|
@@ -57,12 +63,16 @@ export function EventHubHero({
|
|
|
57
63
|
stats,
|
|
58
64
|
statsLabel,
|
|
59
65
|
aside,
|
|
66
|
+
footer,
|
|
60
67
|
className,
|
|
61
68
|
}: EventHubHeroProps) {
|
|
62
69
|
return (
|
|
63
70
|
<div
|
|
64
71
|
className={cn(
|
|
65
|
-
|
|
72
|
+
// 16px between the text column and the aside on a phone: with a
|
|
73
|
+
// tappable poster stack the aside is content, not decoration, and
|
|
74
|
+
// gets the within-section rhythm rather than a section gap.
|
|
75
|
+
"grid grid-cols-1 gap-4 lg:grid-cols-[1fr_620px] lg:items-center lg:gap-10",
|
|
66
76
|
className
|
|
67
77
|
)}
|
|
68
78
|
>
|
|
@@ -131,6 +141,7 @@ export function EventHubHero({
|
|
|
131
141
|
);
|
|
132
142
|
})}
|
|
133
143
|
</div>
|
|
144
|
+
{footer && <div className="order-4 pt-1.5 lg:order-none lg:pt-2">{footer}</div>}
|
|
134
145
|
</div>
|
|
135
146
|
|
|
136
147
|
{aside && <div className="min-w-0">{aside}</div>}
|
|
@@ -141,15 +152,17 @@ export function EventHubHero({
|
|
|
141
152
|
export type EventHubHeroSkeletonProps = {
|
|
142
153
|
/** The right-column slot, e.g. a `PosterStack`-shaped placeholder. Mirrors `aside` on the real component. */
|
|
143
154
|
aside?: React.ReactNode;
|
|
155
|
+
/** Reserves the `footer` row — the tonight strip's 52px. */
|
|
156
|
+
footer?: boolean;
|
|
144
157
|
className?: string;
|
|
145
158
|
};
|
|
146
159
|
|
|
147
|
-
export function EventHubHeroSkeleton({ aside, className }: EventHubHeroSkeletonProps) {
|
|
160
|
+
export function EventHubHeroSkeleton({ aside, footer, className }: EventHubHeroSkeletonProps) {
|
|
148
161
|
return (
|
|
149
162
|
<div
|
|
150
163
|
aria-hidden="true"
|
|
151
164
|
className={cn(
|
|
152
|
-
"grid grid-cols-1 gap-
|
|
165
|
+
"grid grid-cols-1 gap-4 lg:grid-cols-[1fr_620px] lg:items-center lg:gap-10",
|
|
153
166
|
className
|
|
154
167
|
)}
|
|
155
168
|
>
|
|
@@ -174,6 +187,11 @@ export function EventHubHeroSkeleton({ aside, className }: EventHubHeroSkeletonP
|
|
|
174
187
|
</div>
|
|
175
188
|
))}
|
|
176
189
|
</div>
|
|
190
|
+
{footer && (
|
|
191
|
+
<div className="order-4 pt-1.5 lg:order-none lg:pt-2">
|
|
192
|
+
<Skeleton className="h-[52px] w-full rounded-[14px]" />
|
|
193
|
+
</div>
|
|
194
|
+
)}
|
|
177
195
|
</div>
|
|
178
196
|
|
|
179
197
|
{aside && <div className="min-w-0">{aside}</div>}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { EventTonightStrip, EventTonightStripSkeleton } from "./event-tonight-strip";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof EventTonightStrip> = {
|
|
5
|
+
title: "Events/EventTonightStrip",
|
|
6
|
+
component: EventTonightStrip,
|
|
7
|
+
tags: ["autodocs"],
|
|
8
|
+
decorators: [
|
|
9
|
+
(Story) => (
|
|
10
|
+
<div className="w-[343px] bg-surface-2 p-4 md:w-[520px]">
|
|
11
|
+
<Story />
|
|
12
|
+
</div>
|
|
13
|
+
),
|
|
14
|
+
],
|
|
15
|
+
parameters: {
|
|
16
|
+
docs: {
|
|
17
|
+
description: {
|
|
18
|
+
component:
|
|
19
|
+
"The hub's state line under the hero: what is on right now, what is later tonight, and one tap into the timeline. Copy is caller-composed and carries only absolute facts (counts, wall-clock times) so an ISR snapshot can never say something false. Tone `none` renders a non-interactive status line.",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export default meta;
|
|
26
|
+
type Story = StoryObj<typeof EventTonightStrip>;
|
|
27
|
+
|
|
28
|
+
export const Live: Story = {
|
|
29
|
+
args: {
|
|
30
|
+
tone: "live",
|
|
31
|
+
lead: "3 live now",
|
|
32
|
+
details: ["12 more tonight", "next at 20:00"],
|
|
33
|
+
actionLabel: "Open tonight in the schedule",
|
|
34
|
+
href: "#events-day-2026-09-17",
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const Tonight: Story = {
|
|
39
|
+
args: {
|
|
40
|
+
tone: "tonight",
|
|
41
|
+
lead: "9 tonight",
|
|
42
|
+
details: ["first at 19:00"],
|
|
43
|
+
actionLabel: "Open tonight in the schedule",
|
|
44
|
+
href: "#events-day-2026-09-17",
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const Quiet: Story = {
|
|
49
|
+
args: {
|
|
50
|
+
tone: "quiet",
|
|
51
|
+
lead: "Nothing on tonight",
|
|
52
|
+
details: ["next Fri 20:00", "7 events"],
|
|
53
|
+
actionLabel: "Open Friday in the schedule",
|
|
54
|
+
href: "#events-day-2026-09-19",
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const Over: Story = {
|
|
59
|
+
args: {
|
|
60
|
+
tone: "quiet",
|
|
61
|
+
lead: "Tonight's events are over",
|
|
62
|
+
details: ["next tomorrow 18:00", "4 events"],
|
|
63
|
+
actionLabel: "Open tomorrow in the schedule",
|
|
64
|
+
href: "#events-day-2026-09-18",
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const None: Story = {
|
|
69
|
+
args: {
|
|
70
|
+
tone: "none",
|
|
71
|
+
lead: "Nothing scheduled in the next 14 days",
|
|
72
|
+
details: ["Check back soon"],
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const Skeleton: Story = {
|
|
77
|
+
render: () => <EventTonightStripSkeleton />,
|
|
78
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { EventTonightStrip } from "./event-tonight-strip";
|
|
4
|
+
import { EventDoor } from "./event-door";
|
|
5
|
+
import { PosterStack } from "./poster-stack";
|
|
6
|
+
|
|
7
|
+
describe("EventTonightStrip", () => {
|
|
8
|
+
it("is an anchor with the lead, the details and the action as its name", () => {
|
|
9
|
+
render(
|
|
10
|
+
<EventTonightStrip
|
|
11
|
+
tone="live"
|
|
12
|
+
lead="3 live now"
|
|
13
|
+
details={["12 more tonight", "next at 20:00"]}
|
|
14
|
+
actionLabel="Open tonight in the schedule"
|
|
15
|
+
href="/events/schedule#events-day-2026-09-17"
|
|
16
|
+
/>
|
|
17
|
+
);
|
|
18
|
+
const link = screen.getByRole("link");
|
|
19
|
+
expect(link).toHaveAttribute("href", "/events/schedule#events-day-2026-09-17");
|
|
20
|
+
expect(link).toHaveAccessibleName(
|
|
21
|
+
"3 live now 12 more tonight next at 20:00. Open tonight in the schedule"
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("renders a status line, not a link, when there is nothing to open", () => {
|
|
26
|
+
render(<EventTonightStrip tone="none" lead="Nothing scheduled" />);
|
|
27
|
+
expect(screen.queryByRole("link")).toBeNull();
|
|
28
|
+
expect(screen.getByRole("status")).toHaveTextContent("Nothing scheduled");
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("EventDoor", () => {
|
|
33
|
+
it("is an anchor named by the category, with the image decorative", () => {
|
|
34
|
+
render(<EventDoor name="Concerts" href="/events/concerts" imageSrc="/stage.webp" />);
|
|
35
|
+
const link = screen.getByRole("link", { name: "Concerts" });
|
|
36
|
+
expect(link).toHaveAttribute("href", "/events/concerts");
|
|
37
|
+
expect(screen.getByRole("presentation", { hidden: true })).toHaveAttribute("alt", "");
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("PosterStack", () => {
|
|
42
|
+
it("makes a captioned poster an anchor named by its caption", () => {
|
|
43
|
+
// The float's reduced-motion check reads matchMedia, which jsdom lacks.
|
|
44
|
+
window.matchMedia = () => ({ matches: true }) as MediaQueryList;
|
|
45
|
+
render(
|
|
46
|
+
<PosterStack
|
|
47
|
+
posters={[
|
|
48
|
+
{ src: "/a.jpg", alt: "A poster", caption: "Subcarpați live", href: "/events/e/a" },
|
|
49
|
+
{ src: "/b.jpg", alt: "B poster" },
|
|
50
|
+
]}
|
|
51
|
+
/>
|
|
52
|
+
);
|
|
53
|
+
const link = screen.getByRole("link", { name: "Subcarpați live" });
|
|
54
|
+
expect(link).toHaveAttribute("href", "/events/e/a");
|
|
55
|
+
// The uncaptioned poster stays decorative: no anchor, its own alt.
|
|
56
|
+
expect(screen.getAllByRole("link")).toHaveLength(1);
|
|
57
|
+
expect(screen.getByAltText("B poster")).toBeInTheDocument();
|
|
58
|
+
});
|
|
59
|
+
});
|