@bearmenu/ui 0.8.4 → 0.8.7
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-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/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/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/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 +164 -0
- package/dist/components/event-compact-row.d.ts +73 -0
- package/dist/components/event-compact-row.js +134 -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 +122 -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/motion-icon-controls.js +3 -379
- 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/tag-group.js +3 -26
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/tag.js +1 -1
- 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/package.json +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/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/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/event-card.stories.tsx +248 -0
- package/src/components/event-card.test.tsx +188 -0
- package/src/components/event-card.tsx +329 -0
- package/src/components/event-compact-row.stories.tsx +221 -0
- package/src/components/event-compact-row.test.tsx +51 -0
- package/src/components/event-compact-row.tsx +270 -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 +137 -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/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/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/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/skeleton.tsx +18 -0
- 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/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
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
import { Skeleton } from "./skeleton";
|
|
6
|
+
import type { DayRailChipLabels } from "./event-types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* DayRailChip — one day in the horizontally scrolling day rail.
|
|
10
|
+
*
|
|
11
|
+
* Three states the design distinguishes and that are easy to collapse by
|
|
12
|
+
* accident:
|
|
13
|
+
* - ACTIVE: FILLED — accent ground, accent-foreground label, count and dot.
|
|
14
|
+
* - HAS EVENTS: card ground, normal foreground, an accent density dot.
|
|
15
|
+
* - EMPTY: muted text, NO dot, an em dash instead of "0". A day with nothing
|
|
16
|
+
* on stays in the rail — ten of fourteen days routinely read empty, and
|
|
17
|
+
* hiding them makes the rail lie about how far it reaches — but the caller
|
|
18
|
+
* decides whether it is interactive, and a rail that NAVIGATES has nothing
|
|
19
|
+
* to send an empty day to.
|
|
20
|
+
*
|
|
21
|
+
* ── Why active is filled ──────────────────────────────────────────────────
|
|
22
|
+
* It was an accent border and accent text. Measured against the real tokens
|
|
23
|
+
* that is a 1px hairline plus a label at 1.16:1 against its inactive
|
|
24
|
+
* neighbours in light mode — and in dark, where `--primary` (0.66) is DIMMER
|
|
25
|
+
* than `--foreground` (0.96), the active chip read as the recessed one at
|
|
26
|
+
* 2.86:1. Survivable while a large pinned day header also announced the day;
|
|
27
|
+
* not survivable once the rail became the only indicator.
|
|
28
|
+
*
|
|
29
|
+
* An eye scanning fourteen near-identical 62px boxes horizontally, in
|
|
30
|
+
* peripheral vision, while the page moves, resolves AREA and LUMINANCE STEP
|
|
31
|
+
* long before it resolves the hue of a hairline or the weight of 11.5px type.
|
|
32
|
+
* The fill presents ~3,100px² at 5.97:1 (light) / 6.09:1 (dark). A tinted
|
|
33
|
+
* ground would not do it: `bg-accent/10` and `--accent-subtle` both measure
|
|
34
|
+
* ~1.15:1 against the chip's own card, which is no signal at all.
|
|
35
|
+
*
|
|
36
|
+
* `--accent`, deliberately, not `--gradient-primary`: white on that gradient's
|
|
37
|
+
* top stop is 4.45:1, under AA for text, and the gradient has no `.dark`
|
|
38
|
+
* override so the chip's ink direction would invert against the rest of the
|
|
39
|
+
* dark theme. `--accent` also already means "current" here — the bottom nav's
|
|
40
|
+
* active tab uses it.
|
|
41
|
+
*
|
|
42
|
+
* The border stays 1px in BOTH states. Thickening it on active would change
|
|
43
|
+
* the box model, and these chips sit in an equal-width grid that would reflow
|
|
44
|
+
* on every scroll-spy tick.
|
|
45
|
+
*
|
|
46
|
+
* Renders a link when `href` is given, a button when only `onClick` is, and a
|
|
47
|
+
* plain div otherwise. The button announces `aria-current`, not
|
|
48
|
+
* `aria-pressed`: this is the current item in a set of days, not a toggle.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
export type DayRailChipLinkProps = {
|
|
52
|
+
href: string;
|
|
53
|
+
className?: string;
|
|
54
|
+
children: React.ReactNode;
|
|
55
|
+
"aria-current"?: "page";
|
|
56
|
+
onClick?: () => void;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function DefaultLink({ href, ...props }: DayRailChipLinkProps) {
|
|
60
|
+
return <a href={href} {...props} />;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type DayRailChipProps = {
|
|
64
|
+
/** Short weekday label, already localised — "Today", "Fri", "Sâm". */
|
|
65
|
+
label: string;
|
|
66
|
+
/** Day of month, as displayed. */
|
|
67
|
+
dayNumber: string;
|
|
68
|
+
/** Events on this day, after any active filter. */
|
|
69
|
+
count: number;
|
|
70
|
+
isActive?: boolean;
|
|
71
|
+
/** Keeps the bold weight when inactive. */
|
|
72
|
+
isWeekend?: boolean;
|
|
73
|
+
labels: DayRailChipLabels;
|
|
74
|
+
href?: string;
|
|
75
|
+
linkComponent?: React.ComponentType<DayRailChipLinkProps>;
|
|
76
|
+
onClick?: () => void;
|
|
77
|
+
className?: string;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export function DayRailChip({
|
|
81
|
+
label,
|
|
82
|
+
dayNumber,
|
|
83
|
+
count,
|
|
84
|
+
isActive,
|
|
85
|
+
isWeekend,
|
|
86
|
+
labels,
|
|
87
|
+
href,
|
|
88
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
89
|
+
onClick,
|
|
90
|
+
className,
|
|
91
|
+
}: DayRailChipProps) {
|
|
92
|
+
const hasEvents = count > 0;
|
|
93
|
+
|
|
94
|
+
const rootClasses = cn(
|
|
95
|
+
"w-[62px] shrink-0 rounded-xl border px-1 py-2 text-center",
|
|
96
|
+
"transition-colors duration-150 motion-reduce:transition-none",
|
|
97
|
+
isActive ? "border-accent bg-accent" : "border-border bg-card",
|
|
98
|
+
(href || onClick) &&
|
|
99
|
+
// `ring-offset-background` is not decoration: Tailwind's default offset
|
|
100
|
+
// colour is white, which painted a white halo around a focused chip on
|
|
101
|
+
// the dark rail.
|
|
102
|
+
"cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
103
|
+
className
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const body = (
|
|
107
|
+
<>
|
|
108
|
+
<div
|
|
109
|
+
className={cn(
|
|
110
|
+
"text-[11.5px]",
|
|
111
|
+
isActive || isWeekend ? "font-bold" : "font-semibold",
|
|
112
|
+
isActive
|
|
113
|
+
? "text-accent-foreground"
|
|
114
|
+
: hasEvents
|
|
115
|
+
? "text-foreground"
|
|
116
|
+
// NOT `/70`, which measured 2.96:1 light and 4.17:1 dark — an AA
|
|
117
|
+
// failure in both. An empty day is inert content, not a disabled
|
|
118
|
+
// form control, so nothing exempts it. The em dash and the
|
|
119
|
+
// missing dot already say "empty"; `muted-foreground` against
|
|
120
|
+
// `foreground` is still an 18.8 → 5.5 step and plainly quieter.
|
|
121
|
+
: "text-muted-foreground"
|
|
122
|
+
)}
|
|
123
|
+
>
|
|
124
|
+
{label} {dayNumber}
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<div className="mt-[3px] flex items-center justify-center gap-1">
|
|
128
|
+
{/* Kept on the active chip, not dropped: losing it would shift the
|
|
129
|
+
count 5px sideways every time the day changes under the scroll spy.
|
|
130
|
+
Inactive is full `bg-accent`, not `bg-primary/40` — at 40% alpha it
|
|
131
|
+
measured 2.46:1 / 1.93:1 (invisible at 4px), and in light mode
|
|
132
|
+
`--primary` is near-black, so the "warm density dot" was grey. */}
|
|
133
|
+
{hasEvents && (
|
|
134
|
+
<span
|
|
135
|
+
className={cn(
|
|
136
|
+
"h-1 w-1 rounded-full",
|
|
137
|
+
isActive ? "bg-accent-foreground" : "bg-accent"
|
|
138
|
+
)}
|
|
139
|
+
aria-hidden
|
|
140
|
+
/>
|
|
141
|
+
)}
|
|
142
|
+
<span
|
|
143
|
+
className={cn(
|
|
144
|
+
// Full opacity on active, never dimmed: `/80` lands at 4.48:1, an
|
|
145
|
+
// AA fail by 0.02. Hierarchy inside a 50px box comes from the
|
|
146
|
+
// label's weight and the type-size step, which is enough.
|
|
147
|
+
"font-mono text-[10.5px] tabular-nums",
|
|
148
|
+
isActive ? "text-accent-foreground" : "text-muted-foreground"
|
|
149
|
+
)}
|
|
150
|
+
>
|
|
151
|
+
{hasEvents ? count : <span aria-label={labels.noEvents}>—</span>}
|
|
152
|
+
</span>
|
|
153
|
+
</div>
|
|
154
|
+
</>
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
if (href) {
|
|
158
|
+
return (
|
|
159
|
+
<LinkComponent
|
|
160
|
+
href={href}
|
|
161
|
+
className={rootClasses}
|
|
162
|
+
aria-current={isActive ? "page" : undefined}
|
|
163
|
+
onClick={onClick}
|
|
164
|
+
>
|
|
165
|
+
{body}
|
|
166
|
+
</LinkComponent>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (onClick) {
|
|
171
|
+
return (
|
|
172
|
+
<button
|
|
173
|
+
type="button"
|
|
174
|
+
className={rootClasses}
|
|
175
|
+
// `aria-current`, not `aria-pressed`. The rail used to FILTER, where a
|
|
176
|
+
// toggle semantic was right; it now navigates, and this is the current
|
|
177
|
+
// day in a set of days.
|
|
178
|
+
aria-current={isActive ? "true" : undefined}
|
|
179
|
+
onClick={onClick}
|
|
180
|
+
>
|
|
181
|
+
{body}
|
|
182
|
+
</button>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return <div className={rootClasses}>{body}</div>;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export type DayRailChipSkeletonProps = {
|
|
190
|
+
className?: string;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Mirrors one `<DayRailChip>`'s footprint (62×50, 12px radius — the source
|
|
195
|
+
* design's own day-rail skeleton geometry, which also happens to match this
|
|
196
|
+
* chip's rendered size: label + count rows inside `py-2` padding). The caller
|
|
197
|
+
* repeats it with the rail's own `gap-2` to build the loading rail.
|
|
198
|
+
*/
|
|
199
|
+
export function DayRailChipSkeleton({ className }: DayRailChipSkeletonProps) {
|
|
200
|
+
return <Skeleton aria-hidden="true" className={cn("h-[50px] w-[62px] shrink-0", className)} />;
|
|
201
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { DriftRail } from "./drift-rail";
|
|
3
|
+
import { FeaturedRailItem } from "./featured-rail-item";
|
|
4
|
+
|
|
5
|
+
const CHIP_LABELS = [
|
|
6
|
+
"Live music",
|
|
7
|
+
"Rooftop",
|
|
8
|
+
"Free entry",
|
|
9
|
+
"Late night",
|
|
10
|
+
"DJ set",
|
|
11
|
+
"Comedy",
|
|
12
|
+
"Art walk",
|
|
13
|
+
"Brunch",
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
function Chip({ label }: { label: string }) {
|
|
17
|
+
return (
|
|
18
|
+
<span className="inline-flex h-9 shrink-0 items-center rounded-full border border-border bg-card px-4 text-sm font-medium">
|
|
19
|
+
{label}
|
|
20
|
+
</span>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Explicitly annotated rather than `satisfies`: the meta-level decorator makes
|
|
25
|
+
// the inferred type reference storybook's internal CSF module, which tsc cannot
|
|
26
|
+
// name portably (TS2742).
|
|
27
|
+
const meta: Meta<typeof DriftRail> = {
|
|
28
|
+
title: "Events/DriftRail",
|
|
29
|
+
component: DriftRail,
|
|
30
|
+
tags: ["autodocs"],
|
|
31
|
+
parameters: {
|
|
32
|
+
docs: {
|
|
33
|
+
description: {
|
|
34
|
+
component:
|
|
35
|
+
"An infinite horizontal marquee. Duplicates its children once so a `translateX(-50%)` loop is seamless — pass a fixed, ordered list. Pauses on hover, pauses when scrolled off-screen (`IntersectionObserver`), and stops outright under `prefers-reduced-motion`.",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export default meta;
|
|
42
|
+
type Story = StoryObj<typeof DriftRail>;
|
|
43
|
+
|
|
44
|
+
export const Chips: Story = {
|
|
45
|
+
render: () => (
|
|
46
|
+
<div className="max-w-[420px]">
|
|
47
|
+
<DriftRail>
|
|
48
|
+
{CHIP_LABELS.map((label) => (
|
|
49
|
+
<Chip key={label} label={label} />
|
|
50
|
+
))}
|
|
51
|
+
</DriftRail>
|
|
52
|
+
</div>
|
|
53
|
+
),
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Two rails at once — the design's opposing-direction pair, forward at 42s and reverse at 52s. */
|
|
57
|
+
export const OpposingPair: Story = {
|
|
58
|
+
render: () => (
|
|
59
|
+
<div className="max-w-[420px] space-y-4">
|
|
60
|
+
<DriftRail>
|
|
61
|
+
{CHIP_LABELS.slice(0, 5).map((label) => (
|
|
62
|
+
<Chip key={label} label={label} />
|
|
63
|
+
))}
|
|
64
|
+
</DriftRail>
|
|
65
|
+
<DriftRail reverse durationSeconds={52}>
|
|
66
|
+
{CHIP_LABELS.slice(3).map((label) => (
|
|
67
|
+
<Chip key={label} label={label} />
|
|
68
|
+
))}
|
|
69
|
+
</DriftRail>
|
|
70
|
+
</div>
|
|
71
|
+
),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** Wrapping the featured rail's own card — the rail supplies the motion, the card stays static content. */
|
|
75
|
+
export const WithFeaturedItems: Story = {
|
|
76
|
+
render: () => (
|
|
77
|
+
<div className="max-w-[420px]">
|
|
78
|
+
<DriftRail durationSeconds={30}>
|
|
79
|
+
{["Warehouse Rave", "Jazz on the Rooftop", "Open Mic Night", "Street Food Market"].map(
|
|
80
|
+
(title) => (
|
|
81
|
+
<FeaturedRailItem
|
|
82
|
+
key={title}
|
|
83
|
+
title={title}
|
|
84
|
+
venue="Atelier Café"
|
|
85
|
+
note="Sat 5 · 20:00"
|
|
86
|
+
/>
|
|
87
|
+
)
|
|
88
|
+
)}
|
|
89
|
+
</DriftRail>
|
|
90
|
+
</div>
|
|
91
|
+
),
|
|
92
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useId, useRef } from "react";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* DriftRail — an infinite horizontal marquee for logos, tag chips or small
|
|
8
|
+
* poster tiles. The caller passes one set of children; the component renders
|
|
9
|
+
* a second, `aria-hidden` copy right after it so a `translateX(-50%)` loop is
|
|
10
|
+
* seamless. Because the loop depends on the duplicate being pixel-identical
|
|
11
|
+
* to the first pass, keep `children` a fixed, ordered list — no per-render
|
|
12
|
+
* randomisation.
|
|
13
|
+
*
|
|
14
|
+
* Motion is scoped to this instance via a generated id (no shared stylesheet,
|
|
15
|
+
* no class collisions between multiple rails on one page) and is paused two
|
|
16
|
+
* ways, per the source design: an `IntersectionObserver` stops the loop the
|
|
17
|
+
* moment the rail scrolls off-screen, and `prefers-reduced-motion: reduce`
|
|
18
|
+
* removes the animation outright rather than merely slowing it.
|
|
19
|
+
*/
|
|
20
|
+
export type DriftRailProps = {
|
|
21
|
+
children: React.ReactNode;
|
|
22
|
+
/** Runs the loop right-to-left instead of left-to-right. */
|
|
23
|
+
reverse?: boolean;
|
|
24
|
+
durationSeconds?: number;
|
|
25
|
+
/** Pauses the loop while the pointer hovers the rail. */
|
|
26
|
+
pauseOnHover?: boolean;
|
|
27
|
+
className?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const DRIFT_RAIL_DEFAULT_DURATION_SECONDS = 42;
|
|
31
|
+
|
|
32
|
+
export function DriftRail({
|
|
33
|
+
children,
|
|
34
|
+
reverse = false,
|
|
35
|
+
durationSeconds = DRIFT_RAIL_DEFAULT_DURATION_SECONDS,
|
|
36
|
+
pauseOnHover = true,
|
|
37
|
+
className,
|
|
38
|
+
}: DriftRailProps) {
|
|
39
|
+
const scopeId = useId().replace(/[^a-zA-Z0-9]/g, "");
|
|
40
|
+
const scopeClass = `drift-rail-${scopeId}`;
|
|
41
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
const node = containerRef.current;
|
|
45
|
+
if (!node || typeof IntersectionObserver === "undefined") return undefined;
|
|
46
|
+
|
|
47
|
+
const observer = new IntersectionObserver(
|
|
48
|
+
([entry]) => {
|
|
49
|
+
node.dataset.offscreen = entry.isIntersecting ? "false" : "true";
|
|
50
|
+
},
|
|
51
|
+
{ threshold: 0 }
|
|
52
|
+
);
|
|
53
|
+
observer.observe(node);
|
|
54
|
+
return () => observer.disconnect();
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div ref={containerRef} className={cn(scopeClass, "overflow-hidden py-1.5", className)}>
|
|
59
|
+
<style>{`
|
|
60
|
+
.${scopeClass} .drift-rail-run {
|
|
61
|
+
animation: ${scopeClass}-run ${durationSeconds}s linear infinite;
|
|
62
|
+
animation-direction: ${reverse ? "reverse" : "normal"};
|
|
63
|
+
}
|
|
64
|
+
.${scopeClass}[data-offscreen="true"] .drift-rail-run {
|
|
65
|
+
animation-play-state: paused;
|
|
66
|
+
}
|
|
67
|
+
@keyframes ${scopeClass}-run {
|
|
68
|
+
from { transform: translateX(0); }
|
|
69
|
+
to { transform: translateX(-50%); }
|
|
70
|
+
}
|
|
71
|
+
@media (prefers-reduced-motion: reduce) {
|
|
72
|
+
.${scopeClass} .drift-rail-run {
|
|
73
|
+
animation: none;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
${pauseOnHover ? `.${scopeClass}:hover .drift-rail-run { animation-play-state: paused; }` : ""}
|
|
77
|
+
`}</style>
|
|
78
|
+
<div className="drift-rail-run flex w-max items-stretch gap-3.5 motion-reduce:[animation:none]">
|
|
79
|
+
{children}
|
|
80
|
+
<div className="contents" aria-hidden="true">
|
|
81
|
+
{children}
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { Flame, Music } from "lucide-react";
|
|
3
|
+
import { EventCard, EventCardSkeleton } from "./event-card";
|
|
4
|
+
import type { EventCardLabels, EventFormatters, EventPreview } from "./event-types";
|
|
5
|
+
|
|
6
|
+
const labels: EventCardLabels = {
|
|
7
|
+
free: "Free",
|
|
8
|
+
liveNow: "Live now",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const formatters: EventFormatters = {
|
|
12
|
+
time: (iso) =>
|
|
13
|
+
new Date(iso).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }),
|
|
14
|
+
day: (iso) =>
|
|
15
|
+
new Date(iso).toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" }),
|
|
16
|
+
price: (amount) => `${amount} RON`,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const event: EventPreview = {
|
|
20
|
+
id: "e1",
|
|
21
|
+
title: "Psihotrop — From the Beginning to Infinity",
|
|
22
|
+
location: "Atelier Café",
|
|
23
|
+
place_name: "Atelier Café",
|
|
24
|
+
place_slug: "atelier-cafe",
|
|
25
|
+
starts_at: "2026-09-05T20:00:00",
|
|
26
|
+
entry_fee: 50,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Explicitly annotated rather than `satisfies`: the meta-level decorator makes
|
|
30
|
+
// the inferred type reference storybook's internal CSF module, which tsc cannot
|
|
31
|
+
// name portably (TS2742).
|
|
32
|
+
const meta: Meta<typeof EventCard> = {
|
|
33
|
+
title: "Events/EventCard",
|
|
34
|
+
component: EventCard,
|
|
35
|
+
tags: ["autodocs"],
|
|
36
|
+
args: { event, labels, formatters },
|
|
37
|
+
decorators: [
|
|
38
|
+
(Story) => (
|
|
39
|
+
<div className="w-[220px]">
|
|
40
|
+
<Story />
|
|
41
|
+
</div>
|
|
42
|
+
),
|
|
43
|
+
],
|
|
44
|
+
parameters: {
|
|
45
|
+
docs: {
|
|
46
|
+
description: {
|
|
47
|
+
component:
|
|
48
|
+
"Pass `href` **and** `onClick` in production: the anchor keeps the card crawlable and middle-clickable, while `onClick` (calling `preventDefault()`) opens the detail sheet. A card rendered as a bare `<button>` is what stripped every event link out of the hub's rendered DOM.",
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export default meta;
|
|
55
|
+
type Story = StoryObj<typeof EventCard>;
|
|
56
|
+
|
|
57
|
+
export const Default: Story = {};
|
|
58
|
+
|
|
59
|
+
/** `entry_fee: 0` is explicitly free and earns the green pill. */
|
|
60
|
+
export const FreeEntry: Story = {
|
|
61
|
+
args: { event: { ...event, entry_fee: 0 } },
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** An absent `entry_fee` is *unknown*, not free — no pill, no price. */
|
|
65
|
+
export const UnknownEntryFee: Story = {
|
|
66
|
+
args: { event: { ...event, entry_fee: undefined } },
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Overlay pills: hype badge top-left, category tag bottom-left. */
|
|
70
|
+
export const WithBadgeAndTag: Story = {
|
|
71
|
+
args: {
|
|
72
|
+
event: { ...event, entry_fee: 0 },
|
|
73
|
+
badge: (
|
|
74
|
+
<>
|
|
75
|
+
<Flame className="h-3 w-3" aria-hidden />
|
|
76
|
+
Busy
|
|
77
|
+
</>
|
|
78
|
+
),
|
|
79
|
+
tagLabel: (
|
|
80
|
+
<>
|
|
81
|
+
<Music className="h-3 w-3" aria-hidden />
|
|
82
|
+
Live music
|
|
83
|
+
</>
|
|
84
|
+
),
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* `tags` — display labels rendered beneath the venue/meta line, distinct from
|
|
90
|
+
* `tagLabel`'s single overlay pill on the poster.
|
|
91
|
+
*/
|
|
92
|
+
export const WithTags: Story = {
|
|
93
|
+
args: { tags: ["Live Music", "Rock", "18+"] },
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** The live line is composed by the caller — clause order differs per locale. */
|
|
97
|
+
export const LiveNow: Story = {
|
|
98
|
+
args: { liveStatus: "Live now · ends around 23:00" },
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** The featured slot: larger title. Still uncut. */
|
|
102
|
+
export const Emphasis: Story = {
|
|
103
|
+
args: { emphasis: true },
|
|
104
|
+
decorators: [
|
|
105
|
+
(Story) => (
|
|
106
|
+
<div className="w-[320px]">
|
|
107
|
+
<Story />
|
|
108
|
+
</div>
|
|
109
|
+
),
|
|
110
|
+
],
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const LONG_TITLE_EVENT: EventPreview = {
|
|
114
|
+
...event,
|
|
115
|
+
title: "Movie Night: Futni må ikke være så vanskelig — an outdoor screening with live score",
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* THE TITLE IS NEVER TRUNCATED, in any variant. Real listings run long
|
|
120
|
+
* ("Movie Night: Futni m…", "Juventus vs AC Milan…" were the reported bug),
|
|
121
|
+
* and the title is the one thing the card exists to say. It wraps to as many
|
|
122
|
+
* lines as it needs and the card grows downward.
|
|
123
|
+
*/
|
|
124
|
+
export const LongTitle: Story = {
|
|
125
|
+
args: { event: LONG_TITLE_EVENT },
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/** The same long title across every variant, so no combination quietly clips it. */
|
|
129
|
+
export const LongTitleEveryVariant: Story = {
|
|
130
|
+
render: () => (
|
|
131
|
+
<div className="flex items-start gap-4">
|
|
132
|
+
<div className="w-[150px]">
|
|
133
|
+
<EventCard
|
|
134
|
+
event={LONG_TITLE_EVENT}
|
|
135
|
+
labels={labels}
|
|
136
|
+
formatters={formatters}
|
|
137
|
+
posterAspect="4/3"
|
|
138
|
+
/>
|
|
139
|
+
</div>
|
|
140
|
+
<div className="w-[150px]">
|
|
141
|
+
<EventCard
|
|
142
|
+
event={LONG_TITLE_EVENT}
|
|
143
|
+
labels={labels}
|
|
144
|
+
formatters={formatters}
|
|
145
|
+
posterAspect="3/4"
|
|
146
|
+
/>
|
|
147
|
+
</div>
|
|
148
|
+
<div className="w-[150px]">
|
|
149
|
+
<EventCard
|
|
150
|
+
event={LONG_TITLE_EVENT}
|
|
151
|
+
labels={labels}
|
|
152
|
+
formatters={formatters}
|
|
153
|
+
posterAspect="4/5"
|
|
154
|
+
/>
|
|
155
|
+
</div>
|
|
156
|
+
<div className="w-[220px]">
|
|
157
|
+
<EventCard event={LONG_TITLE_EVENT} labels={labels} formatters={formatters} emphasis />
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
),
|
|
161
|
+
decorators: [
|
|
162
|
+
(Story) => (
|
|
163
|
+
<div className="w-[760px]">
|
|
164
|
+
<Story />
|
|
165
|
+
</div>
|
|
166
|
+
),
|
|
167
|
+
],
|
|
168
|
+
parameters: {
|
|
169
|
+
docs: {
|
|
170
|
+
description: {
|
|
171
|
+
story:
|
|
172
|
+
"`items-start` on the row is what keeps a three-line title from stretching its neighbours — the pattern any grid of these cards should copy.",
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/** 3/4 poster — the hub's original portrait deck/rail/tape ratio. */
|
|
179
|
+
export const PortraitPoster: Story = {
|
|
180
|
+
args: { posterAspect: "3/4", showPrice: true },
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 4:3 (the house shape, shared with dish/drink/place) beside 4:5 (the
|
|
185
|
+
* Instagram source ratio, used by the hub's editorial poster surfaces —
|
|
186
|
+
* `CategoryDeck`, `PosterTape`, `Coverflow`). Side by side so the crop
|
|
187
|
+
* difference is visible rather than guessed at.
|
|
188
|
+
*/
|
|
189
|
+
export const HouseVsInstagramAspect: Story = {
|
|
190
|
+
render: () => (
|
|
191
|
+
<div className="flex gap-4">
|
|
192
|
+
<div className="w-[150px]">
|
|
193
|
+
<EventCard event={event} labels={labels} formatters={formatters} posterAspect="4/3" />
|
|
194
|
+
</div>
|
|
195
|
+
<div className="w-[150px]">
|
|
196
|
+
<EventCard event={event} labels={labels} formatters={formatters} posterAspect="4/5" />
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
),
|
|
200
|
+
decorators: [
|
|
201
|
+
(Story) => (
|
|
202
|
+
<div className="w-[340px]">
|
|
203
|
+
<Story />
|
|
204
|
+
</div>
|
|
205
|
+
),
|
|
206
|
+
],
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/** The timeline supplies its own hour heading, so the card drops the meta line. */
|
|
210
|
+
export const InsideAnHourGroup: Story = {
|
|
211
|
+
args: { hideWhen: true },
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/** Loading state — identical geometry to `Default`, so nothing shifts on data arrival. */
|
|
215
|
+
export const Loading: Story = {
|
|
216
|
+
render: () => <EventCardSkeleton />,
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/** Skeleton beside the real card, so any geometry drift is visible at a glance. */
|
|
220
|
+
export const LoadingVsLoaded: Story = {
|
|
221
|
+
render: () => (
|
|
222
|
+
<div className="flex gap-4">
|
|
223
|
+
<EventCardSkeleton />
|
|
224
|
+
<EventCard event={event} labels={labels} formatters={formatters} />
|
|
225
|
+
</div>
|
|
226
|
+
),
|
|
227
|
+
decorators: [
|
|
228
|
+
(Story) => (
|
|
229
|
+
<div className="w-[460px]">
|
|
230
|
+
<Story />
|
|
231
|
+
</div>
|
|
232
|
+
),
|
|
233
|
+
],
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/** With a resolved poster. The caller composes the CDN URL; the DS never does. */
|
|
237
|
+
export const WithPoster: Story = {
|
|
238
|
+
args: {
|
|
239
|
+
event: {
|
|
240
|
+
...event,
|
|
241
|
+
poster_url:
|
|
242
|
+
"data:image/svg+xml;utf8," +
|
|
243
|
+
encodeURIComponent(
|
|
244
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="240" height="180"><rect width="240" height="180" fill="#c8b8a8"/><text x="50%" y="50%" font-family="monospace" font-size="12" fill="#5a4a3a" text-anchor="middle">poster</text></svg>`
|
|
245
|
+
),
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
};
|