@bearmenu/ui 0.8.40 → 0.8.42
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-5SGKGFMN.js → chunk-K6ACNU2L.js} +6 -1
- package/dist/{chunk-ZUBBMLBD.js → chunk-RQPUBKFU.js} +1 -1
- package/dist/{chunk-FQAIYKAS.js → chunk-WHUG6QEI.js} +8 -2
- package/dist/components/alert-dialog.js +1 -1
- 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/button.js +1 -1
- package/dist/components/calendar.js +1 -1
- package/dist/components/carousel.js +1 -1
- package/dist/components/coach-marks.js +1 -1
- package/dist/components/combobox.js +1 -1
- 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/empty-state.js +4 -2
- package/dist/components/event-detail-panel.js +1 -1
- package/dist/components/event-list-row-wide.js +1 -1
- package/dist/components/filter-category-row.js +1 -1
- package/dist/components/link-button.js +1 -1
- package/dist/components/menu-family-block.js +1 -1
- package/dist/components/menu-index-list.js +1 -1
- package/dist/components/menu-item.js +3 -3
- package/dist/components/multi-select-combobox.js +2 -2
- package/dist/components/pagination.js +1 -1
- package/dist/components/pan-zoom-viewport.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 +18 -0
- package/dist/components/responsive-overlay.js +55 -30
- package/dist/components/searchable-select.js +1 -1
- package/dist/components/signature-chip.js +2 -2
- package/dist/components/sliding-panels.js +1 -1
- 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/button.test.tsx +10 -0
- package/src/components/button.tsx +6 -1
- 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/empty-state.test.tsx +8 -0
- package/src/components/empty-state.tsx +3 -1
- package/src/components/responsive-overlay.stories.tsx +3 -0
- package/src/components/responsive-overlay.test.tsx +93 -0
- package/src/components/responsive-overlay.tsx +52 -3
- 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
|
@@ -68,58 +68,81 @@ type DrawerContentProps = React.ComponentPropsWithoutRef<typeof DrawerPrimitive.
|
|
|
68
68
|
* `--scrim-overlay` in globals.css, and why it is not `--scrim`).
|
|
69
69
|
*/
|
|
70
70
|
overlayClassName?: string;
|
|
71
|
+
/**
|
|
72
|
+
* A fixed sheet height instead of sizing to content — the booking frames'
|
|
73
|
+
* ~640px table sheet (floor/14-w1) draws one instead of growing with its
|
|
74
|
+
* content. A number is px; a string is passed through (`"70vh"`). Only
|
|
75
|
+
* meaningful on a vertical (`top`/`bottom`) drawer, and always capped by the
|
|
76
|
+
* shared `max-h-[85vh]` on short viewports. Omit it to keep every existing
|
|
77
|
+
* caller's content-sized sheet — this is opt-in, not a new default.
|
|
78
|
+
*/
|
|
79
|
+
snapHeight?: number | string;
|
|
71
80
|
};
|
|
72
81
|
|
|
73
82
|
const DrawerContent = React.forwardRef<
|
|
74
83
|
React.ElementRef<typeof DrawerPrimitive.Content>,
|
|
75
84
|
DrawerContentProps
|
|
76
|
-
>(
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
direction === "bottom"
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
?
|
|
88
|
-
:
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
85
|
+
>(
|
|
86
|
+
(
|
|
87
|
+
{ className, variant = "flush", showHandle, overlayClassName, snapHeight, style, children, ...props },
|
|
88
|
+
ref
|
|
89
|
+
) => {
|
|
90
|
+
const { direction, modal } = React.useContext(DrawerContext);
|
|
91
|
+
const isVertical = direction === "top" || direction === "bottom";
|
|
92
|
+
const handleEnabled = showHandle ?? (direction === "bottom");
|
|
93
|
+
const fixedHeight =
|
|
94
|
+
isVertical && snapHeight !== undefined
|
|
95
|
+
? typeof snapHeight === "number"
|
|
96
|
+
? `${snapHeight}px`
|
|
97
|
+
: snapHeight
|
|
98
|
+
: undefined;
|
|
99
|
+
|
|
100
|
+
const positionClasses =
|
|
101
|
+
direction === "bottom"
|
|
102
|
+
? "inset-x-0 bottom-0 flex-col rounded-t-2xl border-t [padding-bottom:max(env(safe-area-inset-bottom),0px)]"
|
|
103
|
+
: direction === "top"
|
|
104
|
+
? "inset-x-0 top-0 flex-col rounded-b-2xl border-b [padding-top:max(env(safe-area-inset-top),0px)]"
|
|
105
|
+
: direction === "left"
|
|
106
|
+
? "inset-y-0 left-0 h-full w-3/4 max-w-sm flex-col rounded-r-2xl border-r [padding-left:max(env(safe-area-inset-left),0px)]"
|
|
107
|
+
: "inset-y-0 right-0 h-full w-3/4 max-w-sm flex-col rounded-l-2xl border-l [padding-right:max(env(safe-area-inset-right),0px)]";
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<DrawerPortal>
|
|
111
|
+
<DrawerOverlay className={overlayClassName} />
|
|
112
|
+
<DrawerPrimitive.Content
|
|
113
|
+
ref={ref}
|
|
114
|
+
data-slot="drawer-content"
|
|
115
|
+
// Radix hides the page from assistive tech but never says so on the
|
|
116
|
+
// dialog itself; `aria-modal` is what tells a screen reader to stay in.
|
|
117
|
+
aria-modal={modal ? true : undefined}
|
|
118
|
+
style={fixedHeight !== undefined ? { ...style, height: fixedHeight } : style}
|
|
119
|
+
className={cn(
|
|
120
|
+
"fixed z-[1000] flex border-border bg-card shadow-[var(--shadow-overlay)]",
|
|
121
|
+
positionClasses,
|
|
122
|
+
// The single height cap for every vertical drawer, fixed height or
|
|
123
|
+
// not (a `snapHeight` taller than the viewport still backs off to
|
|
124
|
+
// this on a short phone). Consumers should NOT restate it: six app
|
|
125
|
+
// call sites carried an identical `!max-h-[90vh]`, so this default
|
|
126
|
+
// governed nothing and changing it here would have reached none of
|
|
127
|
+
// them.
|
|
128
|
+
isVertical ? "max-h-[85vh]" : "",
|
|
129
|
+
variant === "floating" && "m-3 rounded-2xl border",
|
|
130
|
+
className
|
|
131
|
+
)}
|
|
132
|
+
{...props}
|
|
133
|
+
>
|
|
134
|
+
{handleEnabled && (
|
|
135
|
+
<div
|
|
136
|
+
aria-hidden
|
|
137
|
+
className="mx-auto mt-2 h-1 w-10 shrink-0 rounded-full bg-muted-foreground/30"
|
|
138
|
+
/>
|
|
139
|
+
)}
|
|
140
|
+
{children}
|
|
141
|
+
</DrawerPrimitive.Content>
|
|
142
|
+
</DrawerPortal>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
);
|
|
123
146
|
DrawerContent.displayName = "DrawerContent";
|
|
124
147
|
|
|
125
148
|
const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
|
@@ -47,6 +47,14 @@ describe("EmptyState", () => {
|
|
|
47
47
|
expect(tile).toHaveClass("size-16", "sm:size-18", "[&_svg]:size-7");
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
+
// reports/02-p4, calendar/02-c2: the tile draws the shadow alone, no border.
|
|
51
|
+
it("draws the icon tile borderless, with the shadow alone", () => {
|
|
52
|
+
render(<EmptyState title="Empty" description="" />);
|
|
53
|
+
const tile = document.querySelector('[data-slot="empty-state-icon"]');
|
|
54
|
+
expect(tile).toHaveClass("shadow-sm");
|
|
55
|
+
expect(tile).not.toHaveClass("border", "border-border");
|
|
56
|
+
});
|
|
57
|
+
|
|
50
58
|
it("renders no empty paragraph when there is no body", () => {
|
|
51
59
|
render(<EmptyState title="Empty" description="" />);
|
|
52
60
|
expect(screen.getByRole("status").querySelector("p")).toBeNull();
|
|
@@ -118,7 +118,9 @@ const EmptyState = React.forwardRef<HTMLDivElement, EmptyStateProps>(
|
|
|
118
118
|
className={cn(
|
|
119
119
|
// The glyph size is the tile's, whatever size the caller's icon
|
|
120
120
|
// node asked for — a 40px icon in a 64px tile has no margin.
|
|
121
|
-
|
|
121
|
+
// No border: the frames (reports/02-p4, calendar/02-c2) draw the
|
|
122
|
+
// tile with the shadow alone.
|
|
123
|
+
"mb-4 flex size-16 shrink-0 items-center justify-center rounded-[20px] shadow-sm sm:size-18 sm:rounded-[22px] [&_svg]:size-7",
|
|
122
124
|
variant === "error" ? "bg-destructive/5" : "bg-card"
|
|
123
125
|
)}
|
|
124
126
|
>
|
|
@@ -62,6 +62,8 @@ export const AddAnItem: Story = {
|
|
|
62
62
|
* floor/14-w1 — a working rail. `desktop="sheet"` with `modal={false}` and
|
|
63
63
|
* `wideFrom="lg"`: from 1024 the page stays live beside it (click a table and
|
|
64
64
|
* the rail shows that one instead of closing); under 1024 it is the drawer.
|
|
65
|
+
* `drawerHeight={640}` matches the frame's fixed-height phone sheet — resize
|
|
66
|
+
* the preview under 1024px and open a table to see it hold its height.
|
|
65
67
|
*/
|
|
66
68
|
export const WorkingRail: Story = {
|
|
67
69
|
render: () => {
|
|
@@ -83,6 +85,7 @@ export const WorkingRail: Story = {
|
|
|
83
85
|
desktop="sheet"
|
|
84
86
|
modal={false}
|
|
85
87
|
wideFrom="lg"
|
|
88
|
+
drawerHeight={640}
|
|
86
89
|
closeLabel="Close"
|
|
87
90
|
>
|
|
88
91
|
<p className="text-[14px] text-muted-foreground">
|
|
@@ -295,6 +295,99 @@ describe("ResponsiveOverlay", () => {
|
|
|
295
295
|
});
|
|
296
296
|
});
|
|
297
297
|
|
|
298
|
+
describe("initialFocusRef", () => {
|
|
299
|
+
function WithTarget(props: Partial<React.ComponentProps<typeof ResponsiveOverlay>> = {}) {
|
|
300
|
+
const targetRef = React.useRef<HTMLButtonElement>(null);
|
|
301
|
+
return (
|
|
302
|
+
<ResponsiveOverlay
|
|
303
|
+
open
|
|
304
|
+
onOpenChange={() => {}}
|
|
305
|
+
title="Add an item"
|
|
306
|
+
closeLabel="Close"
|
|
307
|
+
initialFocusRef={targetRef}
|
|
308
|
+
{...props}
|
|
309
|
+
>
|
|
310
|
+
<button ref={targetRef} type="button">
|
|
311
|
+
Confirm
|
|
312
|
+
</button>
|
|
313
|
+
</ResponsiveOverlay>
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
it("dialog: focuses the given element instead of the Close button", async () => {
|
|
318
|
+
setWidth(true);
|
|
319
|
+
render(<WithTarget />);
|
|
320
|
+
await waitFor(() => expect(screen.getByRole("button", { name: "Confirm" })).toHaveFocus());
|
|
321
|
+
expect(screen.getByRole("button", { name: "Close" })).not.toHaveFocus();
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("sheet: focuses the given element instead of the Close button", async () => {
|
|
325
|
+
setWidth(true);
|
|
326
|
+
render(<WithTarget desktop="sheet" />);
|
|
327
|
+
await waitFor(() => expect(screen.getByRole("button", { name: "Confirm" })).toHaveFocus());
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("drawer: focuses the given element instead of the Close button", async () => {
|
|
331
|
+
setWidth(false);
|
|
332
|
+
render(<WithTarget />);
|
|
333
|
+
await waitFor(() => expect(screen.getByRole("button", { name: "Confirm" })).toHaveFocus());
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("keeps today's default (first tabbable) when the prop is omitted", async () => {
|
|
337
|
+
setWidth(true);
|
|
338
|
+
open();
|
|
339
|
+
await waitFor(() => expect(screen.getByRole("button", { name: "Close" })).toHaveFocus());
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
describe("phone drawer chrome", () => {
|
|
344
|
+
// Measured off the booking frames' table sheet (floor/14-w1): 16px
|
|
345
|
+
// gutter and a 22px title on the phone drawer; the desktop dialog/sheet
|
|
346
|
+
// keep their existing 20px / 19px.
|
|
347
|
+
it("tightens the gutter and grows the title on the drawer only", () => {
|
|
348
|
+
open();
|
|
349
|
+
const header = document.querySelector('[data-slot="responsive-overlay-header"]');
|
|
350
|
+
expect(header).toHaveClass("px-4", "pt-4");
|
|
351
|
+
expect(screen.getByText("Add an item")).toHaveClass("text-[22px]");
|
|
352
|
+
const body = document.querySelector('[data-slot="responsive-overlay-body"]');
|
|
353
|
+
expect(body).toHaveClass("px-4");
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("leaves the desktop dialog's gutter and title alone", () => {
|
|
357
|
+
setWidth(true);
|
|
358
|
+
open();
|
|
359
|
+
const header = document.querySelector('[data-slot="responsive-overlay-header"]');
|
|
360
|
+
expect(header).toHaveClass("px-5", "pt-5");
|
|
361
|
+
expect(screen.getByText("Add an item")).toHaveClass("text-[19px]");
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("leaves the desktop sheet's gutter and title alone", () => {
|
|
365
|
+
setWidth(true);
|
|
366
|
+
open({ desktop: "sheet" });
|
|
367
|
+
const header = document.querySelector('[data-slot="responsive-overlay-header"]');
|
|
368
|
+
expect(header).toHaveClass("px-5", "pt-5");
|
|
369
|
+
expect(screen.getByText("Add an item")).toHaveClass("text-[19px]");
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
describe("drawerHeight", () => {
|
|
374
|
+
it("is content-sized when omitted", () => {
|
|
375
|
+
open();
|
|
376
|
+
expect(screen.getByRole("dialog").style.height).toBe("");
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("fixes the drawer's height when given", () => {
|
|
380
|
+
open({ drawerHeight: 640 });
|
|
381
|
+
expect(screen.getByRole("dialog").style.height).toBe("640px");
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("does not affect the desktop dialog", () => {
|
|
385
|
+
setWidth(true);
|
|
386
|
+
open({ drawerHeight: 640 });
|
|
387
|
+
expect(screen.getByRole("dialog").style.height).toBe("");
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
|
|
298
391
|
it("stays out of the DOM while closed", () => {
|
|
299
392
|
render(
|
|
300
393
|
<ResponsiveOverlay open={false} onOpenChange={() => {}} title="Add an item">
|
|
@@ -113,6 +113,24 @@ export type ResponsiveOverlayProps = {
|
|
|
113
113
|
className?: string;
|
|
114
114
|
/** Classes for the scrolling body. */
|
|
115
115
|
bodyClassName?: string;
|
|
116
|
+
/**
|
|
117
|
+
* Focus this element on open instead of Radix's default — the first
|
|
118
|
+
* tabbable in DOM order, which in every mode here is the header's round
|
|
119
|
+
* Close button, since the header renders before the body. That is the
|
|
120
|
+
* right target for most overlays, but a confirm-style body (a primary
|
|
121
|
+
* action, a field that should be pre-filled) wants focus lower down.
|
|
122
|
+
* Passing a ref calls `event.preventDefault()` on Radix's
|
|
123
|
+
* `onOpenAutoFocus` and focuses this element instead; omitting the prop
|
|
124
|
+
* keeps today's default in every mode (dialog, sheet, drawer).
|
|
125
|
+
*/
|
|
126
|
+
initialFocusRef?: React.RefObject<HTMLElement | null>;
|
|
127
|
+
/**
|
|
128
|
+
* A fixed height for the phone drawer instead of sizing to its content —
|
|
129
|
+
* `DrawerContent`'s `snapHeight` (the frames' ~640px table sheet). Applies
|
|
130
|
+
* only when the narrow drawer is showing; the desktop dialog/sheet are
|
|
131
|
+
* unaffected. Omit to keep today's content-sized drawer.
|
|
132
|
+
*/
|
|
133
|
+
drawerHeight?: number | string;
|
|
116
134
|
};
|
|
117
135
|
|
|
118
136
|
/**
|
|
@@ -159,6 +177,8 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
|
|
|
159
177
|
wideFrom = "md",
|
|
160
178
|
className,
|
|
161
179
|
bodyClassName,
|
|
180
|
+
initialFocusRef,
|
|
181
|
+
drawerHeight,
|
|
162
182
|
},
|
|
163
183
|
ref
|
|
164
184
|
) => {
|
|
@@ -166,6 +186,18 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
|
|
|
166
186
|
const container: "drawer" | "dialog" | "sheet" = isWide ? desktop : "drawer";
|
|
167
187
|
useFocusReturn(open);
|
|
168
188
|
|
|
189
|
+
// Radix (and vaul, underneath the drawer) call this before moving focus
|
|
190
|
+
// themselves; preventing the default there is what stops the first-
|
|
191
|
+
// tabbable fallback from ever running.
|
|
192
|
+
const handleOpenAutoFocus = React.useCallback(
|
|
193
|
+
(event: Event) => {
|
|
194
|
+
if (!initialFocusRef?.current) return;
|
|
195
|
+
event.preventDefault();
|
|
196
|
+
initialFocusRef.current.focus();
|
|
197
|
+
},
|
|
198
|
+
[initialFocusRef]
|
|
199
|
+
);
|
|
200
|
+
|
|
169
201
|
// The name and the description are Radix's own: its `Title` and
|
|
170
202
|
// `Description` register ids in the dialog context and `Content` points
|
|
171
203
|
// `aria-labelledby` / `aria-describedby` at them. Passing our own ids
|
|
@@ -177,10 +209,19 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
|
|
|
177
209
|
const Title = CONTAINER_TITLE[container];
|
|
178
210
|
const Description = CONTAINER_DESCRIPTION[container];
|
|
179
211
|
|
|
212
|
+
// The phone drawer draws a tighter gutter and a smaller title than the
|
|
213
|
+
// desktop dialog/sheet — measured off the booking frames' table sheet
|
|
214
|
+
// (floor/14-w1): 16px, not 20; 22px, not 19. Scoped to `drawer` only, so
|
|
215
|
+
// the desktop containers (already frames-checked elsewhere) don't move.
|
|
216
|
+
const isDrawer = container === "drawer";
|
|
217
|
+
|
|
180
218
|
const header = (
|
|
181
|
-
<div
|
|
219
|
+
<div
|
|
220
|
+
data-slot="responsive-overlay-header"
|
|
221
|
+
className={cn("flex items-start gap-3", isDrawer ? "px-4 pt-4" : "px-5 pt-5")}
|
|
222
|
+
>
|
|
182
223
|
<div className="min-w-0 flex-1">
|
|
183
|
-
<Title className="
|
|
224
|
+
<Title className={cn("font-bold leading-tight", isDrawer ? "text-[22px]" : "text-[19px]")}>
|
|
184
225
|
{title}
|
|
185
226
|
</Title>
|
|
186
227
|
{subtitle ? (
|
|
@@ -208,7 +249,11 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
|
|
|
208
249
|
const body = (
|
|
209
250
|
<div
|
|
210
251
|
data-slot="responsive-overlay-body"
|
|
211
|
-
className={cn(
|
|
252
|
+
className={cn(
|
|
253
|
+
"min-h-0 flex-1 overflow-y-auto py-4",
|
|
254
|
+
isDrawer ? "px-4" : "px-5",
|
|
255
|
+
bodyClassName
|
|
256
|
+
)}
|
|
212
257
|
>
|
|
213
258
|
{children}
|
|
214
259
|
</div>
|
|
@@ -232,6 +277,7 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
|
|
|
232
277
|
attachment="inset"
|
|
233
278
|
hideClose
|
|
234
279
|
overlayClassName="bg-scrim-overlay backdrop-blur-none"
|
|
280
|
+
onOpenAutoFocus={handleOpenAutoFocus}
|
|
235
281
|
{...describedBy}
|
|
236
282
|
data-slot="responsive-overlay-sheet"
|
|
237
283
|
data-modal={modal ? "true" : "false"}
|
|
@@ -263,6 +309,7 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
|
|
|
263
309
|
size={size}
|
|
264
310
|
showCloseButton={false}
|
|
265
311
|
overlayClassName="bg-scrim-overlay backdrop-blur-none"
|
|
312
|
+
onOpenAutoFocus={handleOpenAutoFocus}
|
|
266
313
|
{...describedBy}
|
|
267
314
|
className={cn(
|
|
268
315
|
// The dialog's own `grid gap-4 p-6` would put air between the
|
|
@@ -284,6 +331,8 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
|
|
|
284
331
|
<DrawerContent
|
|
285
332
|
ref={ref}
|
|
286
333
|
overlayClassName="bg-scrim-overlay"
|
|
334
|
+
onOpenAutoFocus={handleOpenAutoFocus}
|
|
335
|
+
{...(drawerHeight !== undefined ? { snapHeight: drawerHeight } : {})}
|
|
287
336
|
{...describedBy}
|
|
288
337
|
className={cn("flex max-h-[85vh] flex-col p-0", className)}
|
|
289
338
|
>
|
|
@@ -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
|
});
|