@bearmenu/ui 0.8.17 → 0.8.19
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-BRXI6EDO.js +84 -0
- package/dist/{chunk-54FQQZZJ.js → chunk-EQYB4Q77.js} +1 -1
- package/dist/chunk-QRSIJC4X.js +156 -0
- 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-hub-hero.d.ts +12 -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/menu-family-block.js +4 -3
- package/dist/components/menu-hub-hero.d.ts +7 -1
- package/dist/components/menu-hub-hero.js +2 -1
- package/dist/components/poster-stack.d.ts +36 -3
- package/dist/components/poster-stack.js +87 -48
- package/dist/components/snap-rail.d.ts +2 -2
- package/dist/components/snap-rail.js +1 -1
- 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-hub-hero.tsx +25 -3
- package/src/components/event-tonight-strip.stories.tsx +78 -0
- package/src/components/event-tonight-strip.test.tsx +49 -0
- package/src/components/event-tonight-strip.tsx +134 -0
- package/src/components/menu-family-block.tsx +13 -6
- 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 +104 -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 +142 -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 { CalendarDays, ChevronRight, 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
|
+
<ChevronRight 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
|
+
}
|
|
@@ -123,12 +123,18 @@ const GROUND = PHOTO_GROUND;
|
|
|
123
123
|
const INK_PHONE =
|
|
124
124
|
"bg-[linear-gradient(to_bottom,oklch(0.16_0.012_55_/_0.12)_0%,oklch(0.16_0.012_55_/_0.55)_38%,oklch(0.16_0.012_55_/_0.92)_62%,oklch(0.16_0.012_55_/_0.96)_100%)]";
|
|
125
125
|
/**
|
|
126
|
-
* From `lg` the ramp runs to the right:
|
|
127
|
-
*
|
|
128
|
-
*
|
|
126
|
+
* From `lg` the ramp runs to the right: over the photo third, dark under the
|
|
127
|
+
* rows — the rows sit on the ink itself, no opaque paper, so the photograph
|
|
128
|
+
* still reads through the ramp's shoulder.
|
|
129
|
+
*
|
|
130
|
+
* THE ONLY GRADIENT FROM `lg`. The phone's local text scrim (below) is off
|
|
131
|
+
* at this width: a vertical scrim rising from the photo column's foot met
|
|
132
|
+
* this horizontal ramp at the column's bottom-right corner and the two read
|
|
133
|
+
* as a seam. Its contrast job moved here — the ramp opens at 0.5 rather
|
|
134
|
+
* than 0.32, which is what the name and count needed on a bright photograph.
|
|
129
135
|
*/
|
|
130
136
|
const INK_WIDE =
|
|
131
|
-
"lg:bg-[linear-gradient(to_right,oklch(0.16_0.012_55_/_0.
|
|
137
|
+
"lg:bg-[linear-gradient(to_right,oklch(0.16_0.012_55_/_0.5)_0%,oklch(0.16_0.012_55_/_0.66)_34%,oklch(0.16_0.012_55_/_0.9)_52%,oklch(0.16_0.012_55_/_0.96)_100%)]";
|
|
132
138
|
|
|
133
139
|
/**
|
|
134
140
|
* A second, LOCAL scrim under the name, count and action — the photo column's
|
|
@@ -200,8 +206,9 @@ export function MenuFamilyBlock({
|
|
|
200
206
|
<div
|
|
201
207
|
className={cn(
|
|
202
208
|
"relative flex min-h-[220px] flex-col justify-end p-3.5",
|
|
203
|
-
|
|
204
|
-
|
|
209
|
+
TEXT_SCRIM,
|
|
210
|
+
// The two-column card has ONE gradient from `lg` — see `INK_WIDE`.
|
|
211
|
+
wide && "lg:min-h-[320px] lg:p-5 lg:before:hidden"
|
|
205
212
|
)}
|
|
206
213
|
>
|
|
207
214
|
{pill && (
|
|
@@ -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,31 @@ type PosterSlotConfig = {
|
|
|
51
89
|
zIndexClassName?: string;
|
|
52
90
|
};
|
|
53
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Phone geometry: the three slots span 336px (112 / 128 / 112 wide, the
|
|
94
|
+
* outer two overlapping the middle by 8px each side), inside a stage the
|
|
95
|
+
* component centres in whatever column it is given — the stack used to hug
|
|
96
|
+
* the left gutter. 196px tall, the height the lowest slot actually reaches,
|
|
97
|
+
* so the block below sits at the page's rhythm rather than under 20px of
|
|
98
|
+
* empty stage. Touch targets are 112×149 and 128×171.
|
|
99
|
+
*/
|
|
100
|
+
const PHONE_STAGE_CLASS = "mx-auto w-[336px] max-w-full";
|
|
54
101
|
const POSTER_SLOTS: PosterSlotConfig[] = [
|
|
55
102
|
{
|
|
56
|
-
positionClassName: "left-
|
|
103
|
+
positionClassName: "left-0 top-[28px] w-[112px] md:left-0 md:top-[52px] md:w-[196px]",
|
|
57
104
|
restTransform: "rotate(-5deg)",
|
|
58
105
|
animationName: "ds-poster-stack-float-1",
|
|
59
106
|
animationDurationMs: 9000,
|
|
60
107
|
},
|
|
61
108
|
{
|
|
62
|
-
positionClassName: "left-[
|
|
109
|
+
positionClassName: "left-[104px] top-0 w-[128px] md:left-[168px] md:w-[232px]",
|
|
63
110
|
restTransform: "rotate(3deg)",
|
|
64
111
|
animationName: "ds-poster-stack-float-2",
|
|
65
112
|
animationDurationMs: 11000,
|
|
66
113
|
zIndexClassName: "z-[3]",
|
|
67
114
|
},
|
|
68
115
|
{
|
|
69
|
-
positionClassName: "left-[
|
|
116
|
+
positionClassName: "left-[224px] top-[44px] w-[112px] md:left-[378px] md:top-24 md:w-[196px]",
|
|
70
117
|
restTransform: "rotate(6deg)",
|
|
71
118
|
animationName: "ds-poster-stack-float-3",
|
|
72
119
|
animationDurationMs: 10000,
|
|
@@ -77,7 +124,11 @@ const POSTER_SLOTS: PosterSlotConfig[] = [
|
|
|
77
124
|
* shadow is an art-directed literal rather than a `--shadow-card` token. */
|
|
78
125
|
const POSTER_SHADOW = "0 22px 50px oklch(0.3 0.02 60 / 0.24)";
|
|
79
126
|
|
|
80
|
-
export function PosterStack({
|
|
127
|
+
export function PosterStack({
|
|
128
|
+
posters,
|
|
129
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
130
|
+
className,
|
|
131
|
+
}: PosterStackProps) {
|
|
81
132
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
82
133
|
const posterRefs = useRef<Array<HTMLDivElement | null>>([]);
|
|
83
134
|
const [failedIndices, setFailedIndices] = useState<Set<number>>(new Set());
|
|
@@ -106,12 +157,44 @@ export function PosterStack({ posters, className }: PosterStackProps) {
|
|
|
106
157
|
const visiblePosters = posters.slice(0, POSTER_SLOTS.length);
|
|
107
158
|
|
|
108
159
|
return (
|
|
109
|
-
<div
|
|
160
|
+
<div
|
|
161
|
+
ref={containerRef}
|
|
162
|
+
className={cn("relative h-[196px] md:h-[400px] md:w-auto md:mx-0", PHONE_STAGE_CLASS, className)}
|
|
163
|
+
>
|
|
110
164
|
<style>{POSTER_STACK_ANIMATION_STYLE}</style>
|
|
111
165
|
|
|
112
166
|
{visiblePosters.map((poster, index) => {
|
|
113
167
|
const slot = POSTER_SLOTS[index];
|
|
114
168
|
const showPoster = !!poster.src && !failedIndices.has(index);
|
|
169
|
+
const face = (
|
|
170
|
+
<>
|
|
171
|
+
{showPoster ? (
|
|
172
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
173
|
+
<img
|
|
174
|
+
src={poster.src}
|
|
175
|
+
alt={poster.caption ? "" : poster.alt}
|
|
176
|
+
loading="lazy"
|
|
177
|
+
decoding="async"
|
|
178
|
+
className={cn(
|
|
179
|
+
"h-full w-full object-cover",
|
|
180
|
+
poster.href &&
|
|
181
|
+
"transition-transform duration-300 group-hover/poster:scale-[1.03] motion-reduce:transition-none"
|
|
182
|
+
)}
|
|
183
|
+
onError={() => setFailedIndices((current) => new Set(current).add(index))}
|
|
184
|
+
/>
|
|
185
|
+
) : (
|
|
186
|
+
(poster.fallback ?? <EventPosterFallback />)
|
|
187
|
+
)}
|
|
188
|
+
{poster.caption && (
|
|
189
|
+
<span className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent px-2 pb-2 pt-6">
|
|
190
|
+
<span className="line-clamp-2 text-[11px] font-semibold leading-[1.25] text-white md:text-[12.5px]">
|
|
191
|
+
{poster.caption}
|
|
192
|
+
</span>
|
|
193
|
+
</span>
|
|
194
|
+
)}
|
|
195
|
+
</>
|
|
196
|
+
);
|
|
197
|
+
|
|
115
198
|
return (
|
|
116
199
|
<div
|
|
117
200
|
key={poster.alt || index}
|
|
@@ -122,7 +205,8 @@ export function PosterStack({ posters, className }: PosterStackProps) {
|
|
|
122
205
|
className={cn(
|
|
123
206
|
"absolute aspect-[3/4] overflow-hidden rounded-xl bg-muted",
|
|
124
207
|
slot.positionClassName,
|
|
125
|
-
slot.zIndexClassName
|
|
208
|
+
slot.zIndexClassName,
|
|
209
|
+
poster.href && "hover:z-[4] focus-within:z-[4]"
|
|
126
210
|
)}
|
|
127
211
|
style={{
|
|
128
212
|
transform: slot.restTransform,
|
|
@@ -134,18 +218,19 @@ export function PosterStack({ posters, className }: PosterStackProps) {
|
|
|
134
218
|
animationDirection: "alternate",
|
|
135
219
|
}}
|
|
136
220
|
>
|
|
137
|
-
{
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
221
|
+
{poster.href ? (
|
|
222
|
+
<LinkComponent
|
|
223
|
+
href={poster.href}
|
|
224
|
+
className={cn(
|
|
225
|
+
"group/poster relative block h-full w-full overflow-hidden rounded-xl",
|
|
226
|
+
"transition-transform duration-150 active:scale-[0.98] motion-reduce:transition-none",
|
|
227
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
228
|
+
)}
|
|
229
|
+
>
|
|
230
|
+
{face}
|
|
231
|
+
</LinkComponent>
|
|
232
|
+
) : (
|
|
233
|
+
<div className="relative h-full w-full">{face}</div>
|
|
149
234
|
)}
|
|
150
235
|
</div>
|
|
151
236
|
);
|
|
@@ -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: {
|