@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
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
import { Button } from "./button";
|
|
7
|
+
import { ScrollFade } from "./scroll-fade";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* TimeSlotRail — one row of `TimeSlot size="touch"` chips that scrolls
|
|
11
|
+
* sideways instead of wrapping. Built for the pick bar's "accept at …" rail:
|
|
12
|
+
* 21 slots would wrap to three rows over the floor plan.
|
|
13
|
+
*
|
|
14
|
+
* The rail owns scrolling and keyboard; the chips stay the caller's. Children
|
|
15
|
+
* are whatever the caller renders (period groups, separators and all), and the
|
|
16
|
+
* rail finds the chips in the DOM by `data-slot="time-slot"`. It therefore
|
|
17
|
+
* never needs to know a chip's value, and holds no selection state: the chip
|
|
18
|
+
* that is `aria-pressed` is the selected one.
|
|
19
|
+
*
|
|
20
|
+
* Semantics: `role="group"` with a label, chips are `aria-pressed` buttons.
|
|
21
|
+
* Not a radiogroup: radios select on arrow, and here a selection rewrites the
|
|
22
|
+
* plan and may start a write, so arrows move FOCUS only and Enter / Space
|
|
23
|
+
* picks. One tab stop (roving tabindex): the pressed chip, else the first.
|
|
24
|
+
*
|
|
25
|
+
* - On mount the selected chip is centred without animation; afterwards it
|
|
26
|
+
* scrolls smoothly, or instantly under `prefers-reduced-motion`.
|
|
27
|
+
* - Edge chevrons show on fine pointers only, and only while that edge has
|
|
28
|
+
* more to reveal. Labels come in via `scrollLabels`; without it no chevrons.
|
|
29
|
+
* - Every string is a prop: the package has none of its own.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const CHIP = '[data-slot="time-slot"]';
|
|
33
|
+
|
|
34
|
+
export type TimeSlotRailScrollLabels = {
|
|
35
|
+
/** "Earlier times" — the chevron at the start edge. */
|
|
36
|
+
previous: string;
|
|
37
|
+
/** "Later times" — the chevron at the end edge. */
|
|
38
|
+
next: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type TimeSlotRailProps = {
|
|
42
|
+
/** Names the group for assistive tech — "Start time on Wed 18 Nov". */
|
|
43
|
+
"aria-label": string;
|
|
44
|
+
/** Labels for the fine-pointer chevrons. Omit to render none. */
|
|
45
|
+
scrollLabels?: TimeSlotRailScrollLabels;
|
|
46
|
+
/** `TimeSlot`s, optionally wrapped in period groups and separators. */
|
|
47
|
+
children: React.ReactNode;
|
|
48
|
+
className?: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function chipsOf(scroller: HTMLElement): HTMLButtonElement[] {
|
|
52
|
+
return Array.from(scroller.querySelectorAll<HTMLButtonElement>(CHIP)).filter((chip) => !chip.disabled);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function prefersReducedMotion(): boolean {
|
|
56
|
+
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function centreChip(scroller: HTMLElement, chip: HTMLElement, behavior: ScrollBehavior) {
|
|
60
|
+
if (typeof scroller.scrollTo !== "function") return;
|
|
61
|
+
const s = scroller.getBoundingClientRect();
|
|
62
|
+
const c = chip.getBoundingClientRect();
|
|
63
|
+
const left = scroller.scrollLeft + (c.left - s.left) - (scroller.clientWidth - c.width) / 2;
|
|
64
|
+
scroller.scrollTo({ left: Math.max(0, left), behavior });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function TimeSlotRail({
|
|
68
|
+
"aria-label": ariaLabel,
|
|
69
|
+
scrollLabels,
|
|
70
|
+
children,
|
|
71
|
+
className,
|
|
72
|
+
}: TimeSlotRailProps) {
|
|
73
|
+
const scrollerRef = React.useRef<HTMLDivElement>(null);
|
|
74
|
+
const centredRef = React.useRef<Element | null>(null);
|
|
75
|
+
const [canScrollBack, setCanScrollBack] = React.useState(false);
|
|
76
|
+
const [canScrollForward, setCanScrollForward] = React.useState(false);
|
|
77
|
+
|
|
78
|
+
// One tab stop: the pressed chip, else the first. Runs after every render
|
|
79
|
+
// and on DOM changes, because the chips are the caller's and re-render alone.
|
|
80
|
+
const syncTabStops = React.useCallback(() => {
|
|
81
|
+
const scroller = scrollerRef.current;
|
|
82
|
+
if (!scroller) return;
|
|
83
|
+
const chips = chipsOf(scroller);
|
|
84
|
+
const stop = chips.find((chip) => chip.getAttribute("aria-pressed") === "true") ?? chips[0];
|
|
85
|
+
for (const chip of chips) chip.tabIndex = chip === stop ? 0 : -1;
|
|
86
|
+
}, []);
|
|
87
|
+
|
|
88
|
+
const syncEdges = React.useCallback(() => {
|
|
89
|
+
const el = scrollerRef.current;
|
|
90
|
+
if (!el) return;
|
|
91
|
+
const overflow = el.scrollWidth - el.clientWidth;
|
|
92
|
+
setCanScrollBack(el.scrollLeft > 1);
|
|
93
|
+
setCanScrollForward(overflow > 1 && el.scrollLeft < overflow - 1);
|
|
94
|
+
}, []);
|
|
95
|
+
|
|
96
|
+
React.useEffect(() => {
|
|
97
|
+
const scroller = scrollerRef.current;
|
|
98
|
+
if (!scroller) return;
|
|
99
|
+
syncTabStops();
|
|
100
|
+
syncEdges();
|
|
101
|
+
scroller.addEventListener("scroll", syncEdges, { passive: true });
|
|
102
|
+
const resize = new ResizeObserver(syncEdges);
|
|
103
|
+
resize.observe(scroller);
|
|
104
|
+
const mutation = new MutationObserver(() => {
|
|
105
|
+
syncTabStops();
|
|
106
|
+
syncEdges();
|
|
107
|
+
});
|
|
108
|
+
mutation.observe(scroller, { childList: true, subtree: true, attributes: true, attributeFilter: ["aria-pressed", "disabled"] });
|
|
109
|
+
return () => {
|
|
110
|
+
scroller.removeEventListener("scroll", syncEdges);
|
|
111
|
+
resize.disconnect();
|
|
112
|
+
mutation.disconnect();
|
|
113
|
+
};
|
|
114
|
+
}, [syncTabStops, syncEdges]);
|
|
115
|
+
|
|
116
|
+
// Bring a newly selected chip to the middle. The first time is the mount:
|
|
117
|
+
// no animation. Later picks glide, unless the user asked for less motion.
|
|
118
|
+
React.useEffect(() => {
|
|
119
|
+
const scroller = scrollerRef.current;
|
|
120
|
+
if (!scroller) return;
|
|
121
|
+
const selected = scroller.querySelector(`${CHIP}[aria-pressed="true"]`);
|
|
122
|
+
if (!selected || selected === centredRef.current) return;
|
|
123
|
+
const first = centredRef.current === null;
|
|
124
|
+
centredRef.current = selected;
|
|
125
|
+
if (!(selected instanceof HTMLElement)) return;
|
|
126
|
+
centreChip(scroller, selected, first || prefersReducedMotion() ? "auto" : "smooth");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
130
|
+
const scroller = scrollerRef.current;
|
|
131
|
+
const target: EventTarget = event.target;
|
|
132
|
+
if (!scroller || !(target instanceof HTMLButtonElement) || !target.matches(CHIP)) return;
|
|
133
|
+
const chips = chipsOf(scroller);
|
|
134
|
+
const index = chips.indexOf(target);
|
|
135
|
+
if (index === -1) return;
|
|
136
|
+
const rtl = getComputedStyle(scroller).direction === "rtl";
|
|
137
|
+
let next: number;
|
|
138
|
+
switch (event.key) {
|
|
139
|
+
case "ArrowRight":
|
|
140
|
+
next = index + (rtl ? -1 : 1);
|
|
141
|
+
break;
|
|
142
|
+
case "ArrowLeft":
|
|
143
|
+
next = index + (rtl ? 1 : -1);
|
|
144
|
+
break;
|
|
145
|
+
case "Home":
|
|
146
|
+
next = 0;
|
|
147
|
+
break;
|
|
148
|
+
case "End":
|
|
149
|
+
next = chips.length - 1;
|
|
150
|
+
break;
|
|
151
|
+
default:
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
event.preventDefault();
|
|
155
|
+
const chip = chips[Math.min(Math.max(next, 0), chips.length - 1)];
|
|
156
|
+
if (!chip) return;
|
|
157
|
+
for (const other of chips) other.tabIndex = other === chip ? 0 : -1;
|
|
158
|
+
chip.focus();
|
|
159
|
+
// Focus alone may leave the chip under the edge fade or a chevron.
|
|
160
|
+
if (typeof chip.scrollIntoView === "function") chip.scrollIntoView({ inline: "nearest", block: "nearest" });
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// Moving focus by mouse or tap also moves the tab stop, so Shift+Tab out
|
|
164
|
+
// and back in lands where the user was.
|
|
165
|
+
const onFocus = (event: React.FocusEvent<HTMLDivElement>) => {
|
|
166
|
+
const scroller = scrollerRef.current;
|
|
167
|
+
const target: EventTarget = event.target;
|
|
168
|
+
if (!scroller || !(target instanceof HTMLButtonElement) || !target.matches(CHIP)) return;
|
|
169
|
+
for (const chip of chipsOf(scroller)) chip.tabIndex = chip === target ? 0 : -1;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const page = (direction: 1 | -1) => {
|
|
173
|
+
const el = scrollerRef.current;
|
|
174
|
+
if (!el || typeof el.scrollBy !== "function") return;
|
|
175
|
+
el.scrollBy({ left: direction * el.clientWidth * 0.8, behavior: prefersReducedMotion() ? "auto" : "smooth" });
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<div data-slot="time-slot-rail" className="relative">
|
|
180
|
+
<ScrollFade
|
|
181
|
+
ref={scrollerRef}
|
|
182
|
+
orientation="horizontal"
|
|
183
|
+
fadeColor="background"
|
|
184
|
+
size="sm"
|
|
185
|
+
role="group"
|
|
186
|
+
aria-label={ariaLabel}
|
|
187
|
+
onKeyDown={onKeyDown}
|
|
188
|
+
onFocus={onFocus}
|
|
189
|
+
className={className}
|
|
190
|
+
scrollClassName="flex snap-x snap-proximity scroll-px-10 gap-2 px-1.5 py-1.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden [&_[data-slot=time-slot]]:shrink-0"
|
|
191
|
+
>
|
|
192
|
+
{children}
|
|
193
|
+
</ScrollFade>
|
|
194
|
+
{scrollLabels && canScrollBack && (
|
|
195
|
+
<Button
|
|
196
|
+
type="button"
|
|
197
|
+
variant="ghost"
|
|
198
|
+
size="icon-sm"
|
|
199
|
+
tabIndex={-1}
|
|
200
|
+
aria-label={scrollLabels.previous}
|
|
201
|
+
onClick={() => page(-1)}
|
|
202
|
+
className={cn("absolute top-1/2 left-0 z-20 hidden -translate-y-1/2 bg-background/90 [@media(pointer:fine)]:inline-flex")}
|
|
203
|
+
>
|
|
204
|
+
<ChevronLeft aria-hidden />
|
|
205
|
+
</Button>
|
|
206
|
+
)}
|
|
207
|
+
{scrollLabels && canScrollForward && (
|
|
208
|
+
<Button
|
|
209
|
+
type="button"
|
|
210
|
+
variant="ghost"
|
|
211
|
+
size="icon-sm"
|
|
212
|
+
tabIndex={-1}
|
|
213
|
+
aria-label={scrollLabels.next}
|
|
214
|
+
onClick={() => page(1)}
|
|
215
|
+
className={cn("absolute top-1/2 right-0 z-20 hidden -translate-y-1/2 bg-background/90 [@media(pointer:fine)]:inline-flex")}
|
|
216
|
+
>
|
|
217
|
+
<ChevronRight aria-hidden />
|
|
218
|
+
</Button>
|
|
219
|
+
)}
|
|
220
|
+
</div>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
@@ -34,6 +34,43 @@ export const Selected: Story = { args: { time: "19:30", meta: "Popular", selecte
|
|
|
34
34
|
export const SelectedPlain: Story = { args: { time: "20:30", selected: true } };
|
|
35
35
|
export const Unavailable: Story = { args: { time: "18:00", unavailable: true } };
|
|
36
36
|
|
|
37
|
+
const touchArgs = { size: "touch", className: "w-[84px]" } as const;
|
|
38
|
+
|
|
39
|
+
/** Touch size: 44px floor, 16px time, 12px meta. */
|
|
40
|
+
export const TouchPlain: Story = { args: { ...touchArgs, time: "19:30" } };
|
|
41
|
+
export const TouchFits: Story = { args: { ...touchArgs, time: "19:30", meta: "R1+R2", fit: "fits" } };
|
|
42
|
+
export const TouchTight: Story = { args: { ...touchArgs, time: "20:00", meta: "Join R3+R4", fit: "tight" } };
|
|
43
|
+
export const TouchNone: Story = { args: { ...touchArgs, time: "21:00", meta: "Full", fit: "none" } };
|
|
44
|
+
export const TouchSelected: Story = { args: { ...touchArgs, time: "19:30", meta: "R1+R2", fit: "fits", selected: true } };
|
|
45
|
+
export const TouchSelectedNone: Story = { args: { ...touchArgs, time: "21:00", meta: "Full", fit: "none", selected: true } };
|
|
46
|
+
/** Locked, not taken: dimmed with no strike-through. */
|
|
47
|
+
export const TouchDisabled: Story = { args: { ...touchArgs, time: "19:30", meta: "R1+R2", fit: "fits", disabled: true } };
|
|
48
|
+
export const Disabled: Story = { args: { time: "19:30", meta: "Popular", disabled: true } };
|
|
49
|
+
|
|
50
|
+
/** Every touch state side by side, light and dark. */
|
|
51
|
+
export const TouchStates: StoryObj<typeof TimeSlot> = {
|
|
52
|
+
render: () => {
|
|
53
|
+
const states = (
|
|
54
|
+
<div className="flex flex-wrap gap-1.5">
|
|
55
|
+
<TimeSlot {...touchArgs} time="19:30" meta="R1+R2" fit="fits" />
|
|
56
|
+
<TimeSlot {...touchArgs} time="20:00" meta="Join R3+R4" fit="tight" />
|
|
57
|
+
<TimeSlot {...touchArgs} time="21:00" meta="Full" fit="none" />
|
|
58
|
+
<TimeSlot {...touchArgs} time="19:30" meta="R1+R2" fit="fits" selected />
|
|
59
|
+
<TimeSlot {...touchArgs} time="20:00" meta="Join R3+R4" fit="tight" selected />
|
|
60
|
+
<TimeSlot {...touchArgs} time="21:00" meta="Full" fit="none" selected />
|
|
61
|
+
<TimeSlot {...touchArgs} time="19:30" meta="R1+R2" fit="fits" disabled />
|
|
62
|
+
<TimeSlot {...touchArgs} time="18:00" meta="R2" fit="fits" unavailable />
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
return (
|
|
66
|
+
<div className="flex w-[640px] flex-col gap-4">
|
|
67
|
+
<div className="rounded-xl bg-background p-3 text-foreground">{states}</div>
|
|
68
|
+
<div className="dark rounded-xl bg-background p-3 text-foreground">{states}</div>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
37
74
|
function groupsFor(selected: string, onSelect: (time: string) => void): TimeSlotGroup[] {
|
|
38
75
|
const slot = (time: string, meta?: string, tone?: "waitlist") => ({
|
|
39
76
|
key: time,
|
|
@@ -0,0 +1,113 @@
|
|
|
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 { TimeSlot } from "./time-slot";
|
|
5
|
+
|
|
6
|
+
describe("TimeSlot size=touch", () => {
|
|
7
|
+
it("clears the 44px floor and the 12px type floor", () => {
|
|
8
|
+
render(<TimeSlot size="touch" time="19:30" meta="R1+R2" />);
|
|
9
|
+
const slot = screen.getByRole("button");
|
|
10
|
+
expect(slot).toHaveClass("min-h-11", "rounded-xl");
|
|
11
|
+
expect(screen.getByText("R1+R2").parentElement).toHaveClass("text-xs");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("selects with the accent fill, not the gradient, so dark mode keeps its contrast", () => {
|
|
15
|
+
render(<TimeSlot size="touch" time="19:30" meta="R1" fit="fits" selected />);
|
|
16
|
+
const slot = screen.getByRole("button");
|
|
17
|
+
expect(slot).toHaveAttribute("aria-pressed", "true");
|
|
18
|
+
expect(slot).toHaveClass("border-accent", "bg-accent", "text-accent-foreground");
|
|
19
|
+
expect(slot.className).not.toContain("gradient-primary");
|
|
20
|
+
expect(slot.querySelector('[data-slot="time-slot-fit-dot"]')).toHaveClass("bg-accent-foreground");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("applies the caller's className, which wins over the default width", () => {
|
|
24
|
+
render(<TimeSlot size="touch" time="19:30" className="w-[84px] shrink-0" />);
|
|
25
|
+
const slot = screen.getByRole("button");
|
|
26
|
+
expect(slot).toHaveClass("w-[84px]", "shrink-0");
|
|
27
|
+
expect(slot).not.toHaveClass("w-full");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("TimeSlot fit", () => {
|
|
32
|
+
it("fits: a success dot and success ink", () => {
|
|
33
|
+
render(<TimeSlot size="touch" time="19:30" meta="R1+R2" fit="fits" />);
|
|
34
|
+
expect(screen.getByRole("button")).toHaveAttribute("data-fit", "fits");
|
|
35
|
+
expect(screen.getByText("R1+R2").parentElement).toHaveClass("text-success-ink");
|
|
36
|
+
expect(document.querySelector('[data-slot="time-slot-fit-dot"]')).toHaveClass("bg-success");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("tight: a warning dot and warning ink", () => {
|
|
40
|
+
render(<TimeSlot size="touch" time="20:00" meta="Join R3+R4" fit="tight" />);
|
|
41
|
+
expect(screen.getByText("Join R3+R4").parentElement).toHaveClass("text-warning-ink");
|
|
42
|
+
expect(document.querySelector('[data-slot="time-slot-fit-dot"]')).toHaveClass("bg-warning-deep");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("none: dashed and muted, with no dot, no strike-through and no fade, and still pickable", async () => {
|
|
46
|
+
const user = userEvent.setup();
|
|
47
|
+
const onSelect = vi.fn();
|
|
48
|
+
render(<TimeSlot size="touch" time="21:00" meta="Full" fit="none" onSelect={onSelect} />);
|
|
49
|
+
const slot = screen.getByRole("button");
|
|
50
|
+
expect(slot).toHaveClass("border-dashed", "bg-transparent", "text-muted-foreground");
|
|
51
|
+
expect(slot.className).not.toContain("opacity-");
|
|
52
|
+
expect(screen.getByText("21:00")).not.toHaveClass("line-through");
|
|
53
|
+
expect(document.querySelector('[data-slot="time-slot-fit-dot"]')).toBeNull();
|
|
54
|
+
await user.click(slot);
|
|
55
|
+
expect(onSelect).toHaveBeenCalledOnce();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("selected none is solid, not dashed", () => {
|
|
59
|
+
render(<TimeSlot size="touch" time="21:00" meta="Full" fit="none" selected />);
|
|
60
|
+
expect(screen.getByRole("button")).not.toHaveClass("border-dashed");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("does not type-check a fit without a meta", () => {
|
|
64
|
+
// @ts-expect-error fit requires meta: the dot is aria-hidden, so the meta is what says the fit
|
|
65
|
+
const slot = <TimeSlot size="touch" time="19:30" fit="fits" />;
|
|
66
|
+
expect(slot).toBeTruthy();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("wraps a long meta on two lines instead of truncating it", () => {
|
|
70
|
+
render(<TimeSlot size="touch" time="20:00" meta="Join R3+R4+R5" fit="tight" />);
|
|
71
|
+
expect(screen.getByText("Join R3+R4+R5")).toHaveClass("line-clamp-2", "break-words");
|
|
72
|
+
expect(screen.getByText("Join R3+R4+R5")).not.toHaveClass("truncate");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("is ignored at the default size", () => {
|
|
76
|
+
render(<TimeSlot time="19:30" meta="Popular" fit="fits" />);
|
|
77
|
+
const slot = screen.getByRole("button");
|
|
78
|
+
expect(slot).not.toHaveAttribute("data-fit");
|
|
79
|
+
expect(document.querySelector('[data-slot="time-slot-fit-dot"]')).toBeNull();
|
|
80
|
+
expect(screen.getByText("Popular")).toHaveClass("text-accent");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("TimeSlot disabled", () => {
|
|
85
|
+
it("locks without striking through, and does not report picks", async () => {
|
|
86
|
+
const user = userEvent.setup();
|
|
87
|
+
const onSelect = vi.fn();
|
|
88
|
+
render(<TimeSlot time="19:30" disabled onSelect={onSelect} />);
|
|
89
|
+
const slot = screen.getByRole("button");
|
|
90
|
+
expect(slot).toHaveAttribute("aria-disabled", "true");
|
|
91
|
+
expect(slot).toHaveClass("opacity-50", "cursor-not-allowed");
|
|
92
|
+
expect(screen.getByText("19:30")).not.toHaveClass("line-through");
|
|
93
|
+
await user.click(slot);
|
|
94
|
+
expect(onSelect).not.toHaveBeenCalled();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("stays focusable, unlike unavailable", () => {
|
|
98
|
+
render(
|
|
99
|
+
<>
|
|
100
|
+
<TimeSlot time="19:30" disabled />
|
|
101
|
+
<TimeSlot time="18:00" unavailable />
|
|
102
|
+
</>
|
|
103
|
+
);
|
|
104
|
+
expect(screen.getByRole("button", { name: "19:30" })).not.toBeDisabled();
|
|
105
|
+
expect(screen.getByRole("button", { name: "18:00" })).toBeDisabled();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("unavailable still strikes the time through and fades", () => {
|
|
109
|
+
render(<TimeSlot time="18:00" unavailable disabled />);
|
|
110
|
+
expect(screen.getByText("18:00")).toHaveClass("line-through");
|
|
111
|
+
expect(screen.getByRole("button")).toHaveClass("opacity-[0.32]");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -21,62 +21,158 @@ import { Skeleton } from "./skeleton";
|
|
|
21
21
|
* 7px gaps.
|
|
22
22
|
*
|
|
23
23
|
* A slot is a button with `aria-pressed`: one of many times, picked once.
|
|
24
|
+
*
|
|
25
|
+
* ── size="touch" ──────────────────────────────────────────────────────────
|
|
26
|
+
* The default slot is 35px tall with a 9.5px meta line: under both the 44px
|
|
27
|
+
* touch floor and the 12px type floor, so a staff tool on a phone cannot use
|
|
28
|
+
* it. `touch` is `min-h-11`, 16px mono time, 12px meta, 12px radius. Selected
|
|
29
|
+
* is a FILL of `--accent` with `accent-foreground` type (the reasoning is
|
|
30
|
+
* DayRailChip's: the gradient's primary-foreground flips dark-on-gradient in
|
|
31
|
+
* dark mode), and the meta stays at full opacity.
|
|
32
|
+
*
|
|
33
|
+
* `fit` (touch only) says how well the slot suits the party, in words AND a
|
|
34
|
+
* dot, so nothing hangs on colour: `fits` (success), `tight` (warning, a join
|
|
35
|
+
* or a squeeze), `none` (dashed, muted — still tappable, the manager can
|
|
36
|
+
* override, so no strike-through and no fading).
|
|
37
|
+
*
|
|
38
|
+
* `disabled` means LOCKED, not taken: half opacity, not-allowed, no strike.
|
|
39
|
+
* It uses `aria-disabled` rather than the `disabled` attribute so a locked
|
|
40
|
+
* rail keeps focus where it was. `unavailable` is still the taken/past state.
|
|
24
41
|
*/
|
|
25
42
|
|
|
26
43
|
export type TimeSlotTone = "default" | "waitlist";
|
|
44
|
+
export type TimeSlotSize = "default" | "touch";
|
|
45
|
+
export type TimeSlotFit = "fits" | "tight" | "none";
|
|
27
46
|
|
|
28
|
-
|
|
47
|
+
/**
|
|
48
|
+
* `fit` needs a `meta`: the meta text says the fit in words, and the dot is
|
|
49
|
+
* aria-hidden, so a fit without one would hang on colour alone.
|
|
50
|
+
*/
|
|
51
|
+
export type TimeSlotFitProps =
|
|
52
|
+
| {
|
|
53
|
+
/** "Popular", "2 left", "Waitlist" — already localised. */
|
|
54
|
+
meta?: string;
|
|
55
|
+
fit?: undefined;
|
|
56
|
+
}
|
|
57
|
+
| {
|
|
58
|
+
/** Says the fit in words — "R1+R2", "Join R3+R4", "Full". */
|
|
59
|
+
meta: string;
|
|
60
|
+
/** Touch only. How well the slot suits the party. Requires `meta`. */
|
|
61
|
+
fit: TimeSlotFit;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type TimeSlotProps = TimeSlotFitProps & {
|
|
29
65
|
/** The time as displayed, already formatted in the venue's timezone — "19:30". */
|
|
30
66
|
time: string;
|
|
31
|
-
/** "Popular", "2 left", "Waitlist" — already localised. */
|
|
32
|
-
meta?: string;
|
|
33
67
|
/** `waitlist` renders the meta muted: the slot books onto the list, not a table. */
|
|
34
68
|
tone?: TimeSlotTone;
|
|
35
69
|
selected?: boolean;
|
|
36
70
|
/** Taken or past: faded and struck through, not clickable. */
|
|
37
71
|
unavailable?: boolean;
|
|
72
|
+
/** `default` is the booking flow's 35px slot; `touch` meets the 44px / 12px floors. */
|
|
73
|
+
size?: TimeSlotSize;
|
|
74
|
+
/** Locked, not taken: dimmed, not-allowed, no strike-through, still focusable. */
|
|
75
|
+
disabled?: boolean;
|
|
38
76
|
onSelect?: () => void;
|
|
39
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* Full accessible name, when the visible text is not enough ("19:30, popular").
|
|
79
|
+
* It REPLACES the visible text, so with `fit` it must say the fit in words
|
|
80
|
+
* ("19:30, fits at R1 and R2"): the dot is aria-hidden.
|
|
81
|
+
*/
|
|
40
82
|
"aria-label"?: string;
|
|
41
83
|
className?: string;
|
|
42
84
|
};
|
|
43
85
|
|
|
86
|
+
const FIT_DOT = {
|
|
87
|
+
fits: "bg-success",
|
|
88
|
+
tight: "bg-warning-deep",
|
|
89
|
+
} as const;
|
|
90
|
+
|
|
91
|
+
const FIT_META = {
|
|
92
|
+
fits: "text-success-ink",
|
|
93
|
+
tight: "text-warning-ink",
|
|
94
|
+
none: "text-muted-foreground",
|
|
95
|
+
} as const;
|
|
96
|
+
|
|
44
97
|
export function TimeSlot({
|
|
45
98
|
time,
|
|
46
99
|
meta,
|
|
47
100
|
tone = "default",
|
|
48
101
|
selected,
|
|
49
102
|
unavailable,
|
|
103
|
+
size = "default",
|
|
104
|
+
fit,
|
|
105
|
+
disabled,
|
|
50
106
|
onSelect,
|
|
51
107
|
"aria-label": ariaLabel,
|
|
52
108
|
className,
|
|
53
109
|
}: TimeSlotProps) {
|
|
110
|
+
const touch = size === "touch";
|
|
111
|
+
// `fit` is a touch-only vocabulary; the default slot ignores it.
|
|
112
|
+
const activeFit = touch ? fit : undefined;
|
|
113
|
+
const locked = disabled && !unavailable;
|
|
114
|
+
const interactive = !unavailable && !locked;
|
|
115
|
+
const dashed = activeFit === "none" && !selected;
|
|
116
|
+
|
|
117
|
+
const fitDot =
|
|
118
|
+
activeFit && activeFit !== "none" ? (
|
|
119
|
+
<span
|
|
120
|
+
aria-hidden
|
|
121
|
+
data-slot="time-slot-fit-dot"
|
|
122
|
+
className={cn(
|
|
123
|
+
"size-1.5 shrink-0 rounded-full",
|
|
124
|
+
selected ? "bg-accent-foreground" : FIT_DOT[activeFit]
|
|
125
|
+
)}
|
|
126
|
+
/>
|
|
127
|
+
) : null;
|
|
128
|
+
|
|
54
129
|
return (
|
|
55
130
|
<button
|
|
56
131
|
type="button"
|
|
57
132
|
data-slot="time-slot"
|
|
133
|
+
data-size={size}
|
|
134
|
+
data-fit={activeFit}
|
|
58
135
|
aria-pressed={selected ? true : false}
|
|
59
136
|
aria-label={ariaLabel}
|
|
137
|
+
aria-disabled={locked ? true : undefined}
|
|
60
138
|
disabled={unavailable}
|
|
61
|
-
onClick={onSelect}
|
|
139
|
+
onClick={locked ? undefined : onSelect}
|
|
62
140
|
className={cn(
|
|
141
|
+
"flex w-full flex-col items-center border text-center",
|
|
63
142
|
// Content sits at the top: a slot without a meta line in a row with
|
|
64
143
|
// one is stretched by the grid, and the time must not drop to the middle.
|
|
65
|
-
|
|
144
|
+
touch
|
|
145
|
+
? "min-h-11 justify-center gap-1 rounded-xl px-2 py-2 leading-[1.2]"
|
|
146
|
+
: "justify-start rounded-[11px] px-1 py-2 leading-[1.2]",
|
|
147
|
+
interactive ? "cursor-pointer" : "cursor-not-allowed",
|
|
66
148
|
// A press state, not just hover: this is the most-tapped control in
|
|
67
149
|
// the flow and the primary device has no hover at all, so without it
|
|
68
150
|
// a tap gives nothing back until the fill repaints.
|
|
69
|
-
|
|
151
|
+
touch
|
|
152
|
+
? "transition-[background-color,border-color,color,transform] duration-150 motion-reduce:transition-none"
|
|
153
|
+
: "transition-[border-color,filter,transform] duration-150 motion-reduce:transition-none",
|
|
154
|
+
interactive && "active:scale-[0.98]",
|
|
70
155
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
71
156
|
selected
|
|
72
|
-
?
|
|
73
|
-
|
|
74
|
-
|
|
157
|
+
? touch
|
|
158
|
+
? "border-accent bg-accent text-accent-foreground"
|
|
159
|
+
: cn(
|
|
160
|
+
"border-transparent bg-[image:var(--gradient-primary)] text-primary-foreground",
|
|
161
|
+
interactive && "hover:brightness-[0.94]"
|
|
162
|
+
)
|
|
163
|
+
: dashed
|
|
164
|
+
? "border-dashed border-border bg-transparent text-muted-foreground"
|
|
165
|
+
: "border-border bg-card text-foreground",
|
|
166
|
+
!selected && interactive && "hover:border-accent/50",
|
|
167
|
+
unavailable && "opacity-[0.32]",
|
|
168
|
+
locked && "opacity-50",
|
|
169
|
+
className
|
|
75
170
|
)}
|
|
76
171
|
>
|
|
77
172
|
<b
|
|
78
173
|
className={cn(
|
|
79
|
-
"block font-mono
|
|
174
|
+
"block font-mono leading-none font-bold tabular-nums",
|
|
175
|
+
touch ? "text-base" : "text-[14.5px]",
|
|
80
176
|
unavailable && "line-through"
|
|
81
177
|
)}
|
|
82
178
|
>
|
|
@@ -85,15 +181,22 @@ export function TimeSlot({
|
|
|
85
181
|
{meta && (
|
|
86
182
|
<span
|
|
87
183
|
className={cn(
|
|
88
|
-
|
|
184
|
+
touch
|
|
185
|
+
? "flex max-w-full items-center justify-center gap-1 text-xs leading-4"
|
|
186
|
+
: "mt-[3px] block text-[9.5px] leading-[1.2]",
|
|
89
187
|
selected
|
|
90
|
-
?
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
188
|
+
? touch
|
|
189
|
+
? "text-accent-foreground"
|
|
190
|
+
: "text-primary-foreground/80"
|
|
191
|
+
: activeFit
|
|
192
|
+
? FIT_META[activeFit]
|
|
193
|
+
: tone === "waitlist"
|
|
194
|
+
? "text-muted-foreground"
|
|
195
|
+
: "text-accent"
|
|
94
196
|
)}
|
|
95
197
|
>
|
|
96
|
-
{
|
|
198
|
+
{fitDot}
|
|
199
|
+
{touch ? <span className="line-clamp-2 break-words">{meta}</span> : meta}
|
|
97
200
|
</span>
|
|
98
201
|
)}
|
|
99
202
|
</button>
|