@bearmenu/ui 0.8.3 → 0.8.6
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-2WDJR6K7.js +76 -0
- package/dist/chunk-637L7XQ3.js +85 -0
- package/dist/chunk-6XMYEZVR.js +26 -0
- package/dist/chunk-DNSXMHYX.js +8 -0
- package/dist/chunk-FDITZ2VM.js +16 -0
- package/dist/chunk-GPH6L4DX.js +55 -0
- package/dist/{chunk-N2KNBPAQ.js → chunk-QXHMPJ35.js} +1 -0
- package/dist/chunk-TT3WFS6S.js +379 -0
- package/dist/chunk-UXWAOISG.js +170 -0
- package/dist/{chunk-UZ3PCIE5.js → chunk-XWMRABZW.js} +2 -2
- package/dist/{chunk-VBA45GLP.js → chunk-ZDAHKI3A.js} +12 -2
- 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/card.js +4 -127
- package/dist/components/category-deck.d.ts +50 -0
- package/dist/components/category-deck.js +128 -0
- package/dist/components/command.d.ts +15 -15
- package/dist/components/concept-card.d.ts +43 -0
- package/dist/components/concept-card.js +81 -0
- package/dist/components/coverflow.d.ts +123 -0
- package/dist/components/coverflow.js +300 -0
- package/dist/components/date-input.d.ts +1 -1
- package/dist/components/day-rail-chip.d.ts +82 -0
- package/dist/components/day-rail-chip.js +105 -0
- package/dist/components/drift-rail.d.ts +28 -0
- package/dist/components/drift-rail.js +3 -0
- package/dist/components/event-card.d.ts +97 -0
- package/dist/components/event-card.js +161 -0
- package/dist/components/event-compact-row.d.ts +72 -0
- package/dist/components/event-compact-row.js +133 -0
- package/dist/components/event-day-header.d.ts +65 -0
- package/dist/components/event-day-header.js +95 -0
- package/dist/components/event-day-separator.d.ts +46 -0
- package/dist/components/event-day-separator.js +50 -0
- package/dist/components/event-detail-panel.d.ts +80 -0
- package/dist/components/event-detail-panel.js +251 -0
- package/dist/components/event-hub-hero.d.ts +34 -0
- package/dist/components/event-hub-hero.js +57 -0
- package/dist/components/event-index-row.d.ts +68 -0
- package/dist/components/event-index-row.js +84 -0
- package/dist/components/event-list-row-wide.d.ts +101 -0
- package/dist/components/event-list-row-wide.js +218 -0
- package/dist/components/event-list-row.d.ts +82 -0
- package/dist/components/event-list-row.js +173 -0
- package/dist/components/event-poster-fallback.d.ts +20 -0
- package/dist/components/event-poster-fallback.js +3 -0
- package/dist/components/event-types.d.ts +118 -0
- package/dist/components/event-types.js +2 -0
- package/dist/components/events-empty-state.d.ts +44 -0
- package/dist/components/events-empty-state.js +73 -0
- package/dist/components/facet-group.d.ts +93 -0
- package/dist/components/facet-group.js +222 -0
- package/dist/components/featured-rail-item.d.ts +42 -0
- package/dist/components/featured-rail-item.js +88 -0
- package/dist/components/hour-band.d.ts +68 -0
- package/dist/components/hour-band.js +53 -0
- package/dist/components/hour-group.d.ts +67 -0
- package/dist/components/hour-group.js +59 -0
- package/dist/components/icon.d.ts +18 -0
- package/dist/components/icon.js +31 -0
- package/dist/components/item.d.ts +2 -2
- package/dist/components/motion-icon-controls.d.ts +26 -0
- package/dist/components/motion-icon-controls.js +4 -0
- package/dist/components/motion-icon-set.d.ts +27 -0
- package/dist/components/motion-icon-set.js +139 -0
- package/dist/components/motion-icon.d.ts +44 -0
- package/dist/components/motion-icon.js +3 -0
- package/dist/components/multi-select-combobox.js +1 -1
- package/dist/components/place-about-tab.js +3 -3
- package/dist/components/place-details-mobile.js +3 -3
- package/dist/components/poster-stack.d.ts +30 -0
- package/dist/components/poster-stack.js +104 -0
- package/dist/components/poster-tape.d.ts +36 -0
- package/dist/components/poster-tape.js +64 -0
- package/dist/components/poster-wall.d.ts +25 -0
- package/dist/components/poster-wall.js +183 -0
- package/dist/components/quiet-stretch.d.ts +43 -0
- package/dist/components/quiet-stretch.js +54 -0
- package/dist/components/section-header.d.ts +53 -0
- package/dist/components/section-header.js +63 -0
- package/dist/components/segmented-nav.d.ts +62 -0
- package/dist/components/segmented-nav.js +54 -0
- package/dist/components/skeleton-card.js +1 -1
- package/dist/components/skeleton.d.ts +17 -0
- package/dist/components/skeleton.js +1 -1
- package/dist/components/spinner.d.ts +2 -2
- package/dist/components/tag-group.js +3 -26
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/tag.js +1 -1
- package/dist/components/text.d.ts +3 -3
- package/dist/components/timeline-band.d.ts +46 -0
- package/dist/components/timeline-band.js +5 -0
- package/dist/components/venue-events-block.d.ts +143 -0
- package/dist/components/venue-events-block.js +222 -0
- package/dist/components/weekend-panel.d.ts +44 -0
- package/dist/components/weekend-panel.js +231 -0
- package/dist/lib/icon-registry.d.ts +58 -0
- package/dist/lib/icon-registry.js +39 -0
- package/package.json +2 -1
- package/src/components/accordion.stories.tsx +1 -1
- package/src/components/card.tsx +44 -1
- package/src/components/category-deck.stories.tsx +96 -0
- package/src/components/category-deck.tsx +227 -0
- package/src/components/combobox.stories.tsx +1 -1
- package/src/components/concept-card.stories.tsx +80 -0
- package/src/components/concept-card.tsx +143 -0
- package/src/components/coverflow.stories.tsx +240 -0
- package/src/components/coverflow.test.tsx +193 -0
- package/src/components/coverflow.tsx +550 -0
- package/src/components/currency-input.stories.tsx +3 -3
- package/src/components/day-rail-chip.stories.tsx +145 -0
- package/src/components/day-rail-chip.tsx +201 -0
- package/src/components/drift-rail.stories.tsx +92 -0
- package/src/components/drift-rail.tsx +86 -0
- package/src/components/empty-state.stories.tsx +3 -3
- package/src/components/event-card.stories.tsx +248 -0
- package/src/components/event-card.test.tsx +177 -0
- package/src/components/event-card.tsx +321 -0
- package/src/components/event-compact-row.stories.tsx +221 -0
- package/src/components/event-compact-row.tsx +268 -0
- package/src/components/event-day-header.stories.tsx +124 -0
- package/src/components/event-day-header.tsx +180 -0
- package/src/components/event-day-separator.stories.tsx +70 -0
- package/src/components/event-day-separator.tsx +95 -0
- package/src/components/event-detail-panel.stories.tsx +191 -0
- package/src/components/event-detail-panel.tsx +360 -0
- package/src/components/event-hub-hero.stories.tsx +66 -0
- package/src/components/event-hub-hero.tsx +110 -0
- package/src/components/event-index-row.stories.tsx +116 -0
- package/src/components/event-index-row.tsx +181 -0
- package/src/components/event-list-row-wide.stories.tsx +204 -0
- package/src/components/event-list-row-wide.tsx +386 -0
- package/src/components/event-list-row.stories.tsx +159 -0
- package/src/components/event-list-row.tsx +335 -0
- package/src/components/event-poster-fallback.stories.tsx +52 -0
- package/src/components/event-poster-fallback.tsx +31 -0
- package/src/components/event-types.ts +133 -0
- package/src/components/events-empty-state.stories.tsx +59 -0
- package/src/components/events-empty-state.tsx +131 -0
- package/src/components/facet-group.stories.tsx +247 -0
- package/src/components/facet-group.test.tsx +225 -0
- package/src/components/facet-group.tsx +352 -0
- package/src/components/featured-rail-item.stories.tsx +86 -0
- package/src/components/featured-rail-item.tsx +153 -0
- package/src/components/form.stories.tsx +1 -1
- package/src/components/hour-band.stories.tsx +159 -0
- package/src/components/hour-band.tsx +126 -0
- package/src/components/hour-group.stories.tsx +100 -0
- package/src/components/hour-group.tsx +124 -0
- package/src/components/icon.tsx +57 -0
- package/src/components/input-otp.stories.tsx +1 -1
- package/src/components/markdown-renderer.stories.tsx +1 -1
- package/src/components/motion-icon-controls.test.tsx +78 -0
- package/src/components/motion-icon-controls.tsx +552 -0
- package/src/components/motion-icon-set.stories.tsx +185 -0
- package/src/components/motion-icon-set.tsx +219 -0
- package/src/components/motion-icon.test.tsx +159 -0
- package/src/components/motion-icon.tsx +163 -0
- package/src/components/multi-select-combobox.stories.tsx +1 -1
- package/src/components/phone-frame.stories.tsx +1 -1
- package/src/components/poster-stack.stories.tsx +68 -0
- package/src/components/poster-stack.tsx +155 -0
- package/src/components/poster-tape.stories.tsx +70 -0
- package/src/components/poster-tape.tsx +122 -0
- package/src/components/poster-wall.stories.tsx +95 -0
- package/src/components/poster-wall.tsx +220 -0
- package/src/components/quiet-stretch.stories.tsx +62 -0
- package/src/components/quiet-stretch.tsx +115 -0
- package/src/components/rating.stories.tsx +3 -3
- package/src/components/searchable-select.stories.tsx +1 -1
- package/src/components/section-header.stories.tsx +70 -0
- package/src/components/section-header.tsx +134 -0
- package/src/components/segmented-nav.stories.tsx +118 -0
- package/src/components/segmented-nav.tsx +113 -0
- package/src/components/selection-bar.stories.tsx +1 -1
- package/src/components/skeleton-card.stories.tsx +3 -3
- package/src/components/skeleton.tsx +18 -0
- package/src/components/sliding-panels.stories.tsx +1 -1
- package/src/components/stepper.stories.tsx +1 -1
- package/src/components/sticky-container.stories.tsx +3 -3
- package/src/components/tag.tsx +11 -1
- package/src/components/timeline-band.stories.tsx +206 -0
- package/src/components/timeline-band.tsx +207 -0
- package/src/components/toggle-group.stories.tsx +1 -1
- package/src/components/tooltip.stories.tsx +2 -2
- package/src/components/venue-events-block.stories.tsx +216 -0
- package/src/components/venue-events-block.tsx +408 -0
- package/src/components/weekend-panel.stories.tsx +92 -0
- package/src/components/weekend-panel.tsx +322 -0
- package/src/globals.css +19 -0
- package/src/lib/icon-registry.ts +139 -0
- package/src/test/setup.ts +5 -1
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MotionIcon — the root for character-animated icons.
|
|
5
|
+
*
|
|
6
|
+
* An animated icon performs a behaviour the object it depicts would actually
|
|
7
|
+
* perform (a roof jumps, utensils uncross, a pin drops and plants) rather than
|
|
8
|
+
* tweening a property. The rules that make container motion feel right — short
|
|
9
|
+
* durations, one or two properties, restraint — produce dead icons, so this
|
|
10
|
+
* primitive is built for the opposite:
|
|
11
|
+
*
|
|
12
|
+
* - Gestures run 400–700ms in five or six beats (anticipation, action, apex,
|
|
13
|
+
* impact, settle), which deliberately exceeds the usual UI ceiling. An icon
|
|
14
|
+
* gesture is a performance the user chose to trigger, not a transition they
|
|
15
|
+
* are waiting through.
|
|
16
|
+
* - Amplitudes are 25–40% of the icon box. At a 22px render on a 24-unit grid
|
|
17
|
+
* one unit is 0.92px, so anything under ~6 units is physically invisible.
|
|
18
|
+
* - The silhouette must change. If the outline is the same shape throughout,
|
|
19
|
+
* nothing reads at icon size.
|
|
20
|
+
*
|
|
21
|
+
* Three variants, not two:
|
|
22
|
+
* rest — the plain glyph.
|
|
23
|
+
* active — the held pose. Equal to `rest` for gestures that return
|
|
24
|
+
* (a roof that lands again); a genuinely different pose for ones
|
|
25
|
+
* that stay changed (uncrossed utensils, a filled pin).
|
|
26
|
+
* play — the performance: keyframe arrays that land on `active`.
|
|
27
|
+
*
|
|
28
|
+
* `play` is driven by `replayToken`, which callers bump on POINTER DOWN rather
|
|
29
|
+
* than on the resulting state change. Waiting for state means waiting for
|
|
30
|
+
* navigation or a network write, which disconnects the gesture from the finger
|
|
31
|
+
* and reads as dead at any amplitude.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import * as React from "react";
|
|
35
|
+
import { motion, useAnimationControls, type Transition, type Variants } from "framer-motion";
|
|
36
|
+
|
|
37
|
+
import { cn } from "../lib/utils";
|
|
38
|
+
|
|
39
|
+
export type MotionIconProps = {
|
|
40
|
+
/** The held state. Drives `active` vs `rest`, and the stroke weight. */
|
|
41
|
+
active?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Bump on pointer down to perform the gesture. Changing this value replays
|
|
44
|
+
* `play` even when `active` has not changed, which is what makes the icon
|
|
45
|
+
* respond to the touch rather than to its consequence.
|
|
46
|
+
*/
|
|
47
|
+
replayToken?: number;
|
|
48
|
+
className?: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type MotionIconVariant = "rest" | "active" | "play";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A gesture is a performance with beats, so it is a tween with explicit
|
|
55
|
+
* `times`. A spring interpolates two keyframes and puts its overshoot at the
|
|
56
|
+
* end, which cannot express anticipation.
|
|
57
|
+
*/
|
|
58
|
+
export function beats(times: number[], duration = 0.62): Transition {
|
|
59
|
+
return { duration, times, ease: "easeInOut" };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Leaving is not a performance — it returns the glyph, firmly and fast. */
|
|
63
|
+
export const returnTransition: Transition = { type: "spring", stiffness: 320, damping: 26 };
|
|
64
|
+
|
|
65
|
+
type PoseValues = Record<string, number>;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Builds the three variants for a moving part.
|
|
69
|
+
* `play` values are keyframe arrays; `rest`/`active` are the two held poses.
|
|
70
|
+
*/
|
|
71
|
+
export function poses(
|
|
72
|
+
rest: PoseValues,
|
|
73
|
+
active: PoseValues,
|
|
74
|
+
play: Record<string, number[]>,
|
|
75
|
+
transition: Transition
|
|
76
|
+
): Variants {
|
|
77
|
+
return {
|
|
78
|
+
rest: { ...rest, transition: returnTransition },
|
|
79
|
+
active: { ...active, transition: returnTransition },
|
|
80
|
+
play: { ...play, transition },
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const rootVariants: Variants = {
|
|
85
|
+
rest: { strokeWidth: 1.75, transition: { duration: 0.18 } },
|
|
86
|
+
active: { strokeWidth: 2.25, transition: { duration: 0.18 } },
|
|
87
|
+
play: { strokeWidth: 2.25, transition: { duration: 0.12 } },
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Mount poses without playing; pointer down plays; arriving plays unless the
|
|
92
|
+
* pointer already started it; leaving returns.
|
|
93
|
+
*
|
|
94
|
+
* Both effects compare against a ref rather than trusting the dependency array,
|
|
95
|
+
* so they fire once per actual change and not once per render.
|
|
96
|
+
*/
|
|
97
|
+
export function useIconGesture(active: boolean, replayToken: number) {
|
|
98
|
+
const controls = useAnimationControls();
|
|
99
|
+
const lastPlayAt = React.useRef(0);
|
|
100
|
+
const seenToken = React.useRef(0);
|
|
101
|
+
// `null` until mounted, so the pose effect can tell a real change from the
|
|
102
|
+
// first run and never performs the gesture on page load.
|
|
103
|
+
const seenActive = React.useRef<boolean | null>(null);
|
|
104
|
+
|
|
105
|
+
React.useEffect(() => {
|
|
106
|
+
controls.set(active ? "active" : "rest");
|
|
107
|
+
seenActive.current = active;
|
|
108
|
+
// Mount only — the two effects below own everything after that.
|
|
109
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
110
|
+
}, []);
|
|
111
|
+
|
|
112
|
+
React.useEffect(() => {
|
|
113
|
+
if (replayToken === seenToken.current) return;
|
|
114
|
+
seenToken.current = replayToken;
|
|
115
|
+
lastPlayAt.current = Date.now();
|
|
116
|
+
void controls.start("play");
|
|
117
|
+
}, [replayToken, controls]);
|
|
118
|
+
|
|
119
|
+
React.useEffect(() => {
|
|
120
|
+
if (seenActive.current === null || seenActive.current === active) return;
|
|
121
|
+
seenActive.current = active;
|
|
122
|
+
if (!active) {
|
|
123
|
+
void controls.start("rest");
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
// The pointer that caused this state change already started the gesture.
|
|
127
|
+
if (Date.now() - lastPlayAt.current < 600) return;
|
|
128
|
+
void controls.start("play");
|
|
129
|
+
}, [active, controls]);
|
|
130
|
+
|
|
131
|
+
return controls;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function MotionIcon({
|
|
135
|
+
active = false,
|
|
136
|
+
replayToken = 0,
|
|
137
|
+
className,
|
|
138
|
+
children,
|
|
139
|
+
}: MotionIconProps & { children: React.ReactNode }) {
|
|
140
|
+
const controls = useIconGesture(active, replayToken);
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<motion.svg
|
|
144
|
+
viewBox="0 0 24 24"
|
|
145
|
+
// Parts leave the 24-unit box at the apex of a jump. Callers give the
|
|
146
|
+
// icon padding rather than clipping the gesture.
|
|
147
|
+
overflow="visible"
|
|
148
|
+
fill="none"
|
|
149
|
+
stroke="currentColor"
|
|
150
|
+
strokeLinecap="round"
|
|
151
|
+
strokeLinejoin="round"
|
|
152
|
+
variants={rootVariants}
|
|
153
|
+
// Without this the active icon replays on hydration and every other one
|
|
154
|
+
// flashes through its pose on first paint.
|
|
155
|
+
initial={false}
|
|
156
|
+
animate={controls}
|
|
157
|
+
className={cn("size-6", className)}
|
|
158
|
+
aria-hidden
|
|
159
|
+
>
|
|
160
|
+
{children}
|
|
161
|
+
</motion.svg>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { PosterStack } from "./poster-stack";
|
|
3
|
+
|
|
4
|
+
function posterDataUri(label: string, fill: string) {
|
|
5
|
+
return (
|
|
6
|
+
"data:image/svg+xml;utf8," +
|
|
7
|
+
encodeURIComponent(
|
|
8
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="300" height="400"><rect width="300" height="400" fill="${fill}"/><text x="50%" y="50%" font-family="monospace" font-size="20" fill="#fff" text-anchor="middle">${label}</text></svg>`
|
|
9
|
+
)
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Explicitly annotated rather than `satisfies`: the meta-level decorator makes
|
|
14
|
+
// the inferred type reference storybook's internal CSF module, which tsc cannot
|
|
15
|
+
// name portably (TS2742).
|
|
16
|
+
const meta: Meta<typeof PosterStack> = {
|
|
17
|
+
title: "Events/PosterStack",
|
|
18
|
+
component: PosterStack,
|
|
19
|
+
tags: ["autodocs"],
|
|
20
|
+
args: {
|
|
21
|
+
posters: [
|
|
22
|
+
{ src: posterDataUri("1", "#7a5c46"), alt: "Psihotrop poster" },
|
|
23
|
+
{ src: posterDataUri("2", "#4a6a5c"), alt: "Warehouse rave poster" },
|
|
24
|
+
{ src: posterDataUri("3", "#6a5c8a"), alt: "Jazz night poster" },
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
decorators: [
|
|
28
|
+
(Story) => (
|
|
29
|
+
<div className="w-[500px]">
|
|
30
|
+
<Story />
|
|
31
|
+
</div>
|
|
32
|
+
),
|
|
33
|
+
],
|
|
34
|
+
parameters: {
|
|
35
|
+
docs: {
|
|
36
|
+
description: {
|
|
37
|
+
component:
|
|
38
|
+
"The float pauses via IntersectionObserver when scrolled off-screen and stops entirely under `prefers-reduced-motion: reduce`.",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export default meta;
|
|
45
|
+
type Story = StoryObj<typeof PosterStack>;
|
|
46
|
+
|
|
47
|
+
export const Default: Story = {};
|
|
48
|
+
|
|
49
|
+
/** Fewer than three posters renders only the slots given. */
|
|
50
|
+
export const TwoPosters: Story = {
|
|
51
|
+
args: {
|
|
52
|
+
posters: [
|
|
53
|
+
{ src: posterDataUri("1", "#7a5c46"), alt: "Psihotrop poster" },
|
|
54
|
+
{ src: posterDataUri("2", "#4a6a5c"), alt: "Warehouse rave poster" },
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** No `src` renders the neutral placeholder block, same as `EventCard`. */
|
|
60
|
+
export const MissingSources: Story = {
|
|
61
|
+
args: {
|
|
62
|
+
posters: [
|
|
63
|
+
{ alt: "Psihotrop poster" },
|
|
64
|
+
{ alt: "Warehouse rave poster" },
|
|
65
|
+
{ alt: "Jazz night poster" },
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { useEffect, useRef, useState } from "react";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* PosterStack — three overlapping, gently rotated posters that float, the
|
|
9
|
+
* hub hero's aside slot in the source design.
|
|
10
|
+
*
|
|
11
|
+
* Two motion rules, both mandatory:
|
|
12
|
+
* 1. Stops entirely under `prefers-reduced-motion: reduce` — enforced via
|
|
13
|
+
* an injected `<style>` media query rather than relying on Tailwind's
|
|
14
|
+
* `motion-reduce:` variant, because the float is a custom `@keyframes`
|
|
15
|
+
* animation Tailwind cannot express, and only a `!important` CSS rule
|
|
16
|
+
* reliably beats the element's own inline `animation` style.
|
|
17
|
+
* 2. Pauses while the stack is scrolled off-screen — an IntersectionObserver
|
|
18
|
+
* toggles `animation-play-state` on the three poster nodes so nothing
|
|
19
|
+
* spends a paint budget while it isn't visible. Skipped entirely when
|
|
20
|
+
* reduced motion is on, since nothing is animating to pause.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export type PosterStackPoster = {
|
|
24
|
+
/** Absent renders a neutral placeholder block instead of an image. */
|
|
25
|
+
src?: string;
|
|
26
|
+
alt: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type PosterStackProps = {
|
|
30
|
+
/** Rendered in order; only the first three are shown. */
|
|
31
|
+
posters: PosterStackPoster[];
|
|
32
|
+
className?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const POSTER_STACK_OBSERVER_ROOT_MARGIN = "200px";
|
|
36
|
+
|
|
37
|
+
const POSTER_STACK_ANIMATION_STYLE = `
|
|
38
|
+
@keyframes ds-poster-stack-float-1 { to { transform: rotate(-5deg) translateY(-16px); } }
|
|
39
|
+
@keyframes ds-poster-stack-float-2 { to { transform: rotate(3deg) translateY(14px); } }
|
|
40
|
+
@keyframes ds-poster-stack-float-3 { to { transform: rotate(6deg) translateY(-11px); } }
|
|
41
|
+
@media (prefers-reduced-motion: reduce) {
|
|
42
|
+
[data-ds-poster-stack-item] { animation: none !important; }
|
|
43
|
+
}
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
type PosterSlotConfig = {
|
|
47
|
+
positionClassName: string;
|
|
48
|
+
restTransform: string;
|
|
49
|
+
animationName: string;
|
|
50
|
+
animationDurationMs: number;
|
|
51
|
+
zIndexClassName?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const POSTER_SLOTS: PosterSlotConfig[] = [
|
|
55
|
+
{
|
|
56
|
+
positionClassName: "left-[6px] top-[34px] w-[120px] md:left-0 md:top-[52px] md:w-[196px]",
|
|
57
|
+
restTransform: "rotate(-5deg)",
|
|
58
|
+
animationName: "ds-poster-stack-float-1",
|
|
59
|
+
animationDurationMs: 9000,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
positionClassName: "left-[110px] top-0 w-[140px] md:left-[168px] md:w-[232px]",
|
|
63
|
+
restTransform: "rotate(3deg)",
|
|
64
|
+
animationName: "ds-poster-stack-float-2",
|
|
65
|
+
animationDurationMs: 11000,
|
|
66
|
+
zIndexClassName: "z-[3]",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
positionClassName: "left-[238px] top-[58px] w-[120px] md:left-[378px] md:top-24 md:w-[196px]",
|
|
70
|
+
restTransform: "rotate(6deg)",
|
|
71
|
+
animationName: "ds-poster-stack-float-3",
|
|
72
|
+
animationDurationMs: 10000,
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
/** A warm, drop-shadowed decorative float — not a content surface, so the
|
|
77
|
+
* shadow is an art-directed literal rather than a `--shadow-card` token. */
|
|
78
|
+
const POSTER_SHADOW = "0 22px 50px oklch(0.3 0.02 60 / 0.24)";
|
|
79
|
+
|
|
80
|
+
export function PosterStack({ posters, className }: PosterStackProps) {
|
|
81
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
82
|
+
const posterRefs = useRef<Array<HTMLDivElement | null>>([]);
|
|
83
|
+
const [failedIndices, setFailedIndices] = useState<Set<number>>(new Set());
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (typeof window === "undefined") return;
|
|
87
|
+
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
|
88
|
+
|
|
89
|
+
const container = containerRef.current;
|
|
90
|
+
if (!container) return;
|
|
91
|
+
|
|
92
|
+
const observer = new IntersectionObserver(
|
|
93
|
+
([entry]) => {
|
|
94
|
+
const playState = entry.isIntersecting ? "running" : "paused";
|
|
95
|
+
for (const poster of posterRefs.current) {
|
|
96
|
+
if (poster) poster.style.animationPlayState = playState;
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
{ rootMargin: POSTER_STACK_OBSERVER_ROOT_MARGIN }
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
observer.observe(container);
|
|
103
|
+
return () => observer.disconnect();
|
|
104
|
+
}, []);
|
|
105
|
+
|
|
106
|
+
const visiblePosters = posters.slice(0, POSTER_SLOTS.length);
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div ref={containerRef} className={cn("relative h-[250px] md:h-[400px]", className)}>
|
|
110
|
+
<style>{POSTER_STACK_ANIMATION_STYLE}</style>
|
|
111
|
+
|
|
112
|
+
{visiblePosters.map((poster, index) => {
|
|
113
|
+
const slot = POSTER_SLOTS[index];
|
|
114
|
+
const showPoster = !!poster.src && !failedIndices.has(index);
|
|
115
|
+
return (
|
|
116
|
+
<div
|
|
117
|
+
key={poster.alt || index}
|
|
118
|
+
ref={(node) => {
|
|
119
|
+
posterRefs.current[index] = node;
|
|
120
|
+
}}
|
|
121
|
+
data-ds-poster-stack-item
|
|
122
|
+
className={cn(
|
|
123
|
+
"absolute aspect-[3/4] overflow-hidden rounded-xl bg-muted",
|
|
124
|
+
slot.positionClassName,
|
|
125
|
+
slot.zIndexClassName
|
|
126
|
+
)}
|
|
127
|
+
style={{
|
|
128
|
+
transform: slot.restTransform,
|
|
129
|
+
boxShadow: POSTER_SHADOW,
|
|
130
|
+
animationName: slot.animationName,
|
|
131
|
+
animationDuration: `${slot.animationDurationMs}ms`,
|
|
132
|
+
animationTimingFunction: "ease-in-out",
|
|
133
|
+
animationIterationCount: "infinite",
|
|
134
|
+
animationDirection: "alternate",
|
|
135
|
+
}}
|
|
136
|
+
>
|
|
137
|
+
{showPoster && (
|
|
138
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
139
|
+
<img
|
|
140
|
+
src={poster.src}
|
|
141
|
+
alt={poster.alt}
|
|
142
|
+
loading="lazy"
|
|
143
|
+
decoding="async"
|
|
144
|
+
className="h-full w-full object-cover"
|
|
145
|
+
onError={() =>
|
|
146
|
+
setFailedIndices((current) => new Set(current).add(index))
|
|
147
|
+
}
|
|
148
|
+
/>
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
})}
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { PosterTape, type PosterTapeItem } from "./poster-tape";
|
|
3
|
+
|
|
4
|
+
const items: PosterTapeItem[] = [
|
|
5
|
+
{ id: "1", title: "Warehouse Rave", meta: "SAT 20:00" },
|
|
6
|
+
{ id: "2", title: "Jazz on the Rooftop", meta: "FRI 19:30" },
|
|
7
|
+
{ id: "3", title: "Open Mic Night", meta: "THU 21:00" },
|
|
8
|
+
{ id: "4", title: "Street Food Market", meta: "SUN 12:00" },
|
|
9
|
+
{ id: "5", title: "Vinyl & Vermouth", meta: "WED 18:00" },
|
|
10
|
+
{ id: "6", title: "Psihotrop — From the Beginning to Infinity", meta: "SAT 22:00" },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
// Explicitly annotated rather than `satisfies`: the meta-level decorator makes
|
|
14
|
+
// the inferred type reference storybook's internal CSF module, which tsc cannot
|
|
15
|
+
// name portably (TS2742).
|
|
16
|
+
const meta: Meta<typeof PosterTape> = {
|
|
17
|
+
title: "Events/PosterTape",
|
|
18
|
+
component: PosterTape,
|
|
19
|
+
tags: ["autodocs"],
|
|
20
|
+
args: { items },
|
|
21
|
+
decorators: [
|
|
22
|
+
(Story) => (
|
|
23
|
+
<div className="max-w-[420px]">
|
|
24
|
+
<Story />
|
|
25
|
+
</div>
|
|
26
|
+
),
|
|
27
|
+
],
|
|
28
|
+
parameters: {
|
|
29
|
+
docs: {
|
|
30
|
+
description: {
|
|
31
|
+
component:
|
|
32
|
+
"The closing tape: small 4:5 posters drifting past at a slower, quieter speed than the featured rail. Built on `<DriftRail>` — same seamless loop and pause mechanics, just smaller items and a 60s default.",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export default meta;
|
|
39
|
+
type Story = StoryObj<typeof PosterTape>;
|
|
40
|
+
|
|
41
|
+
/** No poster URLs supplied — every tile falls back to `<EventPosterFallback>`, same as `<EventCard>`. */
|
|
42
|
+
export const Default: Story = {};
|
|
43
|
+
|
|
44
|
+
export const Linked: Story = {
|
|
45
|
+
args: {
|
|
46
|
+
items: items.map((item) => ({ ...item, href: `/events/${item.id}` })),
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const Faster: Story = {
|
|
51
|
+
args: { durationSeconds: 24 },
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Titles wrap in full at the tape's 132px width. This is the surface the
|
|
56
|
+
* report came from — "Movie Night: Futni m…", "Juventus vs AC Milan…" — and
|
|
57
|
+
* `<DriftRail>` lays its items out `items-stretch`, so a wrapped title makes
|
|
58
|
+
* the tape taller without breaking the seamless loop or the poster row's
|
|
59
|
+
* alignment.
|
|
60
|
+
*/
|
|
61
|
+
export const LongTitles: Story = {
|
|
62
|
+
args: {
|
|
63
|
+
items: [
|
|
64
|
+
{ id: "1", title: "Movie Night: Futni må ikke være så vanskelig", meta: "SAT 20:00" },
|
|
65
|
+
{ id: "2", title: "Juventus vs AC Milan — Serie A, live on the big screen", meta: "SUN 21:45" },
|
|
66
|
+
{ id: "3", title: "Afterwork Karaoke at the Old Brewery Courtyard", meta: "THU 18:30" },
|
|
67
|
+
{ id: "4", title: "Open Mic", meta: "WED 21:00" },
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
import { DriftRail } from "./drift-rail";
|
|
7
|
+
import { EventPosterFallback } from "./event-poster-fallback";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* PosterTape — the closing marquee: small 4:5 posters drifting past at a
|
|
11
|
+
* slower, quieter speed than `<DriftRail>`'s default. Built on top of
|
|
12
|
+
* `<DriftRail>` rather than re-implementing the loop: both share the exact
|
|
13
|
+
* same seam-free-duplication and pause mechanics (`IntersectionObserver` +
|
|
14
|
+
* `prefers-reduced-motion`), and `<PosterTape>` only owns the item markup and
|
|
15
|
+
* a slower default speed.
|
|
16
|
+
*/
|
|
17
|
+
export type PosterTapeItem = {
|
|
18
|
+
id: string;
|
|
19
|
+
title: string;
|
|
20
|
+
/** Caller-composed, e.g. "Sat 5 · 20:00". Rendered as mono, small. */
|
|
21
|
+
meta: string;
|
|
22
|
+
posterUrl?: string;
|
|
23
|
+
href?: string;
|
|
24
|
+
/** Rendered instead of the poster when it's absent or failed to load. Defaults to `<EventPosterFallback />`. */
|
|
25
|
+
fallback?: ReactNode;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type PosterTapeLinkProps = {
|
|
29
|
+
href: string;
|
|
30
|
+
className?: string;
|
|
31
|
+
children: React.ReactNode;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function DefaultLink({ href, ...props }: PosterTapeLinkProps) {
|
|
35
|
+
return <a href={href} {...props} />;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type PosterTapeProps = {
|
|
39
|
+
items: PosterTapeItem[];
|
|
40
|
+
/** Defaults to a plain `<a>`. Pass `next/link` in a Next app. */
|
|
41
|
+
linkComponent?: React.ComponentType<PosterTapeLinkProps>;
|
|
42
|
+
durationSeconds?: number;
|
|
43
|
+
className?: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const POSTER_TAPE_DEFAULT_DURATION_SECONDS = 60;
|
|
47
|
+
|
|
48
|
+
function PosterTapeItemCard({
|
|
49
|
+
item,
|
|
50
|
+
LinkComponent,
|
|
51
|
+
}: {
|
|
52
|
+
item: PosterTapeItem;
|
|
53
|
+
LinkComponent: React.ComponentType<PosterTapeLinkProps>;
|
|
54
|
+
}) {
|
|
55
|
+
const [imgError, setImgError] = useState(false);
|
|
56
|
+
const showPoster = !!item.posterUrl && !imgError;
|
|
57
|
+
|
|
58
|
+
const body = (
|
|
59
|
+
<>
|
|
60
|
+
<div className="aspect-[4/5] w-full overflow-hidden rounded-xl bg-muted">
|
|
61
|
+
{showPoster ? (
|
|
62
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
63
|
+
<img
|
|
64
|
+
src={item.posterUrl}
|
|
65
|
+
alt={item.title}
|
|
66
|
+
title={item.title}
|
|
67
|
+
loading="lazy"
|
|
68
|
+
decoding="async"
|
|
69
|
+
className="h-full w-full object-cover"
|
|
70
|
+
onError={() => setImgError(true)}
|
|
71
|
+
/>
|
|
72
|
+
) : (
|
|
73
|
+
(item.fallback ?? <EventPosterFallback />)
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
{/* The tape is 132px wide, which is exactly why the title used to read
|
|
77
|
+
"Movie Night: Futni m…". It wraps in full instead. `<DriftRail>` lays
|
|
78
|
+
its items out `items-stretch`, so a taller card only makes the tape
|
|
79
|
+
taller — the posters stay on one line and the loop's duplicate pass
|
|
80
|
+
is still pixel-identical, because both passes render the same items. */}
|
|
81
|
+
<div className="mt-1.5 text-[11px] font-semibold leading-[1.3] sm:mt-[7px] sm:text-[11.5px]">
|
|
82
|
+
{item.title}
|
|
83
|
+
</div>
|
|
84
|
+
<div className="mt-0.5 truncate font-mono text-[10px] text-muted-foreground sm:text-[10.5px]">
|
|
85
|
+
{item.meta}
|
|
86
|
+
</div>
|
|
87
|
+
</>
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const itemClasses = "block w-[132px] shrink-0";
|
|
91
|
+
|
|
92
|
+
if (item.href) {
|
|
93
|
+
return (
|
|
94
|
+
<LinkComponent
|
|
95
|
+
href={item.href}
|
|
96
|
+
className={cn(
|
|
97
|
+
itemClasses,
|
|
98
|
+
"rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
99
|
+
)}
|
|
100
|
+
>
|
|
101
|
+
{body}
|
|
102
|
+
</LinkComponent>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return <div className={itemClasses}>{body}</div>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function PosterTape({
|
|
110
|
+
items,
|
|
111
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
112
|
+
durationSeconds = POSTER_TAPE_DEFAULT_DURATION_SECONDS,
|
|
113
|
+
className,
|
|
114
|
+
}: PosterTapeProps) {
|
|
115
|
+
return (
|
|
116
|
+
<DriftRail durationSeconds={durationSeconds} className={className}>
|
|
117
|
+
{items.map((item) => (
|
|
118
|
+
<PosterTapeItemCard key={item.id} item={item} LinkComponent={LinkComponent} />
|
|
119
|
+
))}
|
|
120
|
+
</DriftRail>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { PosterWall, PosterWallSkeleton } from "./poster-wall";
|
|
3
|
+
|
|
4
|
+
function tile(color: string) {
|
|
5
|
+
return {
|
|
6
|
+
src:
|
|
7
|
+
"data:image/svg+xml;utf8," +
|
|
8
|
+
encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60"><rect width="60" height="60" fill="${color}"/></svg>`),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const TILE_COLORS = ["#7c5a3a", "#5a4a3a", "#8a6a4a", "#3a2a1a", "#6a4a2a", "#4a3a2a"];
|
|
13
|
+
|
|
14
|
+
function tiles(count: number) {
|
|
15
|
+
return Array.from({ length: count }, (_, index) => tile(TILE_COLORS[index % TILE_COLORS.length]));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Explicitly annotated rather than `satisfies`: the meta-level decorator makes
|
|
19
|
+
// the inferred type reference storybook's internal CSF module, which tsc cannot
|
|
20
|
+
// name portably (TS2742).
|
|
21
|
+
const meta: Meta<typeof PosterWall> = {
|
|
22
|
+
title: "Events/PosterWall",
|
|
23
|
+
component: PosterWall,
|
|
24
|
+
tags: ["autodocs"],
|
|
25
|
+
parameters: {
|
|
26
|
+
layout: "fullscreen",
|
|
27
|
+
docs: {
|
|
28
|
+
description: {
|
|
29
|
+
component:
|
|
30
|
+
"The hub's masthead — a dark tiled photo grid behind a sweeping light and a veil. `children` is a slot; the wall only owns the chrome and the container sizing/alignment for `variant`.",
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export default meta;
|
|
37
|
+
type Story = StoryObj<typeof PosterWall>;
|
|
38
|
+
|
|
39
|
+
export const Default: Story = {
|
|
40
|
+
args: {
|
|
41
|
+
tiles: tiles(48),
|
|
42
|
+
children: (
|
|
43
|
+
<>
|
|
44
|
+
<div className="font-mono text-xs uppercase tracking-[0.2em] text-white/70">
|
|
45
|
+
Events this weekend
|
|
46
|
+
</div>
|
|
47
|
+
<h1 className="max-w-md text-4xl font-black tracking-[-0.02em]">
|
|
48
|
+
Where the city goes tonight
|
|
49
|
+
</h1>
|
|
50
|
+
</>
|
|
51
|
+
),
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** The bottom-aligned mobile treatment: shorter wall, top-fading veil, fewer/larger tiles. */
|
|
56
|
+
export const Compact: Story = {
|
|
57
|
+
args: {
|
|
58
|
+
variant: "compact",
|
|
59
|
+
tiles: tiles(36),
|
|
60
|
+
children: (
|
|
61
|
+
<>
|
|
62
|
+
<div className="font-mono text-[11px] uppercase tracking-[0.2em] text-white/70">
|
|
63
|
+
This weekend
|
|
64
|
+
</div>
|
|
65
|
+
<h1 className="text-2xl font-black tracking-[-0.02em]">Where the city goes tonight</h1>
|
|
66
|
+
</>
|
|
67
|
+
),
|
|
68
|
+
},
|
|
69
|
+
decorators: [
|
|
70
|
+
(Story) => (
|
|
71
|
+
<div className="mx-auto w-[375px]">
|
|
72
|
+
<Story />
|
|
73
|
+
</div>
|
|
74
|
+
),
|
|
75
|
+
],
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** No tiles supplied — every slot falls back to the striped placeholder. */
|
|
79
|
+
export const NoTiles: Story = {
|
|
80
|
+
args: { children: <h1 className="text-3xl font-black">No poster data yet</h1> },
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** The tile grid at rest — no sweeping light in the loading state. */
|
|
84
|
+
export const Skeleton: Story = {
|
|
85
|
+
render: () => <PosterWallSkeleton />,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** The compact mobile masthead's loading state. */
|
|
89
|
+
export const SkeletonCompact: Story = {
|
|
90
|
+
render: () => (
|
|
91
|
+
<div className="mx-auto w-[375px]">
|
|
92
|
+
<PosterWallSkeleton variant="compact" />
|
|
93
|
+
</div>
|
|
94
|
+
),
|
|
95
|
+
};
|