@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,220 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
import { Skeleton } from "./skeleton";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* PosterWall — the hub's full-bleed masthead: a dark tiled photo grid behind a
|
|
9
|
+
* sweeping light and a veil, with the caller's content laid over it.
|
|
10
|
+
*
|
|
11
|
+
* The wall is deliberately dark and un-themed (fixed dark tokens, not
|
|
12
|
+
* `background`/`foreground`) in both light and dark mode — it's a photo
|
|
13
|
+
* backdrop, the same reasoning `EventCard`'s black overlay pills use over a
|
|
14
|
+
* poster image, not a themed surface.
|
|
15
|
+
*
|
|
16
|
+
* `children` is a slot: PosterWall only owns the tiles/light/veil chrome and
|
|
17
|
+
* the container's size, alignment and padding for the given `variant`. All
|
|
18
|
+
* overlay typography belongs to the caller.
|
|
19
|
+
*
|
|
20
|
+
* The sweep is a genuine `@keyframes` animation (not Framer Motion) because
|
|
21
|
+
* its exact timing is part of the design spec and it needs to keep running
|
|
22
|
+
* indefinitely at zero React cost — the keyframes are scoped to this file via
|
|
23
|
+
* a `<style>` tag since editing the DS's global stylesheet is out of scope
|
|
24
|
+
* for this component.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const POSTER_WALL_DESKTOP_TILE_COUNT = 48;
|
|
28
|
+
const POSTER_WALL_COMPACT_TILE_COUNT = 36;
|
|
29
|
+
const POSTER_WALL_SWEEP_KEYFRAMES_ID = "ds-poster-wall-sweep-keyframes";
|
|
30
|
+
|
|
31
|
+
function usePrefersReducedMotion(): boolean {
|
|
32
|
+
const [reduced, setReduced] = React.useState(false);
|
|
33
|
+
|
|
34
|
+
React.useEffect(() => {
|
|
35
|
+
if (typeof window === "undefined" || !window.matchMedia) return;
|
|
36
|
+
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
37
|
+
setReduced(query.matches);
|
|
38
|
+
const onChange = () => setReduced(query.matches);
|
|
39
|
+
query.addEventListener("change", onChange);
|
|
40
|
+
return () => query.removeEventListener("change", onChange);
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
return reduced;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function useInViewport<T extends Element>(): [React.RefObject<T | null>, boolean] {
|
|
47
|
+
const ref = React.useRef<T | null>(null);
|
|
48
|
+
const [inView, setInView] = React.useState(true);
|
|
49
|
+
|
|
50
|
+
React.useEffect(() => {
|
|
51
|
+
const node = ref.current;
|
|
52
|
+
if (!node || typeof IntersectionObserver === "undefined") return;
|
|
53
|
+
const observer = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting), {
|
|
54
|
+
threshold: 0,
|
|
55
|
+
});
|
|
56
|
+
observer.observe(node);
|
|
57
|
+
return () => observer.disconnect();
|
|
58
|
+
}, []);
|
|
59
|
+
|
|
60
|
+
return [ref, inView];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type PosterWallTile = {
|
|
64
|
+
src?: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type PosterWallProps = {
|
|
68
|
+
/** Background tiles. Desktop renders 48, `compact` renders 36 — shorter arrays are padded with placeholders. */
|
|
69
|
+
tiles?: PosterWallTile[];
|
|
70
|
+
/** `compact` is the bottom-aligned mobile treatment (fewer, larger tiles, a top-fading veil). */
|
|
71
|
+
variant?: "default" | "compact";
|
|
72
|
+
/** The overlay content — fully caller-composed. */
|
|
73
|
+
children?: React.ReactNode;
|
|
74
|
+
className?: string;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export function PosterWall({ tiles, variant = "default", children, className }: PosterWallProps) {
|
|
78
|
+
const isCompact = variant === "compact";
|
|
79
|
+
const tileCount = isCompact ? POSTER_WALL_COMPACT_TILE_COUNT : POSTER_WALL_DESKTOP_TILE_COUNT;
|
|
80
|
+
const slots = Array.from({ length: tileCount }, (_, index): PosterWallTile => tiles?.[index] ?? {});
|
|
81
|
+
|
|
82
|
+
const prefersReducedMotion = usePrefersReducedMotion();
|
|
83
|
+
const [wallRef, inView] = useInViewport<HTMLDivElement>();
|
|
84
|
+
const isSweepPaused = prefersReducedMotion || !inView;
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div
|
|
88
|
+
ref={wallRef}
|
|
89
|
+
className={cn(
|
|
90
|
+
"relative overflow-hidden rounded-[18px] bg-[oklch(0.22_0.012_60)]",
|
|
91
|
+
isCompact ? "h-[330px]" : "h-[420px]",
|
|
92
|
+
className
|
|
93
|
+
)}
|
|
94
|
+
>
|
|
95
|
+
<style>{`
|
|
96
|
+
@keyframes ${POSTER_WALL_SWEEP_KEYFRAMES_ID} {
|
|
97
|
+
0% { transform: translateX(-30%); }
|
|
98
|
+
100% { transform: translateX(30%); }
|
|
99
|
+
}
|
|
100
|
+
`}</style>
|
|
101
|
+
|
|
102
|
+
<div
|
|
103
|
+
className={cn(
|
|
104
|
+
"absolute inset-0 grid auto-rows-fr gap-[2px] opacity-50",
|
|
105
|
+
isCompact ? "grid-cols-6" : "grid-cols-12"
|
|
106
|
+
)}
|
|
107
|
+
aria-hidden
|
|
108
|
+
>
|
|
109
|
+
{slots.map((tile, index) =>
|
|
110
|
+
tile.src ? (
|
|
111
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
112
|
+
<img
|
|
113
|
+
key={index}
|
|
114
|
+
src={tile.src}
|
|
115
|
+
alt=""
|
|
116
|
+
loading="lazy"
|
|
117
|
+
decoding="async"
|
|
118
|
+
className="h-full w-full object-cover"
|
|
119
|
+
/>
|
|
120
|
+
) : (
|
|
121
|
+
<div
|
|
122
|
+
key={index}
|
|
123
|
+
className="h-full w-full bg-[repeating-linear-gradient(45deg,color-mix(in_oklch,white_10%,transparent)_0px,color-mix(in_oklch,white_10%,transparent)_2px,transparent_2px,transparent_8px)]"
|
|
124
|
+
/>
|
|
125
|
+
)
|
|
126
|
+
)}
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
{/* Reduced motion and off-screen both resolve to "paused" via JS, not a
|
|
130
|
+
`motion-reduce:` class — the animation properties are inline styles
|
|
131
|
+
(so `animation-duration` can stay a plain string), and an inline
|
|
132
|
+
style always outranks a class on the same property. */}
|
|
133
|
+
<div
|
|
134
|
+
className="absolute -inset-[20%]"
|
|
135
|
+
style={{
|
|
136
|
+
background:
|
|
137
|
+
"radial-gradient(circle at 30% 50%, oklch(1 0 0 / 0.34) 0%, transparent 42%)",
|
|
138
|
+
animationName: POSTER_WALL_SWEEP_KEYFRAMES_ID,
|
|
139
|
+
animationDuration: "18s",
|
|
140
|
+
animationTimingFunction: "linear",
|
|
141
|
+
animationIterationCount: "infinite",
|
|
142
|
+
animationPlayState: isSweepPaused ? "paused" : "running",
|
|
143
|
+
}}
|
|
144
|
+
aria-hidden
|
|
145
|
+
/>
|
|
146
|
+
|
|
147
|
+
<div
|
|
148
|
+
className="absolute inset-0"
|
|
149
|
+
style={{
|
|
150
|
+
background: isCompact
|
|
151
|
+
? "linear-gradient(to top, oklch(0.2 0.01 60 / 0.93) 0%, oklch(0.2 0.01 60 / 0.7) 55%, oklch(0.2 0.01 60 / 0.35) 100%)"
|
|
152
|
+
: "linear-gradient(90deg, oklch(0.2 0.01 60 / 0.88) 0%, oklch(0.2 0.01 60 / 0.55) 55%, oklch(0.2 0.01 60 / 0.2) 100%)",
|
|
153
|
+
}}
|
|
154
|
+
aria-hidden
|
|
155
|
+
/>
|
|
156
|
+
|
|
157
|
+
<div
|
|
158
|
+
className={cn(
|
|
159
|
+
"relative flex h-full min-w-0 flex-col text-white",
|
|
160
|
+
isCompact ? "justify-end gap-[11px] p-5" : "justify-center gap-[15px] px-12"
|
|
161
|
+
)}
|
|
162
|
+
>
|
|
163
|
+
{children}
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export type PosterWallSkeletonProps = {
|
|
170
|
+
/** `compact` is the bottom-aligned mobile treatment (fewer, larger tiles, a top-fading veil). */
|
|
171
|
+
variant?: "default" | "compact";
|
|
172
|
+
className?: string;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/** The tile grid at rest — the sweeping light never renders in the loading state. */
|
|
176
|
+
export function PosterWallSkeleton({ variant = "default", className }: PosterWallSkeletonProps) {
|
|
177
|
+
const isCompact = variant === "compact";
|
|
178
|
+
const tileCount = isCompact ? POSTER_WALL_COMPACT_TILE_COUNT : POSTER_WALL_DESKTOP_TILE_COUNT;
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<div
|
|
182
|
+
aria-hidden="true"
|
|
183
|
+
className={cn(
|
|
184
|
+
"relative overflow-hidden rounded-[18px] bg-[oklch(0.22_0.012_60)]",
|
|
185
|
+
isCompact ? "h-[330px]" : "h-[420px]",
|
|
186
|
+
className
|
|
187
|
+
)}
|
|
188
|
+
>
|
|
189
|
+
<div
|
|
190
|
+
className={cn(
|
|
191
|
+
"absolute inset-0 grid auto-rows-fr gap-[2px] opacity-50",
|
|
192
|
+
isCompact ? "grid-cols-6" : "grid-cols-12"
|
|
193
|
+
)}
|
|
194
|
+
>
|
|
195
|
+
{Array.from({ length: tileCount }, (_, index) => (
|
|
196
|
+
<Skeleton key={index} className="h-full w-full rounded-none bg-white/10" />
|
|
197
|
+
))}
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<div
|
|
201
|
+
className="absolute inset-0"
|
|
202
|
+
style={{
|
|
203
|
+
background: isCompact
|
|
204
|
+
? "linear-gradient(to top, oklch(0.2 0.01 60 / 0.93) 0%, oklch(0.2 0.01 60 / 0.7) 55%, oklch(0.2 0.01 60 / 0.35) 100%)"
|
|
205
|
+
: "linear-gradient(90deg, oklch(0.2 0.01 60 / 0.88) 0%, oklch(0.2 0.01 60 / 0.55) 55%, oklch(0.2 0.01 60 / 0.2) 100%)",
|
|
206
|
+
}}
|
|
207
|
+
/>
|
|
208
|
+
|
|
209
|
+
<div
|
|
210
|
+
className={cn(
|
|
211
|
+
"relative flex h-full min-w-0 flex-col",
|
|
212
|
+
isCompact ? "justify-end gap-[11px] p-5" : "justify-center gap-[15px] px-12"
|
|
213
|
+
)}
|
|
214
|
+
>
|
|
215
|
+
<Skeleton className={cn("bg-white/15", isCompact ? "h-[11px] w-40" : "h-3 w-52")} />
|
|
216
|
+
<Skeleton className={cn("bg-white/20", isCompact ? "h-7 w-64" : "h-10 w-96")} />
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { QuietStretch, type QuietStretchDay } from "./quiet-stretch";
|
|
3
|
+
|
|
4
|
+
const days: QuietStretchDay[] = [
|
|
5
|
+
{ id: "mon", dayLabel: "Mon 7", count: 0 },
|
|
6
|
+
{ id: "tue", dayLabel: "Tue 8", count: 0 },
|
|
7
|
+
{ id: "wed", dayLabel: "Wed 9", count: 2 },
|
|
8
|
+
{ id: "thu", dayLabel: "Thu 10", count: 0 },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const meta: Meta<typeof QuietStretch> = {
|
|
12
|
+
title: "Events/QuietStretch",
|
|
13
|
+
component: QuietStretch,
|
|
14
|
+
tags: ["autodocs"],
|
|
15
|
+
args: {
|
|
16
|
+
label: "QUIET STRETCH",
|
|
17
|
+
days,
|
|
18
|
+
emptyLabel: "nothing yet",
|
|
19
|
+
actionLabel: "Expand",
|
|
20
|
+
},
|
|
21
|
+
decorators: [
|
|
22
|
+
(Story) => (
|
|
23
|
+
<div className="w-[640px]">
|
|
24
|
+
<Story />
|
|
25
|
+
</div>
|
|
26
|
+
),
|
|
27
|
+
],
|
|
28
|
+
parameters: {
|
|
29
|
+
docs: {
|
|
30
|
+
description: {
|
|
31
|
+
component:
|
|
32
|
+
"The collapsed run of quiet days on the timeline, summarising a run of near-empty days into one row. Responsive rather than a separate mobile component: past `sm` the label/days/action sit on one row, below it they stack.",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export default meta;
|
|
39
|
+
type Story = StoryObj<typeof QuietStretch>;
|
|
40
|
+
|
|
41
|
+
export const Default: Story = {
|
|
42
|
+
args: { onAction: () => {} },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** No link or handler — the action renders as static text, not a control. */
|
|
46
|
+
export const NonInteractive: Story = {};
|
|
47
|
+
|
|
48
|
+
export const AsLink: Story = {
|
|
49
|
+
args: { href: "#quiet-stretch" },
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Narrow viewport — label, days and action stack, days wrap at a tighter gap. */
|
|
53
|
+
export const Narrow: Story = {
|
|
54
|
+
args: { onAction: () => {} },
|
|
55
|
+
decorators: [
|
|
56
|
+
(Story) => (
|
|
57
|
+
<div className="w-[360px]">
|
|
58
|
+
<Story />
|
|
59
|
+
</div>
|
|
60
|
+
),
|
|
61
|
+
],
|
|
62
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { ChevronDown } from "lucide-react";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* QuietStretch — the collapsed run of quiet days on the timeline, summarising
|
|
9
|
+
* a run of near-empty days into one row so the reader doesn't scroll past
|
|
10
|
+
* dozens of "nothing on" headers to reach the next thing worth seeing.
|
|
11
|
+
*
|
|
12
|
+
* Responsive rather than a separate mobile component: past `sm` the label,
|
|
13
|
+
* days and action sit on one row; below it they stack, and the days list
|
|
14
|
+
* wraps at a tighter gap (14px vs. the desktop 22px).
|
|
15
|
+
*
|
|
16
|
+
* The action renders as a link whenever `href` is given, a button when only
|
|
17
|
+
* `onAction` is — same anchor rule as the rest of the events surface.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type QuietStretchLinkProps = {
|
|
21
|
+
href: string;
|
|
22
|
+
className?: string;
|
|
23
|
+
children: React.ReactNode;
|
|
24
|
+
onClick?: (mouseEvent: React.MouseEvent) => void;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function DefaultLink({ href, ...props }: QuietStretchLinkProps) {
|
|
28
|
+
return <a href={href} {...props} />;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type QuietStretchDay = {
|
|
32
|
+
id: string;
|
|
33
|
+
/** Already formatted and localised, e.g. "Mon 7". */
|
|
34
|
+
dayLabel: string;
|
|
35
|
+
count: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type QuietStretchProps = {
|
|
39
|
+
/** e.g. "QUIET STRETCH". */
|
|
40
|
+
label: string;
|
|
41
|
+
days: QuietStretchDay[];
|
|
42
|
+
/** Shown in place of a zero count, e.g. "nothing yet". */
|
|
43
|
+
emptyLabel: string;
|
|
44
|
+
/** e.g. "Expand". */
|
|
45
|
+
actionLabel: string;
|
|
46
|
+
href?: string;
|
|
47
|
+
linkComponent?: React.ComponentType<QuietStretchLinkProps>;
|
|
48
|
+
onAction?: (mouseEvent: React.MouseEvent) => void;
|
|
49
|
+
className?: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export function QuietStretch({
|
|
53
|
+
label,
|
|
54
|
+
days,
|
|
55
|
+
emptyLabel,
|
|
56
|
+
actionLabel,
|
|
57
|
+
href,
|
|
58
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
59
|
+
onAction,
|
|
60
|
+
className,
|
|
61
|
+
}: QuietStretchProps) {
|
|
62
|
+
const isInteractive = !!href || !!onAction;
|
|
63
|
+
|
|
64
|
+
const actionClasses = cn(
|
|
65
|
+
"inline-flex items-center gap-1 whitespace-nowrap rounded-sm text-[13px] font-bold text-primary sm:ml-auto",
|
|
66
|
+
isInteractive &&
|
|
67
|
+
cn(
|
|
68
|
+
"-m-1 cursor-pointer p-1",
|
|
69
|
+
"transition-colors duration-150 hover:text-primary/80 motion-reduce:transition-none",
|
|
70
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
71
|
+
)
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const action = (
|
|
75
|
+
<>
|
|
76
|
+
{actionLabel}
|
|
77
|
+
<ChevronDown className="h-3.5 w-3.5" aria-hidden />
|
|
78
|
+
</>
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div
|
|
83
|
+
className={cn(
|
|
84
|
+
"my-2 flex flex-col gap-3 rounded-xl bg-muted px-[22px] py-5",
|
|
85
|
+
"sm:flex-row sm:items-center sm:gap-4",
|
|
86
|
+
className
|
|
87
|
+
)}
|
|
88
|
+
>
|
|
89
|
+
<span className="whitespace-nowrap text-xs font-extrabold tracking-widest text-muted-foreground">
|
|
90
|
+
{label}
|
|
91
|
+
</span>
|
|
92
|
+
|
|
93
|
+
<div className="flex flex-wrap gap-3.5 text-[13px] sm:gap-[22px]">
|
|
94
|
+
{days.map((day) => (
|
|
95
|
+
<span key={day.id} className="whitespace-nowrap">
|
|
96
|
+
<span className="font-bold">{day.dayLabel}</span>{" "}
|
|
97
|
+
<span className="text-muted-foreground">{day.count > 0 ? day.count : emptyLabel}</span>
|
|
98
|
+
</span>
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
{href ? (
|
|
103
|
+
<LinkComponent href={href} className={actionClasses} onClick={onAction}>
|
|
104
|
+
{action}
|
|
105
|
+
</LinkComponent>
|
|
106
|
+
) : onAction ? (
|
|
107
|
+
<button type="button" className={actionClasses} onClick={onAction}>
|
|
108
|
+
{action}
|
|
109
|
+
</button>
|
|
110
|
+
) : (
|
|
111
|
+
<span className={actionClasses}>{action}</span>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
2
|
import { Rating } from "./rating";
|
|
3
3
|
|
|
4
|
-
const meta = {
|
|
4
|
+
const meta: Meta<typeof Rating> = {
|
|
5
5
|
title: "Components/Rating",
|
|
6
6
|
component: Rating,
|
|
7
7
|
tags: ["autodocs"],
|
|
8
|
-
}
|
|
8
|
+
};
|
|
9
9
|
|
|
10
10
|
export default meta;
|
|
11
|
-
type Story = StoryObj<typeof
|
|
11
|
+
type Story = StoryObj<typeof Rating>;
|
|
12
12
|
|
|
13
13
|
export const Default: Story = {
|
|
14
14
|
render: () => <Rating value={4} reviewCount={128} />,
|
|
@@ -10,7 +10,7 @@ const meta = {
|
|
|
10
10
|
} satisfies Meta<typeof SearchableSelect>;
|
|
11
11
|
|
|
12
12
|
export default meta;
|
|
13
|
-
type Story = StoryObj<typeof
|
|
13
|
+
type Story = StoryObj<typeof SearchableSelect>;
|
|
14
14
|
|
|
15
15
|
const frameworks = [
|
|
16
16
|
{ value: "next", label: "Next.js", description: "The React Framework" },
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { SectionHeader, SectionHeaderSkeleton } from "./section-header";
|
|
3
|
+
|
|
4
|
+
// Explicitly annotated rather than `satisfies`: the meta-level decorator makes
|
|
5
|
+
// the inferred type reference storybook's internal CSF module, which tsc cannot
|
|
6
|
+
// name portably (TS2742).
|
|
7
|
+
const meta: Meta<typeof SectionHeader> = {
|
|
8
|
+
title: "Events/SectionHeader",
|
|
9
|
+
component: SectionHeader,
|
|
10
|
+
tags: ["autodocs"],
|
|
11
|
+
args: { title: "Tonight" },
|
|
12
|
+
decorators: [
|
|
13
|
+
(Story) => (
|
|
14
|
+
<div className="w-[360px]">
|
|
15
|
+
<Story />
|
|
16
|
+
</div>
|
|
17
|
+
),
|
|
18
|
+
],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export default meta;
|
|
22
|
+
type Story = StoryObj<typeof SectionHeader>;
|
|
23
|
+
|
|
24
|
+
export const Default: Story = {};
|
|
25
|
+
|
|
26
|
+
export const WithCount: Story = {
|
|
27
|
+
args: { count: 12 },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const WithAction: Story = {
|
|
31
|
+
args: { count: 12, action: "See all", actionHref: "/events" },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const WithSubtitle: Story = {
|
|
35
|
+
args: {
|
|
36
|
+
title: "This weekend",
|
|
37
|
+
subtitle: "Curated by the team, updated every Friday.",
|
|
38
|
+
action: "See all",
|
|
39
|
+
actionHref: "/events/weekend",
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** `onAction` renders a button instead of a link — used inside a sheet/drawer. */
|
|
44
|
+
export const ButtonAction: Story = {
|
|
45
|
+
args: { action: "Reset", onAction: () => {} },
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const AsH1: Story = {
|
|
49
|
+
args: { as: "h1", title: "All events" },
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Loading state — identical geometry to `Default`, so nothing shifts on data arrival. */
|
|
53
|
+
export const Loading: Story = {
|
|
54
|
+
render: () => <SectionHeaderSkeleton />,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Reserves space for count, action and subtitle — the richest header shape. */
|
|
58
|
+
export const LoadingFull: Story = {
|
|
59
|
+
render: () => <SectionHeaderSkeleton showCount showAction showSubtitle />,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Loading above loaded, so any geometry drift is visible at a glance. */
|
|
63
|
+
export const LoadingVsLoaded: Story = {
|
|
64
|
+
render: () => (
|
|
65
|
+
<div>
|
|
66
|
+
<SectionHeaderSkeleton showCount showAction />
|
|
67
|
+
<SectionHeader title="Tonight" count={12} action="See all" actionHref="/events" />
|
|
68
|
+
</div>
|
|
69
|
+
),
|
|
70
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { ChevronRight } from "lucide-react";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
import { Skeleton } from "./skeleton";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* SectionHeader — the title row every rail, list and hub section opens with:
|
|
10
|
+
* title (+ optional count), an optional trailing action, and an optional
|
|
11
|
+
* subtitle line beneath.
|
|
12
|
+
*
|
|
13
|
+
* Renders a link when `actionHref` is given, a button when only `onAction`
|
|
14
|
+
* is, and no action at all otherwise — same href/onClick contract as
|
|
15
|
+
* `EventCard`. No app schema, no i18n, no app hooks.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type SectionHeaderLinkProps = {
|
|
19
|
+
href: string;
|
|
20
|
+
className?: string;
|
|
21
|
+
children: React.ReactNode;
|
|
22
|
+
onClick?: (mouseEvent: React.MouseEvent) => void;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function DefaultLink({ href, ...props }: SectionHeaderLinkProps) {
|
|
26
|
+
return <a href={href} {...props} />;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type SectionHeaderProps = {
|
|
30
|
+
title: string;
|
|
31
|
+
/** Renders inline after the title in muted text, e.g. "12". */
|
|
32
|
+
count?: number;
|
|
33
|
+
/** Sub-text below the title row. */
|
|
34
|
+
subtitle?: string;
|
|
35
|
+
/** Trailing action label, e.g. "See all". */
|
|
36
|
+
action?: string;
|
|
37
|
+
actionHref?: string;
|
|
38
|
+
/** Defaults to a plain `<a>`. Pass `next/link` in a Next app. */
|
|
39
|
+
linkComponent?: React.ComponentType<SectionHeaderLinkProps>;
|
|
40
|
+
onAction?: (mouseEvent: React.MouseEvent) => void;
|
|
41
|
+
/**
|
|
42
|
+
* Heading level for `title`, or `"p"` / `"div"` for a section opener that
|
|
43
|
+
* should NOT enter the document outline — e.g. an index whose own group
|
|
44
|
+
* headings are the page's semantic `h2`s, where a generic opener above them
|
|
45
|
+
* would dilute the headings that matter. Defaults to `"h2"`.
|
|
46
|
+
*/
|
|
47
|
+
as?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "div";
|
|
48
|
+
className?: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const ACTION_CLASSES = cn(
|
|
52
|
+
"inline-flex shrink-0 items-center gap-0.5 rounded-sm text-[14px] text-muted-foreground",
|
|
53
|
+
"transition-colors duration-150 hover:text-foreground motion-reduce:transition-none",
|
|
54
|
+
"cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
export function SectionHeader({
|
|
58
|
+
title,
|
|
59
|
+
count,
|
|
60
|
+
subtitle,
|
|
61
|
+
action,
|
|
62
|
+
actionHref,
|
|
63
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
64
|
+
onAction,
|
|
65
|
+
as: Heading = "h2",
|
|
66
|
+
className,
|
|
67
|
+
}: SectionHeaderProps) {
|
|
68
|
+
const actionIcon = <ChevronRight className="h-3.5 w-3.5" aria-hidden />;
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div className={cn("flex flex-col mb-3.5", className)}>
|
|
72
|
+
<div className="flex items-center justify-between gap-2">
|
|
73
|
+
<div className="flex min-w-0 items-baseline">
|
|
74
|
+
<Heading className="truncate text-[17px] font-bold tracking-[-0.01em] text-foreground md:text-[18px]">
|
|
75
|
+
{title}
|
|
76
|
+
</Heading>
|
|
77
|
+
{count !== undefined && (
|
|
78
|
+
<span className="ml-0.5 shrink-0 text-[12px] text-muted-foreground">{count}</span>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
{action &&
|
|
83
|
+
(actionHref ? (
|
|
84
|
+
<LinkComponent href={actionHref} className={ACTION_CLASSES} onClick={onAction}>
|
|
85
|
+
{action}
|
|
86
|
+
{actionIcon}
|
|
87
|
+
</LinkComponent>
|
|
88
|
+
) : onAction ? (
|
|
89
|
+
<button type="button" className={ACTION_CLASSES} onClick={onAction}>
|
|
90
|
+
{action}
|
|
91
|
+
{actionIcon}
|
|
92
|
+
</button>
|
|
93
|
+
) : null)}
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
{subtitle && (
|
|
97
|
+
<p className="mt-0.5 text-[13px] text-muted-foreground md:text-[14px]">{subtitle}</p>
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type SectionHeaderSkeletonProps = {
|
|
104
|
+
/** Shows the inline count placeholder, matching `SectionHeaderProps["count"]`. */
|
|
105
|
+
showCount?: boolean;
|
|
106
|
+
/** Shows the trailing action placeholder, matching `SectionHeaderProps["action"]`. */
|
|
107
|
+
showAction?: boolean;
|
|
108
|
+
/** Shows the subtitle placeholder, matching `SectionHeaderProps["subtitle"]`. */
|
|
109
|
+
showSubtitle?: boolean;
|
|
110
|
+
className?: string;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** Mirrors `<SectionHeader>`'s geometry exactly so nothing shifts when data lands. */
|
|
114
|
+
export function SectionHeaderSkeleton({
|
|
115
|
+
showCount,
|
|
116
|
+
showAction,
|
|
117
|
+
showSubtitle,
|
|
118
|
+
className,
|
|
119
|
+
}: SectionHeaderSkeletonProps) {
|
|
120
|
+
return (
|
|
121
|
+
<div aria-hidden="true" className={cn("flex flex-col mb-3.5", className)}>
|
|
122
|
+
<div className="flex items-center justify-between gap-2">
|
|
123
|
+
<div className="flex min-w-0 items-baseline gap-1">
|
|
124
|
+
<Skeleton className="h-[17px] w-32 md:h-[18px]" />
|
|
125
|
+
{showCount && <Skeleton className="h-3 w-4" />}
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
{showAction && <Skeleton className="h-3.5 w-16" />}
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
{showSubtitle && <Skeleton className="mt-0.5 h-[13px] w-48 md:h-3.5" />}
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|