@bearmenu/ui 0.8.57 → 0.8.59

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/package.json CHANGED
@@ -1,8 +1,7 @@
1
1
  {
2
2
  "name": "@bearmenu/ui",
3
- "version": "0.8.57",
4
- "packageManager": "pnpm@10.28.1",
5
- "description": "BearMenu design system \u2014 shared UI tokens, primitives, and components",
3
+ "version": "0.8.59",
4
+ "description": "BearMenu design system — shared UI tokens, primitives, and components",
6
5
  "license": "UNLICENSED",
7
6
  "repository": {
8
7
  "type": "git",
@@ -17,226 +16,20 @@
17
16
  "src"
18
17
  ],
19
18
  "exports": {
20
- "./components/accordion": "./src/components/accordion.tsx",
21
- "./components/action-bar": "./src/components/action-bar.tsx",
22
- "./components/activity-row": "./src/components/activity-row.tsx",
23
- "./components/alert": "./src/components/alert.tsx",
24
- "./components/alert-dialog": "./src/components/alert-dialog.tsx",
25
- "./components/aspect-ratio": "./src/components/aspect-ratio.tsx",
26
- "./components/avatar": "./src/components/avatar.tsx",
27
- "./components/badge": "./src/components/badge.tsx",
28
- "./components/banner": "./src/components/banner.tsx",
29
- "./components/booking-row": "./src/components/booking-row.tsx",
30
- "./components/booking-summary-card": "./src/components/booking-summary-card.tsx",
31
- "./components/breadcrumb": "./src/components/breadcrumb.tsx",
32
- "./components/button": "./src/components/button.tsx",
33
- "./components/button-group": "./src/components/button-group.tsx",
34
- "./components/calendar": "./src/components/calendar.tsx",
35
- "./components/card": "./src/components/card.tsx",
36
- "./components/carousel": "./src/components/carousel.tsx",
37
- "./components/category-deck": "./src/components/category-deck.tsx",
38
- "./components/checkbox": "./src/components/checkbox.tsx",
39
- "./components/circular-progress": "./src/components/circular-progress.tsx",
40
- "./components/coach-marks": "./src/components/coach-marks.tsx",
41
- "./components/collapsible": "./src/components/collapsible.tsx",
42
- "./components/combobox": "./src/components/combobox.tsx",
43
- "./components/command": "./src/components/command.tsx",
44
- "./components/concept-card": "./src/components/concept-card.tsx",
45
- "./components/concept-compare": "./src/components/concept-compare.tsx",
46
- "./components/concept-compare-rail": "./src/components/concept-compare-rail.tsx",
47
- "./components/concept-convergence": "./src/components/concept-convergence.tsx",
48
- "./components/convergence": "./src/components/convergence.tsx",
49
- "./components/coverflow": "./src/components/coverflow.tsx",
50
- "./components/currency-input": "./src/components/currency-input.tsx",
51
- "./components/date-input": "./src/components/date-input.tsx",
52
- "./components/day-rail-chip": "./src/components/day-rail-chip.tsx",
53
- "./components/description-list": "./src/components/description-list.tsx",
54
- "./components/dialog": "./src/components/dialog.tsx",
55
- "./components/doorway-tiles": "./src/components/doorway-tiles.tsx",
56
- "./components/drawer": "./src/components/drawer.tsx",
57
- "./components/drift-rail": "./src/components/drift-rail.tsx",
58
- "./components/dropdown-menu": "./src/components/dropdown-menu.tsx",
59
- "./components/empty-state": "./src/components/empty-state.tsx",
60
- "./components/event-card": "./src/components/event-card.tsx",
61
- "./components/event-compact-row": "./src/components/event-compact-row.tsx",
62
- "./components/event-day-header": "./src/components/event-day-header.tsx",
63
- "./components/event-day-separator": "./src/components/event-day-separator.tsx",
64
- "./components/event-detail-panel": "./src/components/event-detail-panel.tsx",
65
- "./components/event-hub-hero": "./src/components/event-hub-hero.tsx",
66
- "./components/event-index-row": "./src/components/event-index-row.tsx",
67
- "./components/event-list-row": "./src/components/event-list-row.tsx",
68
- "./components/event-list-row-wide": "./src/components/event-list-row-wide.tsx",
69
- "./components/event-poster-fallback": "./src/components/event-poster-fallback.tsx",
70
- "./components/event-tonight-strip": "./src/components/event-tonight-strip.tsx",
71
- "./components/event-types": "./src/components/event-types.ts",
72
- "./components/events-empty-state": "./src/components/events-empty-state.tsx",
73
- "./components/explainer-steps": "./src/components/explainer-steps.tsx",
74
- "./components/facet-group": "./src/components/facet-group.tsx",
75
- "./components/featured-rail-item": "./src/components/featured-rail-item.tsx",
76
- "./components/field": "./src/components/field.tsx",
77
- "./components/filter-category-row": "./src/components/filter-category-row.tsx",
78
- "./components/form": "./src/components/form.tsx",
79
- "./components/glass-ground": "./src/components/glass-ground.tsx",
80
- "./components/grade-atom": "./src/components/grade-atom.tsx",
81
- "./components/haptic-switch": "./src/components/haptic-switch.tsx",
82
- "./components/hold-countdown": "./src/components/hold-countdown.tsx",
83
- "./components/hub-surface": "./src/components/hub-surface.tsx",
84
- "./components/hue-chip": "./src/components/hue-chip.tsx",
85
- "./components/icon": "./src/components/icon.tsx",
86
- "./components/input": "./src/components/input.tsx",
87
- "./components/input-group": "./src/components/input-group.tsx",
88
- "./components/input-otp": "./src/components/input-otp.tsx",
89
- "./components/item": "./src/components/item.tsx",
90
- "./components/kbd": "./src/components/kbd.tsx",
91
- "./components/label": "./src/components/label.tsx",
92
- "./components/kitchen-dossier": "./src/components/kitchen-dossier.tsx",
93
- "./components/lazy-markdown": "./src/components/lazy-markdown.tsx",
94
- "./components/link-button": "./src/components/link-button.tsx",
95
- "./components/markdown-renderer": "./src/components/markdown-renderer.tsx",
96
- "./components/menu-dead-end": "./src/components/menu-dead-end.tsx",
97
- "./components/menu-family-block": "./src/components/menu-family-block.tsx",
98
- "./components/menu-family-card": "./src/components/menu-family-card.tsx",
99
- "./components/menu-field": "./src/components/menu-field.tsx",
100
- "./components/menu-filter-chips": "./src/components/menu-filter-chips.tsx",
101
- "./components/menu-group-rows": "./src/components/menu-group-rows.tsx",
102
- "./components/menu-hub-hero": "./src/components/menu-hub-hero.tsx",
103
- "./components/menu-index-header": "./src/components/menu-index-header.tsx",
104
- "./components/menu-index-list": "./src/components/menu-index-list.tsx",
105
- "./components/menu-intent-hero": "./src/components/menu-intent-hero.tsx",
106
- "./components/menu-item": "./src/components/menu-item.tsx",
107
- "./components/menu-pick-row": "./src/components/menu-pick-row.tsx",
108
- "./components/menu-kitchen-list": "./src/components/menu-kitchen-list.tsx",
109
- "./components/menu-mocks": "./src/components/menu-mocks.ts",
110
- "./components/menu-types": "./src/components/menu-types.ts",
111
- "./components/motion-icon": "./src/components/motion-icon.tsx",
112
- "./components/motion-icon-actions": "./src/components/motion-icon-actions.tsx",
113
- "./components/motion-icon-controls": "./src/components/motion-icon-controls.tsx",
114
- "./components/motion-icon-set": "./src/components/motion-icon-set.tsx",
115
- "./components/multi-select-combobox": "./src/components/multi-select-combobox.tsx",
116
- "./components/multi-select-menu": "./src/components/multi-select-menu.tsx",
117
- "./components/native-select": "./src/components/native-select.tsx",
118
- "./components/open-now-list": "./src/components/open-now-list.tsx",
119
- "./components/pagination": "./src/components/pagination.tsx",
120
- "./components/pan-zoom-viewport": "./src/components/pan-zoom-viewport.tsx",
121
- "./components/party-counter": "./src/components/party-counter.tsx",
122
- "./components/phone-frame": "./src/components/phone-frame.tsx",
123
- "./components/photo-ground": "./src/components/photo-ground.tsx",
124
- "./components/place-about-tab": "./src/components/place-about-tab.tsx",
125
- "./components/place-card": "./src/components/place-card.tsx",
126
- "./components/place-details-mobile": "./src/components/place-details-mobile.tsx",
127
- "./components/place-schedule-week": "./src/components/place-schedule-week.tsx",
128
- "./components/place-types": "./src/components/place-types.ts",
129
- "./components/popover": "./src/components/popover.tsx",
130
- "./components/poster-stack": "./src/components/poster-stack.tsx",
131
- "./components/poster-tape": "./src/components/poster-tape.tsx",
132
- "./components/poster-wall": "./src/components/poster-wall.tsx",
133
- "./components/progress": "./src/components/progress.tsx",
134
- "./components/qa-block": "./src/components/qa-block.tsx",
135
- "./components/qr-panel": "./src/components/qr-panel.tsx",
136
- "./components/quiet-stretch": "./src/components/quiet-stretch.tsx",
137
- "./components/radio-group": "./src/components/radio-group.tsx",
138
- "./components/reconcile-bar": "./src/components/reconcile-bar.tsx",
139
- "./components/responsive-overlay": "./src/components/responsive-overlay.tsx",
140
- "./components/rating": "./src/components/rating.tsx",
141
- "./components/scroll-area": "./src/components/scroll-area.tsx",
142
- "./components/scroll-fade": "./src/components/scroll-fade.tsx",
143
- "./components/search-bar": "./src/components/search-bar.tsx",
144
- "./components/searchable-select": "./src/components/searchable-select.tsx",
145
- "./components/section-header": "./src/components/section-header.tsx",
146
- "./components/segmented-nav": "./src/components/segmented-nav.tsx",
147
- "./components/select": "./src/components/select.tsx",
148
- "./components/selection-bar": "./src/components/selection-bar.tsx",
149
- "./components/separator": "./src/components/separator.tsx",
150
- "./components/sheet": "./src/components/sheet.tsx",
151
- "./components/signature-chip": "./src/components/signature-chip.tsx",
152
- "./components/skeleton": "./src/components/skeleton.tsx",
153
- "./components/skeleton-card": "./src/components/skeleton-card.tsx",
154
- "./components/slider": "./src/components/slider.tsx",
155
- "./components/sliding-panels": "./src/components/sliding-panels.tsx",
156
- "./components/sonner": "./src/components/sonner.tsx",
157
- "./components/sort-option-list": "./src/components/sort-option-list.tsx",
158
- "./components/spinner": "./src/components/spinner.tsx",
159
- "./components/split-share-row": "./src/components/split-share-row.tsx",
160
- "./components/stat-banner": "./src/components/stat-banner.tsx",
161
- "./components/stepper": "./src/components/stepper.tsx",
162
- "./components/sticky-container": "./src/components/sticky-container.tsx",
163
- "./components/story": "./src/components/story.tsx",
164
- "./components/switch": "./src/components/switch.tsx",
165
- "./components/table": "./src/components/table.tsx",
166
- "./components/table-ledger-card": "./src/components/table-ledger-card.tsx",
167
- "./components/table-trigger-band": "./src/components/table-trigger-band.tsx",
168
- "./components/tabs": "./src/components/tabs.tsx",
169
- "./components/tag": "./src/components/tag.tsx",
170
- "./components/tag-group": "./src/components/tag-group.tsx",
171
- "./components/text": "./src/components/text.tsx",
172
- "./components/textarea": "./src/components/textarea.tsx",
173
- "./components/time-slot": "./src/components/time-slot.tsx",
174
- "./components/timeline-day": "./src/components/timeline-day.tsx",
175
- "./components/timeline-hour": "./src/components/timeline-hour.tsx",
176
- "./components/timeline-hour-puck": "./src/components/timeline-hour-puck.tsx",
177
- "./components/toggle": "./src/components/toggle.tsx",
178
- "./components/toggle-group": "./src/components/toggle-group.tsx",
179
- "./components/tooltip": "./src/components/tooltip.tsx",
180
- "./components/top-comparison": "./src/components/top-comparison.tsx",
181
- "./components/top-cross-link": "./src/components/top-cross-link.tsx",
182
- "./components/top-ranking-entry": "./src/components/top-ranking-entry.tsx",
183
- "./components/top-ranking-hero": "./src/components/top-ranking-hero.tsx",
184
- "./components/top-ranking-legend": "./src/components/top-ranking-legend.tsx",
185
- "./components/top-types": "./src/components/top-types.ts",
186
- "./components/use-mobile": "./src/components/use-mobile.tsx",
187
- "./components/venue-events-block": "./src/components/venue-events-block.tsx",
188
- "./components/venue-grade-dimensions": "./src/components/venue-grade-dimensions.tsx",
189
- "./components/venue-quotes": "./src/components/venue-quotes.tsx",
190
- "./components/venue-wall": "./src/components/venue-wall.tsx",
191
- "./components/weekday-chip": "./src/components/weekday-chip.tsx",
192
- "./components/weekday-theme-banner": "./src/components/weekday-theme-banner.tsx",
193
- "./components/weekend-panel": "./src/components/weekend-panel.tsx",
194
19
  "./globals.css": "./src/globals.css",
195
- "./hooks/use-drag-swipe": "./src/hooks/use-drag-swipe.ts",
196
- "./hooks/use-haptic": "./src/hooks/use-haptic.ts",
197
- "./hooks/use-media-query": "./src/hooks/use-media-query.ts",
198
- "./hooks/use-mobile": "./src/hooks/use-mobile.ts",
199
- "./hooks/use-mounted-theme": "./src/hooks/use-mounted-theme.ts",
200
- "./hooks/use-sticky": "./src/hooks/use-sticky.ts",
201
- "./lib/card-hover": "./src/lib/card-hover.ts",
202
- "./lib/grade": "./src/lib/grade.ts",
203
- "./lib/icon-depth": "./src/lib/icon-depth.ts",
204
- "./lib/icon-registry": "./src/lib/icon-registry.ts",
205
- "./lib/motion": "./src/lib/motion.ts",
206
- "./lib/overlay-pill": "./src/lib/overlay-pill.ts",
207
- "./lib/utils": "./src/lib/utils.ts",
208
- "./components/filter-option-chip": "./src/components/filter-option-chip.tsx",
209
- "./components/filter-option-group": "./src/components/filter-option-group.tsx",
210
- "./components/filter-panel-footer": "./src/components/filter-panel-footer.tsx"
211
- },
212
- "publishConfig": {
213
- "exports": {
214
- "./globals.css": "./src/globals.css",
215
- "./components/*": {
216
- "types": "./dist/components/*.d.ts",
217
- "import": "./dist/components/*.js"
218
- },
219
- "./hooks/*": {
220
- "types": "./dist/hooks/*.d.ts",
221
- "import": "./dist/hooks/*.js"
222
- },
223
- "./lib/*": {
224
- "types": "./dist/lib/*.d.ts",
225
- "import": "./dist/lib/*.js"
226
- }
20
+ "./components/*": {
21
+ "types": "./dist/components/*.d.ts",
22
+ "import": "./dist/components/*.js"
23
+ },
24
+ "./hooks/*": {
25
+ "types": "./dist/hooks/*.d.ts",
26
+ "import": "./dist/hooks/*.js"
27
+ },
28
+ "./lib/*": {
29
+ "types": "./dist/lib/*.d.ts",
30
+ "import": "./dist/lib/*.js"
227
31
  }
228
32
  },
229
- "scripts": {
230
- "storybook": "storybook dev -p 6006",
231
- "build-storybook": "storybook build",
232
- "build": "tsup",
233
- "dev": "tsup --watch",
234
- "typecheck": "tsc --noEmit",
235
- "test": "vitest run",
236
- "test:watch": "vitest",
237
- "clean": "rm -rf dist",
238
- "prepublishOnly": "pnpm run build"
239
- },
240
33
  "peerDependencies": {
241
34
  "@hookform/resolvers": "^3.0.0 || ^5.0.0",
242
35
  "@radix-ui/react-accordion": "*",
@@ -345,5 +138,15 @@
345
138
  "vaul": "^1.1.2",
346
139
  "vitest": "^4.1.5",
347
140
  "zod": "^3.25.67"
141
+ },
142
+ "scripts": {
143
+ "storybook": "storybook dev -p 6006",
144
+ "build-storybook": "storybook build",
145
+ "build": "tsup",
146
+ "dev": "tsup --watch",
147
+ "typecheck": "tsc --noEmit",
148
+ "test": "vitest run",
149
+ "test:watch": "vitest",
150
+ "clean": "rm -rf dist"
348
151
  }
349
- }
152
+ }
@@ -0,0 +1,166 @@
1
+ import * as React from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react";
3
+ import { Separator } from "./separator";
4
+ import { Skeleton } from "./skeleton";
5
+ import { TimeSlot, type TimeSlotFit } from "./time-slot";
6
+ import { TimeSlotRail } from "./time-slot-rail";
7
+
8
+ const meta: Meta<typeof TimeSlotRail> = {
9
+ title: "Booking/TimeSlotRail",
10
+ component: TimeSlotRail,
11
+ tags: ["autodocs"],
12
+ parameters: {
13
+ docs: {
14
+ description: {
15
+ component:
16
+ "A single row of touch-size TimeSlots that scrolls sideways. Arrow keys move focus, Enter or Space picks, the selected chip is centred on mount. Every string is a prop.",
17
+ },
18
+ },
19
+ },
20
+ };
21
+
22
+ export default meta;
23
+ type Story = StoryObj<typeof TimeSlotRail>;
24
+
25
+ type Slot = { time: string; fit: TimeSlotFit; meta: string };
26
+
27
+ const SLOTS: Slot[] = [
28
+ { time: "17:00", fit: "fits", meta: "R1" },
29
+ { time: "17:30", fit: "fits", meta: "R1" },
30
+ { time: "18:00", fit: "fits", meta: "R1+R2" },
31
+ { time: "18:30", fit: "tight", meta: "Join R3+R4" },
32
+ { time: "19:00", fit: "fits", meta: "R1+R2" },
33
+ { time: "19:30", fit: "fits", meta: "R1+R2" },
34
+ { time: "20:00", fit: "tight", meta: "Join R3+R4" },
35
+ { time: "20:30", fit: "none", meta: "Full" },
36
+ { time: "21:00", fit: "none", meta: "Full" },
37
+ { time: "21:30", fit: "tight", meta: "Join R5+R6" },
38
+ { time: "22:00", fit: "fits", meta: "R2" },
39
+ { time: "22:30", fit: "fits", meta: "R2" },
40
+ ];
41
+
42
+ const SCROLL_LABELS = { previous: "Earlier times", next: "Later times" };
43
+
44
+ function Chips({
45
+ selected,
46
+ onSelect,
47
+ disabled,
48
+ fit,
49
+ }: {
50
+ selected: string;
51
+ onSelect: (time: string) => void;
52
+ disabled?: boolean;
53
+ fit?: TimeSlotFit;
54
+ }) {
55
+ return (
56
+ <>
57
+ {SLOTS.map((slot) => (
58
+ <TimeSlot
59
+ key={slot.time}
60
+ size="touch"
61
+ time={slot.time}
62
+ fit={fit ?? slot.fit}
63
+ meta={fit === "none" ? "Full" : slot.meta}
64
+ selected={selected === slot.time}
65
+ disabled={disabled}
66
+ onSelect={() => onSelect(slot.time)}
67
+ aria-label={`${slot.time}, ${slot.meta}`}
68
+ className="w-[84px] shrink-0 snap-center"
69
+ />
70
+ ))}
71
+ </>
72
+ );
73
+ }
74
+
75
+ function Inset({ children, dark }: { children: React.ReactNode; dark?: boolean }) {
76
+ return (
77
+ <div
78
+ className={`${dark ? "dark " : ""}w-[343px] rounded-xl bg-background p-1.5 text-foreground`}
79
+ >
80
+ {children}
81
+ </div>
82
+ );
83
+ }
84
+
85
+ function Interactive({ dark, disabled }: { dark?: boolean; disabled?: boolean }) {
86
+ const [selected, setSelected] = React.useState("19:30");
87
+ return (
88
+ <Inset dark={dark}>
89
+ <TimeSlotRail aria-label="Start time on Wed 18 Nov" scrollLabels={SCROLL_LABELS}>
90
+ <Chips selected={selected} onSelect={setSelected} disabled={disabled} />
91
+ </TimeSlotRail>
92
+ </Inset>
93
+ );
94
+ }
95
+
96
+ /** The pick bar's inset at 375px: 3.7 chips show, the partial one says "scroll". */
97
+ export const Default: Story = { render: () => <Interactive /> };
98
+
99
+ export const Dark: Story = { render: () => <Interactive dark /> };
100
+
101
+ /** The whole rail locks while the write is out. */
102
+ export const Locked: Story = { render: () => <Interactive disabled /> };
103
+
104
+ /** Every chip in one fit state, to compare the three side by side. */
105
+ export const FitStates: Story = {
106
+ render: () => (
107
+ <div className="flex flex-col gap-3">
108
+ {(["fits", "tight", "none"] as const).map((fit) => (
109
+ <Inset key={fit}>
110
+ <TimeSlotRail aria-label={`All ${fit}`}>
111
+ <Chips selected="19:30" onSelect={() => {}} fit={fit} />
112
+ </TimeSlotRail>
113
+ </Inset>
114
+ ))}
115
+ </div>
116
+ ),
117
+ };
118
+
119
+ /** Two service periods split by a separator, each its own labelled group. */
120
+ export const Periods: Story = {
121
+ render: () => {
122
+ const [selected, setSelected] = React.useState("20:00");
123
+ const lunch = SLOTS.slice(0, 4);
124
+ const dinner = SLOTS.slice(4);
125
+ const chips = (slots: Slot[]) =>
126
+ slots.map((slot) => (
127
+ <TimeSlot
128
+ key={slot.time}
129
+ size="touch"
130
+ time={slot.time}
131
+ fit={slot.fit}
132
+ meta={slot.meta}
133
+ selected={selected === slot.time}
134
+ onSelect={() => setSelected(slot.time)}
135
+ className="w-[84px] shrink-0 snap-center"
136
+ />
137
+ ));
138
+ return (
139
+ <Inset>
140
+ <TimeSlotRail aria-label="Start time on Wed 18 Nov" scrollLabels={SCROLL_LABELS}>
141
+ <div role="group" aria-label="Lunch" className="flex gap-2">
142
+ {chips(lunch)}
143
+ </div>
144
+ <Separator orientation="vertical" className="mx-1 h-8 self-center" />
145
+ <div role="group" aria-label="Dinner" className="flex gap-2">
146
+ {chips(dinner)}
147
+ </div>
148
+ </TimeSlotRail>
149
+ </Inset>
150
+ );
151
+ },
152
+ };
153
+
154
+ /** Loading is the caller's: skeletons of the same footprint, so the inset does not jump. */
155
+ export const Loading: Story = {
156
+ render: () => (
157
+ <Inset>
158
+ <div role="status" className="flex gap-2 overflow-hidden">
159
+ <span className="sr-only">Checking tables for 12…</span>
160
+ {Array.from({ length: 5 }, (_, i) => (
161
+ <Skeleton key={i} aria-hidden className="h-[52px] w-[84px] shrink-0 rounded-xl" />
162
+ ))}
163
+ </div>
164
+ </Inset>
165
+ ),
166
+ };
@@ -0,0 +1,177 @@
1
+ import * as React from "react";
2
+ import { describe, it, expect, vi, afterEach } from "vitest";
3
+ import { render, screen } from "@testing-library/react";
4
+ import userEvent from "@testing-library/user-event";
5
+ import { TimeSlot } from "./time-slot";
6
+ import { TimeSlotRail } from "./time-slot-rail";
7
+
8
+ const TIMES = ["18:00", "18:30", "19:00", "19:30"];
9
+
10
+ function Rail({
11
+ initial = "19:00",
12
+ disabled,
13
+ unavailable = [],
14
+ scrollLabels,
15
+ }: {
16
+ initial?: string;
17
+ disabled?: boolean;
18
+ unavailable?: string[];
19
+ scrollLabels?: { previous: string; next: string };
20
+ }) {
21
+ const [selected, setSelected] = React.useState(initial);
22
+ return (
23
+ <TimeSlotRail aria-label="Start time" scrollLabels={scrollLabels}>
24
+ {TIMES.map((time) => (
25
+ <TimeSlot
26
+ key={time}
27
+ size="touch"
28
+ time={time}
29
+ fit="fits"
30
+ meta="R1"
31
+ selected={selected === time}
32
+ disabled={disabled}
33
+ unavailable={unavailable.includes(time)}
34
+ onSelect={() => setSelected(time)}
35
+ />
36
+ ))}
37
+ </TimeSlotRail>
38
+ );
39
+ }
40
+
41
+ const chip = (time: string) => screen.getByRole("button", { name: new RegExp(time) });
42
+
43
+ // jsdom has no scrollTo; the rail guards for that, so install a spy per test.
44
+ function spyOnScrollTo() {
45
+ const scrollTo = vi.fn();
46
+ Object.defineProperty(HTMLElement.prototype, "scrollTo", { configurable: true, value: scrollTo });
47
+ return scrollTo;
48
+ }
49
+
50
+ afterEach(() => {
51
+ Reflect.deleteProperty(HTMLElement.prototype, "scrollTo");
52
+ });
53
+
54
+ describe("TimeSlotRail", () => {
55
+ it("is a labelled group of pressed-state buttons", () => {
56
+ render(<Rail />);
57
+ expect(screen.getByRole("group", { name: "Start time" })).toBeInTheDocument();
58
+ expect(chip("19:00")).toHaveAttribute("aria-pressed", "true");
59
+ expect(chip("18:00")).toHaveAttribute("aria-pressed", "false");
60
+ });
61
+
62
+ it("has one tab stop: the selected chip", () => {
63
+ render(<Rail />);
64
+ expect(chip("19:00")).toHaveAttribute("tabindex", "0");
65
+ expect(chip("18:00")).toHaveAttribute("tabindex", "-1");
66
+ expect(chip("19:30")).toHaveAttribute("tabindex", "-1");
67
+ });
68
+
69
+ it("falls back to the first chip when nothing is selected", () => {
70
+ render(<Rail initial="" />);
71
+ expect(chip("18:00")).toHaveAttribute("tabindex", "0");
72
+ });
73
+
74
+ it("moves focus with the arrows but only picks on Enter", async () => {
75
+ const user = userEvent.setup();
76
+ render(<Rail />);
77
+ await user.tab();
78
+ expect(chip("19:00")).toHaveFocus();
79
+ await user.keyboard("{ArrowRight}");
80
+ expect(chip("19:30")).toHaveFocus();
81
+ expect(chip("19:00")).toHaveAttribute("aria-pressed", "true");
82
+ await user.keyboard("{Enter}");
83
+ expect(chip("19:30")).toHaveAttribute("aria-pressed", "true");
84
+ await user.keyboard("{ArrowLeft}{ArrowLeft}");
85
+ expect(chip("18:30")).toHaveFocus();
86
+ });
87
+
88
+ it("stops at the ends and jumps with Home and End", async () => {
89
+ const user = userEvent.setup();
90
+ render(<Rail initial="18:00" />);
91
+ await user.tab();
92
+ await user.keyboard("{ArrowLeft}");
93
+ expect(chip("18:00")).toHaveFocus();
94
+ await user.keyboard("{End}");
95
+ expect(chip("19:30")).toHaveFocus();
96
+ await user.keyboard("{ArrowRight}");
97
+ expect(chip("19:30")).toHaveFocus();
98
+ await user.keyboard("{Home}");
99
+ expect(chip("18:00")).toHaveFocus();
100
+ });
101
+
102
+ it("scrolls a keyboard-focused chip into view", async () => {
103
+ const user = userEvent.setup();
104
+ const scrollIntoView = vi.fn();
105
+ Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { configurable: true, value: scrollIntoView });
106
+ render(<Rail />);
107
+ await user.tab();
108
+ await user.keyboard("{ArrowRight}");
109
+ expect(scrollIntoView).toHaveBeenCalledWith({ inline: "nearest", block: "nearest" });
110
+ Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView");
111
+ });
112
+
113
+ it("pads the scroller so a focus ring is not clipped, and keeps chips at their width", () => {
114
+ render(<Rail />);
115
+ const scroller = screen.getByRole("group", { name: "Start time" });
116
+ expect(scroller).toHaveClass("px-1.5", "py-1.5", "scroll-px-10");
117
+ expect(scroller.className).toContain("[&_[data-slot=time-slot]]:shrink-0");
118
+ });
119
+
120
+ it("skips unavailable chips", async () => {
121
+ const user = userEvent.setup();
122
+ render(<Rail unavailable={["19:30"]} />);
123
+ await user.tab();
124
+ await user.keyboard("{ArrowRight}");
125
+ expect(chip("19:00")).toHaveFocus();
126
+ });
127
+
128
+ it("keeps focus where it was while the rail is locked", async () => {
129
+ const user = userEvent.setup();
130
+ render(<Rail disabled />);
131
+ await user.tab();
132
+ expect(chip("19:00")).toHaveFocus();
133
+ await user.keyboard("{ArrowRight}");
134
+ expect(chip("19:30")).toHaveFocus();
135
+ });
136
+
137
+ it("the focused chip becomes the tab stop", async () => {
138
+ const user = userEvent.setup();
139
+ render(<Rail />);
140
+ await user.click(chip("18:30"));
141
+ expect(chip("18:30")).toHaveAttribute("tabindex", "0");
142
+ });
143
+
144
+ it("centres the selected chip on mount without animation, then glides on a pick", async () => {
145
+ const user = userEvent.setup();
146
+ const scrollTo = spyOnScrollTo();
147
+ render(<Rail />);
148
+ expect(scrollTo).toHaveBeenCalledTimes(1);
149
+ expect(scrollTo).toHaveBeenLastCalledWith(expect.objectContaining({ behavior: "auto" }));
150
+ await user.click(chip("19:30"));
151
+ expect(scrollTo).toHaveBeenCalledTimes(2);
152
+ expect(scrollTo).toHaveBeenLastCalledWith(expect.objectContaining({ behavior: "smooth" }));
153
+ });
154
+
155
+ it("does not glide under prefers-reduced-motion", async () => {
156
+ const user = userEvent.setup();
157
+ const scrollTo = spyOnScrollTo();
158
+ vi.stubGlobal("matchMedia", (query: string) => ({
159
+ matches: query.includes("reduce"),
160
+ media: query,
161
+ addEventListener: () => {},
162
+ removeEventListener: () => {},
163
+ }));
164
+ render(<Rail />);
165
+ await user.click(chip("19:30"));
166
+ expect(scrollTo).toHaveBeenLastCalledWith(expect.objectContaining({ behavior: "auto" }));
167
+ vi.unstubAllGlobals();
168
+ });
169
+
170
+ it("renders no chevrons without labels, and none while nothing overflows", () => {
171
+ const { rerender } = render(<Rail />);
172
+ expect(screen.queryByRole("button", { name: /times/ })).toBeNull();
173
+ rerender(<Rail scrollLabels={{ previous: "Earlier times", next: "Later times" }} />);
174
+ expect(screen.queryByRole("button", { name: "Earlier times" })).toBeNull();
175
+ expect(screen.queryByRole("button", { name: "Later times" })).toBeNull();
176
+ });
177
+ });