@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,550 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
4
|
+
import { ChevronLeft, ChevronRight, Gift } from "lucide-react";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
import { EventPosterFallback } from "./event-poster-fallback";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Coverflow — the busiest-events browser: a 3D perspective lane with a large
|
|
10
|
+
* lead poster flanked by up to three shrinking, desaturating posters on each
|
|
11
|
+
* side. The lane itself drifts a few pixels back and forth
|
|
12
|
+
* (`prefers-reduced-motion` stops this outright; an `IntersectionObserver`
|
|
13
|
+
* pauses it off-screen).
|
|
14
|
+
*
|
|
15
|
+
* IT IS A RING, NOT A LANE. A card's slot comes from `ringOffset` — the
|
|
16
|
+
* SIGNED SHORTEST WAY ROUND the list, not the raw `index - activeIndex`. With
|
|
17
|
+
* seven items and `activeIndex` at 0, item 6 renders at offset −1 (just left
|
|
18
|
+
* of the lead) rather than +6, so both flanks are full at every index instead
|
|
19
|
+
* of the stage emptying out at the two ends, and prev/next keep going past
|
|
20
|
+
* either end. `goTo` already reported a wrapped index; the visuals now agree
|
|
21
|
+
* with it.
|
|
22
|
+
*
|
|
23
|
+
* IDENTITY IS THE ANIMATION: a card renders keyed by `item.id`, and its
|
|
24
|
+
* appearance (position, rotation, scale, depth filter) is a pure function of
|
|
25
|
+
* that offset. Advancing `activeIndex` shifts every offset by one, and
|
|
26
|
+
* because the DOM node stays put the browser tweens it from its old slot to
|
|
27
|
+
* its new one instead of popping straight there.
|
|
28
|
+
*
|
|
29
|
+
* THE WRAP MUST NOT TWEEN. Exactly one card per step crosses the ring's seam
|
|
30
|
+
* — with seven items it goes from offset −3 to +3 — and animating that would
|
|
31
|
+
* drag a poster right across the face of the coverflow. The mechanism chosen:
|
|
32
|
+
* `Coverflow` keeps the previous and current active index in one piece of
|
|
33
|
+
* state (`step`), so a render can tell, per card, whether its offset moved by
|
|
34
|
+
* the same amount the active index did. A card that did not (the only way
|
|
35
|
+
* that happens is crossing the seam) renders WITHOUT its transition classes
|
|
36
|
+
* for that one commit, so the transform applies instantly; the next commit
|
|
37
|
+
* restores them and it tweens normally from its new slot. Two alternatives
|
|
38
|
+
* were rejected: re-keying the card to force a remount throws away the
|
|
39
|
+
* decoded `<img>` and flashes the poster, and an effect-based "clear the
|
|
40
|
+
* transition after paint" lands a frame too late — the slide has already
|
|
41
|
+
* started. The seam sits at the deepest tier, which is also the lowest
|
|
42
|
+
* `z-index`, so the teleport happens behind the stack and off-stage.
|
|
43
|
+
*
|
|
44
|
+
* ONLY THE VISIBLE TIERS ARE MOUNTED. Every item now resolves to some ring
|
|
45
|
+
* offset, so the component renders only `|offset| <= 3` and drops the rest
|
|
46
|
+
* entirely — a fifty-item list paints seven posters, not fifty. Cards enter
|
|
47
|
+
* and leave at tier 3, parked well outside the stage's horizontal clip, so the
|
|
48
|
+
* mount/unmount is never seen. The stage clips on the x axis ONLY; see the
|
|
49
|
+
* note on the container element for why the y axis must stay visible.
|
|
50
|
+
*
|
|
51
|
+
* DEGENERATE LENGTHS: with one item there is no ring, so the arrows are not
|
|
52
|
+
* rendered at all rather than shown inert — see the note below on controls
|
|
53
|
+
* that look operable and do nothing. With two items the shortest path ties at
|
|
54
|
+
* `n / 2`; `ringOffset` resolves the tie to the POSITIVE side, so the other
|
|
55
|
+
* card is always on the right flank instead of flipping between them.
|
|
56
|
+
*
|
|
57
|
+
* `prefers-reduced-motion` already removes the position transition outright
|
|
58
|
+
* (`motion-reduce:transition-none`), so under it every step is a jump and the
|
|
59
|
+
* wrap is not a special case — it cannot read as a flash because nothing else
|
|
60
|
+
* is animating either.
|
|
61
|
+
*
|
|
62
|
+
* ACCESSIBILITY: every item carries a `title`, so every card is a real,
|
|
63
|
+
* focusable control (the injected link when it carries an `href`, otherwise a
|
|
64
|
+
* `<button>`) named after the event it depicts — a keyboard or screen-reader
|
|
65
|
+
* user can jump straight to any visible card the way a mouse user clicks one
|
|
66
|
+
* directly, not just step with `prevLabel`/`nextLabel`. Selecting one calls
|
|
67
|
+
* `onActiveIndexChange` with its index. Cards beyond the visible tiers are
|
|
68
|
+
* not rendered at all, so a screen-reader/keyboard user never lands on an
|
|
69
|
+
* invisible control — there is nothing there to land on. The depth
|
|
70
|
+
* styling (scale/grayscale/opacity) stays purely decorative, applied to an
|
|
71
|
+
* inner media element rather than the focusable wrapper — `filter` creates a
|
|
72
|
+
* new stacking context that would otherwise dim a `box-shadow`-based focus
|
|
73
|
+
* ring along with the poster. The active item's title/meta stays
|
|
74
|
+
* `aria-live="polite"` so a screen-reader user is told what changed after any
|
|
75
|
+
* kind of jump.
|
|
76
|
+
*/
|
|
77
|
+
export type CoverflowItem = {
|
|
78
|
+
/** Stable identity — this is what makes the position change animate rather than pop. */
|
|
79
|
+
id: string;
|
|
80
|
+
title: string;
|
|
81
|
+
/** Caller-composed, e.g. "Fri 4 · 90 going". */
|
|
82
|
+
meta: string;
|
|
83
|
+
posterUrl?: string;
|
|
84
|
+
href?: string;
|
|
85
|
+
/**
|
|
86
|
+
* Top-left overlay slot, rendered exactly as given — bring your own pill,
|
|
87
|
+
* icon and colour. Deliberately unstyled: a wrapper here would cap every
|
|
88
|
+
* caller at one look, and the first real consumer needed a colour-ramped
|
|
89
|
+
* badge the old black pill could not express.
|
|
90
|
+
*/
|
|
91
|
+
badge?: React.ReactNode;
|
|
92
|
+
isFree?: boolean;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export type CoverflowLinkProps = {
|
|
96
|
+
href: string;
|
|
97
|
+
className?: string;
|
|
98
|
+
style?: React.CSSProperties;
|
|
99
|
+
children: React.ReactNode;
|
|
100
|
+
onClick?: (mouseEvent: React.MouseEvent) => void;
|
|
101
|
+
"aria-label"?: string;
|
|
102
|
+
"aria-hidden"?: boolean;
|
|
103
|
+
tabIndex?: number;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
function DefaultLink({ href, ...props }: CoverflowLinkProps) {
|
|
107
|
+
return <a href={href} {...props} />;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type CoverflowProps = {
|
|
111
|
+
items: CoverflowItem[];
|
|
112
|
+
activeIndex: number;
|
|
113
|
+
onActiveIndexChange?: (index: number) => void;
|
|
114
|
+
/**
|
|
115
|
+
* Optional centred caption under the stage, e.g. a pluralized item count
|
|
116
|
+
* ("7 events"). Omit it when `positionLabel` already says everything there
|
|
117
|
+
* is to say — passing a position string here as well renders it twice, with
|
|
118
|
+
* a separator dot between the two identical halves.
|
|
119
|
+
*/
|
|
120
|
+
caption?: string;
|
|
121
|
+
/** Required to render the free pill on an item — no label, no pill. */
|
|
122
|
+
freeLabel: string;
|
|
123
|
+
/** Accessible name for the previous-item control. */
|
|
124
|
+
prevLabel: string;
|
|
125
|
+
/** Accessible name for the next-item control. */
|
|
126
|
+
nextLabel: string;
|
|
127
|
+
/** e.g. "3 of 7" — caller-composed, locale-dependent. Shown next to `caption`. */
|
|
128
|
+
positionLabel?: string;
|
|
129
|
+
linkComponent?: React.ComponentType<CoverflowLinkProps>;
|
|
130
|
+
className?: string;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Depth tiers beyond the lead, indexed by `Math.min(|offset|, 3)`. Index 0 is
|
|
135
|
+
* unused for rotation/scale (the lead is handled separately, scaling up from
|
|
136
|
+
* the shared card box instead of down) but keeps every array the same shape.
|
|
137
|
+
*/
|
|
138
|
+
const COVERFLOW_MAX_VISIBLE_TIER = 3;
|
|
139
|
+
const COVERFLOW_TIER_ROTATE_DEG = [0, 20, 30, 38] as const;
|
|
140
|
+
const COVERFLOW_TIER_SCALE = [1, 0.92, 0.82, 0.72] as const;
|
|
141
|
+
const COVERFLOW_TIER_GRAYSCALE = [0, 0, 0.35, 0.5] as const;
|
|
142
|
+
const COVERFLOW_TIER_OPACITY = [1, 0.88, 0.7, 0.5] as const;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Every card — lead included — shares this DOM box, which is what keeps a
|
|
146
|
+
* change of tier part of the one continuous transform rather than a discrete,
|
|
147
|
+
* unanimatable layout jump the moment a card crosses into (or out of) the lead
|
|
148
|
+
* slot.
|
|
149
|
+
*
|
|
150
|
+
* THE BOX IS THE LEAD'S SIZE, AND THAT IS WHY THE LEAD IS SHARP. It used to be
|
|
151
|
+
* the SIDE card's size, with the lead scaled UP to reach its on-screen width.
|
|
152
|
+
* A browser rasterises a layer at its layout size and then stretches that
|
|
153
|
+
* texture, so the one card a reader actually looks at was the only one being
|
|
154
|
+
* resampled upward — soft by construction. The poster was never the cause: it
|
|
155
|
+
* is fetched well above display width. Sizing the box to the lead puts the lead
|
|
156
|
+
* at scale 1 and makes every other tier scale DOWN, which resamples cleanly.
|
|
157
|
+
*/
|
|
158
|
+
const COVERFLOW_CARD_WIDTH_CLASS = "w-[149px] sm:w-[210px]";
|
|
159
|
+
|
|
160
|
+
/** Keeps the focus ring on the un-filtered wrapper (button/link) and the
|
|
161
|
+
* grayscale/opacity depth effect on the inner media div — see the
|
|
162
|
+
* ACCESSIBILITY note above. */
|
|
163
|
+
const COVERFLOW_CARD_TRANSFORM_TRANSITION_CLASS =
|
|
164
|
+
"transition-[transform,box-shadow] duration-[420ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none motion-reduce:duration-0";
|
|
165
|
+
const COVERFLOW_MEDIA_FILTER_TRANSITION_CLASS =
|
|
166
|
+
"transition-[filter,opacity] duration-[420ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none motion-reduce:duration-0";
|
|
167
|
+
const COVERFLOW_CARD_FOCUS_CLASS =
|
|
168
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2";
|
|
169
|
+
/**
|
|
170
|
+
* Scaled with the cards (see the width class). These are authored at the size
|
|
171
|
+
* they PAINT now: the lead no longer carries a `scale()`, so its shadow is no
|
|
172
|
+
* longer multiplied by one — the old `0 30px 70px` on a 1.75x-scaled element
|
|
173
|
+
* was really a 175px tail, which is what drove it through the stage's bottom
|
|
174
|
+
* edge.
|
|
175
|
+
*/
|
|
176
|
+
const COVERFLOW_SIDE_SHADOW_CLASS = "shadow-[0_11px_27px_oklch(0.3_0.02_60/0.22)]";
|
|
177
|
+
const COVERFLOW_LEAD_SHADOW_CLASS = "shadow-[0_24px_56px_oklch(0.28_0.03_45/0.32)]";
|
|
178
|
+
const COVERFLOW_PILL_CLASS =
|
|
179
|
+
"inline-flex h-6 max-w-[85%] items-center gap-1 rounded-full px-2 text-[11px] font-semibold";
|
|
180
|
+
|
|
181
|
+
const COVERFLOW_DRIFT_DURATION_SECONDS = 26;
|
|
182
|
+
const COVERFLOW_DRIFT_PIXELS = 40;
|
|
183
|
+
|
|
184
|
+
/** Below the lead's z-index the arrows (z-20) and edge fades (z-10) always
|
|
185
|
+
* win, no matter how many cards are mid-transition and briefly promoted. */
|
|
186
|
+
const COVERFLOW_LEAD_Z_INDEX = 6;
|
|
187
|
+
|
|
188
|
+
const COVERFLOW_ARROW_CLASS =
|
|
189
|
+
"absolute top-1/2 z-20 flex h-11 w-11 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full bg-background/85 text-foreground shadow-[var(--shadow-overlay)] backdrop-blur-sm transition-colors duration-150 hover:bg-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 motion-reduce:transition-none";
|
|
190
|
+
|
|
191
|
+
function wrapIndex(index: number, length: number): number {
|
|
192
|
+
return ((index % length) + length) % length;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Signed shortest way round the ring: `0`, `±1` … up to `±floor(length / 2)`.
|
|
197
|
+
* A tie (`raw === length / 2`, reachable only on an even-length ring, most
|
|
198
|
+
* visibly the two-item one) resolves to the POSITIVE side, so that card is
|
|
199
|
+
* always on the right flank rather than oscillating between the two.
|
|
200
|
+
*/
|
|
201
|
+
function ringOffset(delta: number, length: number): number {
|
|
202
|
+
const raw = wrapIndex(delta, length);
|
|
203
|
+
return raw > length / 2 ? raw - length : raw;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
type CoverflowCardProps = {
|
|
207
|
+
item: CoverflowItem;
|
|
208
|
+
index: number;
|
|
209
|
+
/** Signed ring offset from the lead — always within `±COVERFLOW_MAX_VISIBLE_TIER`. */
|
|
210
|
+
offset: number;
|
|
211
|
+
/** Set for the single card crossing the ring's seam this commit — see the wrap note above. */
|
|
212
|
+
suppressTransition: boolean;
|
|
213
|
+
freeLabel: string;
|
|
214
|
+
onActivate: (index: number) => void;
|
|
215
|
+
linkComponent: React.ComponentType<CoverflowLinkProps>;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
function CoverflowCard({
|
|
219
|
+
item,
|
|
220
|
+
index,
|
|
221
|
+
offset,
|
|
222
|
+
suppressTransition,
|
|
223
|
+
freeLabel,
|
|
224
|
+
onActivate,
|
|
225
|
+
linkComponent: LinkComponent,
|
|
226
|
+
}: CoverflowCardProps) {
|
|
227
|
+
const isLead = offset === 0;
|
|
228
|
+
const magnitude = Math.abs(offset);
|
|
229
|
+
const tier = Math.min(magnitude, COVERFLOW_MAX_VISIBLE_TIER);
|
|
230
|
+
const sign = Math.sign(offset);
|
|
231
|
+
|
|
232
|
+
const wrapperStyle: React.CSSProperties = {
|
|
233
|
+
// The lead sits at `--cf-scale-lead`, which is now 1 — see the width class.
|
|
234
|
+
// Side tiers multiply their depth ratio by `--cf-tier-base`, the side/lead
|
|
235
|
+
// width ratio, because the shared box is the LEAD's size: without it every
|
|
236
|
+
// side card would render at nearly lead width. It is a CSS var rather than
|
|
237
|
+
// a constant here because the ratio differs per breakpoint, and calc()
|
|
238
|
+
// cannot divide two lengths to derive it live.
|
|
239
|
+
transform: isLead
|
|
240
|
+
? "translate(-50%, -50%) scale(var(--cf-scale-lead))"
|
|
241
|
+
: `translate(-50%, -50%) translateX(calc(${sign} * var(--cf-x${tier}))) rotateY(${-sign * COVERFLOW_TIER_ROTATE_DEG[tier]}deg) scale(calc(var(--cf-tier-base) * ${COVERFLOW_TIER_SCALE[tier]}))`,
|
|
242
|
+
zIndex: Math.max(COVERFLOW_LEAD_Z_INDEX - magnitude, 0),
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const mediaStyle: React.CSSProperties = {
|
|
246
|
+
opacity: COVERFLOW_TIER_OPACITY[tier],
|
|
247
|
+
filter:
|
|
248
|
+
COVERFLOW_TIER_GRAYSCALE[tier] > 0
|
|
249
|
+
? `grayscale(${COVERFLOW_TIER_GRAYSCALE[tier] * 100}%)`
|
|
250
|
+
: "none",
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const media = (
|
|
254
|
+
<div
|
|
255
|
+
className={cn(
|
|
256
|
+
"relative aspect-[4/5] overflow-hidden rounded-[10px] bg-muted",
|
|
257
|
+
!suppressTransition && COVERFLOW_MEDIA_FILTER_TRANSITION_CLASS
|
|
258
|
+
)}
|
|
259
|
+
style={mediaStyle}
|
|
260
|
+
>
|
|
261
|
+
{item.posterUrl ? (
|
|
262
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
263
|
+
<img
|
|
264
|
+
src={item.posterUrl}
|
|
265
|
+
alt=""
|
|
266
|
+
loading={isLead ? "eager" : "lazy"}
|
|
267
|
+
decoding="async"
|
|
268
|
+
className="h-full w-full object-cover"
|
|
269
|
+
/>
|
|
270
|
+
) : (
|
|
271
|
+
<EventPosterFallback />
|
|
272
|
+
)}
|
|
273
|
+
|
|
274
|
+
<div className="absolute inset-x-2 top-2 flex items-start justify-between gap-2">
|
|
275
|
+
{/* The badge renders AS GIVEN — no pill of ours around it.
|
|
276
|
+
It used to be wrapped in `COVERFLOW_PILL_CLASS bg-black/62
|
|
277
|
+
text-white`, which meant a caller could only ever produce a black
|
|
278
|
+
pill here. The events hub's hype badge is a colour-ramped
|
|
279
|
+
speedometer on a brand gradient, so to survive that wrapper it had
|
|
280
|
+
been hand-rolled down to a monochrome flame — the carousel showed a
|
|
281
|
+
flat grey badge where every other event surface showed the real one.
|
|
282
|
+
A caller wanting the old look styles it itself; `isFree` below keeps
|
|
283
|
+
its pill because that one IS the component's own. */}
|
|
284
|
+
{item.badge ?? <span />}
|
|
285
|
+
{item.isFree && freeLabel && (
|
|
286
|
+
<span className={cn(COVERFLOW_PILL_CLASS, "bg-success font-bold text-success-foreground")}>
|
|
287
|
+
<Gift className="h-3 w-3" aria-hidden />
|
|
288
|
+
{freeLabel}
|
|
289
|
+
</span>
|
|
290
|
+
)}
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
const wrapperClassName = cn(
|
|
296
|
+
"absolute left-1/2 top-1/2 block bg-transparent p-0 text-left",
|
|
297
|
+
COVERFLOW_CARD_WIDTH_CLASS,
|
|
298
|
+
isLead ? "cursor-default" : "cursor-pointer",
|
|
299
|
+
isLead ? COVERFLOW_LEAD_SHADOW_CLASS : COVERFLOW_SIDE_SHADOW_CLASS,
|
|
300
|
+
"rounded-[10px]",
|
|
301
|
+
!suppressTransition && COVERFLOW_CARD_TRANSFORM_TRANSITION_CLASS,
|
|
302
|
+
COVERFLOW_CARD_FOCUS_CLASS
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
// The lead never needs to reselect itself — clicking it should only follow
|
|
306
|
+
// its own `href`, same as before.
|
|
307
|
+
const handleActivate = isLead ? undefined : () => onActivate(index);
|
|
308
|
+
|
|
309
|
+
if (item.href) {
|
|
310
|
+
return (
|
|
311
|
+
<LinkComponent
|
|
312
|
+
href={item.href}
|
|
313
|
+
className={wrapperClassName}
|
|
314
|
+
style={wrapperStyle}
|
|
315
|
+
aria-label={item.title}
|
|
316
|
+
onClick={handleActivate}
|
|
317
|
+
>
|
|
318
|
+
{media}
|
|
319
|
+
</LinkComponent>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return (
|
|
324
|
+
<button
|
|
325
|
+
type="button"
|
|
326
|
+
className={wrapperClassName}
|
|
327
|
+
style={wrapperStyle}
|
|
328
|
+
aria-label={item.title}
|
|
329
|
+
onClick={handleActivate}
|
|
330
|
+
>
|
|
331
|
+
{media}
|
|
332
|
+
</button>
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function Coverflow({
|
|
337
|
+
items,
|
|
338
|
+
activeIndex,
|
|
339
|
+
onActiveIndexChange,
|
|
340
|
+
caption,
|
|
341
|
+
freeLabel,
|
|
342
|
+
prevLabel,
|
|
343
|
+
nextLabel,
|
|
344
|
+
positionLabel,
|
|
345
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
346
|
+
className,
|
|
347
|
+
}: CoverflowProps) {
|
|
348
|
+
const scopeId = useId().replace(/[^a-zA-Z0-9]/g, "");
|
|
349
|
+
const scopeClass = `coverflow-${scopeId}`;
|
|
350
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
351
|
+
|
|
352
|
+
const length = items.length;
|
|
353
|
+
const active = length > 0 ? wrapIndex(activeIndex, length) : 0;
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The previous and current active index, as one value. Kept in state rather
|
|
357
|
+
* than a ref because a ref written during render gives the wrong answer on
|
|
358
|
+
* StrictMode's second pass, and because this has to be readable DURING the
|
|
359
|
+
* render that repositions the cards: it is what tells a card whether it moved
|
|
360
|
+
* with the stage or jumped the seam, and the answer decides whether that
|
|
361
|
+
* card's transition classes are applied at all.
|
|
362
|
+
*/
|
|
363
|
+
const [step, setStep] = useState({ from: active, to: active, length });
|
|
364
|
+
if (step.to !== active || step.length !== length) {
|
|
365
|
+
// React re-runs the component and throws this pass away, so `step` is
|
|
366
|
+
// already correct by the time anything commits. A length change resets the
|
|
367
|
+
// pair — offsets computed against a different ring size mean nothing.
|
|
368
|
+
setStep(
|
|
369
|
+
step.length === length
|
|
370
|
+
? { from: step.to, to: active, length }
|
|
371
|
+
: { from: active, to: active, length }
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
useEffect(() => {
|
|
376
|
+
const node = containerRef.current;
|
|
377
|
+
if (!node || typeof IntersectionObserver === "undefined") return undefined;
|
|
378
|
+
|
|
379
|
+
const observer = new IntersectionObserver(
|
|
380
|
+
([entry]) => {
|
|
381
|
+
node.dataset.offscreen = entry.isIntersecting ? "false" : "true";
|
|
382
|
+
},
|
|
383
|
+
{ threshold: 0 }
|
|
384
|
+
);
|
|
385
|
+
observer.observe(node);
|
|
386
|
+
return () => observer.disconnect();
|
|
387
|
+
}, []);
|
|
388
|
+
|
|
389
|
+
if (length === 0) return null;
|
|
390
|
+
|
|
391
|
+
const activeItem = items[active];
|
|
392
|
+
// No clamp at the ends: prev from 0 reports `length - 1` and next from
|
|
393
|
+
// `length - 1` reports 0, so the index the parent stores matches the ring.
|
|
394
|
+
const goTo = (index: number) => onActiveIndexChange?.(wrapIndex(index, length));
|
|
395
|
+
|
|
396
|
+
/** How far the lead itself moved, the short way round — 0 on a settled render. */
|
|
397
|
+
const activeDelta = ringOffset(step.to - step.from, length);
|
|
398
|
+
|
|
399
|
+
const visibleCards = items
|
|
400
|
+
.map((item, index) => {
|
|
401
|
+
const offset = ringOffset(index - active, length);
|
|
402
|
+
// A card that moved by anything other than `activeDelta` did not travel
|
|
403
|
+
// with the stage — it came round the back. That is the wrap, and the only
|
|
404
|
+
// thing that produces it.
|
|
405
|
+
const suppressTransition = offset !== ringOffset(index - step.from, length) - activeDelta;
|
|
406
|
+
return { item, index, offset, suppressTransition };
|
|
407
|
+
})
|
|
408
|
+
.filter(({ offset }) => Math.abs(offset) <= COVERFLOW_MAX_VISIBLE_TIER);
|
|
409
|
+
|
|
410
|
+
const hasRing = length > 1;
|
|
411
|
+
|
|
412
|
+
return (
|
|
413
|
+
<div className={cn(scopeClass, "w-full", className)}>
|
|
414
|
+
<style>{`
|
|
415
|
+
.${scopeClass} {
|
|
416
|
+
/*
|
|
417
|
+
* Every card shares one DOM box (COVERFLOW_CARD_WIDTH_CLASS above),
|
|
418
|
+
* and that box is now the LEAD's width. These are the visual
|
|
419
|
+
* footprints the JS transform math targets.
|
|
420
|
+
*
|
|
421
|
+
* --cf-scale-lead is therefore 1: the lead paints at its own size and
|
|
422
|
+
* is never resampled upward, which is what fixed it reading soft.
|
|
423
|
+
* --cf-tier-base is --cf-side-w / --cf-lead-w, the factor every side
|
|
424
|
+
* tier multiplies its depth ratio by; it must be kept in sync by hand
|
|
425
|
+
* if either width changes, because calc() cannot divide two lengths.
|
|
426
|
+
* 77 / 149 = 0.5168 120 / 210 = 0.5714
|
|
427
|
+
*
|
|
428
|
+
* Both footprints are 20% down from where they started (186/96 and
|
|
429
|
+
* 262/150): the lead was large enough that a poster meant for a feed
|
|
430
|
+
* thumbnail was being asked to carry a hero, and it showed.
|
|
431
|
+
*/
|
|
432
|
+
--cf-lead-w: 149px;
|
|
433
|
+
--cf-side-w: 77px;
|
|
434
|
+
--cf-gap: 21px;
|
|
435
|
+
--cf-scale-lead: 1;
|
|
436
|
+
--cf-tier-base: 0.5168;
|
|
437
|
+
--cf-x1: calc(var(--cf-lead-w) / 2 + var(--cf-gap) + var(--cf-side-w) / 2);
|
|
438
|
+
--cf-x2: calc(var(--cf-x1) + var(--cf-side-w) + var(--cf-gap));
|
|
439
|
+
--cf-x3: calc(var(--cf-x2) + var(--cf-side-w) + var(--cf-gap));
|
|
440
|
+
}
|
|
441
|
+
@media (min-width: 640px) {
|
|
442
|
+
.${scopeClass} {
|
|
443
|
+
--cf-lead-w: 210px;
|
|
444
|
+
--cf-side-w: 120px;
|
|
445
|
+
--cf-gap: 26px;
|
|
446
|
+
--cf-tier-base: 0.5714;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
.${scopeClass} .coverflow-lane {
|
|
450
|
+
animation: ${scopeClass}-drift ${COVERFLOW_DRIFT_DURATION_SECONDS}s ease-in-out infinite alternate;
|
|
451
|
+
}
|
|
452
|
+
.${scopeClass}[data-offscreen="true"] .coverflow-lane {
|
|
453
|
+
animation-play-state: paused;
|
|
454
|
+
}
|
|
455
|
+
@keyframes ${scopeClass}-drift {
|
|
456
|
+
from { transform: translateX(-${COVERFLOW_DRIFT_PIXELS}px); }
|
|
457
|
+
to { transform: translateX(${COVERFLOW_DRIFT_PIXELS}px); }
|
|
458
|
+
}
|
|
459
|
+
@media (prefers-reduced-motion: reduce) {
|
|
460
|
+
.${scopeClass} .coverflow-lane {
|
|
461
|
+
animation: none;
|
|
462
|
+
transform: none;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
`}</style>
|
|
466
|
+
|
|
467
|
+
<div
|
|
468
|
+
ref={containerRef}
|
|
469
|
+
// CLIPS HORIZONTALLY ONLY. The clip is what hides a card teleporting
|
|
470
|
+
// round the back of the ring, and that only ever happens off the left
|
|
471
|
+
// and right edges — but `overflow-hidden` clips both axes, so it was
|
|
472
|
+
// also slicing a hard horizontal line through the cards' drop shadows
|
|
473
|
+
// at the top and bottom edges.
|
|
474
|
+
//
|
|
475
|
+
// `overflow-x: clip` rather than `hidden` is the load-bearing part:
|
|
476
|
+
// `hidden` forces the other axis to compute to `auto` (a scroll
|
|
477
|
+
// container), so `overflow-x-hidden overflow-y-visible` is not a thing
|
|
478
|
+
// the cascade will give you. `clip` carries no such rule, so the pair
|
|
479
|
+
// below means what it says.
|
|
480
|
+
className="relative flex h-[300px] items-center justify-center overflow-x-clip overflow-y-visible [perspective:900px] sm:h-[380px] sm:[perspective:1400px]"
|
|
481
|
+
>
|
|
482
|
+
<div
|
|
483
|
+
aria-hidden="true"
|
|
484
|
+
className="pointer-events-none absolute inset-y-0 left-0 z-10 w-[120px] bg-gradient-to-r from-background to-transparent"
|
|
485
|
+
/>
|
|
486
|
+
<div
|
|
487
|
+
aria-hidden="true"
|
|
488
|
+
className="pointer-events-none absolute inset-y-0 right-0 z-10 w-[120px] bg-gradient-to-l from-background to-transparent"
|
|
489
|
+
/>
|
|
490
|
+
|
|
491
|
+
{/* One item is not a ring — an arrow here would be a control that
|
|
492
|
+
looks operable and does nothing, so there is no arrow. */}
|
|
493
|
+
{hasRing && (
|
|
494
|
+
<>
|
|
495
|
+
<button
|
|
496
|
+
type="button"
|
|
497
|
+
aria-label={prevLabel}
|
|
498
|
+
onClick={() => goTo(active - 1)}
|
|
499
|
+
className={cn(COVERFLOW_ARROW_CLASS, "left-2 sm:left-4")}
|
|
500
|
+
>
|
|
501
|
+
<ChevronLeft className="h-5 w-5" aria-hidden />
|
|
502
|
+
</button>
|
|
503
|
+
<button
|
|
504
|
+
type="button"
|
|
505
|
+
aria-label={nextLabel}
|
|
506
|
+
onClick={() => goTo(active + 1)}
|
|
507
|
+
className={cn(COVERFLOW_ARROW_CLASS, "right-2 sm:right-4")}
|
|
508
|
+
>
|
|
509
|
+
<ChevronRight className="h-5 w-5" aria-hidden />
|
|
510
|
+
</button>
|
|
511
|
+
</>
|
|
512
|
+
)}
|
|
513
|
+
|
|
514
|
+
<div className="coverflow-lane relative h-full w-full [transform-style:preserve-3d] motion-reduce:[animation:none]">
|
|
515
|
+
{visibleCards.map(({ item, index, offset, suppressTransition }) => (
|
|
516
|
+
<CoverflowCard
|
|
517
|
+
key={item.id}
|
|
518
|
+
item={item}
|
|
519
|
+
index={index}
|
|
520
|
+
offset={offset}
|
|
521
|
+
suppressTransition={suppressTransition}
|
|
522
|
+
freeLabel={freeLabel}
|
|
523
|
+
onActivate={goTo}
|
|
524
|
+
linkComponent={LinkComponent}
|
|
525
|
+
/>
|
|
526
|
+
))}
|
|
527
|
+
</div>
|
|
528
|
+
</div>
|
|
529
|
+
|
|
530
|
+
<div className="mt-4 space-y-4 text-center">
|
|
531
|
+
<div aria-live="polite">
|
|
532
|
+
<h3 className="text-[17px] font-extrabold">{activeItem.title}</h3>
|
|
533
|
+
<p className="mt-[3px] text-[12.5px] text-muted-foreground">{activeItem.meta}</p>
|
|
534
|
+
</div>
|
|
535
|
+
|
|
536
|
+
{/* The dot is a separator, not decoration: it only exists when there
|
|
537
|
+
are in fact two things to separate. With one of them absent the
|
|
538
|
+
row collapses to a single centred span, no dangling glyph, no gap
|
|
539
|
+
— and the whole row disappears when neither is given. */}
|
|
540
|
+
{(caption || positionLabel) && (
|
|
541
|
+
<div className="flex items-center justify-center gap-2 text-[12.5px] text-muted-foreground">
|
|
542
|
+
{caption && <span>{caption}</span>}
|
|
543
|
+
{caption && positionLabel && <span aria-hidden="true">·</span>}
|
|
544
|
+
{positionLabel && <span>{positionLabel}</span>}
|
|
545
|
+
</div>
|
|
546
|
+
)}
|
|
547
|
+
</div>
|
|
548
|
+
</div>
|
|
549
|
+
);
|
|
550
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { DayRailChip, DayRailChipSkeleton } from "./day-rail-chip";
|
|
3
|
+
import type { DayRailChipLabels } from "./event-types";
|
|
4
|
+
|
|
5
|
+
const labels: DayRailChipLabels = { noEvents: "No events" };
|
|
6
|
+
|
|
7
|
+
const meta: Meta<typeof DayRailChip> = {
|
|
8
|
+
title: "Events/DayRailChip",
|
|
9
|
+
component: DayRailChip,
|
|
10
|
+
tags: ["autodocs"],
|
|
11
|
+
args: { labels },
|
|
12
|
+
parameters: {
|
|
13
|
+
docs: {
|
|
14
|
+
description: {
|
|
15
|
+
component:
|
|
16
|
+
"One day in the timeline's day rail. An empty day keeps its place in the rail with an em dash and no density dot — ten of fourteen days routinely read empty, and dropping them would make the rail lie about how far ahead it reaches.",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export default meta;
|
|
23
|
+
type Story = StoryObj<typeof DayRailChip>;
|
|
24
|
+
|
|
25
|
+
export const Active: Story = {
|
|
26
|
+
args: { label: "Today", dayNumber: "3", count: 15, isActive: true },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const HasEvents: Story = {
|
|
30
|
+
args: { label: "Fri", dayNumber: "4", count: 17 },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Weekend days keep the bold weight while inactive, so they stay findable. */
|
|
34
|
+
export const Weekend: Story = {
|
|
35
|
+
args: { label: "Sat", dayNumber: "5", count: 23, isWeekend: true },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** No dot, an em dash rather than "0", muted label — but still in the rail. */
|
|
39
|
+
export const Empty: Story = {
|
|
40
|
+
args: { label: "Fri", dayNumber: "11", count: 0 },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** The full fortnight, as the rail actually renders it. */
|
|
44
|
+
export const TheRail: Story = {
|
|
45
|
+
render: () => {
|
|
46
|
+
const days: Array<[string, string, number, boolean]> = [
|
|
47
|
+
["Today", "3", 15, false],
|
|
48
|
+
["Fri", "4", 17, true],
|
|
49
|
+
["Sat", "5", 23, true],
|
|
50
|
+
["Sun", "6", 10, true],
|
|
51
|
+
["Mon", "7", 5, false],
|
|
52
|
+
["Tue", "8", 3, false],
|
|
53
|
+
["Wed", "9", 3, false],
|
|
54
|
+
["Thu", "10", 2, false],
|
|
55
|
+
["Fri", "11", 0, true],
|
|
56
|
+
["Sat", "12", 4, true],
|
|
57
|
+
["Sun", "13", 3, true],
|
|
58
|
+
["Mon", "14", 0, false],
|
|
59
|
+
];
|
|
60
|
+
return (
|
|
61
|
+
<div className="flex max-w-[390px] gap-2 overflow-x-auto px-4 pb-1">
|
|
62
|
+
{days.map(([label, dayNumber, count, isWeekend], i) => (
|
|
63
|
+
<DayRailChip
|
|
64
|
+
key={`${label}-${dayNumber}`}
|
|
65
|
+
label={label}
|
|
66
|
+
dayNumber={dayNumber}
|
|
67
|
+
count={count}
|
|
68
|
+
isWeekend={isWeekend}
|
|
69
|
+
isActive={i === 0}
|
|
70
|
+
labels={labels}
|
|
71
|
+
onClick={() => {}}
|
|
72
|
+
/>
|
|
73
|
+
))}
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** Loading state — one chip's footprint (62×50, 12px radius). */
|
|
80
|
+
export const Loading: Story = {
|
|
81
|
+
render: () => <DayRailChipSkeleton />,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** The full fortnight, loading — six chips at the rail's own `gap-2` (8px). */
|
|
85
|
+
export const LoadingRail: Story = {
|
|
86
|
+
render: () => (
|
|
87
|
+
<div className="flex max-w-[390px] gap-2 overflow-x-auto px-4 pb-1">
|
|
88
|
+
{Array.from({ length: 6 }).map((_, index) => (
|
|
89
|
+
<DayRailChipSkeleton key={index} />
|
|
90
|
+
))}
|
|
91
|
+
</div>
|
|
92
|
+
),
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** A loading rail beside the loaded one, so any geometry drift is visible at a glance. */
|
|
96
|
+
export const LoadingVsLoaded: Story = {
|
|
97
|
+
render: () => (
|
|
98
|
+
<div className="space-y-2">
|
|
99
|
+
<div className="flex max-w-[390px] gap-2 overflow-x-auto px-4 pb-1">
|
|
100
|
+
{Array.from({ length: 6 }).map((_, index) => (
|
|
101
|
+
<DayRailChipSkeleton key={index} />
|
|
102
|
+
))}
|
|
103
|
+
</div>
|
|
104
|
+
<div className="flex max-w-[390px] gap-2 overflow-x-auto px-4 pb-1">
|
|
105
|
+
<DayRailChip label="Today" dayNumber="3" count={15} isActive labels={labels} onClick={() => {}} />
|
|
106
|
+
<DayRailChip label="Fri" dayNumber="4" count={17} labels={labels} onClick={() => {}} />
|
|
107
|
+
<DayRailChip label="Sat" dayNumber="5" count={23} isWeekend labels={labels} onClick={() => {}} />
|
|
108
|
+
<DayRailChip label="Sun" dayNumber="6" count={10} isWeekend labels={labels} onClick={() => {}} />
|
|
109
|
+
<DayRailChip label="Mon" dayNumber="7" count={5} labels={labels} onClick={() => {}} />
|
|
110
|
+
<DayRailChip label="Tue" dayNumber="8" count={3} labels={labels} onClick={() => {}} />
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
),
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** With a filter applied, counts redraw and most days fall to zero. */
|
|
117
|
+
export const FilteredToFreeEntry: Story = {
|
|
118
|
+
render: () => {
|
|
119
|
+
const days: Array<[string, string, number]> = [
|
|
120
|
+
["Today", "3", 2],
|
|
121
|
+
["Fri", "4", 3],
|
|
122
|
+
["Sat", "5", 2],
|
|
123
|
+
["Sun", "6", 0],
|
|
124
|
+
["Mon", "7", 1],
|
|
125
|
+
["Tue", "8", 0],
|
|
126
|
+
["Wed", "9", 1],
|
|
127
|
+
["Thu", "10", 0],
|
|
128
|
+
];
|
|
129
|
+
return (
|
|
130
|
+
<div className="flex max-w-[390px] gap-2 overflow-x-auto px-4 pb-1">
|
|
131
|
+
{days.map(([label, dayNumber, count], i) => (
|
|
132
|
+
<DayRailChip
|
|
133
|
+
key={`${label}-${dayNumber}`}
|
|
134
|
+
label={label}
|
|
135
|
+
dayNumber={dayNumber}
|
|
136
|
+
count={count}
|
|
137
|
+
isActive={i === 2}
|
|
138
|
+
labels={labels}
|
|
139
|
+
onClick={() => {}}
|
|
140
|
+
/>
|
|
141
|
+
))}
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
},
|
|
145
|
+
};
|