@bearmenu/ui 0.8.56 → 0.8.58

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.
@@ -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
+ });
@@ -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-1.5 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
+ });