@bearmenu/ui 0.8.51 → 0.8.52
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-H4ORZT2D.js +14 -0
- package/dist/{chunk-S5GDARLW.js → chunk-WY2FP76J.js} +28 -5
- package/dist/components/alert.d.ts +2 -2
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/banner.d.ts +1 -1
- package/dist/components/button.d.ts +2 -2
- package/dist/components/card.d.ts +2 -2
- package/dist/components/category-deck.d.ts +6 -1
- package/dist/components/category-deck.js +39 -35
- package/dist/components/command.d.ts +15 -15
- package/dist/components/empty-state.d.ts +2 -2
- package/dist/components/event-card.d.ts +7 -0
- package/dist/components/event-card.js +9 -0
- package/dist/components/event-compact-row.d.ts +6 -0
- package/dist/components/event-compact-row.js +5 -3
- package/dist/components/event-hub-hero.d.ts +5 -0
- package/dist/components/event-hub-hero.js +14 -3
- package/dist/components/event-list-row-wide.d.ts +6 -0
- package/dist/components/event-list-row-wide.js +5 -3
- package/dist/components/event-tonight-strip.d.ts +3 -1
- package/dist/components/event-tonight-strip.js +3 -2
- package/dist/components/featured-rail-item.d.ts +3 -1
- package/dist/components/featured-rail-item.js +3 -1
- package/dist/components/kitchen-dossier.d.ts +7 -1
- package/dist/components/kitchen-dossier.js +2 -1
- package/dist/components/menu-hub-hero.d.ts +7 -1
- package/dist/components/menu-hub-hero.js +2 -0
- package/dist/components/place-card.d.ts +7 -0
- package/dist/components/place-card.js +10 -2
- package/dist/components/poster-stack.d.ts +2 -0
- package/dist/components/poster-stack.js +2 -1
- package/dist/components/section-header.d.ts +6 -1
- package/dist/components/section-header.js +4 -2
- package/dist/components/spinner.d.ts +2 -2
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/text.d.ts +2 -2
- package/dist/components/timeline-day.js +1 -1
- package/dist/components/timeline-hour.d.ts +13 -1
- package/dist/components/timeline-hour.js +1 -1
- package/dist/components/toggle.d.ts +1 -1
- package/package.json +217 -23
- package/src/components/category-deck.tsx +11 -0
- package/src/components/event-card.test.tsx +6 -0
- package/src/components/event-card.tsx +17 -0
- package/src/components/event-compact-row.tsx +34 -24
- package/src/components/event-hub-hero.tsx +15 -1
- package/src/components/event-list-row-wide.tsx +25 -15
- package/src/components/event-tonight-strip.test.tsx +12 -0
- package/src/components/event-tonight-strip.tsx +8 -1
- package/src/components/featured-rail-item.tsx +5 -0
- package/src/components/kitchen-dossier.tsx +14 -1
- package/src/components/menu-hub-hero.tsx +13 -0
- package/src/components/place-card.stories.tsx +10 -0
- package/src/components/place-card.test.tsx +31 -0
- package/src/components/place-card.tsx +22 -6
- package/src/components/poster-stack.tsx +3 -0
- package/src/components/section-header.tsx +8 -1
- package/src/components/timeline-day.stories.tsx +30 -0
- package/src/components/timeline-day.test.tsx +24 -0
- package/src/components/timeline-hour.tsx +40 -6
- package/src/lib/poster-overlay-frame.tsx +26 -0
|
@@ -4,6 +4,7 @@ import { memo, useState } from "react";
|
|
|
4
4
|
import { Radio } from "lucide-react";
|
|
5
5
|
import { cn } from "../lib/utils";
|
|
6
6
|
import { EventPosterFallback } from "./event-poster-fallback";
|
|
7
|
+
import { PosterOverlayFrame } from "../lib/poster-overlay-frame";
|
|
7
8
|
import { Skeleton } from "./skeleton";
|
|
8
9
|
import { Tag } from "./tag";
|
|
9
10
|
import { TagGroup } from "./tag-group";
|
|
@@ -105,6 +106,12 @@ export type EventCompactRowProps = {
|
|
|
105
106
|
priority?: boolean;
|
|
106
107
|
/** Rendered in place of the poster when it is absent or fails to load. Defaults to `<EventPosterFallback />`. */
|
|
107
108
|
fallback?: React.ReactNode;
|
|
109
|
+
/**
|
|
110
|
+
* Decoration laid over the poster's box, unclipped (it may cross the
|
|
111
|
+
* poster's edge), above the row. Absolutely positioned, `aria-hidden`,
|
|
112
|
+
* pointer-transparent; it takes no layout.
|
|
113
|
+
*/
|
|
114
|
+
posterOverlay?: React.ReactNode;
|
|
108
115
|
};
|
|
109
116
|
|
|
110
117
|
export const EventCompactRow = memo(function EventCompactRow({
|
|
@@ -125,6 +132,7 @@ export const EventCompactRow = memo(function EventCompactRow({
|
|
|
125
132
|
className,
|
|
126
133
|
priority,
|
|
127
134
|
fallback = <EventPosterFallback />,
|
|
135
|
+
posterOverlay,
|
|
128
136
|
}: EventCompactRowProps) {
|
|
129
137
|
const [imgError, setImgError] = useState(false);
|
|
130
138
|
const isSm = size === "sm";
|
|
@@ -169,30 +177,32 @@ export const EventCompactRow = memo(function EventCompactRow({
|
|
|
169
177
|
phone-scoped: a 14px bold title and 11px of vertical padding below
|
|
170
178
|
`lg`; from `lg` the 13px semibold title and 8px padding the desktop
|
|
171
179
|
timeline and the hub's venue block already had. */}
|
|
172
|
-
<
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
180
|
+
<PosterOverlayFrame overlay={posterOverlay}>
|
|
181
|
+
<div
|
|
182
|
+
className={cn(
|
|
183
|
+
"relative aspect-[4/5] w-16 shrink-0 overflow-hidden rounded-[10px] bg-muted",
|
|
184
|
+
isEnded && "opacity-45",
|
|
185
|
+
)}
|
|
186
|
+
>
|
|
187
|
+
{showPoster ? (
|
|
188
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
189
|
+
<img
|
|
190
|
+
src={event.poster_url}
|
|
191
|
+
alt=""
|
|
192
|
+
loading={priority ? "eager" : "lazy"}
|
|
193
|
+
decoding="async"
|
|
194
|
+
className="h-full w-full object-cover"
|
|
195
|
+
onError={() => setImgError(true)}
|
|
196
|
+
/>
|
|
197
|
+
) : (
|
|
198
|
+
// A bare `bg-muted` rectangle was survivable at 34px. At 58x72 beside
|
|
199
|
+
// real posters it reads as a broken image rather than an absent one —
|
|
200
|
+
// so this row finally joins every sibling in the family in having a
|
|
201
|
+
// designed fallback.
|
|
202
|
+
fallback
|
|
203
|
+
)}
|
|
204
|
+
</div>
|
|
205
|
+
</PosterOverlayFrame>
|
|
196
206
|
|
|
197
207
|
<div className="min-w-0 flex-1">
|
|
198
208
|
{/* The title wraps in full — never truncated, in this row or any of
|
|
@@ -29,6 +29,11 @@ export type EventHubHeroStat = {
|
|
|
29
29
|
label: string;
|
|
30
30
|
/** `live` marks the figure that is happening right now. */
|
|
31
31
|
tone?: "default" | "live";
|
|
32
|
+
/**
|
|
33
|
+
* Decoration beside the figure, absolutely positioned just past its right
|
|
34
|
+
* edge: no layout. Decorative and `aria-hidden`.
|
|
35
|
+
*/
|
|
36
|
+
adornment?: React.ReactNode;
|
|
32
37
|
};
|
|
33
38
|
|
|
34
39
|
export type EventHubHeroProps = {
|
|
@@ -131,10 +136,19 @@ export function EventHubHero({
|
|
|
131
136
|
className={cn(
|
|
132
137
|
"text-[19px] font-black leading-none tracking-[-0.02em] tabular-nums",
|
|
133
138
|
isLive ? "text-success" : "text-foreground",
|
|
134
|
-
"lg:text-[30px] lg:tracking-[-0.03em]"
|
|
139
|
+
"lg:text-[30px] lg:tracking-[-0.03em]",
|
|
140
|
+
stat.adornment && "relative w-fit"
|
|
135
141
|
)}
|
|
136
142
|
>
|
|
137
143
|
{stat.value}
|
|
144
|
+
{stat.adornment && (
|
|
145
|
+
<span
|
|
146
|
+
className="pointer-events-none absolute left-full top-1/2 ml-1.5 -translate-y-1/2"
|
|
147
|
+
aria-hidden
|
|
148
|
+
>
|
|
149
|
+
{stat.adornment}
|
|
150
|
+
</span>
|
|
151
|
+
)}
|
|
138
152
|
</span>
|
|
139
153
|
<span className="mt-0.5 text-[11px] text-muted-foreground lg:mt-[5px] lg:text-[12.5px]">
|
|
140
154
|
{stat.label}
|
|
@@ -6,6 +6,7 @@ import { MapPin, Radio } from "lucide-react";
|
|
|
6
6
|
import { cn } from "../lib/utils";
|
|
7
7
|
import { buttonVariants } from "./button";
|
|
8
8
|
import { EventPosterFallback } from "./event-poster-fallback";
|
|
9
|
+
import { PosterOverlayFrame } from "../lib/poster-overlay-frame";
|
|
9
10
|
import { Separator } from "./separator";
|
|
10
11
|
import { Skeleton } from "./skeleton";
|
|
11
12
|
import { Tag } from "./tag";
|
|
@@ -106,6 +107,12 @@ export type EventListRowWideProps = {
|
|
|
106
107
|
* line. Same prop and same reason as `EventIndexRow`.
|
|
107
108
|
*/
|
|
108
109
|
hideRule?: boolean;
|
|
110
|
+
/**
|
|
111
|
+
* Decoration laid over the thumbnail's box, unclipped (it may cross the
|
|
112
|
+
* thumbnail's edge). Absolutely positioned, `aria-hidden`,
|
|
113
|
+
* pointer-transparent; it takes no layout.
|
|
114
|
+
*/
|
|
115
|
+
posterOverlay?: ReactNode;
|
|
109
116
|
};
|
|
110
117
|
|
|
111
118
|
export const EventListRowWide = memo(function EventListRowWide({
|
|
@@ -123,6 +130,7 @@ export const EventListRowWide = memo(function EventListRowWide({
|
|
|
123
130
|
priority,
|
|
124
131
|
fallback = <EventPosterFallback />,
|
|
125
132
|
hideRule,
|
|
133
|
+
posterOverlay,
|
|
126
134
|
}: EventListRowWideProps) {
|
|
127
135
|
const [imgError, setImgError] = useState(false);
|
|
128
136
|
|
|
@@ -178,21 +186,23 @@ export const EventListRowWide = memo(function EventListRowWide({
|
|
|
178
186
|
</div>
|
|
179
187
|
</div>
|
|
180
188
|
|
|
181
|
-
<
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
189
|
+
<PosterOverlayFrame overlay={posterOverlay}>
|
|
190
|
+
<div className="aspect-square w-[104px] shrink-0 overflow-hidden rounded-[10px] bg-muted">
|
|
191
|
+
{showPoster ? (
|
|
192
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
193
|
+
<img
|
|
194
|
+
src={event.poster_url}
|
|
195
|
+
alt=""
|
|
196
|
+
loading={priority ? "eager" : "lazy"}
|
|
197
|
+
decoding="async"
|
|
198
|
+
className="h-full w-full object-cover"
|
|
199
|
+
onError={() => setImgError(true)}
|
|
200
|
+
/>
|
|
201
|
+
) : (
|
|
202
|
+
fallback
|
|
203
|
+
)}
|
|
204
|
+
</div>
|
|
205
|
+
</PosterOverlayFrame>
|
|
196
206
|
|
|
197
207
|
<div className="min-w-0 flex-1">
|
|
198
208
|
<div className="flex flex-wrap items-center gap-2">
|
|
@@ -21,6 +21,18 @@ describe("EventTonightStrip", () => {
|
|
|
21
21
|
);
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
+
it("draws the live indicator in place of the pulsing dot", () => {
|
|
25
|
+
render(
|
|
26
|
+
<EventTonightStrip
|
|
27
|
+
tone="live"
|
|
28
|
+
lead="3 live now"
|
|
29
|
+
href="/events/schedule"
|
|
30
|
+
liveIndicator={<i data-testid="flame" />}
|
|
31
|
+
/>
|
|
32
|
+
);
|
|
33
|
+
expect(screen.getByTestId("flame")).toBeInTheDocument();
|
|
34
|
+
});
|
|
35
|
+
|
|
24
36
|
it("renders a status line, not a link, when there is nothing to open", () => {
|
|
25
37
|
render(<EventTonightStrip tone="none" lead="Nothing scheduled" />);
|
|
26
38
|
expect(screen.queryByRole("link")).toBeNull();
|
|
@@ -51,6 +51,8 @@ export type EventTonightStripProps = {
|
|
|
51
51
|
href?: string;
|
|
52
52
|
/** Defaults to a plain `<a>`. Pass `next/link` in a Next app. */
|
|
53
53
|
linkComponent?: React.ComponentType<EventTonightStripLinkProps>;
|
|
54
|
+
/** Replaces the pulsing dot of the `live` tone, in the dot's own slot. Decorative. */
|
|
55
|
+
liveIndicator?: React.ReactNode;
|
|
54
56
|
className?: string;
|
|
55
57
|
};
|
|
56
58
|
|
|
@@ -61,12 +63,17 @@ export function EventTonightStrip({
|
|
|
61
63
|
actionLabel,
|
|
62
64
|
href,
|
|
63
65
|
linkComponent: LinkComponent = DefaultLink,
|
|
66
|
+
liveIndicator,
|
|
64
67
|
className,
|
|
65
68
|
}: EventTonightStripProps) {
|
|
66
69
|
const interactive = !!href;
|
|
67
70
|
|
|
68
71
|
const mark =
|
|
69
|
-
tone === "live" ? (
|
|
72
|
+
tone === "live" && liveIndicator ? (
|
|
73
|
+
<span className="flex h-4 w-4 items-center justify-center" aria-hidden>
|
|
74
|
+
{liveIndicator}
|
|
75
|
+
</span>
|
|
76
|
+
) : tone === "live" ? (
|
|
70
77
|
<span className="relative flex h-2.5 w-2.5" aria-hidden>
|
|
71
78
|
<span className="absolute inline-flex h-full w-full rounded-full bg-success/40 motion-live-pulse motion-reduce:animate-none" />
|
|
72
79
|
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-success" />
|
|
@@ -34,6 +34,8 @@ export type FeaturedRailItemProps = {
|
|
|
34
34
|
note: string;
|
|
35
35
|
/** Top-left overlay pill. Include its icon. */
|
|
36
36
|
tagLabel?: React.ReactNode;
|
|
37
|
+
/** Decorative overlay inside the poster box (`relative`, clipping): an absolutely positioned, `aria-hidden` ornament. */
|
|
38
|
+
posterOverlay?: React.ReactNode;
|
|
37
39
|
posterUrl?: string;
|
|
38
40
|
href?: string;
|
|
39
41
|
/** Defaults to a plain `<a>`. Pass `next/link` in a Next app. */
|
|
@@ -49,6 +51,7 @@ export function FeaturedRailItem({
|
|
|
49
51
|
venue,
|
|
50
52
|
note,
|
|
51
53
|
tagLabel,
|
|
54
|
+
posterOverlay,
|
|
52
55
|
posterUrl,
|
|
53
56
|
href,
|
|
54
57
|
linkComponent: LinkComponent = DefaultLink,
|
|
@@ -84,6 +87,8 @@ export function FeaturedRailItem({
|
|
|
84
87
|
</span>
|
|
85
88
|
</div>
|
|
86
89
|
)}
|
|
90
|
+
|
|
91
|
+
{posterOverlay}
|
|
87
92
|
</div>
|
|
88
93
|
|
|
89
94
|
<div className="px-0.5 pt-2.5">
|
|
@@ -96,6 +96,12 @@ export type KitchenDossierProps = {
|
|
|
96
96
|
partDurationMs?: number;
|
|
97
97
|
/** The foot's action — pass a rendered link or button. */
|
|
98
98
|
action?: ReactNode;
|
|
99
|
+
/**
|
|
100
|
+
* Drawn in place of the open-until pill's dot, in the dot's slot (roughly
|
|
101
|
+
* 16-22px square). Decorative and `aria-hidden`; only rendered with
|
|
102
|
+
* `labels.openUntil`.
|
|
103
|
+
*/
|
|
104
|
+
openIndicator?: ReactNode;
|
|
99
105
|
linkComponent?: MenuLinkComponent;
|
|
100
106
|
className?: string;
|
|
101
107
|
};
|
|
@@ -128,6 +134,7 @@ export function KitchenDossier({
|
|
|
128
134
|
formatScore,
|
|
129
135
|
partDurationMs,
|
|
130
136
|
action,
|
|
137
|
+
openIndicator,
|
|
131
138
|
linkComponent: LinkComponent = DefaultLink,
|
|
132
139
|
className,
|
|
133
140
|
}: KitchenDossierProps) {
|
|
@@ -249,7 +256,13 @@ export function KitchenDossier({
|
|
|
249
256
|
<div className="relative min-h-[184px] flex-1">
|
|
250
257
|
{labels.openUntil && (
|
|
251
258
|
<span className="absolute left-0 top-3 inline-flex h-[30px] items-center gap-[7px] rounded-full bg-[oklch(0.16_0.012_55_/_0.78)] px-3 text-[11.5px] font-bold text-white">
|
|
252
|
-
|
|
259
|
+
{openIndicator ? (
|
|
260
|
+
<span className="flex shrink-0 items-center justify-center" aria-hidden>
|
|
261
|
+
{openIndicator}
|
|
262
|
+
</span>
|
|
263
|
+
) : (
|
|
264
|
+
<i className="h-[7px] w-[7px] rounded-full bg-success" aria-hidden />
|
|
265
|
+
)}
|
|
253
266
|
{labels.openUntil}
|
|
254
267
|
</span>
|
|
255
268
|
)}
|
|
@@ -66,6 +66,12 @@ export type MenuHubHeroProps = {
|
|
|
66
66
|
* `h2` so the document has one h1, not two.
|
|
67
67
|
*/
|
|
68
68
|
headingLevel?: "h1" | "h2";
|
|
69
|
+
/**
|
|
70
|
+
* A decorative layer over the photographs and their scrim, under the text:
|
|
71
|
+
* fill the banner with `absolute inset-0`. Clipped by the banner's rounded
|
|
72
|
+
* edge. Decorative and `aria-hidden`.
|
|
73
|
+
*/
|
|
74
|
+
sceneOverlay?: React.ReactNode;
|
|
69
75
|
className?: string;
|
|
70
76
|
};
|
|
71
77
|
|
|
@@ -91,6 +97,7 @@ export function MenuHubHero({
|
|
|
91
97
|
backgroundUrl,
|
|
92
98
|
tileUrls,
|
|
93
99
|
headingLevel: Heading = "h1",
|
|
100
|
+
sceneOverlay,
|
|
94
101
|
className,
|
|
95
102
|
}: MenuHubHeroProps) {
|
|
96
103
|
return (
|
|
@@ -157,6 +164,12 @@ export function MenuHubHero({
|
|
|
157
164
|
)}
|
|
158
165
|
/>
|
|
159
166
|
|
|
167
|
+
{sceneOverlay && (
|
|
168
|
+
<div aria-hidden className="pointer-events-none absolute inset-0">
|
|
169
|
+
{sceneOverlay}
|
|
170
|
+
</div>
|
|
171
|
+
)}
|
|
172
|
+
|
|
160
173
|
<div className="relative z-[1] flex w-full flex-col justify-center gap-3 p-5 text-white lg:gap-4 lg:px-12 lg:py-11">
|
|
161
174
|
{kicker && (
|
|
162
175
|
<span className="inline-flex w-max items-center gap-[7px] rounded-full bg-white/15 px-3 py-1.5 text-[11px] font-extrabold tracking-[0.11em]">
|
|
@@ -97,6 +97,16 @@ export const WithPhotoOverlay: Story = {
|
|
|
97
97
|
},
|
|
98
98
|
};
|
|
99
99
|
|
|
100
|
+
/** A caller's glyph in the open-now dot's slot (16-20px); the closed badge is unchanged. */
|
|
101
|
+
export const WithOpenIndicator: Story = {
|
|
102
|
+
args: {
|
|
103
|
+
place: { ...basePlace, open_now: true },
|
|
104
|
+
openIndicator: (
|
|
105
|
+
<span className="block h-[18px] w-[18px] rounded-full bg-amber-500 ring-2 ring-white/70" />
|
|
106
|
+
),
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
100
110
|
export const WithFooter: Story = {
|
|
101
111
|
args: {
|
|
102
112
|
footer: <span className="text-xs text-muted-foreground">Served as Margherita · 45 RON · 350g</span>,
|
|
@@ -291,6 +291,37 @@ describe("PlaceCard", () => {
|
|
|
291
291
|
});
|
|
292
292
|
});
|
|
293
293
|
|
|
294
|
+
describe("PlaceCard openIndicator", () => {
|
|
295
|
+
it("replaces the green dot when the place is open", () => {
|
|
296
|
+
const { container } = render(
|
|
297
|
+
<PlaceCard
|
|
298
|
+
place={makePlace({ open_now: true })}
|
|
299
|
+
labels={LABELS}
|
|
300
|
+
openIndicator={<i data-testid="indicator" />}
|
|
301
|
+
/>
|
|
302
|
+
);
|
|
303
|
+
expect(screen.getByTestId("indicator")).toBeInTheDocument();
|
|
304
|
+
expect(container.querySelector(".bg-green-500")).not.toBeInTheDocument();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("keeps the default dot without an indicator", () => {
|
|
308
|
+
const { container } = render(<PlaceCard place={makePlace({ open_now: true })} labels={LABELS} />);
|
|
309
|
+
expect(container.querySelector(".bg-green-500")).toBeInTheDocument();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("does not render the indicator when closed, and keeps the badge", () => {
|
|
313
|
+
render(
|
|
314
|
+
<PlaceCard
|
|
315
|
+
place={makePlace({ open_now: false })}
|
|
316
|
+
labels={LABELS}
|
|
317
|
+
openIndicator={<i data-testid="indicator" />}
|
|
318
|
+
/>
|
|
319
|
+
);
|
|
320
|
+
expect(screen.queryByTestId("indicator")).not.toBeInTheDocument();
|
|
321
|
+
expect(screen.getByText("Closed")).toBeInTheDocument();
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
294
325
|
describe("PlaceCardSkeleton", () => {
|
|
295
326
|
it("is hidden from assistive tech and reserves the footer when asked", () => {
|
|
296
327
|
const { container, rerender } = render(<PlaceCardSkeleton />);
|
|
@@ -134,6 +134,13 @@ export type PlaceCardProps = {
|
|
|
134
134
|
* trending badge on the API's `trending` flag).
|
|
135
135
|
*/
|
|
136
136
|
photoOverlay?: ReactNode;
|
|
137
|
+
/**
|
|
138
|
+
* Drawn in place of the green open-now dot, in the dot's top-right slot
|
|
139
|
+
* (roughly 16-20px square). Decorative: it is only rendered when
|
|
140
|
+
* `place.open_now === true` and should be `aria-hidden`. The closed badge is
|
|
141
|
+
* untouched.
|
|
142
|
+
*/
|
|
143
|
+
openIndicator?: ReactNode;
|
|
137
144
|
className?: string;
|
|
138
145
|
};
|
|
139
146
|
|
|
@@ -191,6 +198,7 @@ export const PlaceCard = memo(function PlaceCard({
|
|
|
191
198
|
onOpen,
|
|
192
199
|
footer,
|
|
193
200
|
photoOverlay,
|
|
201
|
+
openIndicator,
|
|
194
202
|
className,
|
|
195
203
|
}: PlaceCardProps) {
|
|
196
204
|
const [imgError, setImgError] = useState(false);
|
|
@@ -258,12 +266,20 @@ export const PlaceCard = memo(function PlaceCard({
|
|
|
258
266
|
</div>
|
|
259
267
|
)}
|
|
260
268
|
|
|
261
|
-
{place.open_now === true &&
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
269
|
+
{place.open_now === true &&
|
|
270
|
+
(openIndicator ? (
|
|
271
|
+
<span
|
|
272
|
+
className="pointer-events-none absolute right-2 top-2 flex items-center justify-center"
|
|
273
|
+
aria-hidden
|
|
274
|
+
>
|
|
275
|
+
{openIndicator}
|
|
276
|
+
</span>
|
|
277
|
+
) : (
|
|
278
|
+
<span
|
|
279
|
+
className="absolute right-2.5 top-2.5 h-2 w-2 rounded-full bg-green-500 ring-2 ring-white/70"
|
|
280
|
+
aria-hidden
|
|
281
|
+
/>
|
|
282
|
+
))}
|
|
267
283
|
{place.open_now === false && (
|
|
268
284
|
/* `Badge variant="overlay"` + the shared cover geometry: this pill and
|
|
269
285
|
the caller's `photoOverlay` sit on the same photo and must match. */
|
|
@@ -60,6 +60,8 @@ export type PosterStackPoster = {
|
|
|
60
60
|
caption?: string;
|
|
61
61
|
/** Rendered when `src` is absent or fails. Defaults to `<EventPosterFallback />`. */
|
|
62
62
|
fallback?: React.ReactNode;
|
|
63
|
+
/** Decorative overlay on the poster face (it tilts and floats with the poster): an absolutely positioned, `aria-hidden` ornament. */
|
|
64
|
+
overlay?: React.ReactNode;
|
|
63
65
|
/** Load now at high priority instead of lazily. For the poster that is the
|
|
64
66
|
* page's LCP element; every other poster stays lazy. */
|
|
65
67
|
eager?: boolean;
|
|
@@ -207,6 +209,7 @@ export function PosterStack({
|
|
|
207
209
|
</span>
|
|
208
210
|
</span>
|
|
209
211
|
)}
|
|
212
|
+
{poster.overlay}
|
|
210
213
|
</>
|
|
211
214
|
);
|
|
212
215
|
|
|
@@ -66,6 +66,11 @@ export type SectionHeaderProps = {
|
|
|
66
66
|
* behaviour; it only holds the slot.
|
|
67
67
|
*/
|
|
68
68
|
trailing?: React.ReactNode;
|
|
69
|
+
/**
|
|
70
|
+
* Absolute decoration anchored to the title group's own box. Takes no
|
|
71
|
+
* layout; the group is `relative` for it. Decorative and `aria-hidden`.
|
|
72
|
+
*/
|
|
73
|
+
titleAdornment?: React.ReactNode;
|
|
69
74
|
className?: string;
|
|
70
75
|
};
|
|
71
76
|
|
|
@@ -98,6 +103,7 @@ export function SectionHeader({
|
|
|
98
103
|
onAction,
|
|
99
104
|
as: Heading = "h2",
|
|
100
105
|
trailing,
|
|
106
|
+
titleAdornment,
|
|
101
107
|
className,
|
|
102
108
|
}: SectionHeaderProps) {
|
|
103
109
|
const actionIcon = <ChevronRight className="h-3.5 w-3.5" aria-hidden />;
|
|
@@ -106,7 +112,7 @@ export function SectionHeader({
|
|
|
106
112
|
return (
|
|
107
113
|
<div className={cn("flex flex-col mb-3.5", className)}>
|
|
108
114
|
<div className="flex items-center justify-between gap-2">
|
|
109
|
-
<div className="flex min-w-0 items-baseline">
|
|
115
|
+
<div className="relative flex min-w-0 items-baseline">
|
|
110
116
|
<Heading className="truncate text-[17px] font-bold tracking-[-0.01em] text-foreground md:text-[18px]">
|
|
111
117
|
{title}
|
|
112
118
|
</Heading>
|
|
@@ -115,6 +121,7 @@ export function SectionHeader({
|
|
|
115
121
|
{count}
|
|
116
122
|
</span>
|
|
117
123
|
)}
|
|
124
|
+
{titleAdornment}
|
|
118
125
|
</div>
|
|
119
126
|
|
|
120
127
|
{/* The action and the trailing controls share the row's end, so the
|
|
@@ -135,6 +135,36 @@ export const FullDay: Story = {
|
|
|
135
135
|
),
|
|
136
136
|
};
|
|
137
137
|
|
|
138
|
+
/**
|
|
139
|
+
* 02:00 on a Friday: last night's set is still on and leads the day in a live
|
|
140
|
+
* off-axis group (`tone="live"`) — on now, but with no slot on today's axis.
|
|
141
|
+
* Its cards keep the phone gutter, so they line up with tonight's hour below.
|
|
142
|
+
*/
|
|
143
|
+
export const StillOnFromLastNight: Story = {
|
|
144
|
+
render: () => (
|
|
145
|
+
<div className="flex flex-col gap-3 py-6">
|
|
146
|
+
<EventDayHeader
|
|
147
|
+
dayNumber="09"
|
|
148
|
+
weekday="Friday"
|
|
149
|
+
meta="October · 2 events · 1 live"
|
|
150
|
+
size="responsive"
|
|
151
|
+
/>
|
|
152
|
+
<TimelineDay rows={2}>
|
|
153
|
+
<TimelineHour time="Since last night" role="unscheduled" tone="live" countLabel="1 event" layout="rows">
|
|
154
|
+
<Card
|
|
155
|
+
ev={event("a", "Techno: Berlin calling", "Mansion", "23:00", 40)}
|
|
156
|
+
density="row-lg"
|
|
157
|
+
liveStatus="Live now · ends around 05:00"
|
|
158
|
+
/>
|
|
159
|
+
</TimelineHour>
|
|
160
|
+
<TimelineHour time="21:00" countLabel="1 event">
|
|
161
|
+
<Card ev={event("b", "Comedy open mic", "Flying Circus", "21:00", 25)} />
|
|
162
|
+
</TimelineHour>
|
|
163
|
+
</TimelineDay>
|
|
164
|
+
</div>
|
|
165
|
+
),
|
|
166
|
+
};
|
|
167
|
+
|
|
138
168
|
/** A quiet Tuesday: one hour, one event, no hero — no lane on desktop either. */
|
|
139
169
|
export const OneHour: Story = {
|
|
140
170
|
render: () => (
|
|
@@ -58,6 +58,30 @@ describe("TimelineDay", () => {
|
|
|
58
58
|
expect(container.querySelector(".font-mono.tabular-nums.whitespace-nowrap")).toBeNull();
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
+
it("gives a live unscheduled group a live heading and the hours' phone gutter", () => {
|
|
62
|
+
const { container } = render(
|
|
63
|
+
<TimelineHour time="Since last night" role="unscheduled" tone="live" countLabel="1 event">
|
|
64
|
+
<p>set</p>
|
|
65
|
+
</TimelineHour>
|
|
66
|
+
);
|
|
67
|
+
const section = container.querySelector("section");
|
|
68
|
+
expect(section).toHaveAttribute("data-tone", "live");
|
|
69
|
+
expect(section).toHaveClass("pl-[70px]", "lg:pl-0");
|
|
70
|
+
expect(screen.getByText("Since last night")).toHaveClass("text-success", "font-semibold");
|
|
71
|
+
expect(screen.getByText("Since last night")).not.toHaveClass("font-mono");
|
|
72
|
+
// Still off the axis: no station.
|
|
73
|
+
expect(container.querySelector(".font-mono.tabular-nums.whitespace-nowrap")).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("ignores tone on a stationed hour", () => {
|
|
77
|
+
const { container } = render(
|
|
78
|
+
<TimelineHour time="21:00" tone="live">
|
|
79
|
+
<p>a</p>
|
|
80
|
+
</TimelineHour>
|
|
81
|
+
);
|
|
82
|
+
expect(container.querySelector("section")).not.toHaveAttribute("data-tone");
|
|
83
|
+
});
|
|
84
|
+
|
|
61
85
|
it("rules between rows but not between cards", () => {
|
|
62
86
|
const { container: rows } = render(
|
|
63
87
|
<TimelineHour time="21:00" layout="rows">
|
|
@@ -51,6 +51,15 @@ import { TIMELINE_MOBILE_GUTTER_CLASS, TimelineHourPuck } from "./timeline-hour-
|
|
|
51
51
|
* label, a rule, the count) at both widths, after the day's
|
|
52
52
|
* real programme.
|
|
53
53
|
*
|
|
54
|
+
* `tone="live"` (with `unscheduled` only) is the same off-axis group for
|
|
55
|
+
* events that are ON NOW but have no slot on today's axis — last night's set
|
|
56
|
+
* still running at 02:00, day three of a festival. It leads the day, so the
|
|
57
|
+
* quiet mono heading ("time to be confirmed") would dress "on right now" as
|
|
58
|
+
* "we don't know when": the label goes sans, semibold and success-tinted
|
|
59
|
+
* behind a pulsing dot, the rule takes the live hairline's tint, and the
|
|
60
|
+
* phone keeps the hours' gutter inset so its cards line up with the cards
|
|
61
|
+
* below instead of running 70px wider and reading as the day's feature.
|
|
62
|
+
*
|
|
54
63
|
* `layout`: `cards` stacks posters with a gap (a card is its own frame, a
|
|
55
64
|
* hairline between two posters reads as a third object) and from `lg` lays
|
|
56
65
|
* them on a two/three-up grid; `rows` is the same gapped stack on the phone
|
|
@@ -67,6 +76,9 @@ export type TimelineHourProps = {
|
|
|
67
76
|
* the caller's label for the group ("Time to be confirmed"). */
|
|
68
77
|
time: string;
|
|
69
78
|
role?: TimelineHourRole;
|
|
79
|
+
/** `live`: an `unscheduled` group whose events are on now. Ignored on
|
|
80
|
+
* other roles, which say "live" through `isLive`. */
|
|
81
|
+
tone?: "default" | "live";
|
|
70
82
|
/** Marks this as the hour currently in progress. */
|
|
71
83
|
isLive?: boolean;
|
|
72
84
|
/** Under the station when live, e.g. "NOW ON"; screen-reader-only in the chip. */
|
|
@@ -109,6 +121,7 @@ function ruleFromLg(items: React.ReactNode[]) {
|
|
|
109
121
|
export function TimelineHour({
|
|
110
122
|
time,
|
|
111
123
|
role = "hour",
|
|
124
|
+
tone = "default",
|
|
112
125
|
isLive,
|
|
113
126
|
liveLabel,
|
|
114
127
|
countLabel,
|
|
@@ -121,6 +134,7 @@ export function TimelineHour({
|
|
|
121
134
|
const isHero = role === "hero";
|
|
122
135
|
const isContinuation = role === "continuation";
|
|
123
136
|
const isUnscheduled = role === "unscheduled";
|
|
137
|
+
const isLiveGroup = isUnscheduled && tone === "live";
|
|
124
138
|
const hasStation = !isUnscheduled;
|
|
125
139
|
const isCards = layout === "cards";
|
|
126
140
|
const items = React.Children.toArray(children);
|
|
@@ -129,10 +143,12 @@ export function TimelineHour({
|
|
|
129
143
|
return (
|
|
130
144
|
<section
|
|
131
145
|
data-timeline-hour={role}
|
|
146
|
+
data-tone={isLiveGroup ? "live" : undefined}
|
|
132
147
|
className={cn(
|
|
133
148
|
"relative flex flex-col pt-2.5",
|
|
134
149
|
// The phone gutter: the puck's column plus the 12px gap `HourBand` had.
|
|
135
|
-
|
|
150
|
+
// A live group keeps it so its cards align with the hours' cards.
|
|
151
|
+
(hasStation || isLiveGroup) && "pl-[70px] lg:pl-0",
|
|
136
152
|
"lg:pt-[22px]",
|
|
137
153
|
isHero
|
|
138
154
|
? cn(
|
|
@@ -183,17 +199,35 @@ export function TimelineHour({
|
|
|
183
199
|
className={cn(
|
|
184
200
|
"items-center gap-2.5",
|
|
185
201
|
isUnscheduled ? "flex h-4" : "hidden lg:flex lg:h-4",
|
|
186
|
-
isHero && "lg:hidden"
|
|
202
|
+
isHero && "lg:hidden",
|
|
203
|
+
// The heading spans the gutter; only the cards sit inset.
|
|
204
|
+
isLiveGroup && "-ml-[70px] lg:ml-0"
|
|
187
205
|
)}
|
|
188
206
|
>
|
|
189
|
-
{
|
|
190
|
-
<span className="
|
|
207
|
+
{isLiveGroup ? (
|
|
208
|
+
<span className="flex items-center gap-1.5 text-xs font-semibold text-success">
|
|
209
|
+
<span
|
|
210
|
+
aria-hidden="true"
|
|
211
|
+
className="size-1.5 shrink-0 animate-pulse rounded-full bg-success motion-reduce:animate-none"
|
|
212
|
+
/>
|
|
191
213
|
{time}
|
|
192
214
|
</span>
|
|
215
|
+
) : (
|
|
216
|
+
isUnscheduled && (
|
|
217
|
+
<span className="font-mono text-[11.5px] text-muted-foreground tabular-nums lg:text-xs">
|
|
218
|
+
{time}
|
|
219
|
+
</span>
|
|
220
|
+
)
|
|
193
221
|
)}
|
|
194
|
-
<Separator className="flex-1" />
|
|
222
|
+
<Separator className={cn("flex-1", isLiveGroup && "bg-success/30")} />
|
|
195
223
|
{countLabel && (
|
|
196
|
-
<span
|
|
224
|
+
<span
|
|
225
|
+
className={cn(
|
|
226
|
+
isLiveGroup
|
|
227
|
+
? "text-xs text-muted-foreground"
|
|
228
|
+
: "text-[10.5px] text-muted-foreground/70 lg:text-[11px]"
|
|
229
|
+
)}
|
|
230
|
+
>
|
|
197
231
|
{countLabel}
|
|
198
232
|
</span>
|
|
199
233
|
)}
|