@bearmenu/ui 0.8.41 → 0.8.43
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/{chunk-TB2K5JN3.js → chunk-BFS4M7KD.js} +26 -9
- package/dist/{chunk-KGXTBTCJ.js → chunk-CLFE4U5T.js} +1 -1
- package/dist/{chunk-NF5UMLSL.js → chunk-FVFAOW36.js} +46 -40
- package/dist/{chunk-ZUBBMLBD.js → chunk-RQPUBKFU.js} +1 -1
- package/dist/{chunk-FQAIYKAS.js → chunk-WHUG6QEI.js} +8 -2
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/badge.js +1 -1
- package/dist/components/booking-row.js +1 -1
- package/dist/components/button.d.ts +2 -2
- package/dist/components/command.d.ts +3 -3
- package/dist/components/concept-convergence.js +1 -1
- package/dist/components/drawer.d.ts +9 -0
- package/dist/components/drawer.js +1 -1
- package/dist/components/filter-category-row.js +1 -1
- package/dist/components/menu-item.js +3 -3
- package/dist/components/multi-select-combobox.js +1 -1
- package/dist/components/place-card.js +1 -1
- package/dist/components/place-details-mobile.js +1 -1
- package/dist/components/responsive-overlay.d.ts +23 -0
- package/dist/components/responsive-overlay.js +57 -34
- package/dist/components/signature-chip.js +2 -2
- 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/toggle-group.js +9 -5
- package/dist/components/toggle.d.ts +11 -2
- package/dist/components/toggle.js +1 -1
- package/dist/components/top-ranking-entry.js +3 -3
- package/package.json +1 -1
- package/src/components/badge.stories.tsx +22 -1
- package/src/components/badge.test.tsx +37 -0
- package/src/components/badge.tsx +8 -2
- package/src/components/drawer.stories.tsx +25 -0
- package/src/components/drawer.test.tsx +50 -0
- package/src/components/drawer.tsx +70 -47
- package/src/components/responsive-overlay.stories.tsx +62 -0
- package/src/components/responsive-overlay.test.tsx +134 -0
- package/src/components/responsive-overlay.tsx +66 -7
- package/src/components/toggle-group.stories.tsx +17 -0
- package/src/components/toggle-group.test.tsx +65 -0
- package/src/components/toggle-group.tsx +13 -5
- package/src/components/toggle.test.tsx +50 -1
- package/src/components/toggle.tsx +35 -4
- package/src/globals.css +7 -1
|
@@ -32,8 +32,12 @@ const CONTAINER_DESCRIPTION = {
|
|
|
32
32
|
* The opener is tracked while the overlay is CLOSED rather than captured when
|
|
33
33
|
* it opens: by the time an effect could run on the opening render, the dialog
|
|
34
34
|
* has already mounted and pulled focus into itself.
|
|
35
|
+
*
|
|
36
|
+
* `returnFocus={false}` disables the `requestAnimationFrame` refocus below,
|
|
37
|
+
* but tracking keeps running regardless — cheap, and it means flipping the
|
|
38
|
+
* prop back on mid-lifecycle has an opener ready immediately.
|
|
35
39
|
*/
|
|
36
|
-
function useFocusReturn(open: boolean) {
|
|
40
|
+
function useFocusReturn(open: boolean, returnFocus: boolean) {
|
|
37
41
|
const openerRef = React.useRef<HTMLElement | null>(null);
|
|
38
42
|
const wasOpen = React.useRef(false);
|
|
39
43
|
|
|
@@ -48,7 +52,7 @@ function useFocusReturn(open: boolean) {
|
|
|
48
52
|
const opener = openerRef.current;
|
|
49
53
|
// The overlay is still unmounting on this tick, and it takes focus with
|
|
50
54
|
// it; restoring on the next frame lands after that.
|
|
51
|
-
if (opener && opener.isConnected) {
|
|
55
|
+
if (returnFocus && opener && opener.isConnected) {
|
|
52
56
|
requestAnimationFrame(() => opener.focus());
|
|
53
57
|
}
|
|
54
58
|
}
|
|
@@ -62,7 +66,7 @@ function useFocusReturn(open: boolean) {
|
|
|
62
66
|
}
|
|
63
67
|
document.addEventListener("focusin", track);
|
|
64
68
|
return () => document.removeEventListener("focusin", track);
|
|
65
|
-
}, [open]);
|
|
69
|
+
}, [open, returnFocus]);
|
|
66
70
|
}
|
|
67
71
|
|
|
68
72
|
export type ResponsiveOverlayProps = {
|
|
@@ -124,6 +128,29 @@ export type ResponsiveOverlayProps = {
|
|
|
124
128
|
* keeps today's default in every mode (dialog, sheet, drawer).
|
|
125
129
|
*/
|
|
126
130
|
initialFocusRef?: React.RefObject<HTMLElement | null>;
|
|
131
|
+
/**
|
|
132
|
+
* Whether closing the overlay returns focus to whatever had it before the
|
|
133
|
+
* overlay opened. Two things do that today: Radix's `onCloseAutoFocus` —
|
|
134
|
+
* the FocusScope's own auto-focus-on-unmount, refocusing the element that
|
|
135
|
+
* was active before the overlay trapped focus — and this component's own
|
|
136
|
+
* `useFocusReturn`, which refocuses that same element a frame later so it
|
|
137
|
+
* survives the overlay's own unmount. `true` (the default) keeps today's
|
|
138
|
+
* behaviour in every mode (dialog, sheet, drawer): the opener gets focus
|
|
139
|
+
* back.
|
|
140
|
+
*
|
|
141
|
+
* Pass `false` when closing hands focus to another surface that takes it
|
|
142
|
+
* itself — a pick-mode bar docking over the header, say. Both paths are
|
|
143
|
+
* disabled and focus stays wherever the consumer puts it (typically inside
|
|
144
|
+
* the same `onOpenChange(false)` handler that closes the overlay).
|
|
145
|
+
*/
|
|
146
|
+
returnFocus?: boolean;
|
|
147
|
+
/**
|
|
148
|
+
* A fixed height for the phone drawer instead of sizing to its content —
|
|
149
|
+
* `DrawerContent`'s `snapHeight` (the frames' ~640px table sheet). Applies
|
|
150
|
+
* only when the narrow drawer is showing; the desktop dialog/sheet are
|
|
151
|
+
* unaffected. Omit to keep today's content-sized drawer.
|
|
152
|
+
*/
|
|
153
|
+
drawerHeight?: number | string;
|
|
127
154
|
};
|
|
128
155
|
|
|
129
156
|
/**
|
|
@@ -171,12 +198,14 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
|
|
|
171
198
|
className,
|
|
172
199
|
bodyClassName,
|
|
173
200
|
initialFocusRef,
|
|
201
|
+
returnFocus = true,
|
|
202
|
+
drawerHeight,
|
|
174
203
|
},
|
|
175
204
|
ref
|
|
176
205
|
) => {
|
|
177
206
|
const isWide = useMediaQuery(wideFrom === "lg" ? DESKTOP_MEDIA_QUERY : MD_MEDIA_QUERY);
|
|
178
207
|
const container: "drawer" | "dialog" | "sheet" = isWide ? desktop : "drawer";
|
|
179
|
-
useFocusReturn(open);
|
|
208
|
+
useFocusReturn(open, returnFocus);
|
|
180
209
|
|
|
181
210
|
// Radix (and vaul, underneath the drawer) call this before moving focus
|
|
182
211
|
// themselves; preventing the default there is what stops the first-
|
|
@@ -190,6 +219,19 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
|
|
|
190
219
|
[initialFocusRef]
|
|
191
220
|
);
|
|
192
221
|
|
|
222
|
+
// Radix's FocusScope calls this on unmount before it runs its own default
|
|
223
|
+
// action — refocusing whatever was active before the scope trapped focus,
|
|
224
|
+
// i.e. the opener. Vaul forwards it straight through to the same Radix
|
|
225
|
+
// primitive underneath the drawer, so one handler covers all three modes.
|
|
226
|
+
// Leaving it a no-op when `returnFocus` is true reproduces today's
|
|
227
|
+
// behaviour exactly: the prop was never passed before this change.
|
|
228
|
+
const handleCloseAutoFocus = React.useCallback(
|
|
229
|
+
(event: Event) => {
|
|
230
|
+
if (!returnFocus) event.preventDefault();
|
|
231
|
+
},
|
|
232
|
+
[returnFocus]
|
|
233
|
+
);
|
|
234
|
+
|
|
193
235
|
// The name and the description are Radix's own: its `Title` and
|
|
194
236
|
// `Description` register ids in the dialog context and `Content` points
|
|
195
237
|
// `aria-labelledby` / `aria-describedby` at them. Passing our own ids
|
|
@@ -201,10 +243,19 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
|
|
|
201
243
|
const Title = CONTAINER_TITLE[container];
|
|
202
244
|
const Description = CONTAINER_DESCRIPTION[container];
|
|
203
245
|
|
|
246
|
+
// The phone drawer draws a tighter gutter and a smaller title than the
|
|
247
|
+
// desktop dialog/sheet — measured off the booking frames' table sheet
|
|
248
|
+
// (floor/14-w1): 16px, not 20; 22px, not 19. Scoped to `drawer` only, so
|
|
249
|
+
// the desktop containers (already frames-checked elsewhere) don't move.
|
|
250
|
+
const isDrawer = container === "drawer";
|
|
251
|
+
|
|
204
252
|
const header = (
|
|
205
|
-
<div
|
|
253
|
+
<div
|
|
254
|
+
data-slot="responsive-overlay-header"
|
|
255
|
+
className={cn("flex items-start gap-3", isDrawer ? "px-4 pt-4" : "px-5 pt-5")}
|
|
256
|
+
>
|
|
206
257
|
<div className="min-w-0 flex-1">
|
|
207
|
-
<Title className="
|
|
258
|
+
<Title className={cn("font-bold leading-tight", isDrawer ? "text-[22px]" : "text-[19px]")}>
|
|
208
259
|
{title}
|
|
209
260
|
</Title>
|
|
210
261
|
{subtitle ? (
|
|
@@ -232,7 +283,11 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
|
|
|
232
283
|
const body = (
|
|
233
284
|
<div
|
|
234
285
|
data-slot="responsive-overlay-body"
|
|
235
|
-
className={cn(
|
|
286
|
+
className={cn(
|
|
287
|
+
"min-h-0 flex-1 overflow-y-auto py-4",
|
|
288
|
+
isDrawer ? "px-4" : "px-5",
|
|
289
|
+
bodyClassName
|
|
290
|
+
)}
|
|
236
291
|
>
|
|
237
292
|
{children}
|
|
238
293
|
</div>
|
|
@@ -257,6 +312,7 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
|
|
|
257
312
|
hideClose
|
|
258
313
|
overlayClassName="bg-scrim-overlay backdrop-blur-none"
|
|
259
314
|
onOpenAutoFocus={handleOpenAutoFocus}
|
|
315
|
+
onCloseAutoFocus={handleCloseAutoFocus}
|
|
260
316
|
{...describedBy}
|
|
261
317
|
data-slot="responsive-overlay-sheet"
|
|
262
318
|
data-modal={modal ? "true" : "false"}
|
|
@@ -289,6 +345,7 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
|
|
|
289
345
|
showCloseButton={false}
|
|
290
346
|
overlayClassName="bg-scrim-overlay backdrop-blur-none"
|
|
291
347
|
onOpenAutoFocus={handleOpenAutoFocus}
|
|
348
|
+
onCloseAutoFocus={handleCloseAutoFocus}
|
|
292
349
|
{...describedBy}
|
|
293
350
|
className={cn(
|
|
294
351
|
// The dialog's own `grid gap-4 p-6` would put air between the
|
|
@@ -311,6 +368,8 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
|
|
|
311
368
|
ref={ref}
|
|
312
369
|
overlayClassName="bg-scrim-overlay"
|
|
313
370
|
onOpenAutoFocus={handleOpenAutoFocus}
|
|
371
|
+
onCloseAutoFocus={handleCloseAutoFocus}
|
|
372
|
+
{...(drawerHeight !== undefined ? { snapHeight: drawerHeight } : {})}
|
|
314
373
|
{...describedBy}
|
|
315
374
|
className={cn("flex max-h-[85vh] flex-col p-0", className)}
|
|
316
375
|
>
|
|
@@ -95,6 +95,23 @@ export const Segmented: Story = {
|
|
|
95
95
|
),
|
|
96
96
|
};
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Text-label cells — the calendar toolbar's Timeline/List, the reservation
|
|
100
|
+
* sheet's booked-by source, the rules confirmation mode. Each cell is a full
|
|
101
|
+
* 44px hit target (the accessibility floor); the raised card it draws stays
|
|
102
|
+
* the frames' 36px, centred inside.
|
|
103
|
+
*/
|
|
104
|
+
export const SegmentedLabels: Story = {
|
|
105
|
+
render: () => (
|
|
106
|
+
<div className="w-[220px]">
|
|
107
|
+
<ToggleGroup type="single" variant="segmented" defaultValue="list" aria-label="View">
|
|
108
|
+
<ToggleGroupItem value="timeline">Timeline</ToggleGroupItem>
|
|
109
|
+
<ToggleGroupItem value="list">List</ToggleGroupItem>
|
|
110
|
+
</ToggleGroup>
|
|
111
|
+
</div>
|
|
112
|
+
),
|
|
113
|
+
};
|
|
114
|
+
|
|
98
115
|
/** Choice chips that wrap: the booking flow's seating preference (B2). */
|
|
99
116
|
export const Chips: Story = {
|
|
100
117
|
render: () => (
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
|
|
5
|
+
import { ToggleGroup, ToggleGroupItem } from "./toggle-group";
|
|
6
|
+
|
|
7
|
+
describe("ToggleGroup segmented variant", () => {
|
|
8
|
+
it("gives each cell a 44px hit target with a smaller pill drawn inside it", () => {
|
|
9
|
+
render(
|
|
10
|
+
<ToggleGroup type="single" variant="segmented" value="timeline" aria-label="View">
|
|
11
|
+
<ToggleGroupItem value="timeline">Timeline</ToggleGroupItem>
|
|
12
|
+
<ToggleGroupItem value="list">List</ToggleGroupItem>
|
|
13
|
+
</ToggleGroup>
|
|
14
|
+
);
|
|
15
|
+
const timeline = screen.getByRole("radio", { name: "Timeline" });
|
|
16
|
+
expect(timeline).toHaveClass("h-11");
|
|
17
|
+
const pill = timeline.querySelector('[class*="group-data-[state=on]/segment:bg-card"]');
|
|
18
|
+
expect(pill).not.toBeNull();
|
|
19
|
+
expect(pill).toHaveClass("h-9", "w-full");
|
|
20
|
+
expect(pill).toContainElement(screen.getByText("Timeline"));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("keeps the group's own capsule padding-free — the cell fills its full height", () => {
|
|
24
|
+
render(
|
|
25
|
+
<ToggleGroup type="single" variant="segmented" value="light" aria-label="Theme">
|
|
26
|
+
<ToggleGroupItem value="light">Light</ToggleGroupItem>
|
|
27
|
+
<ToggleGroupItem value="dark">Dark</ToggleGroupItem>
|
|
28
|
+
</ToggleGroup>
|
|
29
|
+
);
|
|
30
|
+
const group = document.querySelector('[data-slot="toggle-group"]');
|
|
31
|
+
expect(group).not.toHaveClass("data-[variant=segmented]:p-0.5");
|
|
32
|
+
expect(group).toHaveClass("data-[variant=segmented]:gap-0.5");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("toggling selection moves the on-state to the newly active cell only", async () => {
|
|
36
|
+
const user = userEvent.setup();
|
|
37
|
+
function Harness() {
|
|
38
|
+
return (
|
|
39
|
+
<ToggleGroup type="single" defaultValue="timeline" variant="segmented" aria-label="View">
|
|
40
|
+
<ToggleGroupItem value="timeline">Timeline</ToggleGroupItem>
|
|
41
|
+
<ToggleGroupItem value="list">List</ToggleGroupItem>
|
|
42
|
+
</ToggleGroup>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
render(<Harness />);
|
|
46
|
+
const timeline = screen.getByRole("radio", { name: "Timeline" });
|
|
47
|
+
const list = screen.getByRole("radio", { name: "List" });
|
|
48
|
+
expect(timeline).toHaveAttribute("data-state", "on");
|
|
49
|
+
expect(list).toHaveAttribute("data-state", "off");
|
|
50
|
+
await user.click(list);
|
|
51
|
+
expect(list).toHaveAttribute("data-state", "on");
|
|
52
|
+
expect(timeline).toHaveAttribute("data-state", "off");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("does not wrap a non-segmented cell's children in a pill", () => {
|
|
56
|
+
render(
|
|
57
|
+
<ToggleGroup type="single" variant="chip" value="a" aria-label="Choice">
|
|
58
|
+
<ToggleGroupItem value="a">No preference</ToggleGroupItem>
|
|
59
|
+
</ToggleGroup>
|
|
60
|
+
);
|
|
61
|
+
const item = screen.getByRole("radio", { name: "No preference" });
|
|
62
|
+
expect(item.querySelector('[class*="group-data-[state=on]/segment"]')).toBeNull();
|
|
63
|
+
expect(item.firstChild?.textContent).toBe("No preference");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -5,7 +5,7 @@ import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
|
|
|
5
5
|
import { type VariantProps } from "class-variance-authority";
|
|
6
6
|
|
|
7
7
|
import { cn } from "../lib/utils";
|
|
8
|
-
import { toggleVariants } from "./toggle";
|
|
8
|
+
import { SegmentedToggleContent, toggleVariants } from "./toggle";
|
|
9
9
|
|
|
10
10
|
const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants>>({
|
|
11
11
|
size: "default",
|
|
@@ -27,8 +27,12 @@ function ToggleGroup({
|
|
|
27
27
|
className={cn(
|
|
28
28
|
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
|
|
29
29
|
// The segmented control's capsule: muted ground, the on cell a raised
|
|
30
|
-
// card inside it (see `toggleVariants.segmented`).
|
|
31
|
-
|
|
30
|
+
// card inside it (see `toggleVariants.segmented`). No padding here —
|
|
31
|
+
// each cell is the full 44px capsule height (the hit target), and
|
|
32
|
+
// draws its own smaller 36px card centred inside
|
|
33
|
+
// (`SegmentedToggleContent`), so the capsule stays the 44px the
|
|
34
|
+
// booking frames draw it at.
|
|
35
|
+
"data-[variant=segmented]:gap-0.5 data-[variant=segmented]:rounded-full data-[variant=segmented]:bg-muted",
|
|
32
36
|
// Chips wrap onto as many rows as they need, 7px apart.
|
|
33
37
|
"data-[variant=chip]:flex-wrap data-[variant=chip]:gap-[7px] data-[variant=chip]:rounded-none",
|
|
34
38
|
// The tab bar spans its container: a muted capsule, 4px inset.
|
|
@@ -66,7 +70,7 @@ function ToggleGroupItem({
|
|
|
66
70
|
}),
|
|
67
71
|
"min-w-0 flex-1 shrink-0 shadow-none focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
|
|
68
72
|
resolved === "segmented"
|
|
69
|
-
? "rounded-full
|
|
73
|
+
? "rounded-full"
|
|
70
74
|
: // Tabs and chips size themselves (`toggleVariants`' compounds);
|
|
71
75
|
// the item's own rounding must not square them off.
|
|
72
76
|
resolved === "tab"
|
|
@@ -79,7 +83,11 @@ function ToggleGroupItem({
|
|
|
79
83
|
)}
|
|
80
84
|
{...props}
|
|
81
85
|
>
|
|
82
|
-
{
|
|
86
|
+
{resolved === "segmented" ? (
|
|
87
|
+
<SegmentedToggleContent>{children}</SegmentedToggleContent>
|
|
88
|
+
) : (
|
|
89
|
+
children
|
|
90
|
+
)}
|
|
83
91
|
</ToggleGroupPrimitive.Item>
|
|
84
92
|
);
|
|
85
93
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
2
3
|
|
|
3
|
-
import { toggleVariants } from "./toggle";
|
|
4
|
+
import { Toggle, toggleVariants } from "./toggle";
|
|
4
5
|
|
|
5
6
|
describe("toggleVariants", () => {
|
|
6
7
|
// White on a half-strength terracotta measured 2.2:1. Hover steps the
|
|
@@ -16,4 +17,52 @@ describe("toggleVariants", () => {
|
|
|
16
17
|
expect(classes).toContain("data-[state=on]:text-accent-foreground");
|
|
17
18
|
}
|
|
18
19
|
});
|
|
20
|
+
|
|
21
|
+
// The 44px floor: the cell's own box is the hit target, not just the card
|
|
22
|
+
// it draws. `size` must never shrink it back below 44 — that is exactly the
|
|
23
|
+
// merge order chip/tab already guard against. `toggleVariants` alone is
|
|
24
|
+
// pre-merge (real callers run it through `cn`/twMerge), so render it.
|
|
25
|
+
it("holds the segmented cell at the 44px hit-target floor for every size", () => {
|
|
26
|
+
for (const size of ["sm", "default", "lg"] as const) {
|
|
27
|
+
render(
|
|
28
|
+
<Toggle variant="segmented" size={size} aria-label={`toggle-${size}`}>
|
|
29
|
+
X
|
|
30
|
+
</Toggle>
|
|
31
|
+
);
|
|
32
|
+
expect(screen.getByRole("button", { name: `toggle-${size}` })).toHaveClass("h-11");
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// The cell itself no longer paints the card or the shadow — that moved to
|
|
37
|
+
// `SegmentedToggleContent`'s inner pill, so the outer 44px box stays
|
|
38
|
+
// invisible and only the pill it wraps is 36px and shows the "on" state.
|
|
39
|
+
it("leaves the segmented cell unpainted; the base accent fill is neutralised", () => {
|
|
40
|
+
const classes = toggleVariants({ variant: "segmented" });
|
|
41
|
+
expect(classes).not.toContain("data-[state=on]:bg-card");
|
|
42
|
+
expect(classes).not.toContain("shadow-[var(--shadow-card)]");
|
|
43
|
+
expect(classes).toContain("data-[state=on]:bg-transparent");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("Toggle", () => {
|
|
48
|
+
it("wraps a segmented toggle's content in the 36px pill", () => {
|
|
49
|
+
render(
|
|
50
|
+
<Toggle variant="segmented" aria-label="Light">
|
|
51
|
+
Light
|
|
52
|
+
</Toggle>
|
|
53
|
+
);
|
|
54
|
+
const button = screen.getByRole("button", { name: "Light" });
|
|
55
|
+
expect(button).toHaveClass("h-11", "group/segment");
|
|
56
|
+
const pill = button.querySelector('[class*="group-data-[state=on]/segment:bg-card"]');
|
|
57
|
+
expect(pill).not.toBeNull();
|
|
58
|
+
expect(pill).toHaveClass("h-9");
|
|
59
|
+
expect(pill).toContainElement(screen.getByText("Light"));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("renders other variants' children directly, with no inner pill", () => {
|
|
63
|
+
render(<Toggle aria-label="Bold">B</Toggle>);
|
|
64
|
+
const button = screen.getByRole("button", { name: "Bold" });
|
|
65
|
+
expect(button.querySelector('[class*="group-data-[state=on]/segment"]')).toBeNull();
|
|
66
|
+
expect(button.firstChild?.textContent).toBe("B");
|
|
67
|
+
});
|
|
19
68
|
});
|
|
@@ -22,12 +22,18 @@ const toggleVariants = cva(
|
|
|
22
22
|
"border border-border bg-transparent hover:bg-accent-subtle hover:text-foreground data-[state=on]:border-primary",
|
|
23
23
|
/**
|
|
24
24
|
* One cell of a segmented control: a raised card on the group's
|
|
25
|
-
* muted capsule when on, quiet type when off.
|
|
25
|
+
* muted capsule when on, quiet type when off. This class owns only
|
|
26
|
+
* the cell's layout and text colour — the button itself is the full
|
|
27
|
+
* 44px hit target the accessibility floor asks for. The raised card
|
|
28
|
+
* it draws is smaller (36px, measured off the booking frames'
|
|
29
|
+
* calendar toolbar and the closures scope control), so
|
|
30
|
+
* `SegmentedToggleContent` below carries the paint in an inner span,
|
|
31
|
+
* the same split `MenuPickRow` uses for its add disc. The group
|
|
26
32
|
* (`ToggleGroup variant="segmented"`) supplies the capsule; the cell
|
|
27
33
|
* never fills with the accent, which would read as a primary button.
|
|
28
34
|
*/
|
|
29
35
|
segmented:
|
|
30
|
-
"
|
|
36
|
+
"group/segment relative rounded-full text-muted-foreground hover:bg-transparent hover:text-foreground data-[state=on]:bg-transparent data-[state=on]:text-foreground",
|
|
31
37
|
/**
|
|
32
38
|
* A choice among a few named options that sit in a wrapping row —
|
|
33
39
|
* the booking flow's seating preference ("No preference", "Terrace",
|
|
@@ -58,6 +64,10 @@ const toggleVariants = cva(
|
|
|
58
64
|
compoundVariants: [
|
|
59
65
|
{ variant: "chip", class: "h-auto min-w-0 px-[13px]" },
|
|
60
66
|
{ variant: "tab", class: "h-auto min-w-0 px-3" },
|
|
67
|
+
// The segmented cell's hit target is fixed at the 44px floor
|
|
68
|
+
// regardless of `size` — restated here for the same reason as chip and
|
|
69
|
+
// tab above, so the size variants' `h-*` never wins the merge.
|
|
70
|
+
{ variant: "segmented", class: "h-11 px-0" },
|
|
61
71
|
],
|
|
62
72
|
defaultVariants: {
|
|
63
73
|
variant: "default",
|
|
@@ -66,10 +76,25 @@ const toggleVariants = cva(
|
|
|
66
76
|
}
|
|
67
77
|
);
|
|
68
78
|
|
|
79
|
+
/**
|
|
80
|
+
* The segmented variant's drawn pill — 36px tall, centred in the cell's 44px
|
|
81
|
+
* hit box. Painted here, on `group-data-[state=on]/segment:`, so the outer
|
|
82
|
+
* button (`toggleVariants({variant: "segmented"})`) can stay unpainted and
|
|
83
|
+
* full height. Shared by `Toggle` and `ToggleGroupItem`.
|
|
84
|
+
*/
|
|
85
|
+
function SegmentedToggleContent({ children }: { children?: React.ReactNode }) {
|
|
86
|
+
return (
|
|
87
|
+
<span className="flex h-9 w-full items-center justify-center gap-2 rounded-full text-inherit transition-[background-color,box-shadow] group-data-[state=on]/segment:bg-card group-data-[state=on]/segment:shadow-[var(--shadow-card)]">
|
|
88
|
+
{children}
|
|
89
|
+
</span>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
69
93
|
function Toggle({
|
|
70
94
|
className,
|
|
71
95
|
variant,
|
|
72
96
|
size,
|
|
97
|
+
children,
|
|
73
98
|
...props
|
|
74
99
|
}: React.ComponentProps<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>) {
|
|
75
100
|
return (
|
|
@@ -77,8 +102,14 @@ function Toggle({
|
|
|
77
102
|
data-slot="toggle"
|
|
78
103
|
className={cn(toggleVariants({ variant, size, className }))}
|
|
79
104
|
{...props}
|
|
80
|
-
|
|
105
|
+
>
|
|
106
|
+
{variant === "segmented" ? (
|
|
107
|
+
<SegmentedToggleContent>{children}</SegmentedToggleContent>
|
|
108
|
+
) : (
|
|
109
|
+
children
|
|
110
|
+
)}
|
|
111
|
+
</TogglePrimitive.Root>
|
|
81
112
|
);
|
|
82
113
|
}
|
|
83
114
|
|
|
84
|
-
export { Toggle, toggleVariants };
|
|
115
|
+
export { Toggle, toggleVariants, SegmentedToggleContent };
|
package/src/globals.css
CHANGED
|
@@ -364,7 +364,13 @@ h6 {
|
|
|
364
364
|
--floor-error: oklch(0.56 0.2 27);
|
|
365
365
|
--floor-error-foreground: oklch(0.99 0 0);
|
|
366
366
|
--floor-offer: var(--primary);
|
|
367
|
-
|
|
367
|
+
/* L raised from 0.25 (== --primary, the offer ring's colour) to 0.35: the
|
|
368
|
+
dark hairline read as a heavy black frame around a selected table. Same
|
|
369
|
+
chroma/hue, so it stays legibly the same family, just lighter — still
|
|
370
|
+
11.2:1 on --floor-room and 11.5:1 on --card (floor 3:1), and a clear
|
|
371
|
+
lightness step (ΔL 0.10) from --floor-offer/--primary at this hue so the
|
|
372
|
+
two rings stay distinct. */
|
|
373
|
+
--floor-selected: oklch(0.35 0.05 35);
|
|
368
374
|
--floor-room: oklch(0.99 0.005 75);
|
|
369
375
|
--floor-wall: oklch(0.33 0.02 45);
|
|
370
376
|
--floor-fixture: oklch(0.62 0.018 50);
|