@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,240 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Flame } from "lucide-react";
|
|
4
|
+
import { expect, within } from "storybook/test";
|
|
5
|
+
import { Coverflow, type CoverflowItem } from "./coverflow";
|
|
6
|
+
|
|
7
|
+
const ITEMS: CoverflowItem[] = [
|
|
8
|
+
{
|
|
9
|
+
id: "warehouse-rave",
|
|
10
|
+
title: "Warehouse Rave",
|
|
11
|
+
meta: "Fri 4 · 90 going",
|
|
12
|
+
badge: (
|
|
13
|
+
<>
|
|
14
|
+
<Flame className="h-3 w-3" aria-hidden />
|
|
15
|
+
Busiest
|
|
16
|
+
</>
|
|
17
|
+
),
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: "jazz-rooftop",
|
|
21
|
+
title: "Jazz on the Rooftop",
|
|
22
|
+
meta: "Sat 5 · 64 going",
|
|
23
|
+
isFree: true,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: "open-mic",
|
|
27
|
+
title: "Open Mic Night",
|
|
28
|
+
meta: "Sun 6 · 21 going",
|
|
29
|
+
},
|
|
30
|
+
...Array.from({ length: 4 }, (_, index) => ({
|
|
31
|
+
id: `side-event-${index + 1}`,
|
|
32
|
+
title: `Side event ${index + 1}`,
|
|
33
|
+
meta: `Event ${index + 1} details`,
|
|
34
|
+
})),
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
// Explicitly annotated rather than `satisfies`: the meta-level decorator makes
|
|
38
|
+
// the inferred type reference storybook's internal CSF module, which tsc cannot
|
|
39
|
+
// name portably (TS2742).
|
|
40
|
+
const meta: Meta<typeof Coverflow> = {
|
|
41
|
+
title: "Events/Coverflow",
|
|
42
|
+
component: Coverflow,
|
|
43
|
+
tags: ["autodocs"],
|
|
44
|
+
args: {
|
|
45
|
+
items: ITEMS,
|
|
46
|
+
activeIndex: 0,
|
|
47
|
+
freeLabel: "Free",
|
|
48
|
+
prevLabel: "Previous event",
|
|
49
|
+
nextLabel: "Next event",
|
|
50
|
+
positionLabel: "1 of 7",
|
|
51
|
+
},
|
|
52
|
+
decorators: [
|
|
53
|
+
(Story) => (
|
|
54
|
+
<div className="max-w-[520px]">
|
|
55
|
+
<Story />
|
|
56
|
+
</div>
|
|
57
|
+
),
|
|
58
|
+
],
|
|
59
|
+
parameters: {
|
|
60
|
+
docs: {
|
|
61
|
+
description: {
|
|
62
|
+
component:
|
|
63
|
+
"The busiest-events browser: a 3D perspective **ring** with a large lead poster flanked by desaturating, shrinking side posters. A card's slot is the *signed shortest way round the list*, not `index - activeIndex`, so both flanks stay full at every index — at `activeIndex: 0` the last item sits immediately to the left of the lead rather than leaving that half of the stage empty — and prev/next carry on past either end, reporting the wrapped index through `onActiveIndexChange`. Only `|offset| <= 3` is mounted, so a long list paints seven posters, not all of them. Exactly one card per step crosses the ring's seam; it renders without its transition classes for that one commit so it teleports round the back instantly instead of sliding across the face of the stage. The lane drifts a few pixels back and forth, pauses off-screen, and stops outright (along with the position transition) under `prefers-reduced-motion`. Every rendered card is a real, focusable control — the injected link when it carries an `href`, otherwise a `<button>` — so a keyboard or screen-reader user can jump straight to any of them. The prev/next arrows float over the stage, vertically centred on the poster row, and are not rendered at all for a single item, where they would be inert.",
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export default meta;
|
|
70
|
+
type Story = StoryObj<typeof Coverflow>;
|
|
71
|
+
|
|
72
|
+
export const Default: Story = {};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The ring at its old worst case. `activeIndex: 0` used to leave the entire
|
|
76
|
+
* left half of the stage empty with three cards stacked to the right; now item
|
|
77
|
+
* 6 renders at offset −1, item 5 at −2 and item 4 at −3, so both flanks are
|
|
78
|
+
* full. Step backwards from here and the index reported to the parent is 6,
|
|
79
|
+
* not a clamp at 0.
|
|
80
|
+
*/
|
|
81
|
+
export const RingAtIndexZero: Story = {
|
|
82
|
+
render: (args) => {
|
|
83
|
+
function RingCoverflow() {
|
|
84
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
85
|
+
return (
|
|
86
|
+
<Coverflow
|
|
87
|
+
{...args}
|
|
88
|
+
activeIndex={activeIndex}
|
|
89
|
+
positionLabel={`${activeIndex + 1} of ${args.items!.length}`}
|
|
90
|
+
onActiveIndexChange={setActiveIndex}
|
|
91
|
+
/>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return <RingCoverflow />;
|
|
95
|
+
},
|
|
96
|
+
parameters: {
|
|
97
|
+
docs: {
|
|
98
|
+
description: {
|
|
99
|
+
story:
|
|
100
|
+
"Start at 0 and press *Previous*: the ring turns to item 6 instead of refusing. One card per step comes round the back — it teleports at the deepest tier, off-stage and behind the depth stack, rather than tweening across the middle.",
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export const Free: Story = {
|
|
107
|
+
args: { activeIndex: 1, positionLabel: "2 of 7" },
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Three items: the ring is smaller than the visible window, so every item is
|
|
112
|
+
* on stage at once — one on each flank rather than three.
|
|
113
|
+
*/
|
|
114
|
+
export const FewSidePosters: Story = {
|
|
115
|
+
args: { items: ITEMS.slice(0, 3), positionLabel: "1 of 3" },
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Two items. The shortest path ties at `n / 2`, and the tie always resolves to
|
|
120
|
+
* the positive side, so the second card sits on the RIGHT flank at both
|
|
121
|
+
* indices instead of flipping sides as you step.
|
|
122
|
+
*/
|
|
123
|
+
export const TwoItems: Story = {
|
|
124
|
+
render: (args) => {
|
|
125
|
+
function TwoItemCoverflow() {
|
|
126
|
+
const items = args.items!.slice(0, 2);
|
|
127
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
128
|
+
return (
|
|
129
|
+
<Coverflow
|
|
130
|
+
{...args}
|
|
131
|
+
items={items}
|
|
132
|
+
activeIndex={activeIndex}
|
|
133
|
+
positionLabel={`${activeIndex + 1} of ${items.length}`}
|
|
134
|
+
onActiveIndexChange={setActiveIndex}
|
|
135
|
+
/>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return <TwoItemCoverflow />;
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A single item — no ring, so no side posters AND no arrows. A prev/next
|
|
144
|
+
* control here could only ever return to the card already showing, and this
|
|
145
|
+
* component does not render controls that look operable and do nothing.
|
|
146
|
+
*/
|
|
147
|
+
export const NoSidePosters: Story = {
|
|
148
|
+
args: { items: ITEMS.slice(0, 1), positionLabel: "1 of 1" },
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* `caption` is optional. This is the consumer app's shape: `positionLabel`
|
|
153
|
+
* alone, on one clean line with no separator dot and no gap where the caption
|
|
154
|
+
* would have been. Passing a position string as `caption` *as well* is what
|
|
155
|
+
* printed "1 of 7 · 1 of 7".
|
|
156
|
+
*/
|
|
157
|
+
export const PositionWithoutCaption: Story = {
|
|
158
|
+
args: { activeIndex: 2, caption: undefined, positionLabel: "3 of 7" },
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/** Both halves, for a caller that has a real caption to add to the position. */
|
|
162
|
+
export const CaptionAndPosition: Story = {
|
|
163
|
+
args: { activeIndex: 2, caption: "7 events", positionLabel: "3 of 7" },
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The caller owns `activeIndex` — the floating arrows advance it and the
|
|
168
|
+
* `aria-live` region announces the new lead. Watch the lead physically slide
|
|
169
|
+
* out to the side while the next card slides into its place.
|
|
170
|
+
*/
|
|
171
|
+
export const Interactive: Story = {
|
|
172
|
+
render: (args) => {
|
|
173
|
+
function InteractiveCoverflow() {
|
|
174
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
175
|
+
return (
|
|
176
|
+
<Coverflow
|
|
177
|
+
{...args}
|
|
178
|
+
activeIndex={activeIndex}
|
|
179
|
+
positionLabel={`${activeIndex + 1} of ${args.items!.length}`}
|
|
180
|
+
onActiveIndexChange={setActiveIndex}
|
|
181
|
+
/>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
return <InteractiveCoverflow />;
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Clicking any visible side poster jumps straight to it — the
|
|
190
|
+
* keyboard/screen-reader path a mouse user already has, instead of only
|
|
191
|
+
* stepping with `prevLabel`/`nextLabel`.
|
|
192
|
+
*/
|
|
193
|
+
export const SidePosterSelect: Story = {
|
|
194
|
+
render: (args) => {
|
|
195
|
+
function SelectableCoverflow() {
|
|
196
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
197
|
+
return (
|
|
198
|
+
<Coverflow
|
|
199
|
+
{...args}
|
|
200
|
+
activeIndex={activeIndex}
|
|
201
|
+
positionLabel={`${activeIndex + 1} of ${args.items!.length}`}
|
|
202
|
+
onActiveIndexChange={setActiveIndex}
|
|
203
|
+
/>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
return <SelectableCoverflow />;
|
|
207
|
+
},
|
|
208
|
+
parameters: {
|
|
209
|
+
docs: {
|
|
210
|
+
description: {
|
|
211
|
+
story:
|
|
212
|
+
"Every visible side poster is a real button — click or Tab+Enter to any of them to jump straight there via `onActiveIndexChange`.",
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Focus lands on the outermost, most degraded side poster (`|offset| === 3`)
|
|
220
|
+
* to prove the focus ring survives the tier's grayscale + reduced opacity —
|
|
221
|
+
* the ring lives on the un-filtered button wrapper, not the filtered media
|
|
222
|
+
* inside it.
|
|
223
|
+
*/
|
|
224
|
+
export const SidePosterFocused: Story = {
|
|
225
|
+
args: { activeIndex: 3 },
|
|
226
|
+
play: async ({ canvasElement }) => {
|
|
227
|
+
const canvas = within(canvasElement);
|
|
228
|
+
const poster = await canvas.findByRole("button", { name: "Warehouse Rave" });
|
|
229
|
+
poster.focus();
|
|
230
|
+
await expect(poster).toHaveFocus();
|
|
231
|
+
},
|
|
232
|
+
parameters: {
|
|
233
|
+
docs: {
|
|
234
|
+
description: {
|
|
235
|
+
story:
|
|
236
|
+
"With `activeIndex` at 3, `Warehouse Rave` (index 0) sits three slots away on the left flank — the farthest, most desaturated tier still mounted. Its `focus-visible` ring is on the un-filtered button, so it stays crisp even though the poster art inside is grayscaled and dimmed.",
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { Coverflow, type CoverflowItem } from "./coverflow";
|
|
5
|
+
|
|
6
|
+
const items = (count: number): CoverflowItem[] =>
|
|
7
|
+
Array.from({ length: count }, (_, index) => ({
|
|
8
|
+
id: `e${index}`,
|
|
9
|
+
title: `Event ${index}`,
|
|
10
|
+
meta: `Meta ${index}`,
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
const renderCoverflow = (props: Partial<React.ComponentProps<typeof Coverflow>> = {}) =>
|
|
14
|
+
render(
|
|
15
|
+
<Coverflow
|
|
16
|
+
items={items(7)}
|
|
17
|
+
activeIndex={0}
|
|
18
|
+
freeLabel="Free"
|
|
19
|
+
prevLabel="Previous"
|
|
20
|
+
nextLabel="Next"
|
|
21
|
+
{...props}
|
|
22
|
+
/>
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
/** Every card is named by its title, so the mounted set is readable from the roles. */
|
|
26
|
+
const mountedTitles = () =>
|
|
27
|
+
screen.getAllByRole("button").map((node) => node.getAttribute("aria-label"));
|
|
28
|
+
|
|
29
|
+
describe("Coverflow", () => {
|
|
30
|
+
describe("the ring", () => {
|
|
31
|
+
// The reported defect: at activeIndex 0 nothing had a negative offset, so
|
|
32
|
+
// the left half of the stage was empty while three cards stacked right.
|
|
33
|
+
it("fills both flanks at activeIndex 0", () => {
|
|
34
|
+
renderCoverflow({ activeIndex: 0 });
|
|
35
|
+
|
|
36
|
+
// 7 items, visible window |offset| <= 3 — the whole ring is on stage,
|
|
37
|
+
// items 4/5/6 having wrapped round to offsets -3/-2/-1.
|
|
38
|
+
const titles = mountedTitles().filter((title) => title !== "Previous" && title !== "Next");
|
|
39
|
+
expect(titles).toEqual(expect.arrayContaining(["Event 4", "Event 5", "Event 6"]));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("steps backwards from 0 to the last index instead of clamping", async () => {
|
|
43
|
+
const onActiveIndexChange = vi.fn();
|
|
44
|
+
renderCoverflow({ activeIndex: 0, onActiveIndexChange });
|
|
45
|
+
|
|
46
|
+
await userEvent.click(screen.getByRole("button", { name: "Previous" }));
|
|
47
|
+
expect(onActiveIndexChange).toHaveBeenCalledWith(6);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("steps forwards from the last index back to 0 instead of clamping", async () => {
|
|
51
|
+
const onActiveIndexChange = vi.fn();
|
|
52
|
+
renderCoverflow({ activeIndex: 6, onActiveIndexChange });
|
|
53
|
+
|
|
54
|
+
await userEvent.click(screen.getByRole("button", { name: "Next" }));
|
|
55
|
+
expect(onActiveIndexChange).toHaveBeenCalledWith(0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("reports the index of the side poster that was clicked", async () => {
|
|
59
|
+
const onActiveIndexChange = vi.fn();
|
|
60
|
+
renderCoverflow({ activeIndex: 0, onActiveIndexChange });
|
|
61
|
+
|
|
62
|
+
// Event 6 sits at offset -1, immediately left of the lead.
|
|
63
|
+
await userEvent.click(screen.getByRole("button", { name: "Event 6" }));
|
|
64
|
+
expect(onActiveIndexChange).toHaveBeenCalledWith(6);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("the wrap", () => {
|
|
69
|
+
// The card that comes round the back must NOT tween across the face of the
|
|
70
|
+
// stage. It renders without its transition classes for exactly the commit
|
|
71
|
+
// in which it crosses the seam, so the transform lands instantly.
|
|
72
|
+
it("drops the transition only on the card crossing the seam", () => {
|
|
73
|
+
const ring = items(7);
|
|
74
|
+
const { rerender } = renderCoverflow({ items: ring, activeIndex: 0 });
|
|
75
|
+
|
|
76
|
+
const transitionClass = "transition-[transform,box-shadow]";
|
|
77
|
+
expect(screen.getByRole("button", { name: "Event 4" }).className).toContain(transitionClass);
|
|
78
|
+
|
|
79
|
+
// Advancing by one sends Event 4 from offset -3 to offset +3 the short
|
|
80
|
+
// way round, while Event 6 merely slides from -1 to -2.
|
|
81
|
+
rerender(
|
|
82
|
+
<Coverflow
|
|
83
|
+
items={ring}
|
|
84
|
+
activeIndex={1}
|
|
85
|
+
freeLabel="Free"
|
|
86
|
+
prevLabel="Previous"
|
|
87
|
+
nextLabel="Next"
|
|
88
|
+
/>
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
expect(screen.getByRole("button", { name: "Event 4" }).className).not.toContain(
|
|
92
|
+
transitionClass
|
|
93
|
+
);
|
|
94
|
+
expect(screen.getByRole("button", { name: "Event 6" }).className).toContain(transitionClass);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("keeps the transition on every card when the lead jumps several slots at once", () => {
|
|
98
|
+
const ring = items(20);
|
|
99
|
+
const { rerender } = renderCoverflow({ items: ring, activeIndex: 5 });
|
|
100
|
+
|
|
101
|
+
rerender(
|
|
102
|
+
<Coverflow
|
|
103
|
+
items={ring}
|
|
104
|
+
activeIndex={8}
|
|
105
|
+
freeLabel="Free"
|
|
106
|
+
prevLabel="Previous"
|
|
107
|
+
nextLabel="Next"
|
|
108
|
+
/>
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
// A three-slot jump (clicking the outermost side poster) moves every card
|
|
112
|
+
// by three — nothing crossed the seam, so nothing may pop.
|
|
113
|
+
expect(screen.getByRole("button", { name: "Event 5" }).className).toContain(
|
|
114
|
+
"transition-[transform,box-shadow]"
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("the visible window", () => {
|
|
120
|
+
// A 50-item ring must not paint 50 posters just because every item now
|
|
121
|
+
// resolves to some offset.
|
|
122
|
+
it("mounts only the cards within three slots of the lead", () => {
|
|
123
|
+
renderCoverflow({ items: items(50), activeIndex: 0 });
|
|
124
|
+
|
|
125
|
+
const titles = mountedTitles().filter((title) => title !== "Previous" && title !== "Next");
|
|
126
|
+
expect(titles).toHaveLength(7);
|
|
127
|
+
expect(titles).toEqual(
|
|
128
|
+
expect.arrayContaining([
|
|
129
|
+
"Event 47",
|
|
130
|
+
"Event 48",
|
|
131
|
+
"Event 49",
|
|
132
|
+
"Event 0",
|
|
133
|
+
"Event 1",
|
|
134
|
+
"Event 2",
|
|
135
|
+
"Event 3",
|
|
136
|
+
])
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("degenerate lengths", () => {
|
|
142
|
+
it("renders nothing at all for an empty list", () => {
|
|
143
|
+
const { container } = renderCoverflow({ items: [] });
|
|
144
|
+
expect(container).toBeEmptyDOMElement();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// A control that looks operable and does nothing is the bug the component's
|
|
148
|
+
// own docblock argues against — with one item there is nowhere to step to.
|
|
149
|
+
it("renders no arrows for a single item", () => {
|
|
150
|
+
renderCoverflow({ items: items(1) });
|
|
151
|
+
|
|
152
|
+
expect(screen.queryByRole("button", { name: "Previous" })).not.toBeInTheDocument();
|
|
153
|
+
expect(screen.queryByRole("button", { name: "Next" })).not.toBeInTheDocument();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("keeps the two-item ring's second card on one consistent side", () => {
|
|
157
|
+
const { rerender } = renderCoverflow({ items: items(2), activeIndex: 0 });
|
|
158
|
+
const at0 = screen.getByRole("button", { name: "Event 1" }).getAttribute("style");
|
|
159
|
+
|
|
160
|
+
rerender(
|
|
161
|
+
<Coverflow
|
|
162
|
+
items={items(2)}
|
|
163
|
+
activeIndex={1}
|
|
164
|
+
freeLabel="Free"
|
|
165
|
+
prevLabel="Previous"
|
|
166
|
+
nextLabel="Next"
|
|
167
|
+
/>
|
|
168
|
+
);
|
|
169
|
+
const at1 = screen.getByRole("button", { name: "Event 0" }).getAttribute("style");
|
|
170
|
+
|
|
171
|
+
// Whichever card is not the lead resolves the n/2 tie the same way, so
|
|
172
|
+
// both renders put it in the identical right-flank slot rather than
|
|
173
|
+
// flipping it between the two flanks.
|
|
174
|
+
expect(at0).toBe(at1);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("caption and position", () => {
|
|
179
|
+
it("renders the position on its own, with no separator dot, when there is no caption", () => {
|
|
180
|
+
renderCoverflow({ positionLabel: "1 of 7" });
|
|
181
|
+
|
|
182
|
+
expect(screen.getByText("1 of 7")).toBeInTheDocument();
|
|
183
|
+
expect(screen.queryByText("·")).not.toBeInTheDocument();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("separates the two only when both are given", () => {
|
|
187
|
+
renderCoverflow({ caption: "7 events", positionLabel: "1 of 7" });
|
|
188
|
+
|
|
189
|
+
expect(screen.getByText("7 events")).toBeInTheDocument();
|
|
190
|
+
expect(screen.getByText("·")).toBeInTheDocument();
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
});
|