@bearmenu/ui 0.8.17 → 0.8.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-WYTJR2YU.js → chunk-5CPBSNWQ.js} +6 -1
- package/dist/{chunk-54FQQZZJ.js → chunk-EQYB4Q77.js} +1 -1
- package/dist/chunk-LYREUZH6.js +156 -0
- package/dist/chunk-PW247TG2.js +81 -0
- package/dist/components/alert.d.ts +2 -2
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/card.d.ts +2 -2
- package/dist/components/command.d.ts +15 -15
- package/dist/components/concept-compare-rail.js +2 -2
- package/dist/components/concept-compare.js +2 -2
- package/dist/components/event-card.d.ts +22 -4
- package/dist/components/event-card.js +95 -50
- package/dist/components/event-day-header.d.ts +5 -3
- package/dist/components/event-day-header.js +12 -4
- package/dist/components/event-door.d.ts +41 -0
- package/dist/components/event-door.js +65 -0
- package/dist/components/event-hub-hero.d.ts +10 -2
- package/dist/components/event-hub-hero.js +11 -5
- package/dist/components/event-tonight-strip.d.ts +50 -0
- package/dist/components/event-tonight-strip.js +67 -0
- package/dist/components/item.d.ts +2 -2
- package/dist/components/menu-hub-hero.d.ts +7 -1
- package/dist/components/menu-hub-hero.js +2 -1
- package/dist/components/place-card.js +1 -1
- package/dist/components/poster-stack.d.ts +36 -3
- package/dist/components/poster-stack.js +49 -18
- package/dist/components/snap-rail.d.ts +2 -2
- package/dist/components/snap-rail.js +1 -1
- package/dist/components/spinner.d.ts +2 -2
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/text.d.ts +3 -3
- package/dist/components/timeline-day.d.ts +54 -0
- package/dist/components/timeline-day.js +65 -0
- package/dist/components/timeline-hour-puck.d.ts +47 -0
- package/dist/components/timeline-hour-puck.js +3 -0
- package/dist/components/timeline-hour.d.ts +96 -0
- package/dist/components/{timeline-band.js → timeline-hour.js} +2 -1
- package/dist/components/timeline-playhead.js +1 -3
- package/package.json +1 -1
- package/src/components/event-card.tsx +70 -13
- package/src/components/event-day-header.tsx +19 -7
- package/src/components/event-door.stories.tsx +88 -0
- package/src/components/event-door.tsx +97 -0
- package/src/components/event-hub-hero.tsx +21 -3
- package/src/components/event-tonight-strip.stories.tsx +78 -0
- package/src/components/event-tonight-strip.test.tsx +59 -0
- package/src/components/event-tonight-strip.tsx +134 -0
- package/src/components/menu-hub-hero.test.tsx +13 -0
- package/src/components/menu-hub-hero.tsx +9 -2
- package/src/components/poster-stack.stories.tsx +43 -1
- package/src/components/poster-stack.tsx +98 -19
- package/src/components/snap-rail.tsx +6 -1
- package/src/components/timeline-day.stories.tsx +201 -0
- package/src/components/timeline-day.test.tsx +78 -0
- package/src/components/timeline-day.tsx +114 -0
- package/src/components/timeline-hour-puck.tsx +139 -0
- package/src/components/timeline-hour.tsx +266 -0
- package/src/components/timeline-playhead.tsx +1 -1
- package/dist/chunk-NSNEANOQ.js +0 -104
- package/dist/components/hour-band.d.ts +0 -66
- package/dist/components/hour-band.js +0 -81
- package/dist/components/hour-group.d.ts +0 -67
- package/dist/components/hour-group.js +0 -59
- package/dist/components/timeline-band.d.ts +0 -64
- package/src/components/hour-band.stories.tsx +0 -182
- package/src/components/hour-band.tsx +0 -146
- package/src/components/hour-group.stories.tsx +0 -100
- package/src/components/hour-group.tsx +0 -124
- package/src/components/timeline-band.stories.tsx +0 -206
- package/src/components/timeline-band.tsx +0 -247
|
@@ -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
|
+
}
|
|
@@ -28,6 +28,19 @@ describe("MenuHubHero mosaic", () => {
|
|
|
28
28
|
});
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
+
describe("MenuHubHero heading level", () => {
|
|
32
|
+
it("renders the headline as h1 by default", () => {
|
|
33
|
+
render(<MenuHubHero headline="Every kitchen" />);
|
|
34
|
+
expect(document.querySelector("h1")?.textContent).toBe("Every kitchen");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("renders as h2 for a page that already owns its h1", () => {
|
|
38
|
+
render(<MenuHubHero headline="Every kitchen" headingLevel="h2" />);
|
|
39
|
+
expect(document.querySelector("h1")).toBeNull();
|
|
40
|
+
expect(document.querySelector("h2")?.textContent).toBe("Every kitchen");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
31
44
|
describe("VenueWall mosaic", () => {
|
|
32
45
|
it("follows the hero's rule: six on the phone, eighteen from lg", () => {
|
|
33
46
|
render(<VenueWall places={[]} headline="Every menu" tileUrls={tiles} />);
|
|
@@ -60,6 +60,12 @@ export type MenuHubHeroProps = {
|
|
|
60
60
|
* a 1440px hero.
|
|
61
61
|
*/
|
|
62
62
|
tileUrls?: string[];
|
|
63
|
+
/**
|
|
64
|
+
* The element the headline renders as. `h1` by default; a page that
|
|
65
|
+
* already owns its `<h1>` (an sr-only SEO heading, as on `/menu`) passes
|
|
66
|
+
* `h2` so the document has one h1, not two.
|
|
67
|
+
*/
|
|
68
|
+
headingLevel?: "h1" | "h2";
|
|
63
69
|
className?: string;
|
|
64
70
|
};
|
|
65
71
|
|
|
@@ -84,6 +90,7 @@ export function MenuHubHero({
|
|
|
84
90
|
action,
|
|
85
91
|
backgroundUrl,
|
|
86
92
|
tileUrls,
|
|
93
|
+
headingLevel: Heading = "h1",
|
|
87
94
|
className,
|
|
88
95
|
}: MenuHubHeroProps) {
|
|
89
96
|
return (
|
|
@@ -160,9 +167,9 @@ export function MenuHubHero({
|
|
|
160
167
|
|
|
161
168
|
{/* 28px on a phone: 32px on a 358px column is five or six words a
|
|
162
169
|
line, which left "walk in blind." wrapping alone. */}
|
|
163
|
-
<
|
|
170
|
+
<Heading className="max-w-[640px] font-serif text-[28px] font-semibold leading-[1.06] tracking-[-0.025em] lg:text-[50px] lg:leading-[1.04]">
|
|
164
171
|
{headline}
|
|
165
|
-
</
|
|
172
|
+
</Heading>
|
|
166
173
|
|
|
167
174
|
{body && (
|
|
168
175
|
<p className="hidden max-w-[540px] text-sm leading-[1.6] opacity-[0.84] lg:block lg:text-[15.5px]">
|
|
@@ -56,7 +56,7 @@ export const TwoPosters: Story = {
|
|
|
56
56
|
},
|
|
57
57
|
};
|
|
58
58
|
|
|
59
|
-
/** No `src` renders the
|
|
59
|
+
/** No `src` renders the poster fallback, same as `EventCard`. */
|
|
60
60
|
export const MissingSources: Story = {
|
|
61
61
|
args: {
|
|
62
62
|
posters: [
|
|
@@ -66,3 +66,45 @@ export const MissingSources: Story = {
|
|
|
66
66
|
],
|
|
67
67
|
},
|
|
68
68
|
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Tappable: every poster an anchor to its event, the title captioned at its
|
|
72
|
+
* foot. Hover or focus a poster and it comes to the front; the press scale
|
|
73
|
+
* is on the anchor, the float on the wrapper, so the two never fight.
|
|
74
|
+
*/
|
|
75
|
+
export const Tappable: Story = {
|
|
76
|
+
args: {
|
|
77
|
+
posters: [
|
|
78
|
+
{
|
|
79
|
+
src: posterDataUri("1", "#7a5c46"),
|
|
80
|
+
alt: "Psihotrop poster",
|
|
81
|
+
caption: "Psihotrop — live at Form Space",
|
|
82
|
+
href: "#psihotrop",
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
src: posterDataUri("2", "#4a6a5c"),
|
|
86
|
+
alt: "Warehouse rave poster",
|
|
87
|
+
caption: "Warehouse rave: all night long",
|
|
88
|
+
href: "#warehouse",
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
src: posterDataUri("3", "#6a5c8a"),
|
|
92
|
+
alt: "Jazz night poster",
|
|
93
|
+
caption: "Jazz night at the Jazz Club",
|
|
94
|
+
href: "#jazz",
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** The phone geometry: three posters inside a 343px column, none past the gutter. */
|
|
101
|
+
export const Phone: Story = {
|
|
102
|
+
...Tappable,
|
|
103
|
+
decorators: [
|
|
104
|
+
(Story) => (
|
|
105
|
+
<div className="w-[343px] border-x border-dashed border-border">
|
|
106
|
+
<Story />
|
|
107
|
+
</div>
|
|
108
|
+
),
|
|
109
|
+
],
|
|
110
|
+
};
|
|
@@ -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
|
);
|
|
@@ -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
|
+
};
|