@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
|
@@ -3,11 +3,29 @@
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { useEffect, useRef, useState } from "react";
|
|
5
5
|
import { cn } from "../lib/utils";
|
|
6
|
+
import { EventPosterFallback } from "./event-poster-fallback";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* PosterStack — three overlapping, gently rotated posters that float, the
|
|
9
10
|
* hub hero's aside slot in the source design.
|
|
10
11
|
*
|
|
12
|
+
* TAPPABLE SINCE 0.8.18. The stack was three decorative images: the loudest
|
|
13
|
+
* thing on the hub's first screen, and the one thing on it a reader could not
|
|
14
|
+
* tap. A poster with `href` is an anchor now — the event's detail page for
|
|
15
|
+
* the crawler and a modified click, whatever the app's `linkComponent` does
|
|
16
|
+
* for a plain tap (the hub opens its preview drawer). `caption` prints the
|
|
17
|
+
* title at the poster's foot: it gives the tap a visible reason on touch,
|
|
18
|
+
* where there is no hover, and it is the anchor's accessible name, so the
|
|
19
|
+
* `<img>` goes `alt=""` whenever a caption is set. Without `href` a poster is
|
|
20
|
+
* decorative, as before.
|
|
21
|
+
*
|
|
22
|
+
* The float and the press live on DIFFERENT elements. The wrapper carries
|
|
23
|
+
* the keyframe `transform` (rotation + drift); the anchor inside it carries
|
|
24
|
+
* the press scale and the focus ring. One element cannot animate one
|
|
25
|
+
* `transform` and transition another. The pointed poster comes to the front
|
|
26
|
+
* (`hover:z-[4]`, `focus-within:z-[4]`) — z-index is not animated, it is the
|
|
27
|
+
* "pick this one" cue.
|
|
28
|
+
*
|
|
11
29
|
* Two motion rules, both mandatory:
|
|
12
30
|
* 1. Stops entirely under `prefers-reduced-motion: reduce` — enforced via
|
|
13
31
|
* an injected `<style>` media query rather than relying on Tailwind's
|
|
@@ -20,15 +38,35 @@ import { cn } from "../lib/utils";
|
|
|
20
38
|
* reduced motion is on, since nothing is animating to pause.
|
|
21
39
|
*/
|
|
22
40
|
|
|
41
|
+
export type PosterStackLinkProps = {
|
|
42
|
+
href: string;
|
|
43
|
+
className?: string;
|
|
44
|
+
children: React.ReactNode;
|
|
45
|
+
onClick?: (mouseEvent: React.MouseEvent) => void;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function DefaultLink({ href, ...props }: PosterStackLinkProps) {
|
|
49
|
+
return <a href={href} {...props} />;
|
|
50
|
+
}
|
|
51
|
+
|
|
23
52
|
export type PosterStackPoster = {
|
|
24
|
-
/** Absent renders
|
|
53
|
+
/** Absent renders `fallback` instead of an image. */
|
|
25
54
|
src?: string;
|
|
26
55
|
alt: string;
|
|
56
|
+
/** The event's detail page. With it the poster is an anchor; without it, decorative. */
|
|
57
|
+
href?: string;
|
|
58
|
+
/** Caption at the poster's foot — the event title. Becomes the anchor's
|
|
59
|
+
* accessible name; the `<img>` then gets `alt=""`. */
|
|
60
|
+
caption?: string;
|
|
61
|
+
/** Rendered when `src` is absent or fails. Defaults to `<EventPosterFallback />`. */
|
|
62
|
+
fallback?: React.ReactNode;
|
|
27
63
|
};
|
|
28
64
|
|
|
29
65
|
export type PosterStackProps = {
|
|
30
66
|
/** Rendered in order; only the first three are shown. */
|
|
31
67
|
posters: PosterStackPoster[];
|
|
68
|
+
/** Defaults to a plain `<a>`. Pass `next/link`, or an anchor that opens the app's preview. */
|
|
69
|
+
linkComponent?: React.ComponentType<PosterStackLinkProps>;
|
|
32
70
|
className?: string;
|
|
33
71
|
};
|
|
34
72
|
|
|
@@ -51,22 +89,28 @@ type PosterSlotConfig = {
|
|
|
51
89
|
zIndexClassName?: string;
|
|
52
90
|
};
|
|
53
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Phone geometry fits a 343px column (375 minus the 16px gutters): the third
|
|
94
|
+
* poster used to reach 358px and ran into the right gutter. 112 / 128 / 112
|
|
95
|
+
* wide, 212px tall — 38px shorter than before, which is what lets the door
|
|
96
|
+
* row land in the first screen. Touch targets are 112×149 and 128×171.
|
|
97
|
+
*/
|
|
54
98
|
const POSTER_SLOTS: PosterSlotConfig[] = [
|
|
55
99
|
{
|
|
56
|
-
positionClassName: "left-
|
|
100
|
+
positionClassName: "left-0 top-[28px] w-[112px] md:left-0 md:top-[52px] md:w-[196px]",
|
|
57
101
|
restTransform: "rotate(-5deg)",
|
|
58
102
|
animationName: "ds-poster-stack-float-1",
|
|
59
103
|
animationDurationMs: 9000,
|
|
60
104
|
},
|
|
61
105
|
{
|
|
62
|
-
positionClassName: "left-[
|
|
106
|
+
positionClassName: "left-[104px] top-0 w-[128px] md:left-[168px] md:w-[232px]",
|
|
63
107
|
restTransform: "rotate(3deg)",
|
|
64
108
|
animationName: "ds-poster-stack-float-2",
|
|
65
109
|
animationDurationMs: 11000,
|
|
66
110
|
zIndexClassName: "z-[3]",
|
|
67
111
|
},
|
|
68
112
|
{
|
|
69
|
-
positionClassName: "left-[
|
|
113
|
+
positionClassName: "left-[224px] top-[44px] w-[112px] md:left-[378px] md:top-24 md:w-[196px]",
|
|
70
114
|
restTransform: "rotate(6deg)",
|
|
71
115
|
animationName: "ds-poster-stack-float-3",
|
|
72
116
|
animationDurationMs: 10000,
|
|
@@ -77,7 +121,11 @@ const POSTER_SLOTS: PosterSlotConfig[] = [
|
|
|
77
121
|
* shadow is an art-directed literal rather than a `--shadow-card` token. */
|
|
78
122
|
const POSTER_SHADOW = "0 22px 50px oklch(0.3 0.02 60 / 0.24)";
|
|
79
123
|
|
|
80
|
-
export function PosterStack({
|
|
124
|
+
export function PosterStack({
|
|
125
|
+
posters,
|
|
126
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
127
|
+
className,
|
|
128
|
+
}: PosterStackProps) {
|
|
81
129
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
82
130
|
const posterRefs = useRef<Array<HTMLDivElement | null>>([]);
|
|
83
131
|
const [failedIndices, setFailedIndices] = useState<Set<number>>(new Set());
|
|
@@ -106,12 +154,41 @@ export function PosterStack({ posters, className }: PosterStackProps) {
|
|
|
106
154
|
const visiblePosters = posters.slice(0, POSTER_SLOTS.length);
|
|
107
155
|
|
|
108
156
|
return (
|
|
109
|
-
<div ref={containerRef} className={cn("relative h-[
|
|
157
|
+
<div ref={containerRef} className={cn("relative h-[212px] md:h-[400px]", className)}>
|
|
110
158
|
<style>{POSTER_STACK_ANIMATION_STYLE}</style>
|
|
111
159
|
|
|
112
160
|
{visiblePosters.map((poster, index) => {
|
|
113
161
|
const slot = POSTER_SLOTS[index];
|
|
114
162
|
const showPoster = !!poster.src && !failedIndices.has(index);
|
|
163
|
+
const face = (
|
|
164
|
+
<>
|
|
165
|
+
{showPoster ? (
|
|
166
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
167
|
+
<img
|
|
168
|
+
src={poster.src}
|
|
169
|
+
alt={poster.caption ? "" : poster.alt}
|
|
170
|
+
loading="lazy"
|
|
171
|
+
decoding="async"
|
|
172
|
+
className={cn(
|
|
173
|
+
"h-full w-full object-cover",
|
|
174
|
+
poster.href &&
|
|
175
|
+
"transition-transform duration-300 group-hover/poster:scale-[1.03] motion-reduce:transition-none"
|
|
176
|
+
)}
|
|
177
|
+
onError={() => setFailedIndices((current) => new Set(current).add(index))}
|
|
178
|
+
/>
|
|
179
|
+
) : (
|
|
180
|
+
(poster.fallback ?? <EventPosterFallback />)
|
|
181
|
+
)}
|
|
182
|
+
{poster.caption && (
|
|
183
|
+
<span className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent px-2 pb-2 pt-6">
|
|
184
|
+
<span className="line-clamp-2 text-[11px] font-semibold leading-[1.25] text-white md:text-[12.5px]">
|
|
185
|
+
{poster.caption}
|
|
186
|
+
</span>
|
|
187
|
+
</span>
|
|
188
|
+
)}
|
|
189
|
+
</>
|
|
190
|
+
);
|
|
191
|
+
|
|
115
192
|
return (
|
|
116
193
|
<div
|
|
117
194
|
key={poster.alt || index}
|
|
@@ -122,7 +199,8 @@ export function PosterStack({ posters, className }: PosterStackProps) {
|
|
|
122
199
|
className={cn(
|
|
123
200
|
"absolute aspect-[3/4] overflow-hidden rounded-xl bg-muted",
|
|
124
201
|
slot.positionClassName,
|
|
125
|
-
slot.zIndexClassName
|
|
202
|
+
slot.zIndexClassName,
|
|
203
|
+
poster.href && "hover:z-[4] focus-within:z-[4]"
|
|
126
204
|
)}
|
|
127
205
|
style={{
|
|
128
206
|
transform: slot.restTransform,
|
|
@@ -134,18 +212,19 @@ export function PosterStack({ posters, className }: PosterStackProps) {
|
|
|
134
212
|
animationDirection: "alternate",
|
|
135
213
|
}}
|
|
136
214
|
>
|
|
137
|
-
{
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
215
|
+
{poster.href ? (
|
|
216
|
+
<LinkComponent
|
|
217
|
+
href={poster.href}
|
|
218
|
+
className={cn(
|
|
219
|
+
"group/poster relative block h-full w-full overflow-hidden rounded-xl",
|
|
220
|
+
"transition-transform duration-150 active:scale-[0.98] motion-reduce:transition-none",
|
|
221
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
222
|
+
)}
|
|
223
|
+
>
|
|
224
|
+
{face}
|
|
225
|
+
</LinkComponent>
|
|
226
|
+
) : (
|
|
227
|
+
<div className="relative h-full w-full">{face}</div>
|
|
149
228
|
)}
|
|
150
229
|
</div>
|
|
151
230
|
);
|
|
@@ -25,4 +25,19 @@ describe("SectionHeader", () => {
|
|
|
25
25
|
expect(screen.getByText("Still to start").className).toContain("hidden");
|
|
26
26
|
expect(screen.getByText("Still to start").className).toContain("lg:block");
|
|
27
27
|
});
|
|
28
|
+
|
|
29
|
+
it("holds trailing controls at the row's end, beside the action", () => {
|
|
30
|
+
render(
|
|
31
|
+
<SectionHeader
|
|
32
|
+
title="Concepts"
|
|
33
|
+
action="See all"
|
|
34
|
+
actionHref="/concepts"
|
|
35
|
+
trailing={<button type="button">Next</button>}
|
|
36
|
+
/>
|
|
37
|
+
);
|
|
38
|
+
const next = screen.getByRole("button", { name: "Next" });
|
|
39
|
+
const action = screen.getByRole("link", { name: "See all" });
|
|
40
|
+
expect(next.parentElement).toBe(action.parentElement);
|
|
41
|
+
expect(next.parentElement).not.toContainElement(screen.getByText("Concepts"));
|
|
42
|
+
});
|
|
28
43
|
});
|
|
@@ -60,6 +60,12 @@ export type SectionHeaderProps = {
|
|
|
60
60
|
* would dilute the headings that matter. Defaults to `"h2"`.
|
|
61
61
|
*/
|
|
62
62
|
as?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "div";
|
|
63
|
+
/**
|
|
64
|
+
* Controls at the end of the title row, after `action` — a rail's
|
|
65
|
+
* previous / next pair on desktop. The header owns none of their
|
|
66
|
+
* behaviour; it only holds the slot.
|
|
67
|
+
*/
|
|
68
|
+
trailing?: React.ReactNode;
|
|
63
69
|
className?: string;
|
|
64
70
|
};
|
|
65
71
|
|
|
@@ -91,6 +97,7 @@ export function SectionHeader({
|
|
|
91
97
|
linkComponent: LinkComponent = DefaultLink,
|
|
92
98
|
onAction,
|
|
93
99
|
as: Heading = "h2",
|
|
100
|
+
trailing,
|
|
94
101
|
className,
|
|
95
102
|
}: SectionHeaderProps) {
|
|
96
103
|
const actionIcon = <ChevronRight className="h-3.5 w-3.5" aria-hidden />;
|
|
@@ -110,18 +117,25 @@ export function SectionHeader({
|
|
|
110
117
|
)}
|
|
111
118
|
</div>
|
|
112
119
|
|
|
113
|
-
{action
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
120
|
+
{/* The action and the trailing controls share the row's end, so the
|
|
121
|
+
action never floats to the middle when both are present. */}
|
|
122
|
+
{(action || trailing) && (
|
|
123
|
+
<div className="flex shrink-0 items-center gap-3">
|
|
124
|
+
{action &&
|
|
125
|
+
(actionHref ? (
|
|
126
|
+
<LinkComponent href={actionHref} className={actionClasses} onClick={onAction}>
|
|
127
|
+
{action}
|
|
128
|
+
{actionIcon}
|
|
129
|
+
</LinkComponent>
|
|
130
|
+
) : onAction ? (
|
|
131
|
+
<button type="button" className={actionClasses} onClick={onAction}>
|
|
132
|
+
{action}
|
|
133
|
+
{actionIcon}
|
|
134
|
+
</button>
|
|
135
|
+
) : null)}
|
|
136
|
+
{trailing}
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
125
139
|
</div>
|
|
126
140
|
|
|
127
141
|
{subtitle && (
|
|
@@ -29,6 +29,9 @@ const snapRailVariants = cva(
|
|
|
29
29
|
{
|
|
30
30
|
variants: {
|
|
31
31
|
itemWidth: {
|
|
32
|
+
/** Square door tiles — three and a third per phone screen, so the
|
|
33
|
+
* cut fourth says "swipe"; more as the viewport widens. */
|
|
34
|
+
xs: "[&>*]:w-[30%] sm:[&>*]:w-[22%] md:[&>*]:w-[18%]",
|
|
32
35
|
sm: "[&>*]:w-[46%]",
|
|
33
36
|
md: "[&>*]:w-[60%]",
|
|
34
37
|
lg: "[&>*]:w-[80%]",
|
|
@@ -42,7 +45,9 @@ const snapRailVariants = cva(
|
|
|
42
45
|
bleed: {
|
|
43
46
|
none: "",
|
|
44
47
|
card: "-mx-3.5 px-3.5 scroll-px-3.5",
|
|
45
|
-
page
|
|
48
|
+
/** The consumer's page gutter, read from `--page-gutter` (16px on a
|
|
49
|
+
* phone, 24px from md) — a fixed `-mx-4` was 8px short at md. */
|
|
50
|
+
page: "-mx-[var(--page-gutter,1rem)] px-[var(--page-gutter,1rem)] scroll-px-[var(--page-gutter,1rem)]",
|
|
46
51
|
},
|
|
47
52
|
},
|
|
48
53
|
defaultVariants: {
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { TimelineDay, TimelineDaySkeleton } from "./timeline-day";
|
|
3
|
+
import { TimelineHour } from "./timeline-hour";
|
|
4
|
+
import { EventCard } from "./event-card";
|
|
5
|
+
import { EventDayHeader } from "./event-day-header";
|
|
6
|
+
import type { EventCardLabels, EventFormatters, EventPreview } from "./event-types";
|
|
7
|
+
|
|
8
|
+
const labels: EventCardLabels = { free: "Free", liveNow: "Live now" };
|
|
9
|
+
|
|
10
|
+
const formatters: EventFormatters = {
|
|
11
|
+
time: (iso) => iso.slice(11, 16),
|
|
12
|
+
day: () => "Thu, Sep 3",
|
|
13
|
+
price: (amount) => `${amount} RON`,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const POSTER =
|
|
17
|
+
"https://images.unsplash.com/photo-1514525253161-7a46d19cd819?w=640&q=70&auto=format&fit=crop";
|
|
18
|
+
|
|
19
|
+
const event = (
|
|
20
|
+
id: string,
|
|
21
|
+
title: string,
|
|
22
|
+
place: string,
|
|
23
|
+
hour: string,
|
|
24
|
+
fee?: number,
|
|
25
|
+
poster = POSTER
|
|
26
|
+
): EventPreview => ({
|
|
27
|
+
id,
|
|
28
|
+
title,
|
|
29
|
+
location: place,
|
|
30
|
+
place_name: place,
|
|
31
|
+
starts_at: `2026-09-03T${hour}:00`,
|
|
32
|
+
entry_fee: fee,
|
|
33
|
+
poster_url: poster,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const meta: Meta<typeof TimelineDay> = {
|
|
37
|
+
title: "Events/TimelineDay",
|
|
38
|
+
component: TimelineDay,
|
|
39
|
+
tags: ["autodocs"],
|
|
40
|
+
decorators: [
|
|
41
|
+
(Story) => (
|
|
42
|
+
<div className="mx-auto max-w-[1180px] bg-background px-4">
|
|
43
|
+
<Story />
|
|
44
|
+
</div>
|
|
45
|
+
),
|
|
46
|
+
],
|
|
47
|
+
parameters: {
|
|
48
|
+
layout: "fullscreen",
|
|
49
|
+
docs: {
|
|
50
|
+
description: {
|
|
51
|
+
component:
|
|
52
|
+
"One day of the timeline, ONE DOM for both viewports. Below `lg` it is a column of hours, each a `TimelineHour` with a gutter chip and the hour's cards. From `lg` the same nodes become lane │ spine │ content: the hour with `role=\"hero\"` is placed in the lane by the grid and pins, every other hour stations itself on the spine, and cards past the cap paint as rows (`EventCard density=\"row-lg\"`). Resize the canvas across 1024px to watch one tree change shape. `rows` counts the column-3 children.",
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export default meta;
|
|
59
|
+
type Story = StoryObj<typeof TimelineDay>;
|
|
60
|
+
|
|
61
|
+
function Card(props: {
|
|
62
|
+
ev: EventPreview;
|
|
63
|
+
emphasis?: boolean | "lg";
|
|
64
|
+
hideWhen?: boolean | "below-lg";
|
|
65
|
+
density?: "card" | "row-lg";
|
|
66
|
+
liveStatus?: string;
|
|
67
|
+
priority?: boolean;
|
|
68
|
+
}) {
|
|
69
|
+
return (
|
|
70
|
+
<EventCard
|
|
71
|
+
event={props.ev}
|
|
72
|
+
labels={labels}
|
|
73
|
+
formatters={formatters}
|
|
74
|
+
emphasis={props.emphasis}
|
|
75
|
+
hideWhen={props.hideWhen ?? true}
|
|
76
|
+
density={props.density}
|
|
77
|
+
liveStatus={props.liveStatus}
|
|
78
|
+
showPrice
|
|
79
|
+
tags={["Live music", "Jazz"]}
|
|
80
|
+
href={`#${props.ev.id}`}
|
|
81
|
+
priority={props.priority}
|
|
82
|
+
/>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A six-hour Friday with the hero in the middle: 19:00 opens, the 20:00 hero
|
|
88
|
+
* is lifted into the lane on desktop while its hour's other event stays as a
|
|
89
|
+
* continuation, later hours demote to rows past the cap, and a group of
|
|
90
|
+
* events the ingest never timed trails the day off the axis.
|
|
91
|
+
*/
|
|
92
|
+
export const FullDay: Story = {
|
|
93
|
+
render: () => (
|
|
94
|
+
<div className="flex flex-col gap-3 py-6">
|
|
95
|
+
<EventDayHeader
|
|
96
|
+
dayNumber="03"
|
|
97
|
+
weekday="Thursday"
|
|
98
|
+
meta="September · 9 events · 1 live"
|
|
99
|
+
size="responsive"
|
|
100
|
+
/>
|
|
101
|
+
<TimelineDay rows={6} laneTop="16px">
|
|
102
|
+
<TimelineHour time="19:00" countLabel="2 events">
|
|
103
|
+
<Card ev={event("a", "Vinyl night at the bar", "Joben Bistro", "19:00", 0)} priority />
|
|
104
|
+
<Card ev={event("b", "Pub quiz: nineties", "Klausen", "19:00", 20)} />
|
|
105
|
+
</TimelineHour>
|
|
106
|
+
<TimelineHour time="20:00" role="hero" isLive liveLabel="NOW ON" countLabel="1 event">
|
|
107
|
+
<Card
|
|
108
|
+
ev={event("c", "Subcarpați live — sold-out tour warm-up", "Form Space", "20:00", 60)}
|
|
109
|
+
emphasis="lg"
|
|
110
|
+
hideWhen="below-lg"
|
|
111
|
+
liveStatus="Live now · ends around 23:00"
|
|
112
|
+
/>
|
|
113
|
+
</TimelineHour>
|
|
114
|
+
<TimelineHour time="20:00" role="continuation" isLive liveLabel="NOW ON" countLabel="1 event">
|
|
115
|
+
<Card ev={event("d", "Wine tasting: Dealu Mare", "Enoteca", "20:00", 90)} />
|
|
116
|
+
</TimelineHour>
|
|
117
|
+
<TimelineHour time="21:00" countLabel="3 events" layout="rows">
|
|
118
|
+
<Card ev={event("e", "Comedy open mic", "Flying Circus", "21:00", 25)} density="row-lg" />
|
|
119
|
+
<Card ev={event("f", "Techno: Berlin calling", "Mansion", "21:00", 40)} density="row-lg" />
|
|
120
|
+
<Card ev={event("g", "Salsa social", "Casa Tranzit", "21:00", 0)} density="row-lg" />
|
|
121
|
+
</TimelineHour>
|
|
122
|
+
<TimelineHour time="23:00" countLabel="1 event" layout="rows">
|
|
123
|
+
<Card ev={event("h", "Late-night jazz jam", "Jazz Club", "23:00")} density="row-lg" />
|
|
124
|
+
</TimelineHour>
|
|
125
|
+
<TimelineHour
|
|
126
|
+
time="Time to be confirmed"
|
|
127
|
+
role="unscheduled"
|
|
128
|
+
countLabel="1 event"
|
|
129
|
+
note="The venue has not published a start time."
|
|
130
|
+
>
|
|
131
|
+
<Card ev={event("i", "Board games afternoon", "Insomnia Café", "00:00", 0)} />
|
|
132
|
+
</TimelineHour>
|
|
133
|
+
</TimelineDay>
|
|
134
|
+
</div>
|
|
135
|
+
),
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/** A quiet Tuesday: one hour, one event, no hero — no lane on desktop either. */
|
|
139
|
+
export const OneHour: Story = {
|
|
140
|
+
render: () => (
|
|
141
|
+
<div className="flex flex-col gap-3 py-6">
|
|
142
|
+
<EventDayHeader dayNumber="08" weekday="Tuesday" meta="September · 1 event" size="responsive" />
|
|
143
|
+
<TimelineDay rows={1}>
|
|
144
|
+
<TimelineHour time="19:30" countLabel="1 event">
|
|
145
|
+
<Card ev={event("a", "Book club: Cărtărescu", "Cărturești", "19:30", 0)} priority />
|
|
146
|
+
</TimelineHour>
|
|
147
|
+
</TimelineDay>
|
|
148
|
+
</div>
|
|
149
|
+
),
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Two full days stacked, so the lane's pin and release can be watched at
|
|
154
|
+
* 1024 and 1440: scroll the canvas and the first day's poster holds until
|
|
155
|
+
* its column runs out, then the second day's takes over.
|
|
156
|
+
*/
|
|
157
|
+
export const StickyLane: Story = {
|
|
158
|
+
render: () => (
|
|
159
|
+
<div className="flex flex-col gap-10 py-6">
|
|
160
|
+
{["03", "04"].map((day) => (
|
|
161
|
+
<div key={day} className="flex flex-col gap-3">
|
|
162
|
+
<EventDayHeader
|
|
163
|
+
dayNumber={day}
|
|
164
|
+
weekday={day === "03" ? "Thursday" : "Friday"}
|
|
165
|
+
meta="September · 7 events"
|
|
166
|
+
size="responsive"
|
|
167
|
+
/>
|
|
168
|
+
<TimelineDay rows={4} laneTop="16px">
|
|
169
|
+
<TimelineHour time="18:00" role="hero" countLabel="1 event">
|
|
170
|
+
<Card
|
|
171
|
+
ev={event(`${day}-hero`, "Headliner of the night", "Form Space", "18:00", 50)}
|
|
172
|
+
emphasis="lg"
|
|
173
|
+
hideWhen="below-lg"
|
|
174
|
+
/>
|
|
175
|
+
</TimelineHour>
|
|
176
|
+
{["19:00", "20:00", "21:00", "22:00"].map((hour) => (
|
|
177
|
+
<TimelineHour key={hour} time={hour} countLabel="2 events" layout="rows">
|
|
178
|
+
<Card
|
|
179
|
+
ev={event(`${day}-${hour}-1`, `Something at ${hour}`, "Klausen", hour, 20)}
|
|
180
|
+
density="row-lg"
|
|
181
|
+
/>
|
|
182
|
+
<Card
|
|
183
|
+
ev={event(`${day}-${hour}-2`, `Something else at ${hour}`, "Joben", hour, 0)}
|
|
184
|
+
density="row-lg"
|
|
185
|
+
/>
|
|
186
|
+
</TimelineHour>
|
|
187
|
+
))}
|
|
188
|
+
</TimelineDay>
|
|
189
|
+
</div>
|
|
190
|
+
))}
|
|
191
|
+
</div>
|
|
192
|
+
),
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
export const Skeleton: Story = {
|
|
196
|
+
render: () => (
|
|
197
|
+
<div className="py-6">
|
|
198
|
+
<TimelineDaySkeleton hourCount={3} />
|
|
199
|
+
</div>
|
|
200
|
+
),
|
|
201
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { TimelineDay } from "./timeline-day";
|
|
4
|
+
import { TimelineHour } from "./timeline-hour";
|
|
5
|
+
|
|
6
|
+
describe("TimelineDay", () => {
|
|
7
|
+
it("renders every hour in source order, hero included, with one puck per stationed hour", () => {
|
|
8
|
+
const { container } = render(
|
|
9
|
+
<TimelineDay rows={2}>
|
|
10
|
+
<TimelineHour time="19:00" countLabel="1 event">
|
|
11
|
+
<p>Early</p>
|
|
12
|
+
</TimelineHour>
|
|
13
|
+
<TimelineHour time="20:00" role="hero">
|
|
14
|
+
<p>Hero</p>
|
|
15
|
+
</TimelineHour>
|
|
16
|
+
<TimelineHour time="21:00" countLabel="1 event">
|
|
17
|
+
<p>Late</p>
|
|
18
|
+
</TimelineHour>
|
|
19
|
+
</TimelineDay>
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const hours = [...container.querySelectorAll("[data-timeline-hour]")];
|
|
23
|
+
expect(hours.map((hour) => hour.getAttribute("data-timeline-hour"))).toEqual([
|
|
24
|
+
"hour",
|
|
25
|
+
"hero",
|
|
26
|
+
"hour",
|
|
27
|
+
]);
|
|
28
|
+
// Chronological in the DOM — the phone's visual order and the reading order.
|
|
29
|
+
expect(container.textContent).toMatch(/19:00[^]*Early[^]*20:00[^]*Hero[^]*21:00[^]*Late/);
|
|
30
|
+
expect(screen.getAllByText(/^\d\d:00$/)).toHaveLength(3);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("sets the row count the lane and spine span", () => {
|
|
34
|
+
const { container } = render(
|
|
35
|
+
<TimelineDay rows={5} laneTop="120px">
|
|
36
|
+
<TimelineHour time="19:00">
|
|
37
|
+
<p>x</p>
|
|
38
|
+
</TimelineHour>
|
|
39
|
+
</TimelineDay>
|
|
40
|
+
);
|
|
41
|
+
const root = container.firstElementChild as HTMLElement;
|
|
42
|
+
expect(root.style.getPropertyValue("--timeline-rows")).toBe("5");
|
|
43
|
+
expect(root.style.getPropertyValue("--timeline-lane-top")).toBe("120px");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("gives an unscheduled group an inline heading and no station", () => {
|
|
47
|
+
const { container } = render(
|
|
48
|
+
<TimelineDay rows={1}>
|
|
49
|
+
<TimelineHour time="Time to be confirmed" role="unscheduled" countLabel="2 events" note="No hour">
|
|
50
|
+
<p>a</p>
|
|
51
|
+
<p>b</p>
|
|
52
|
+
</TimelineHour>
|
|
53
|
+
</TimelineDay>
|
|
54
|
+
);
|
|
55
|
+
expect(screen.getByText("Time to be confirmed")).toBeInTheDocument();
|
|
56
|
+
expect(screen.getByText("2 events")).toBeInTheDocument();
|
|
57
|
+
expect(screen.getByText("No hour")).toBeInTheDocument();
|
|
58
|
+
expect(container.querySelector(".font-mono.tabular-nums.whitespace-nowrap")).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("rules between rows but not between cards", () => {
|
|
62
|
+
const { container: rows } = render(
|
|
63
|
+
<TimelineHour time="21:00" layout="rows">
|
|
64
|
+
<p>a</p>
|
|
65
|
+
<p>b</p>
|
|
66
|
+
</TimelineHour>
|
|
67
|
+
);
|
|
68
|
+
const { container: cards } = render(
|
|
69
|
+
<TimelineHour time="21:00" layout="cards">
|
|
70
|
+
<p>a</p>
|
|
71
|
+
<p>b</p>
|
|
72
|
+
</TimelineHour>
|
|
73
|
+
);
|
|
74
|
+
// The hour's own hairline (vertical) plus the heading rule plus one between the two rows.
|
|
75
|
+
expect(rows.querySelectorAll('[data-orientation="horizontal"]').length).toBe(2);
|
|
76
|
+
expect(cards.querySelectorAll('[data-orientation="horizontal"]').length).toBe(1);
|
|
77
|
+
});
|
|
78
|
+
});
|