@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/dist/components/alert.d.ts +2 -2
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/command.d.ts +3 -3
- package/dist/components/spinner.d.ts +2 -2
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/text.d.ts +2 -2
- package/dist/components/time-slot-rail.d.ts +21 -0
- package/dist/components/time-slot-rail.js +168 -0
- package/dist/components/time-slot.d.ts +45 -6
- package/dist/components/time-slot.js +55 -11
- package/dist/components/toggle.d.ts +1 -1
- package/package.json +24 -221
- package/src/components/time-slot-rail.stories.tsx +166 -0
- package/src/components/time-slot-rail.test.tsx +177 -0
- package/src/components/time-slot-rail.tsx +222 -0
- package/src/components/time-slot.stories.tsx +37 -0
- package/src/components/time-slot.test.tsx +113 -0
- package/src/components/time-slot.tsx +120 -17
package/package.json
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bearmenu/ui",
|
|
3
|
-
"version": "0.8.
|
|
4
|
-
"
|
|
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
|
-
"./
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
"./hooks
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
"./lib
|
|
204
|
-
|
|
205
|
-
|
|
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
|
+
});
|