@bearmenu/ui 0.8.13 → 0.8.15
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/README.md +0 -1
- package/dist/{chunk-66MAPLXS.js → chunk-2ADCZXZJ.js} +1 -1
- package/dist/{chunk-2DEIXTBN.js → chunk-2ZXIXQIU.js} +1 -1
- package/dist/{chunk-YOKHBAIH.js → chunk-4OF4T4AI.js} +12 -11
- package/dist/{chunk-MZL7CGCL.js → chunk-4XDJXMLF.js} +2 -2
- package/dist/{chunk-VAXOQS7W.js → chunk-5FCC5L5F.js} +3 -3
- package/dist/{chunk-YYGB4G4Q.js → chunk-CZK2MRS2.js} +1 -1
- package/dist/chunk-DJD3EMPK.js +126 -0
- package/dist/chunk-EZG2NELX.js +5 -0
- package/dist/{chunk-VG3PND3D.js → chunk-FMTJGO76.js} +1 -1
- package/dist/{chunk-UXWAOISG.js → chunk-FVSKGCK3.js} +2 -2
- package/dist/{chunk-2XCZJWTV.js → chunk-GNCWJWFE.js} +1 -1
- package/dist/{chunk-X3WMRKOW.js → chunk-HDR7V6IZ.js} +1 -1
- package/dist/{chunk-HSIYYA2E.js → chunk-HQU6QVAN.js} +5 -5
- package/dist/chunk-I5V446BX.js +27 -0
- package/dist/{chunk-WCTHZKU7.js → chunk-ICOU5QHT.js} +1 -1
- package/dist/{chunk-G7IFL4Z4.js → chunk-L3VEMP5A.js} +57 -9
- package/dist/chunk-LQWGZIYA.js +5 -0
- package/dist/{chunk-XNA2INVQ.js → chunk-MCOHLWRS.js} +2 -2
- package/dist/{chunk-3GWWZKX7.js → chunk-MMUBH76A.js} +21 -1
- package/dist/{chunk-ZDAHKI3A.js → chunk-MWB7S54O.js} +1 -1
- package/dist/{chunk-D5QHEHXJ.js → chunk-NMEVJT5Q.js} +1 -1
- package/dist/{chunk-3TNPSN4D.js → chunk-NSNEANOQ.js} +2 -2
- package/dist/{chunk-WSRWFA6K.js → chunk-NWZGSLPU.js} +1 -1
- package/dist/{chunk-LEAYBICI.js → chunk-OIV7B44M.js} +1 -1
- package/dist/{chunk-2CXU5WZO.js → chunk-P43B3ONY.js} +1 -1
- package/dist/{chunk-FRPPIXMY.js → chunk-PDTPO6NG.js} +1 -1
- package/dist/{chunk-6XMYEZVR.js → chunk-PX6RICS5.js} +1 -1
- package/dist/{chunk-6TEEY5YA.js → chunk-S33K4RC5.js} +1 -1
- package/dist/{chunk-3VMDCCCX.js → chunk-SC2VUTUI.js} +1 -1
- package/dist/{chunk-3NMG3KNA.js → chunk-SXQZ7DLH.js} +3 -3
- package/dist/{chunk-UDTG3RSE.js → chunk-T6S2ZBV6.js} +2 -2
- package/dist/{chunk-R47NEOSI.js → chunk-V4E333QS.js} +29 -4
- package/dist/{chunk-FKNBZF5T.js → chunk-WYTJR2YU.js} +1 -1
- package/dist/{chunk-QXHMPJ35.js → chunk-XBBEX4SF.js} +1 -1
- package/dist/chunk-XCB3F6SF.js +16 -0
- package/dist/{chunk-7BWTYWM3.js → chunk-ZLMRVBD7.js} +1 -1
- package/dist/{chunk-P7XTW2CG.js → chunk-ZLPWEHZO.js} +1 -1
- package/dist/components/accordion.js +1 -1
- package/dist/components/alert-dialog.js +3 -3
- package/dist/components/alert.js +2 -2
- package/dist/components/aspect-ratio.js +2 -2
- package/dist/components/avatar.js +1 -1
- package/dist/components/badge.js +2 -2
- package/dist/components/breadcrumb.js +1 -1
- package/dist/components/button-group.js +1 -1
- package/dist/components/button.d.ts +4 -0
- package/dist/components/button.js +3 -3
- package/dist/components/calendar.js +3 -3
- package/dist/components/card.js +3 -3
- package/dist/components/carousel.js +3 -3
- package/dist/components/category-deck.js +2 -2
- package/dist/components/checkbox.js +2 -2
- package/dist/components/circular-progress.js +1 -1
- package/dist/components/collapsible.js +1 -1
- package/dist/components/combobox.js +6 -6
- package/dist/components/command.js +3 -3
- package/dist/components/concept-card.js +2 -2
- package/dist/components/concept-compare-rail.js +3 -3
- package/dist/components/concept-compare.d.ts +2 -1
- package/dist/components/concept-compare.js +5 -5
- package/dist/components/concept-convergence.js +4 -4
- package/dist/components/convergence.js +3 -3
- package/dist/components/coverflow.js +1 -1
- package/dist/components/currency-input.js +2 -2
- package/dist/components/date-input.js +2 -2
- package/dist/components/day-rail-chip.js +2 -2
- package/dist/components/description-list.js +1 -1
- package/dist/components/dialog.js +2 -2
- package/dist/components/doorway-tiles.d.ts +57 -0
- package/dist/components/doorway-tiles.js +86 -0
- package/dist/components/drawer.js +1 -1
- package/dist/components/drift-rail.js +1 -1
- package/dist/components/dropdown-menu.js +1 -1
- package/dist/components/empty-state.js +4 -4
- package/dist/components/event-card.js +4 -4
- package/dist/components/event-compact-row.js +4 -4
- package/dist/components/event-day-header.js +2 -2
- package/dist/components/event-day-separator.js +3 -3
- package/dist/components/event-detail-panel.js +5 -5
- package/dist/components/event-hub-hero.js +2 -2
- package/dist/components/event-index-row.js +4 -4
- package/dist/components/event-list-row-wide.js +6 -6
- package/dist/components/event-list-row.js +5 -5
- package/dist/components/event-poster-fallback.js +1 -1
- package/dist/components/event-types.js +1 -1
- package/dist/components/events-empty-state.js +1 -1
- package/dist/components/explainer-steps.js +1 -1
- package/dist/components/facet-group.js +2 -2
- package/dist/components/featured-rail-item.js +2 -2
- package/dist/components/field.js +2 -2
- package/dist/components/filter-category-row.js +3 -3
- package/dist/components/form.js +2 -2
- package/dist/components/glass-ground.js +2 -2
- package/dist/components/grade-atom.d.ts +3 -0
- package/dist/components/grade-atom.js +4 -0
- package/dist/components/hour-band.js +4 -4
- package/dist/components/hour-group.js +4 -4
- package/dist/components/hub-surface.js +1 -1
- package/dist/components/hue-chip.js +1 -1
- package/dist/components/icon.js +1 -1
- package/dist/components/input-group.js +1 -1
- package/dist/components/input-otp.js +1 -1
- package/dist/components/input.js +2 -2
- package/dist/components/item.js +1 -1
- package/dist/components/kbd.js +1 -1
- package/dist/components/kitchen-dossier.d.ts +2 -1
- package/dist/components/kitchen-dossier.js +4 -4
- package/dist/components/label.js +2 -2
- package/dist/components/lazy-markdown.js +1 -1
- package/dist/components/link-button.js +3 -3
- package/dist/components/markdown-renderer.js +1 -1
- package/dist/components/menu-dead-end.d.ts +2 -1
- package/dist/components/menu-dead-end.js +1 -1
- package/dist/components/menu-family-block.d.ts +2 -1
- package/dist/components/menu-family-block.js +15 -9
- package/dist/components/menu-family-card.d.ts +2 -1
- package/dist/components/menu-family-card.js +1 -1
- package/dist/components/menu-field.js +1 -1
- package/dist/components/menu-filter-chips.d.ts +2 -1
- package/dist/components/menu-filter-chips.js +3 -3
- package/dist/components/menu-group-rows.d.ts +2 -1
- package/dist/components/menu-group-rows.js +2 -2
- package/dist/components/menu-hub-hero.js +1 -1
- package/dist/components/menu-index-header.d.ts +2 -1
- package/dist/components/menu-index-header.js +2 -2
- package/dist/components/menu-index-list.d.ts +2 -1
- package/dist/components/menu-index-list.js +5 -5
- package/dist/components/menu-intent-hero.d.ts +3 -2
- package/dist/components/menu-intent-hero.js +1 -1
- package/dist/components/menu-item.d.ts +2 -1
- package/dist/components/menu-item.js +4 -4
- package/dist/components/menu-kitchen-list.d.ts +2 -1
- package/dist/components/menu-kitchen-list.js +4 -4
- package/dist/components/menu-mocks.d.ts +16 -5
- package/dist/components/menu-mocks.js +23 -2
- package/dist/components/menu-types.d.ts +21 -34
- package/dist/components/motion-icon-actions.d.ts +27 -0
- package/dist/components/motion-icon-actions.js +336 -0
- package/dist/components/motion-icon-controls.js +3 -3
- package/dist/components/motion-icon-set.d.ts +13 -1
- package/dist/components/motion-icon-set.js +197 -6
- package/dist/components/motion-icon.d.ts +15 -1
- package/dist/components/motion-icon.js +2 -2
- package/dist/components/multi-select-combobox.js +8 -8
- package/dist/components/multi-select-menu.js +2 -2
- package/dist/components/native-select.js +1 -1
- package/dist/components/open-now-list.d.ts +2 -1
- package/dist/components/open-now-list.js +2 -2
- package/dist/components/pagination.js +3 -3
- package/dist/components/phone-frame.js +1 -1
- package/dist/components/photo-ground.js +1 -1
- package/dist/components/place-about-tab.d.ts +2 -1
- package/dist/components/place-about-tab.js +6 -6
- package/dist/components/place-card.d.ts +94 -12
- package/dist/components/place-card.js +203 -128
- package/dist/components/place-details-mobile.d.ts +2 -1
- package/dist/components/place-details-mobile.js +7 -7
- package/dist/components/place-schedule-week.d.ts +2 -1
- package/dist/components/place-schedule-week.js +3 -3
- package/dist/components/place-types.d.ts +3 -265
- package/dist/components/popover.js +2 -2
- package/dist/components/poster-stack.js +1 -1
- package/dist/components/poster-tape.js +1 -1
- package/dist/components/poster-wall.d.ts +22 -6
- package/dist/components/poster-wall.js +197 -12
- package/dist/components/progress.js +1 -1
- package/dist/components/qa-block.d.ts +2 -1
- package/dist/components/qa-block.js +1 -1
- package/dist/components/quiet-stretch.js +1 -1
- package/dist/components/radio-group.js +1 -1
- package/dist/components/rating.js +1 -1
- package/dist/components/scroll-area.js +1 -1
- package/dist/components/scroll-fade.js +2 -2
- package/dist/components/search-bar.js +1 -1
- package/dist/components/searchable-select.js +6 -6
- package/dist/components/section-header.js +2 -2
- package/dist/components/segmented-nav.d.ts +24 -4
- package/dist/components/segmented-nav.js +11 -2
- package/dist/components/select.js +1 -1
- package/dist/components/selection-bar.js +1 -1
- package/dist/components/separator.js +2 -2
- package/dist/components/sheet.js +1 -1
- package/dist/components/signature-chip.js +3 -3
- package/dist/components/skeleton-card.js +3 -3
- package/dist/components/skeleton.js +2 -2
- package/dist/components/slider.js +1 -1
- package/dist/components/sliding-panels.js +3 -3
- package/dist/components/snap-rail.js +2 -2
- package/dist/components/sonner.js +1 -1
- package/dist/components/sort-option-list.js +1 -1
- package/dist/components/spinner.js +1 -1
- package/dist/components/stat-banner.js +1 -1
- package/dist/components/stepper.js +1 -1
- package/dist/components/sticky-container.js +1 -1
- package/dist/components/story.js +1 -1
- package/dist/components/switch.js +1 -1
- package/dist/components/table.js +1 -1
- package/dist/components/tabs.js +2 -2
- package/dist/components/tag-group.js +2 -2
- package/dist/components/tag.js +2 -2
- package/dist/components/text.js +2 -2
- package/dist/components/textarea.js +1 -1
- package/dist/components/timeline-band.js +4 -4
- package/dist/components/timeline-playhead.js +4 -4
- package/dist/components/toggle-group.js +2 -2
- package/dist/components/toggle.js +2 -2
- package/dist/components/tooltip.js +1 -1
- package/dist/components/top-comparison.d.ts +2 -1
- package/dist/components/top-comparison.js +1 -1
- package/dist/components/top-cross-link.d.ts +2 -1
- package/dist/components/top-cross-link.js +1 -1
- package/dist/components/top-ranking-entry.d.ts +2 -1
- package/dist/components/top-ranking-entry.js +6 -6
- package/dist/components/top-ranking-hero.d.ts +2 -1
- package/dist/components/top-ranking-hero.js +1 -1
- package/dist/components/top-ranking-legend.js +1 -1
- package/dist/components/top-types.d.ts +3 -1
- package/dist/components/use-mobile.js +1 -1
- package/dist/components/venue-events-block.js +5 -5
- package/dist/components/venue-grade-dimensions.d.ts +2 -1
- package/dist/components/venue-grade-dimensions.js +1 -1
- package/dist/components/venue-quotes.d.ts +2 -1
- package/dist/components/venue-quotes.js +1 -1
- package/dist/components/venue-wall.d.ts +2 -1
- package/dist/components/venue-wall.js +1 -1
- package/dist/components/weekday-theme-banner.js +1 -1
- package/dist/components/weekend-panel.js +4 -4
- package/dist/hooks/use-drag-swipe.js +1 -1
- package/dist/hooks/use-haptic.js +2 -2
- package/dist/hooks/use-media-query.js +1 -1
- package/dist/hooks/use-mobile.js +1 -1
- package/dist/hooks/use-mounted-theme.js +1 -1
- package/dist/hooks/use-sticky.js +1 -1
- package/dist/lib/card-hover.js +2 -18
- package/dist/lib/grade.d.ts +54 -0
- package/dist/lib/grade.js +2 -0
- package/dist/lib/icon-depth.d.ts +35 -0
- package/dist/lib/icon-depth.js +2 -0
- package/dist/lib/icon-registry.js +1 -1
- package/dist/lib/motion.js +2 -2
- package/dist/lib/overlay-pill.d.ts +30 -0
- package/dist/lib/overlay-pill.js +2 -0
- package/dist/lib/utils.js +1 -1
- package/dist/place-types-BHdeMMiG.d.ts +317 -0
- package/package.json +1 -1
- package/src/components/button.tsx +5 -1
- package/src/components/colour-dictionary.stories.tsx +268 -82
- package/src/components/doorway-tiles.stories.tsx +83 -0
- package/src/components/doorway-tiles.test.tsx +51 -0
- package/src/components/doorway-tiles.tsx +225 -0
- package/src/components/grade-atom.stories.tsx +81 -0
- package/src/components/grade-atom.tsx +256 -0
- package/src/components/hue-chip.stories.tsx +2 -2
- package/src/components/menu-family-block.stories.tsx +29 -0
- package/src/components/menu-family-block.test.tsx +51 -0
- package/src/components/menu-family-block.tsx +20 -4
- package/src/components/menu-intent-hero.tsx +1 -1
- package/src/components/menu-mocks.ts +39 -3
- package/src/components/menu-types.ts +17 -33
- package/src/components/motion-icon-actions.test.tsx +115 -0
- package/src/components/motion-icon-actions.tsx +580 -0
- package/src/components/motion-icon-controls.test.tsx +1 -0
- package/src/components/motion-icon-controls.tsx +18 -13
- package/src/components/motion-icon-set.stories.tsx +62 -3
- package/src/components/motion-icon-set.tsx +349 -2
- package/src/components/motion-icon.test.tsx +180 -1
- package/src/components/motion-icon.tsx +97 -8
- package/src/components/place-card.stories.tsx +66 -76
- package/src/components/place-card.test.tsx +188 -100
- package/src/components/place-card.tsx +356 -148
- package/src/components/place-types.ts +17 -4
- package/src/components/poster-wall.stories.tsx +84 -0
- package/src/components/poster-wall.test.tsx +94 -3
- package/src/components/poster-wall.tsx +257 -9
- package/src/components/segmented-nav.stories.tsx +72 -1
- package/src/components/segmented-nav.test.tsx +96 -0
- package/src/components/segmented-nav.tsx +36 -4
- package/src/globals.css +26 -0
- package/src/hooks/use-haptic.test.tsx +80 -0
- package/src/hooks/use-haptic.ts +45 -4
- package/src/lib/grade.test.ts +82 -0
- package/src/lib/grade.ts +76 -0
- package/src/lib/icon-depth.ts +35 -0
- package/src/lib/overlay-pill.ts +29 -0
- package/dist/components/haptic-button.d.ts +0 -12
- package/dist/components/haptic-button.js +0 -17
- package/dist/components/menu-family-tiles.d.ts +0 -19
- package/dist/components/menu-family-tiles.js +0 -57
- package/src/components/haptic-button.stories.tsx +0 -41
- package/src/components/haptic-button.tsx +0 -19
- package/src/components/menu-family-tiles.stories.tsx +0 -54
- package/src/components/menu-family-tiles.test.tsx +0 -30
- package/src/components/menu-family-tiles.tsx +0 -132
|
@@ -80,6 +80,90 @@ export const NoTiles: Story = {
|
|
|
80
80
|
args: { children: <h1 className="text-3xl font-black">No poster data yet</h1> },
|
|
81
81
|
};
|
|
82
82
|
|
|
83
|
+
function StripFrame({
|
|
84
|
+
width,
|
|
85
|
+
height,
|
|
86
|
+
children,
|
|
87
|
+
}: {
|
|
88
|
+
width: number;
|
|
89
|
+
height: number;
|
|
90
|
+
children: React.ReactNode;
|
|
91
|
+
}) {
|
|
92
|
+
return (
|
|
93
|
+
<div
|
|
94
|
+
className="relative mx-auto overflow-hidden rounded-2xl"
|
|
95
|
+
style={{ width, height }}
|
|
96
|
+
>
|
|
97
|
+
{children}
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The banner backdrop: fills the caller's box, two rows of 4:5 tiles filled
|
|
104
|
+
* from the right edge (soonest-first), full-opacity posters under a veil that
|
|
105
|
+
* is dense on the left and clears on the right.
|
|
106
|
+
*/
|
|
107
|
+
export const Strip: Story = {
|
|
108
|
+
render: () => (
|
|
109
|
+
<StripFrame width={343} height={150}>
|
|
110
|
+
<PosterWall variant="strip" tiles={tiles(12)} />
|
|
111
|
+
<div className="relative flex h-full flex-col justify-between p-4 text-white">
|
|
112
|
+
<h2 className="max-w-[62%] font-serif text-2xl font-semibold leading-[1.05]">
|
|
113
|
+
Today in Cluj-Napoca
|
|
114
|
+
</h2>
|
|
115
|
+
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-white/70">
|
|
116
|
+
4 events today
|
|
117
|
+
</span>
|
|
118
|
+
</div>
|
|
119
|
+
</StripFrame>
|
|
120
|
+
),
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/** The desktop hero box: thirteen columns of 111px posters. */
|
|
124
|
+
export const StripHero: Story = {
|
|
125
|
+
render: () => (
|
|
126
|
+
<StripFrame width={1408} height={280}>
|
|
127
|
+
<PosterWall variant="strip" tiles={tiles(26)} />
|
|
128
|
+
<div className="relative flex h-full max-w-[46%] flex-col justify-center gap-3 px-10 text-white">
|
|
129
|
+
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-white/70">
|
|
130
|
+
33 upcoming events
|
|
131
|
+
</span>
|
|
132
|
+
<h2 className="font-serif text-[44px] leading-[1.06] tracking-[-0.01em]">
|
|
133
|
+
Up next in Cluj-Napoca
|
|
134
|
+
</h2>
|
|
135
|
+
</div>
|
|
136
|
+
</StripFrame>
|
|
137
|
+
),
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/** A sparse city: three posters, lit at the right edge; the rest is wall. */
|
|
141
|
+
export const StripSparse: Story = {
|
|
142
|
+
render: () => (
|
|
143
|
+
<StripFrame width={343} height={150}>
|
|
144
|
+
<PosterWall variant="strip" tiles={tiles(3)} />
|
|
145
|
+
</StripFrame>
|
|
146
|
+
),
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** No events at all: every slot is the placeholder and the sweep is off. */
|
|
150
|
+
export const StripEmpty: Story = {
|
|
151
|
+
render: () => (
|
|
152
|
+
<StripFrame width={343} height={150}>
|
|
153
|
+
<PosterWall variant="strip" tiles={[]} sweep={false} />
|
|
154
|
+
</StripFrame>
|
|
155
|
+
),
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/** The strip's loading state, at rest. */
|
|
159
|
+
export const StripSkeleton: Story = {
|
|
160
|
+
render: () => (
|
|
161
|
+
<StripFrame width={343} height={150}>
|
|
162
|
+
<PosterWallSkeleton variant="strip" />
|
|
163
|
+
</StripFrame>
|
|
164
|
+
),
|
|
165
|
+
};
|
|
166
|
+
|
|
83
167
|
/** The tile grid at rest — no sweeping light in the loading state. */
|
|
84
168
|
export const Skeleton: Story = {
|
|
85
169
|
render: () => <PosterWallSkeleton />,
|
|
@@ -1,10 +1,101 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { render } from "@testing-library/react";
|
|
3
|
-
import { PosterWall } from "./poster-wall";
|
|
1
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
2
|
+
import { act, render } from "@testing-library/react";
|
|
3
|
+
import { PosterWall, stripGeometry } from "./poster-wall";
|
|
4
4
|
|
|
5
5
|
const grid = (container: HTMLElement) =>
|
|
6
6
|
container.querySelector<HTMLElement>("[aria-hidden].grid")!;
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* The setup's ResizeObserver never fires; the strip needs one measurement.
|
|
10
|
+
* Returns a `measure` that delivers it to every observed node, and a `restore`.
|
|
11
|
+
*/
|
|
12
|
+
function observeBox(width: number, height: number) {
|
|
13
|
+
const Original = globalThis.ResizeObserver;
|
|
14
|
+
const observers: { callback: ResizeObserverCallback; target: Element }[] =
|
|
15
|
+
[];
|
|
16
|
+
globalThis.ResizeObserver = class {
|
|
17
|
+
constructor(private callback: ResizeObserverCallback) {}
|
|
18
|
+
observe(target: Element) {
|
|
19
|
+
observers.push({ callback: this.callback, target });
|
|
20
|
+
}
|
|
21
|
+
unobserve() {}
|
|
22
|
+
disconnect() {}
|
|
23
|
+
};
|
|
24
|
+
return {
|
|
25
|
+
measure: () =>
|
|
26
|
+
act(() => {
|
|
27
|
+
for (const { callback, target } of observers) {
|
|
28
|
+
const entry = {
|
|
29
|
+
target,
|
|
30
|
+
contentRect: { width, height },
|
|
31
|
+
} as unknown as ResizeObserverEntry;
|
|
32
|
+
callback([entry], {} as ResizeObserver);
|
|
33
|
+
}
|
|
34
|
+
}),
|
|
35
|
+
restore: () => {
|
|
36
|
+
globalThis.ResizeObserver = Original;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("stripGeometry", () => {
|
|
42
|
+
it("cuts 4:5 tiles in two rows and covers the width", () => {
|
|
43
|
+
// 343×150 phone strip: (150 − 2) / 2 = 74 tall → 59 wide → 6 columns
|
|
44
|
+
expect(stripGeometry(343, 150)).toEqual({
|
|
45
|
+
tileWidth: 59,
|
|
46
|
+
cols: 6,
|
|
47
|
+
slotCount: 12,
|
|
48
|
+
});
|
|
49
|
+
// 1408×280 desktop hero: 139 tall → 111 wide → 13 columns
|
|
50
|
+
expect(stripGeometry(1408, 280)).toEqual({
|
|
51
|
+
tileWidth: 111,
|
|
52
|
+
cols: 13,
|
|
53
|
+
slotCount: 26,
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("caps the slot count", () => {
|
|
58
|
+
expect(stripGeometry(4000, 120).slotCount).toBe(32);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("PosterWall strip", () => {
|
|
63
|
+
let restore: (() => void) | undefined;
|
|
64
|
+
afterEach(() => restore?.());
|
|
65
|
+
|
|
66
|
+
it("paints no tiles before the box is measured", () => {
|
|
67
|
+
const { container } = render(
|
|
68
|
+
<PosterWall variant="strip" tiles={[{ src: "p0.jpg" }]} />,
|
|
69
|
+
);
|
|
70
|
+
expect(container.querySelector("[aria-hidden].grid")).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("fills from the right, never cycles, and pads with placeholders", () => {
|
|
74
|
+
const box = observeBox(343, 150);
|
|
75
|
+
restore = box.restore;
|
|
76
|
+
const tiles = Array.from({ length: 3 }, (_, i) => ({ src: `p${i}.jpg` }));
|
|
77
|
+
const { container } = render(<PosterWall variant="strip" tiles={tiles} />);
|
|
78
|
+
box.measure();
|
|
79
|
+
const g = grid(container);
|
|
80
|
+
expect(g.style.gridAutoColumns).toBe("59px");
|
|
81
|
+
expect(g.children).toHaveLength(12);
|
|
82
|
+
expect(g.querySelectorAll("img")).toHaveLength(3);
|
|
83
|
+
// DOM order is fill order; `direction: rtl` puts index 0 at the top-right.
|
|
84
|
+
expect(g.children[0].getAttribute("src")).toBe("p0.jpg");
|
|
85
|
+
expect(g.children[3].tagName).toBe("DIV");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("keeps the sweep paused when the caller turns it off", () => {
|
|
89
|
+
const { container } = render(
|
|
90
|
+
<PosterWall variant="strip" tiles={[]} sweep={false} />,
|
|
91
|
+
);
|
|
92
|
+
const sweep = container.querySelector<HTMLElement>(
|
|
93
|
+
"[style*='animation-name']",
|
|
94
|
+
)!;
|
|
95
|
+
expect(sweep.style.animationPlayState).toBe("paused");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
8
99
|
describe("PosterWall grid", () => {
|
|
9
100
|
it("sizes the grid to the tiles and completes the last row by cycling them", () => {
|
|
10
101
|
const tiles = Array.from({ length: 5 }, (_, i) => ({ src: `p${i}.jpg` }));
|
|
@@ -30,11 +30,65 @@ import { Skeleton } from "./skeleton";
|
|
|
30
30
|
* indefinitely at zero React cost — the keyframes are scoped to this file via
|
|
31
31
|
* a `<style>` tag since editing the DS's global stylesheet is out of scope
|
|
32
32
|
* for this component.
|
|
33
|
+
*
|
|
34
|
+
* ## `strip` — the wall as a banner backdrop
|
|
35
|
+
*
|
|
36
|
+
* The third variant is the one that does NOT own its box: it fills the
|
|
37
|
+
* caller's (`absolute inset-0`, no height, no radius) so a 16:7 banner or a
|
|
38
|
+
* 280px hero can put the wall behind its own type. Everything about it
|
|
39
|
+
* follows from the tiles having to be legible at ~75px tall:
|
|
40
|
+
*
|
|
41
|
+
* - Tiles are 4:5 portrait in two fixed rows — the shape says "posters", and
|
|
42
|
+
* a portrait crop keeps a poster's headline word visible at 60px wide.
|
|
43
|
+
* - The grid fills column-major FROM THE RIGHT EDGE (`direction: rtl`), so
|
|
44
|
+
* `tiles[0]` is the lit top-right poster and the caller passes them
|
|
45
|
+
* soonest-first. The veil is dense on the left, where the type lives, and
|
|
46
|
+
* clears on the right, where the posters show.
|
|
47
|
+
* - Tiles render at full opacity and the veil does all the darkening: the only
|
|
48
|
+
* way one grid can be wall texture on the left and real artwork on the right.
|
|
49
|
+
* - No cycling. Legible repetition reads as a data bug ("the same poster four
|
|
50
|
+
* times"); slots past the last picture take the striped placeholder, and
|
|
51
|
+
* because the fill starts at the right they land under the dense veil where
|
|
52
|
+
* they read as wall. A sparse city is the common case, not a fallback.
|
|
53
|
+
* - The grid mounts after the first measurement (base and veil paint at once),
|
|
54
|
+
* with `motion-image-enter`, so the tile count never reflows on screen.
|
|
33
55
|
*/
|
|
34
56
|
|
|
35
57
|
const POSTER_WALL_DESKTOP_TILE_COUNT = 48;
|
|
36
58
|
const POSTER_WALL_COMPACT_TILE_COUNT = 36;
|
|
59
|
+
const POSTER_WALL_STRIP_TILE_COUNT = 32;
|
|
60
|
+
const POSTER_WALL_STRIP_ROWS = 2;
|
|
61
|
+
const POSTER_WALL_STRIP_TILE_ASPECT = 4 / 5;
|
|
62
|
+
const POSTER_WALL_STRIP_GAP_PX = 2;
|
|
37
63
|
const POSTER_WALL_SWEEP_KEYFRAMES_ID = "ds-poster-wall-sweep-keyframes";
|
|
64
|
+
const POSTER_WALL_BASE = "oklch(0.22 0.012 60)";
|
|
65
|
+
const POSTER_WALL_VEIL = "oklch(0.2 0.01 60";
|
|
66
|
+
|
|
67
|
+
const STRIP_VEIL = `linear-gradient(90deg, ${POSTER_WALL_VEIL} / 0.92) 0%, ${POSTER_WALL_VEIL} / 0.78) 50%, ${POSTER_WALL_VEIL} / 0.4) 75%, ${POSTER_WALL_VEIL} / 0.25) 100%)`;
|
|
68
|
+
const STRIP_VEIL_WIDE = `linear-gradient(90deg, ${POSTER_WALL_VEIL} / 0.92) 0%, ${POSTER_WALL_VEIL} / 0.8) 40%, ${POSTER_WALL_VEIL} / 0.45) 62%, ${POSTER_WALL_VEIL} / 0.22) 100%)`;
|
|
69
|
+
|
|
70
|
+
const PLACEHOLDER_TILE_CLASS =
|
|
71
|
+
"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)]";
|
|
72
|
+
|
|
73
|
+
/** Tile geometry for a measured strip box: 4:5 tiles in two rows, as many columns as cover the width. */
|
|
74
|
+
export function stripGeometry(width: number, height: number) {
|
|
75
|
+
const rowHeight =
|
|
76
|
+
(height - POSTER_WALL_STRIP_GAP_PX * (POSTER_WALL_STRIP_ROWS - 1)) /
|
|
77
|
+
POSTER_WALL_STRIP_ROWS;
|
|
78
|
+
const tileWidth = Math.max(
|
|
79
|
+
1,
|
|
80
|
+
Math.round(rowHeight * POSTER_WALL_STRIP_TILE_ASPECT),
|
|
81
|
+
);
|
|
82
|
+
const cols = Math.max(
|
|
83
|
+
1,
|
|
84
|
+
Math.ceil(width / (tileWidth + POSTER_WALL_STRIP_GAP_PX)),
|
|
85
|
+
);
|
|
86
|
+
const slotCount = Math.min(
|
|
87
|
+
cols * POSTER_WALL_STRIP_ROWS,
|
|
88
|
+
POSTER_WALL_STRIP_TILE_COUNT,
|
|
89
|
+
);
|
|
90
|
+
return { tileWidth, cols, slotCount };
|
|
91
|
+
}
|
|
38
92
|
|
|
39
93
|
function usePrefersReducedMotion(): boolean {
|
|
40
94
|
const [reduced, setReduced] = React.useState(false);
|
|
@@ -78,11 +132,22 @@ export type PosterWallTile = {
|
|
|
78
132
|
src?: string;
|
|
79
133
|
};
|
|
80
134
|
|
|
135
|
+
export type PosterWallVariant = "default" | "compact" | "strip";
|
|
136
|
+
|
|
81
137
|
export type PosterWallProps = {
|
|
82
138
|
/** Background tiles. The grid sizes itself to how many there are (see above); with none, a placeholder grid renders. */
|
|
83
139
|
tiles?: PosterWallTile[];
|
|
84
|
-
/**
|
|
85
|
-
|
|
140
|
+
/**
|
|
141
|
+
* `compact` is the bottom-aligned mobile treatment (fewer, larger tiles, a
|
|
142
|
+
* top-fading veil). `strip` fills the caller's box as a banner backdrop —
|
|
143
|
+
* two rows of portrait tiles filled from the right edge, soonest-first.
|
|
144
|
+
*/
|
|
145
|
+
variant?: PosterWallVariant;
|
|
146
|
+
/**
|
|
147
|
+
* Whether the light sweeps. Defaults on; a caller turns it off for a wall
|
|
148
|
+
* with nothing on it (an empty strip is a status surface, not a stage).
|
|
149
|
+
*/
|
|
150
|
+
sweep?: boolean;
|
|
86
151
|
/** The overlay content — fully caller-composed. */
|
|
87
152
|
children?: React.ReactNode;
|
|
88
153
|
className?: string;
|
|
@@ -91,9 +156,162 @@ export type PosterWallProps = {
|
|
|
91
156
|
export function PosterWall({
|
|
92
157
|
tiles,
|
|
93
158
|
variant = "default",
|
|
159
|
+
sweep = true,
|
|
94
160
|
children,
|
|
95
161
|
className,
|
|
96
162
|
}: PosterWallProps) {
|
|
163
|
+
if (variant === "strip") {
|
|
164
|
+
return (
|
|
165
|
+
<PosterStrip tiles={tiles} sweep={sweep} className={className}>
|
|
166
|
+
{children}
|
|
167
|
+
</PosterStrip>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return (
|
|
171
|
+
<PosterWallMasthead
|
|
172
|
+
tiles={tiles}
|
|
173
|
+
variant={variant}
|
|
174
|
+
sweep={sweep}
|
|
175
|
+
className={className}
|
|
176
|
+
>
|
|
177
|
+
{children}
|
|
178
|
+
</PosterWallMasthead>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function PosterStrip({
|
|
183
|
+
tiles,
|
|
184
|
+
sweep,
|
|
185
|
+
children,
|
|
186
|
+
className,
|
|
187
|
+
}: {
|
|
188
|
+
tiles?: PosterWallTile[];
|
|
189
|
+
sweep: boolean;
|
|
190
|
+
children?: React.ReactNode;
|
|
191
|
+
className?: string;
|
|
192
|
+
}) {
|
|
193
|
+
const pictures = React.useMemo(
|
|
194
|
+
() => (tiles ?? []).filter((tile) => !!tile.src),
|
|
195
|
+
[tiles],
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const prefersReducedMotion = usePrefersReducedMotion();
|
|
199
|
+
const [wallRef, inView] = useInViewport<HTMLDivElement>();
|
|
200
|
+
const isSweepPaused = !sweep || prefersReducedMotion || !inView;
|
|
201
|
+
|
|
202
|
+
// Nothing is assumed about the box: the grid waits for the first
|
|
203
|
+
// measurement, so the server and the hydrating client paint base + veil only
|
|
204
|
+
// and the tiles arrive once, at their final count.
|
|
205
|
+
const [box, setBox] = React.useState<{
|
|
206
|
+
width: number;
|
|
207
|
+
height: number;
|
|
208
|
+
} | null>(null);
|
|
209
|
+
React.useEffect(() => {
|
|
210
|
+
const node = wallRef.current;
|
|
211
|
+
if (!node || typeof ResizeObserver === "undefined") return;
|
|
212
|
+
const observer = new ResizeObserver(([entry]) =>
|
|
213
|
+
setBox({
|
|
214
|
+
width: entry.contentRect.width,
|
|
215
|
+
height: entry.contentRect.height,
|
|
216
|
+
}),
|
|
217
|
+
);
|
|
218
|
+
observer.observe(node);
|
|
219
|
+
return () => observer.disconnect();
|
|
220
|
+
}, [wallRef]);
|
|
221
|
+
|
|
222
|
+
const geometry = box ? stripGeometry(box.width, box.height) : null;
|
|
223
|
+
const slots: PosterWallTile[] = geometry
|
|
224
|
+
? Array.from(
|
|
225
|
+
{ length: geometry.slotCount },
|
|
226
|
+
(_, index) => pictures[index] ?? {},
|
|
227
|
+
)
|
|
228
|
+
: [];
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
<div
|
|
232
|
+
ref={wallRef}
|
|
233
|
+
className={cn("absolute inset-0 overflow-hidden", className)}
|
|
234
|
+
style={{ backgroundColor: POSTER_WALL_BASE }}
|
|
235
|
+
>
|
|
236
|
+
<style>{`
|
|
237
|
+
@keyframes ${POSTER_WALL_SWEEP_KEYFRAMES_ID} {
|
|
238
|
+
0% { transform: translateX(-30%); }
|
|
239
|
+
100% { transform: translateX(30%); }
|
|
240
|
+
}
|
|
241
|
+
`}</style>
|
|
242
|
+
|
|
243
|
+
{geometry ? (
|
|
244
|
+
<div
|
|
245
|
+
className="motion-image-enter absolute inset-0 grid grid-flow-col grid-rows-2 gap-[2px] [direction:rtl]"
|
|
246
|
+
style={{ gridAutoColumns: `${geometry.tileWidth}px` }}
|
|
247
|
+
aria-hidden
|
|
248
|
+
>
|
|
249
|
+
{slots.map((tile, index) =>
|
|
250
|
+
tile.src ? (
|
|
251
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
252
|
+
<img
|
|
253
|
+
key={index}
|
|
254
|
+
src={tile.src}
|
|
255
|
+
alt=""
|
|
256
|
+
loading="lazy"
|
|
257
|
+
decoding="async"
|
|
258
|
+
className="h-full w-full object-cover"
|
|
259
|
+
/>
|
|
260
|
+
) : (
|
|
261
|
+
<div key={index} className={PLACEHOLDER_TILE_CLASS} />
|
|
262
|
+
),
|
|
263
|
+
)}
|
|
264
|
+
</div>
|
|
265
|
+
) : null}
|
|
266
|
+
|
|
267
|
+
{/* Peak 0.22, not the masthead's 0.34: that was tuned for a half-opacity
|
|
268
|
+
wall and would wash out posters shown at full clarity. */}
|
|
269
|
+
<div
|
|
270
|
+
className="absolute -inset-[20%]"
|
|
271
|
+
style={{
|
|
272
|
+
background:
|
|
273
|
+
"radial-gradient(circle at 30% 50%, oklch(1 0 0 / 0.22) 0%, transparent 42%)",
|
|
274
|
+
animationName: POSTER_WALL_SWEEP_KEYFRAMES_ID,
|
|
275
|
+
animationDuration: "18s",
|
|
276
|
+
animationTimingFunction: "linear",
|
|
277
|
+
animationIterationCount: "infinite",
|
|
278
|
+
animationPlayState: isSweepPaused ? "paused" : "running",
|
|
279
|
+
}}
|
|
280
|
+
aria-hidden
|
|
281
|
+
/>
|
|
282
|
+
|
|
283
|
+
{/* Two veils, one per breakpoint — a gradient swap, not DOM weight. */}
|
|
284
|
+
<div
|
|
285
|
+
className="absolute inset-0 lg:hidden"
|
|
286
|
+
style={{ background: STRIP_VEIL }}
|
|
287
|
+
aria-hidden
|
|
288
|
+
/>
|
|
289
|
+
<div
|
|
290
|
+
className="absolute inset-0 hidden lg:block"
|
|
291
|
+
style={{ background: STRIP_VEIL_WIDE }}
|
|
292
|
+
aria-hidden
|
|
293
|
+
/>
|
|
294
|
+
|
|
295
|
+
{children ? (
|
|
296
|
+
<div className="relative h-full min-w-0 text-white">{children}</div>
|
|
297
|
+
) : null}
|
|
298
|
+
</div>
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function PosterWallMasthead({
|
|
303
|
+
tiles,
|
|
304
|
+
variant,
|
|
305
|
+
sweep,
|
|
306
|
+
children,
|
|
307
|
+
className,
|
|
308
|
+
}: {
|
|
309
|
+
tiles?: PosterWallTile[];
|
|
310
|
+
variant: "default" | "compact";
|
|
311
|
+
sweep: boolean;
|
|
312
|
+
children?: React.ReactNode;
|
|
313
|
+
className?: string;
|
|
314
|
+
}) {
|
|
97
315
|
const isCompact = variant === "compact";
|
|
98
316
|
const maxCols = isCompact ? 6 : 12;
|
|
99
317
|
const maxSlots = isCompact
|
|
@@ -107,7 +325,7 @@ export function PosterWall({
|
|
|
107
325
|
|
|
108
326
|
const prefersReducedMotion = usePrefersReducedMotion();
|
|
109
327
|
const [wallRef, inView] = useInViewport<HTMLDivElement>();
|
|
110
|
-
const isSweepPaused = prefersReducedMotion || !inView;
|
|
328
|
+
const isSweepPaused = !sweep || prefersReducedMotion || !inView;
|
|
111
329
|
|
|
112
330
|
// Measured on the client; the server assumes a phone or a wide desktop
|
|
113
331
|
// column, which is what those two variants are for.
|
|
@@ -172,10 +390,7 @@ export function PosterWall({
|
|
|
172
390
|
className="h-full w-full object-cover"
|
|
173
391
|
/>
|
|
174
392
|
) : (
|
|
175
|
-
<div
|
|
176
|
-
key={index}
|
|
177
|
-
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)]"
|
|
178
|
-
/>
|
|
393
|
+
<div key={index} className={PLACEHOLDER_TILE_CLASS} />
|
|
179
394
|
),
|
|
180
395
|
)}
|
|
181
396
|
</div>
|
|
@@ -223,16 +438,49 @@ export function PosterWall({
|
|
|
223
438
|
}
|
|
224
439
|
|
|
225
440
|
export type PosterWallSkeletonProps = {
|
|
226
|
-
/**
|
|
227
|
-
variant?:
|
|
441
|
+
/** Same meaning as on `PosterWall`; `strip` fills the caller's box. */
|
|
442
|
+
variant?: PosterWallVariant;
|
|
228
443
|
className?: string;
|
|
229
444
|
};
|
|
230
445
|
|
|
446
|
+
/** Twelve tiles: a 343×150 strip at rest. The count is cosmetic — the box is the caller's. */
|
|
447
|
+
const POSTER_WALL_STRIP_SKELETON_TILE_COUNT = 12;
|
|
448
|
+
|
|
231
449
|
/** The tile grid at rest — the sweeping light never renders in the loading state. */
|
|
232
450
|
export function PosterWallSkeleton({
|
|
233
451
|
variant = "default",
|
|
234
452
|
className,
|
|
235
453
|
}: PosterWallSkeletonProps) {
|
|
454
|
+
if (variant === "strip") {
|
|
455
|
+
return (
|
|
456
|
+
<div
|
|
457
|
+
aria-hidden="true"
|
|
458
|
+
className={cn("absolute inset-0 overflow-hidden", className)}
|
|
459
|
+
style={{ backgroundColor: POSTER_WALL_BASE }}
|
|
460
|
+
>
|
|
461
|
+
<div className="absolute inset-0 grid auto-cols-fr grid-flow-col grid-rows-2 gap-[2px] [direction:rtl]">
|
|
462
|
+
{Array.from(
|
|
463
|
+
{ length: POSTER_WALL_STRIP_SKELETON_TILE_COUNT },
|
|
464
|
+
(_, index) => (
|
|
465
|
+
<Skeleton
|
|
466
|
+
key={index}
|
|
467
|
+
className="h-full w-full rounded-none bg-white/10"
|
|
468
|
+
/>
|
|
469
|
+
),
|
|
470
|
+
)}
|
|
471
|
+
</div>
|
|
472
|
+
<div
|
|
473
|
+
className="absolute inset-0 lg:hidden"
|
|
474
|
+
style={{ background: STRIP_VEIL }}
|
|
475
|
+
/>
|
|
476
|
+
<div
|
|
477
|
+
className="absolute inset-0 hidden lg:block"
|
|
478
|
+
style={{ background: STRIP_VEIL_WIDE }}
|
|
479
|
+
/>
|
|
480
|
+
</div>
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
236
484
|
const isCompact = variant === "compact";
|
|
237
485
|
const tileCount = isCompact
|
|
238
486
|
? POSTER_WALL_COMPACT_TILE_COUNT
|
|
@@ -1,6 +1,28 @@
|
|
|
1
|
+
import * as React from "react";
|
|
1
2
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
3
|
import { CalendarClock, LayoutGrid, List, SlidersHorizontal } from "lucide-react";
|
|
3
|
-
import { SegmentedNav } from "./segmented-nav";
|
|
4
|
+
import { SegmentedNav, type SegmentedNavLinkProps } from "./segmented-nav";
|
|
5
|
+
import { MotionUtensils, MotionWine } from "./motion-icon-set";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Module-scoped on purpose: a `linkComponent` created inside `render` is a new
|
|
9
|
+
* element type on every story re-render, and React would remount every
|
|
10
|
+
* segment — killing the pointer-down gesture the story exists to show.
|
|
11
|
+
*/
|
|
12
|
+
function PreventNavLink({ href, children, onClick, ...props }: SegmentedNavLinkProps) {
|
|
13
|
+
return (
|
|
14
|
+
<a
|
|
15
|
+
href={href}
|
|
16
|
+
onClick={(e) => {
|
|
17
|
+
e.preventDefault();
|
|
18
|
+
onClick?.();
|
|
19
|
+
}}
|
|
20
|
+
{...props}
|
|
21
|
+
>
|
|
22
|
+
{children}
|
|
23
|
+
</a>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
4
26
|
|
|
5
27
|
const meta = {
|
|
6
28
|
title: "Components/SegmentedNav",
|
|
@@ -86,6 +108,55 @@ export const InAControlRow: Story = {
|
|
|
86
108
|
),
|
|
87
109
|
};
|
|
88
110
|
|
|
111
|
+
/**
|
|
112
|
+
* CHARACTER-ANIMATED SEGMENTS — the menu hub's Food / Drinks toggle.
|
|
113
|
+
*
|
|
114
|
+
* `icon` as a function receives the segment's `active` state and a
|
|
115
|
+
* `replayToken` that bumps on pointer down, so the utensils uncross and the
|
|
116
|
+
* glass pours on the finger, before the route changes. Both glyphs HOLD their
|
|
117
|
+
* pose while selected, so the active side is readable from the silhouette and
|
|
118
|
+
* not only from the tint. The story swaps `activeId` on click to stand in for
|
|
119
|
+
* navigation.
|
|
120
|
+
*/
|
|
121
|
+
export const WithMotionIcons: Story = {
|
|
122
|
+
args: {
|
|
123
|
+
items: [],
|
|
124
|
+
activeId: "food",
|
|
125
|
+
label: "Menu views",
|
|
126
|
+
},
|
|
127
|
+
render: (args) => {
|
|
128
|
+
const [activeId, setActiveId] = React.useState(args.activeId);
|
|
129
|
+
return (
|
|
130
|
+
<div className="w-[358px]">
|
|
131
|
+
<SegmentedNav
|
|
132
|
+
{...args}
|
|
133
|
+
activeId={activeId}
|
|
134
|
+
onSelect={setActiveId}
|
|
135
|
+
linkComponent={PreventNavLink}
|
|
136
|
+
items={[
|
|
137
|
+
{
|
|
138
|
+
id: "food",
|
|
139
|
+
href: "/menu",
|
|
140
|
+
label: "Food",
|
|
141
|
+
icon: ({ active, replayToken }) => (
|
|
142
|
+
<MotionUtensils active={active} replayToken={replayToken} className={ICON} />
|
|
143
|
+
),
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
id: "drinks",
|
|
147
|
+
href: "/menu/drinks",
|
|
148
|
+
label: "Drinks",
|
|
149
|
+
icon: ({ active, replayToken }) => (
|
|
150
|
+
<MotionWine active={active} replayToken={replayToken} className={ICON} />
|
|
151
|
+
),
|
|
152
|
+
},
|
|
153
|
+
]}
|
|
154
|
+
/>
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
|
|
89
160
|
/** Labels alone, no icons. */
|
|
90
161
|
export const WithoutIcons: Story = {
|
|
91
162
|
args: {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
3
|
+
import {
|
|
4
|
+
SegmentedNav,
|
|
5
|
+
type SegmentedNavIconState,
|
|
6
|
+
type SegmentedNavLinkProps,
|
|
7
|
+
} from "./segmented-nav";
|
|
8
|
+
|
|
9
|
+
const items = [
|
|
10
|
+
{ id: "food", href: "/menu", label: "Food" },
|
|
11
|
+
{ id: "drinks", href: "/menu/drinks", label: "Drinks" },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
/** A real `<a href>` click makes jsdom log "Not implemented: navigation"; this one stays put. */
|
|
15
|
+
function StubLink({ href, children, onClick, ...props }: SegmentedNavLinkProps) {
|
|
16
|
+
return (
|
|
17
|
+
<a
|
|
18
|
+
href={href}
|
|
19
|
+
onClick={(e) => {
|
|
20
|
+
e.preventDefault();
|
|
21
|
+
onClick?.();
|
|
22
|
+
}}
|
|
23
|
+
{...props}
|
|
24
|
+
>
|
|
25
|
+
{children}
|
|
26
|
+
</a>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("SegmentedNav", () => {
|
|
31
|
+
it("marks the active segment as the current page", () => {
|
|
32
|
+
render(<SegmentedNav items={items} activeId="drinks" label="Menu views" />);
|
|
33
|
+
expect(screen.getByRole("link", { name: "Drinks" })).toHaveAttribute("aria-current", "page");
|
|
34
|
+
expect(screen.getByRole("link", { name: "Food" })).not.toHaveAttribute("aria-current");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("renders a static icon as given", () => {
|
|
38
|
+
render(
|
|
39
|
+
<SegmentedNav
|
|
40
|
+
items={[{ ...items[0], icon: <span data-testid="static-icon" /> }]}
|
|
41
|
+
activeId="food"
|
|
42
|
+
label="Menu views"
|
|
43
|
+
/>
|
|
44
|
+
);
|
|
45
|
+
expect(screen.getByTestId("static-icon")).toBeInTheDocument();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("hands a function icon its segment's state and bumps the token on pointer down, not on click", () => {
|
|
49
|
+
const foodIcon = vi.fn((state: SegmentedNavIconState) => (
|
|
50
|
+
<span data-testid="food-icon" data-active={state.active} data-token={state.replayToken} />
|
|
51
|
+
));
|
|
52
|
+
const drinksIcon = vi.fn((state: SegmentedNavIconState) => (
|
|
53
|
+
<span data-testid="drinks-icon" data-token={state.replayToken} />
|
|
54
|
+
));
|
|
55
|
+
render(
|
|
56
|
+
<SegmentedNav
|
|
57
|
+
items={[
|
|
58
|
+
{ ...items[0], icon: foodIcon },
|
|
59
|
+
{ ...items[1], icon: drinksIcon },
|
|
60
|
+
]}
|
|
61
|
+
activeId="food"
|
|
62
|
+
label="Menu views"
|
|
63
|
+
linkComponent={StubLink}
|
|
64
|
+
/>
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
expect(screen.getByTestId("food-icon")).toHaveAttribute("data-active", "true");
|
|
68
|
+
expect(screen.getByTestId("food-icon")).toHaveAttribute("data-token", "0");
|
|
69
|
+
|
|
70
|
+
// The finger lands on Drinks: its glyph performs before any navigation.
|
|
71
|
+
const drinks = screen.getByRole("link", { name: "Drinks" });
|
|
72
|
+
fireEvent.pointerDown(drinks);
|
|
73
|
+
expect(screen.getByTestId("drinks-icon")).toHaveAttribute("data-token", "1");
|
|
74
|
+
// The neighbour is untouched.
|
|
75
|
+
expect(screen.getByTestId("food-icon")).toHaveAttribute("data-token", "0");
|
|
76
|
+
|
|
77
|
+
// A click alone is not a performance — the token answers the pointer only.
|
|
78
|
+
fireEvent.click(drinks);
|
|
79
|
+
expect(screen.getByTestId("drinks-icon")).toHaveAttribute("data-token", "1");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("fires onSelect with the segment id on click", () => {
|
|
83
|
+
const onSelect = vi.fn();
|
|
84
|
+
render(
|
|
85
|
+
<SegmentedNav
|
|
86
|
+
items={items}
|
|
87
|
+
activeId="food"
|
|
88
|
+
label="Menu views"
|
|
89
|
+
onSelect={onSelect}
|
|
90
|
+
linkComponent={StubLink}
|
|
91
|
+
/>
|
|
92
|
+
);
|
|
93
|
+
fireEvent.click(screen.getByRole("link", { name: "Drinks" }));
|
|
94
|
+
expect(onSelect).toHaveBeenCalledWith("drinks");
|
|
95
|
+
});
|
|
96
|
+
});
|