@bearmenu/ui 0.8.57 → 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,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
+ });
@@ -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
- export type TimeSlotProps = {
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
- /** Full accessible name, when the visible text is not enough ("19:30, popular"). */
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
- "flex w-full cursor-pointer flex-col items-center justify-start rounded-[11px] border px-1 py-2 text-center leading-[1.2]",
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
- "transition-[border-color,filter,transform] duration-150 not-disabled:active:scale-[0.98] motion-reduce:transition-none",
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
- ? "border-transparent bg-[image:var(--gradient-primary)] text-primary-foreground hover:brightness-[0.94]"
73
- : "border-border bg-card text-foreground hover:border-accent/50",
74
- unavailable && "cursor-not-allowed opacity-[0.32] hover:border-border"
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 text-[14.5px] leading-none font-bold tabular-nums",
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
- "mt-[3px] block text-[9.5px] leading-[1.2]",
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
- ? "text-primary-foreground/80"
91
- : tone === "waitlist"
92
- ? "text-muted-foreground"
93
- : "text-accent"
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
- {meta}
198
+ {fitDot}
199
+ {touch ? <span className="line-clamp-2 break-words">{meta}</span> : meta}
97
200
  </span>
98
201
  )}
99
202
  </button>