@bearmenu/ui 0.8.16 → 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-36KEHCET.js → chunk-5LTILG3L.js} +15 -4
- package/dist/{chunk-P43B3ONY.js → chunk-5XREWOBB.js} +3 -1
- package/dist/{chunk-CLLY6XB3.js → chunk-6VOWT4YR.js} +6 -3
- package/dist/{chunk-S33K4RC5.js → chunk-BKKQ7GCZ.js} +1 -1
- package/dist/{chunk-ZLPWEHZO.js → chunk-EQYB4Q77.js} +2 -2
- package/dist/chunk-LYREUZH6.js +156 -0
- package/dist/chunk-PW247TG2.js +81 -0
- package/dist/chunk-QY66ME5B.js +207 -0
- package/dist/{chunk-T6S2ZBV6.js → chunk-SDWN6L4O.js} +1 -1
- package/dist/{chunk-CNJQUHCU.js → chunk-SWD6CJ35.js} +2 -1
- package/dist/{chunk-HDR7V6IZ.js → chunk-TIQNM4XD.js} +8 -1
- package/dist/components/alert-dialog.js +1 -1
- package/dist/components/alert.d.ts +2 -2
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/badge.js +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/button.js +1 -1
- package/dist/components/calendar.js +1 -1
- package/dist/components/card.d.ts +2 -2
- package/dist/components/carousel.js +1 -1
- package/dist/components/coach-marks.d.ts +63 -0
- package/dist/components/coach-marks.js +162 -0
- package/dist/components/combobox.js +1 -1
- package/dist/components/command.d.ts +15 -15
- package/dist/components/concept-compare-rail.js +3 -3
- package/dist/components/concept-compare.js +3 -3
- package/dist/components/concept-convergence.js +2 -2
- package/dist/components/doorway-tiles.d.ts +11 -0
- package/dist/components/doorway-tiles.js +5 -3
- package/dist/components/empty-state.js +1 -1
- 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-detail-panel.js +2 -2
- 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-index-row.js +1 -1
- package/dist/components/event-list-row-wide.js +2 -2
- package/dist/components/event-list-row.js +1 -1
- package/dist/components/event-tonight-strip.d.ts +50 -0
- package/dist/components/event-tonight-strip.js +67 -0
- package/dist/components/facet-group.d.ts +8 -1
- package/dist/components/facet-group.js +3 -0
- package/dist/components/filter-category-row.js +1 -1
- package/dist/components/hue-chip.js +1 -1
- package/dist/components/item.d.ts +2 -2
- package/dist/components/item.js +4 -1
- package/dist/components/kitchen-dossier.js +1 -1
- package/dist/components/link-button.js +1 -1
- package/dist/components/menu-family-block.d.ts +8 -1
- package/dist/components/menu-family-block.js +101 -68
- package/dist/components/menu-family-card.js +2 -2
- package/dist/components/menu-filter-chips.d.ts +12 -3
- package/dist/components/menu-filter-chips.js +22 -4
- package/dist/components/menu-hub-hero.d.ts +7 -1
- package/dist/components/menu-hub-hero.js +2 -1
- package/dist/components/menu-index-list.js +1 -1
- package/dist/components/menu-item.js +3 -3
- package/dist/components/menu-kitchen-list.d.ts +14 -2
- package/dist/components/menu-kitchen-list.js +1 -1
- package/dist/components/multi-select-combobox.js +2 -2
- package/dist/components/pagination.js +1 -1
- package/dist/components/photo-ground.d.ts +7 -1
- package/dist/components/photo-ground.js +1 -1
- package/dist/components/place-card.js +2 -2
- package/dist/components/place-details-mobile.js +1 -1
- package/dist/components/poster-stack.d.ts +36 -3
- package/dist/components/poster-stack.js +49 -18
- package/dist/components/searchable-select.js +1 -1
- package/dist/components/section-header.d.ts +7 -1
- package/dist/components/section-header.js +11 -7
- package/dist/components/signature-chip.js +2 -2
- package/dist/components/sliding-panels.js +1 -1
- 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/dist/components/toggle-group.js +9 -4
- package/dist/components/toggle.d.ts +1 -1
- package/dist/components/toggle.js +1 -1
- package/dist/components/top-ranking-entry.js +3 -3
- package/package.json +1 -1
- package/src/components/badge.stories.tsx +31 -1
- package/src/components/badge.tsx +2 -0
- package/src/components/button.stories.tsx +5 -4
- package/src/components/button.test.tsx +11 -1
- package/src/components/button.tsx +16 -5
- package/src/components/coach-marks.stories.tsx +79 -0
- package/src/components/coach-marks.test.tsx +140 -0
- package/src/components/coach-marks.tsx +252 -0
- package/src/components/doorway-tiles.stories.tsx +34 -0
- package/src/components/doorway-tiles.test.tsx +27 -0
- package/src/components/doorway-tiles.tsx +30 -2
- 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/facet-group.tsx +9 -0
- package/src/components/hue-chip.test.tsx +6 -3
- package/src/components/hue-chip.tsx +5 -2
- package/src/components/item.tsx +4 -1
- package/src/components/menu-family-block.stories.tsx +12 -0
- package/src/components/menu-family-block.tsx +53 -20
- package/src/components/menu-filter-chips.tsx +32 -4
- package/src/components/menu-hub-hero.test.tsx +13 -0
- package/src/components/menu-hub-hero.tsx +9 -2
- package/src/components/menu-kitchen-list.tsx +45 -6
- package/src/components/photo-ground.tsx +7 -0
- package/src/components/poster-stack.stories.tsx +43 -1
- package/src/components/poster-stack.tsx +98 -19
- package/src/components/section-header.test.tsx +15 -0
- package/src/components/section-header.tsx +26 -12
- 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/src/components/toggle-group.stories.tsx +37 -1
- package/src/components/toggle-group.tsx +11 -6
- package/src/components/toggle.tsx +8 -0
- package/dist/chunk-NSNEANOQ.js +0 -104
- package/dist/chunk-SXQZ7DLH.js +0 -167
- 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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { ArrowRight, CalendarDays, Clock } from "lucide-react";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
import { Skeleton } from "./skeleton";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* EventTonightStrip — the hub's state line: what is on right now, what is
|
|
10
|
+
* later tonight, and one tap into the timeline.
|
|
11
|
+
*
|
|
12
|
+
* The hero's figures say SCALE ("52 events · 14 days"); this says STATE ("3
|
|
13
|
+
* live now · 12 more tonight · next at 20:00"). It is the one thing on the
|
|
14
|
+
* hub's first screen a reader can tap into today's programme, so it is a real
|
|
15
|
+
* anchor whenever there is a day to open — never a sheet trigger.
|
|
16
|
+
*
|
|
17
|
+
* Copy is caller-composed and carries only absolute facts: counts and
|
|
18
|
+
* wall-clock start times. Never a countdown or "in 12 minutes" — the hub is
|
|
19
|
+
* ISR-cached, and a ten-minute-old snapshot that says "next at 20:00" is at
|
|
20
|
+
* worst one boundary off, while "in 12 minutes" is simply wrong. The DS keeps
|
|
21
|
+
* no clock, no Intl and no i18n: the app derives `tone`, `lead` and `details`
|
|
22
|
+
* from its own clock and re-derives them after hydration.
|
|
23
|
+
*
|
|
24
|
+
* The lead stays `text-foreground`, not `--success`: 13px bold success on a
|
|
25
|
+
* card computes to roughly 4.5:1 in both modes, too close to the floor for
|
|
26
|
+
* the strip's one claim. The dot and the border carry the colour.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export type EventTonightStripTone = "live" | "tonight" | "quiet" | "none";
|
|
30
|
+
|
|
31
|
+
export type EventTonightStripLinkProps = {
|
|
32
|
+
href: string;
|
|
33
|
+
className?: string;
|
|
34
|
+
children: React.ReactNode;
|
|
35
|
+
onClick?: (mouseEvent: React.MouseEvent) => void;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function DefaultLink({ href, ...props }: EventTonightStripLinkProps) {
|
|
39
|
+
return <a href={href} {...props} />;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type EventTonightStripProps = {
|
|
43
|
+
tone: EventTonightStripTone;
|
|
44
|
+
/** The lead segment, bold — "3 live now". */
|
|
45
|
+
lead: string;
|
|
46
|
+
/** Trailing segments, muted, joined with " · " — ["12 more tonight", "next at 20:00"]. */
|
|
47
|
+
details?: string[];
|
|
48
|
+
/** Screen-reader suffix for the anchor — "Open tonight in the schedule". */
|
|
49
|
+
actionLabel?: string;
|
|
50
|
+
/** Absent (tone `none`) renders a non-interactive `role="status"` line. */
|
|
51
|
+
href?: string;
|
|
52
|
+
/** Defaults to a plain `<a>`. Pass `next/link` in a Next app. */
|
|
53
|
+
linkComponent?: React.ComponentType<EventTonightStripLinkProps>;
|
|
54
|
+
className?: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export function EventTonightStrip({
|
|
58
|
+
tone,
|
|
59
|
+
lead,
|
|
60
|
+
details = [],
|
|
61
|
+
actionLabel,
|
|
62
|
+
href,
|
|
63
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
64
|
+
className,
|
|
65
|
+
}: EventTonightStripProps) {
|
|
66
|
+
const interactive = !!href;
|
|
67
|
+
|
|
68
|
+
const mark =
|
|
69
|
+
tone === "live" ? (
|
|
70
|
+
<span className="relative flex h-2.5 w-2.5" aria-hidden>
|
|
71
|
+
<span className="absolute inline-flex h-full w-full rounded-full bg-success/40 motion-live-pulse motion-reduce:animate-none" />
|
|
72
|
+
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-success" />
|
|
73
|
+
</span>
|
|
74
|
+
) : tone === "tonight" ? (
|
|
75
|
+
<Clock className="h-4 w-4 text-muted-foreground" aria-hidden />
|
|
76
|
+
) : (
|
|
77
|
+
<CalendarDays
|
|
78
|
+
className={cn("h-4 w-4", tone === "none" ? "text-muted-foreground/70" : "text-muted-foreground")}
|
|
79
|
+
aria-hidden
|
|
80
|
+
/>
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const body = (
|
|
84
|
+
<>
|
|
85
|
+
<span className="flex h-4 w-4 shrink-0 items-center justify-center">{mark}</span>
|
|
86
|
+
<span className="line-clamp-2 min-w-0 flex-1 text-[13px] leading-[1.3] tabular-nums lg:line-clamp-1 lg:text-sm">
|
|
87
|
+
<span className="font-bold text-foreground">{lead}</span>
|
|
88
|
+
{details.map((detail) => (
|
|
89
|
+
<React.Fragment key={detail}>
|
|
90
|
+
{/* The glyph is decoration; the spaces around it are real, so the
|
|
91
|
+
accessible name reads as words, not one run-on string. */}{" "}
|
|
92
|
+
<span className="text-muted-foreground" aria-hidden>
|
|
93
|
+
·
|
|
94
|
+
</span>{" "}
|
|
95
|
+
<span className="text-muted-foreground">{detail}</span>
|
|
96
|
+
</React.Fragment>
|
|
97
|
+
))}
|
|
98
|
+
{interactive && actionLabel && <span className="sr-only">. {actionLabel}</span>}
|
|
99
|
+
</span>
|
|
100
|
+
{interactive && (
|
|
101
|
+
<ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
|
102
|
+
)}
|
|
103
|
+
</>
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const rootClasses = cn(
|
|
107
|
+
"flex min-h-[52px] w-full items-center gap-2.5 rounded-[14px] border bg-card px-3.5 py-2 text-left",
|
|
108
|
+
tone === "live" ? "border-success/40" : "border-border",
|
|
109
|
+
interactive &&
|
|
110
|
+
cn(
|
|
111
|
+
"cursor-pointer transition-colors duration-150 hover:bg-accent-subtle active:scale-[0.99] motion-reduce:transition-none",
|
|
112
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
113
|
+
),
|
|
114
|
+
className
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
if (href) {
|
|
118
|
+
return (
|
|
119
|
+
<LinkComponent href={href} className={rootClasses}>
|
|
120
|
+
{body}
|
|
121
|
+
</LinkComponent>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<div role="status" className={rootClasses}>
|
|
127
|
+
{body}
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function EventTonightStripSkeleton({ className }: { className?: string }) {
|
|
133
|
+
return <Skeleton className={cn("h-[52px] w-full rounded-[14px]", className)} />;
|
|
134
|
+
}
|
|
@@ -118,6 +118,13 @@ export type FacetGroupProps = Omit<
|
|
|
118
118
|
/** e.g. "Show all". Omit to hide the trailing action. */
|
|
119
119
|
moreLabel?: string;
|
|
120
120
|
onMore?: () => void;
|
|
121
|
+
/**
|
|
122
|
+
* Whether the trailing action has already expanded the options it controls
|
|
123
|
+
* — set it when `onMore` reveals more rows in place (and `moreLabel` reads
|
|
124
|
+
* "Show fewer"), so the button carries `aria-expanded`. Leave it unset when
|
|
125
|
+
* the action opens another surface.
|
|
126
|
+
*/
|
|
127
|
+
moreExpanded?: boolean;
|
|
121
128
|
className?: string;
|
|
122
129
|
};
|
|
123
130
|
|
|
@@ -133,6 +140,7 @@ export function FacetGroup({
|
|
|
133
140
|
onToggle,
|
|
134
141
|
moreLabel,
|
|
135
142
|
onMore,
|
|
143
|
+
moreExpanded,
|
|
136
144
|
className,
|
|
137
145
|
...rest
|
|
138
146
|
}: FacetGroupProps) {
|
|
@@ -304,6 +312,7 @@ export function FacetGroup({
|
|
|
304
312
|
<button
|
|
305
313
|
type="button"
|
|
306
314
|
onClick={onMore}
|
|
315
|
+
aria-expanded={moreExpanded}
|
|
307
316
|
className="cursor-pointer pt-[11px] text-[12.5px] font-bold text-primary transition-colors duration-150 hover:text-primary/80 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:rounded-sm"
|
|
308
317
|
>
|
|
309
318
|
{moreLabel}
|
|
@@ -31,9 +31,12 @@ describe("hueTint", () => {
|
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
describe("HueChip", () => {
|
|
34
|
+
// The label sits in an inner span that ellipsises; the chip is its parent.
|
|
35
|
+
const chipOf = (text: string) => screen.getByText(text).parentElement!;
|
|
36
|
+
|
|
34
37
|
it("wears the tint when it has a hue", () => {
|
|
35
38
|
render(<HueChip hue="oklch(0.62 0.14 82)">Sweet Pastry</HueChip>);
|
|
36
|
-
const chip =
|
|
39
|
+
const chip = chipOf("Sweet Pastry");
|
|
37
40
|
expect(chip.getAttribute("style")).toContain("oklab");
|
|
38
41
|
expect(chip.className).not.toContain("bg-muted");
|
|
39
42
|
});
|
|
@@ -41,7 +44,7 @@ describe("HueChip", () => {
|
|
|
41
44
|
it("falls back to the neutral rather than to a colour", () => {
|
|
42
45
|
// A facet with no family behind it must not borrow another family's colour.
|
|
43
46
|
render(<HueChip>Fast Food</HueChip>);
|
|
44
|
-
const chip =
|
|
47
|
+
const chip = chipOf("Fast Food");
|
|
45
48
|
expect(chip.className).toContain("bg-muted");
|
|
46
49
|
expect(chip.getAttribute("style")).toBeNull();
|
|
47
50
|
});
|
|
@@ -52,7 +55,7 @@ describe("HueChip", () => {
|
|
|
52
55
|
<HueChip as="li">Salad</HueChip>
|
|
53
56
|
</ul>
|
|
54
57
|
);
|
|
55
|
-
expect(
|
|
58
|
+
expect(chipOf("Salad").tagName).toBe("LI");
|
|
56
59
|
});
|
|
57
60
|
|
|
58
61
|
it("takes an accessible name for a chip whose text is a bare figure", () => {
|
|
@@ -86,14 +86,17 @@ export function HueChip({
|
|
|
86
86
|
<Tag
|
|
87
87
|
aria-label={ariaLabel}
|
|
88
88
|
className={cn(
|
|
89
|
-
|
|
89
|
+
// One line, and never wider than its row: a chip that wraps grows past
|
|
90
|
+
// its 28px and paints over its neighbours; a chip that overflows pushes
|
|
91
|
+
// the row. The label ellipsises instead.
|
|
92
|
+
"inline-flex h-7 min-w-0 max-w-full items-center gap-1.5 whitespace-nowrap rounded-full px-2.5 text-[11.5px] text-foreground",
|
|
90
93
|
!hue && "bg-muted",
|
|
91
94
|
className
|
|
92
95
|
)}
|
|
93
96
|
style={hue ? { backgroundColor: hueTint(hue, tone, base) } : undefined}
|
|
94
97
|
>
|
|
95
98
|
{icon}
|
|
96
|
-
{children}
|
|
99
|
+
<span className="min-w-0 truncate">{children}</span>
|
|
97
100
|
</Tag>
|
|
98
101
|
);
|
|
99
102
|
}
|
package/src/components/item.tsx
CHANGED
|
@@ -20,7 +20,10 @@ const itemVariants = cva(
|
|
|
20
20
|
lg: "px-5 py-4",
|
|
21
21
|
},
|
|
22
22
|
interactive: {
|
|
23
|
-
|
|
23
|
+
// The surface tint a hover is, not the brand fill: `bg-accent` here is
|
|
24
|
+
// the brown, and a row that inverts under the cursor loses its muted
|
|
25
|
+
// description. Same step the buttons take (`--accent-subtle`).
|
|
26
|
+
true: "cursor-pointer hover:bg-accent-subtle hover:border-accent/40 active:bg-muted active:scale-[0.99]",
|
|
24
27
|
false: "",
|
|
25
28
|
},
|
|
26
29
|
},
|
|
@@ -61,6 +61,18 @@ export const Default: Story = {
|
|
|
61
61
|
args: { family: { ...mockFamilyBlock, photoUrls: PHOTOS } },
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
+
/** The phone card at every width — for a desktop rail where the two-column card is too wide to scan. */
|
|
65
|
+
export const Compact: Story = {
|
|
66
|
+
args: { family: { ...mockFamilyBlock, photoUrls: PHOTOS }, variant: "compact" },
|
|
67
|
+
decorators: [
|
|
68
|
+
(Story) => (
|
|
69
|
+
<div className="w-[360px] bg-background p-4">
|
|
70
|
+
<Story />
|
|
71
|
+
</div>
|
|
72
|
+
),
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
|
|
64
76
|
/**
|
|
65
77
|
* No photographs: the mosaic ground with the family's initial, never a grey box.
|
|
66
78
|
* A missing photograph is not a broken image, and a placeholder makes a real
|