@bearmenu/ui 0.8.35 → 0.8.37
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-EGU6EG73.js +39 -0
- package/dist/{chunk-NIAL4ZU2.js → chunk-FQAIYKAS.js} +45 -8
- package/dist/{chunk-TVEIUTBQ.js → chunk-IQIJCMG2.js} +1 -1
- package/dist/{chunk-6W25S5CF.js → chunk-KGXTBTCJ.js} +1 -1
- package/dist/chunk-LAEVMNJN.js +135 -0
- package/dist/{chunk-VCRXL7H5.js → chunk-PGQXVJXW.js} +2 -2
- package/dist/{chunk-F3XK34DV.js → chunk-TB2K5JN3.js} +2 -2
- package/dist/{chunk-XNBDACL7.js → chunk-ZUBBMLBD.js} +1 -1
- package/dist/components/badge.d.ts +8 -2
- package/dist/components/badge.js +1 -1
- package/dist/components/banner.d.ts +17 -2
- package/dist/components/banner.js +10 -7
- package/dist/components/booking-row.js +1 -1
- package/dist/components/button-group.js +3 -39
- package/dist/components/calendar.js +1 -1
- package/dist/components/combobox.js +3 -3
- package/dist/components/command.js +2 -2
- package/dist/components/concept-convergence.js +1 -1
- package/dist/components/dialog.js +1 -1
- package/dist/components/dropdown-menu.js +6 -6
- package/dist/components/filter-category-row.js +1 -1
- package/dist/components/input-group.js +1 -1
- package/dist/components/menu-item.js +3 -3
- package/dist/components/multi-select-combobox.js +10 -5
- package/dist/components/multi-select-menu.js +1 -1
- package/dist/components/pan-zoom-viewport.d.ts +137 -0
- package/dist/components/pan-zoom-viewport.js +531 -0
- package/dist/components/place-card.js +2 -2
- package/dist/components/place-details-mobile.js +1 -1
- package/dist/components/responsive-overlay.d.ts +29 -1
- package/dist/components/responsive-overlay.js +46 -7
- package/dist/components/searchable-select.js +3 -3
- package/dist/components/select.js +2 -2
- package/dist/components/selection-bar.js +1 -1
- package/dist/components/sheet.d.ts +6 -0
- package/dist/components/sheet.js +3 -133
- package/dist/components/signature-chip.js +2 -2
- package/dist/components/tabs.d.ts +16 -1
- package/dist/components/tabs.js +21 -3
- package/dist/components/toggle-group.js +1 -1
- package/dist/components/toggle.d.ts +6 -0
- 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 +32 -1
- package/src/components/badge.test.tsx +45 -0
- package/src/components/badge.tsx +41 -3
- package/src/components/banner.stories.tsx +32 -0
- package/src/components/banner.test.tsx +58 -0
- package/src/components/banner.tsx +28 -4
- package/src/components/calendar.tsx +1 -1
- package/src/components/combobox.tsx +1 -1
- package/src/components/command.test.tsx +10 -0
- package/src/components/command.tsx +1 -1
- package/src/components/dialog.tsx +1 -1
- package/src/components/dropdown-menu.test.tsx +31 -0
- package/src/components/dropdown-menu.tsx +6 -6
- package/src/components/input-group.tsx +1 -1
- package/src/components/multi-select-combobox.tsx +7 -1
- package/src/components/multi-select-menu.tsx +1 -1
- package/src/components/pan-zoom-viewport.stories.tsx +134 -0
- package/src/components/pan-zoom-viewport.test.tsx +381 -0
- package/src/components/pan-zoom-viewport.tsx +727 -0
- package/src/components/responsive-overlay.stories.tsx +61 -0
- package/src/components/responsive-overlay.test.tsx +104 -0
- package/src/components/responsive-overlay.tsx +87 -22
- package/src/components/searchable-select.tsx +1 -1
- package/src/components/select.tsx +2 -2
- package/src/components/selection-bar.tsx +1 -1
- package/src/components/sheet.tsx +14 -3
- package/src/components/tabs.stories.tsx +23 -0
- package/src/components/tabs.test.tsx +64 -0
- package/src/components/tabs.tsx +36 -3
- package/src/components/toggle.test.tsx +19 -0
- package/src/components/toggle.tsx +8 -2
- package/src/globals.css +28 -19
|
@@ -58,6 +58,67 @@ export const AddAnItem: Story = {
|
|
|
58
58
|
},
|
|
59
59
|
};
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* floor/14-w1 — a working rail. `desktop="sheet"` with `modal={false}` and
|
|
63
|
+
* `wideFrom="lg"`: from 1024 the page stays live beside it (click a table and
|
|
64
|
+
* the rail shows that one instead of closing); under 1024 it is the drawer.
|
|
65
|
+
*/
|
|
66
|
+
export const WorkingRail: Story = {
|
|
67
|
+
render: () => {
|
|
68
|
+
const [table, setTable] = React.useState<string | null>(null);
|
|
69
|
+
return (
|
|
70
|
+
<div className="flex flex-wrap gap-2">
|
|
71
|
+
{["T1", "T2", "T3", "T4", "T5"].map((label) => (
|
|
72
|
+
<Button key={label} variant="outline" onClick={() => setTable(label)}>
|
|
73
|
+
{label}
|
|
74
|
+
</Button>
|
|
75
|
+
))}
|
|
76
|
+
<ResponsiveOverlay
|
|
77
|
+
open={table !== null}
|
|
78
|
+
onOpenChange={(next) => {
|
|
79
|
+
if (!next) setTable(null);
|
|
80
|
+
}}
|
|
81
|
+
title={table ?? ""}
|
|
82
|
+
subtitle="6 seats · Inside"
|
|
83
|
+
desktop="sheet"
|
|
84
|
+
modal={false}
|
|
85
|
+
wideFrom="lg"
|
|
86
|
+
closeLabel="Close"
|
|
87
|
+
>
|
|
88
|
+
<p className="text-[14px] text-muted-foreground">
|
|
89
|
+
Tonight on {table}: Familia Rusu, seated 36 min.
|
|
90
|
+
</p>
|
|
91
|
+
</ResponsiveOverlay>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** calendar/07-n1 — a form rail: the same right rail, dimming the page. */
|
|
98
|
+
export const FormRail: Story = {
|
|
99
|
+
render: () => {
|
|
100
|
+
const [open, setOpen] = React.useState(false);
|
|
101
|
+
return (
|
|
102
|
+
<>
|
|
103
|
+
<Button variant="cta" onClick={() => setOpen(true)}>
|
|
104
|
+
New reservation
|
|
105
|
+
</Button>
|
|
106
|
+
<ResponsiveOverlay
|
|
107
|
+
open={open}
|
|
108
|
+
onOpenChange={setOpen}
|
|
109
|
+
title="New reservation"
|
|
110
|
+
subtitle="Wed 23 Sep"
|
|
111
|
+
desktop="sheet"
|
|
112
|
+
closeLabel="Close"
|
|
113
|
+
footer={<ActionBar primary={<Button variant="cta">Add reservation</Button>} />}
|
|
114
|
+
>
|
|
115
|
+
<p className="text-[14px] text-muted-foreground">Guest, party, time and table.</p>
|
|
116
|
+
</ResponsiveOverlay>
|
|
117
|
+
</>
|
|
118
|
+
);
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
|
|
61
122
|
/** T1 — an overlay with no footer note: the two actions divide the band. */
|
|
62
123
|
export const JoinTheTable: Story = {
|
|
63
124
|
render: () => {
|
|
@@ -151,6 +151,110 @@ describe("ResponsiveOverlay", () => {
|
|
|
151
151
|
await waitFor(() => expect(opener).toHaveFocus());
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
+
describe('desktop="sheet"', () => {
|
|
155
|
+
/** A width-aware matchMedia, for the md / lg split. */
|
|
156
|
+
function setViewport(px: number) {
|
|
157
|
+
window.matchMedia = ((query: string) => {
|
|
158
|
+
const min = Number(/min-width:\s*(\d+)px/.exec(query)?.[1] ?? Infinity);
|
|
159
|
+
return {
|
|
160
|
+
matches: px >= min,
|
|
161
|
+
media: query,
|
|
162
|
+
onchange: null,
|
|
163
|
+
addEventListener: () => {},
|
|
164
|
+
removeEventListener: () => {},
|
|
165
|
+
addListener: () => {},
|
|
166
|
+
removeListener: () => {},
|
|
167
|
+
dispatchEvent: () => false,
|
|
168
|
+
} as unknown as MediaQueryList;
|
|
169
|
+
}) as typeof window.matchMedia;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const sheet = () => document.querySelector('[data-slot="responsive-overlay-sheet"]');
|
|
173
|
+
|
|
174
|
+
it("is an inset right rail from md, named by its title", () => {
|
|
175
|
+
setWidth(true);
|
|
176
|
+
open({ desktop: "sheet" });
|
|
177
|
+
expect(sheet()).toBeInTheDocument();
|
|
178
|
+
expect(document.querySelector('[data-slot="dialog-content"]')).toBeNull();
|
|
179
|
+
expect(document.querySelector('[data-slot="drawer-content"]')).toBeNull();
|
|
180
|
+
expect(sheet()).toHaveClass("right-3", "rounded-2xl");
|
|
181
|
+
const dialog = screen.getByRole("dialog");
|
|
182
|
+
expect(dialog).toHaveAccessibleName("Add an item");
|
|
183
|
+
expect(dialog).toHaveAccessibleDescription("Table 18 · from the menu");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("is still the drawer on a phone", () => {
|
|
187
|
+
open({ desktop: "sheet", modal: false });
|
|
188
|
+
expect(document.querySelector('[data-slot="drawer-content"]')).toBeInTheDocument();
|
|
189
|
+
expect(sheet()).toBeNull();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("dims the page with the overlay token when modal", () => {
|
|
193
|
+
setWidth(true);
|
|
194
|
+
open({ desktop: "sheet" });
|
|
195
|
+
expect(sheet()).toHaveAttribute("data-modal", "true");
|
|
196
|
+
const scrim = document.querySelector(".bg-scrim-overlay");
|
|
197
|
+
expect(scrim).toBeInTheDocument();
|
|
198
|
+
expect(scrim?.className).not.toContain("bg-black/50");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("leaves the page live when non-modal: no scrim, and a click behind it does not close it", async () => {
|
|
202
|
+
setWidth(true);
|
|
203
|
+
const onOpenChange = vi.fn();
|
|
204
|
+
const onPageClick = vi.fn();
|
|
205
|
+
render(
|
|
206
|
+
<>
|
|
207
|
+
<button type="button" onClick={onPageClick}>
|
|
208
|
+
T4
|
|
209
|
+
</button>
|
|
210
|
+
<ResponsiveOverlay
|
|
211
|
+
open
|
|
212
|
+
onOpenChange={onOpenChange}
|
|
213
|
+
title="T5"
|
|
214
|
+
desktop="sheet"
|
|
215
|
+
modal={false}
|
|
216
|
+
closeLabel="Close"
|
|
217
|
+
>
|
|
218
|
+
<p>body</p>
|
|
219
|
+
</ResponsiveOverlay>
|
|
220
|
+
</>
|
|
221
|
+
);
|
|
222
|
+
expect(sheet()).toHaveAttribute("data-modal", "false");
|
|
223
|
+
expect(document.querySelector(".bg-scrim-overlay")).toBeNull();
|
|
224
|
+
await userEvent.click(screen.getByRole("button", { name: "T4" }));
|
|
225
|
+
expect(onPageClick).toHaveBeenCalledOnce();
|
|
226
|
+
expect(onOpenChange).not.toHaveBeenCalled();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("still closes a non-modal rail on Escape and through the round button", async () => {
|
|
230
|
+
setWidth(true);
|
|
231
|
+
const onOpenChange = vi.fn();
|
|
232
|
+
open({ desktop: "sheet", modal: false, onOpenChange });
|
|
233
|
+
await userEvent.keyboard("{Escape}");
|
|
234
|
+
expect(onOpenChange).toHaveBeenLastCalledWith(false);
|
|
235
|
+
onOpenChange.mockClear();
|
|
236
|
+
await userEvent.click(screen.getByRole("button", { name: "Close" }));
|
|
237
|
+
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('switches at lg with wideFrom="lg"', () => {
|
|
241
|
+
setViewport(900);
|
|
242
|
+
const tablet = open({ desktop: "sheet", wideFrom: "lg" });
|
|
243
|
+
expect(document.querySelector('[data-slot="drawer-content"]')).toBeInTheDocument();
|
|
244
|
+
tablet.unmount();
|
|
245
|
+
|
|
246
|
+
setViewport(1280);
|
|
247
|
+
open({ desktop: "sheet", wideFrom: "lg" });
|
|
248
|
+
expect(sheet()).toBeInTheDocument();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("keeps switching at md by default", () => {
|
|
252
|
+
setViewport(900);
|
|
253
|
+
open({ desktop: "sheet" });
|
|
254
|
+
expect(sheet()).toBeInTheDocument();
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
154
258
|
it("stays out of the DOM while closed", () => {
|
|
155
259
|
render(
|
|
156
260
|
<ResponsiveOverlay open={false} onOpenChange={() => {}} title="Add an item">
|
|
@@ -2,14 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { X } from "lucide-react";
|
|
5
|
-
import { useMediaQuery } from "../hooks/use-media-query";
|
|
5
|
+
import { DESKTOP_MEDIA_QUERY, useMediaQuery } from "../hooks/use-media-query";
|
|
6
6
|
import { cn } from "../lib/utils";
|
|
7
7
|
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "./dialog";
|
|
8
8
|
import { Drawer, DrawerContent, DrawerDescription, DrawerTitle } from "./drawer";
|
|
9
|
+
import { Sheet, SheetContent, SheetDescription, SheetTitle } from "./sheet";
|
|
9
10
|
|
|
10
11
|
/** The board switches container at Tailwind's `md`, not at the library's `lg`. */
|
|
11
12
|
const MD_MEDIA_QUERY = "(min-width: 768px)";
|
|
12
13
|
|
|
14
|
+
/** Each container's own title and description, so Radix / vaul wire the names. */
|
|
15
|
+
const CONTAINER_TITLE = { drawer: DrawerTitle, dialog: DialogTitle, sheet: SheetTitle } as const;
|
|
16
|
+
const CONTAINER_DESCRIPTION = {
|
|
17
|
+
drawer: DrawerDescription,
|
|
18
|
+
dialog: DialogDescription,
|
|
19
|
+
sheet: SheetDescription,
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
13
22
|
/**
|
|
14
23
|
* Sends focus back to whatever opened the overlay once it closes.
|
|
15
24
|
*
|
|
@@ -74,6 +83,32 @@ export type ResponsiveOverlayProps = {
|
|
|
74
83
|
closeLabel?: string;
|
|
75
84
|
/** Widths for the desktop dialog. Mobile is always the full-width drawer. */
|
|
76
85
|
size?: React.ComponentPropsWithoutRef<typeof DialogContent>["size"];
|
|
86
|
+
/**
|
|
87
|
+
* The container on a wide screen. `dialog` (the default) is the centred box.
|
|
88
|
+
* `sheet` is a right rail: an inset panel held 12px off the top, right and
|
|
89
|
+
* bottom, 420px wide — the booking pages' table sheet, booking details,
|
|
90
|
+
* guest panel and create forms (floor/14-w1, calendar/07-n1, guests/07-g6).
|
|
91
|
+
* Narrow screens get the bottom drawer either way.
|
|
92
|
+
*/
|
|
93
|
+
desktop?: "dialog" | "sheet";
|
|
94
|
+
/**
|
|
95
|
+
* Whether the wide `sheet` dims and locks the page. `true` (the default)
|
|
96
|
+
* is a form rail: a scrim, focus held inside, the page inert. `false` is a
|
|
97
|
+
* working rail: no scrim, no focus trap, and the page behind stays live —
|
|
98
|
+
* clicking another table on the floor does NOT close it, so the caller can
|
|
99
|
+
* swap what it shows. Escape and the close button still close it.
|
|
100
|
+
*
|
|
101
|
+
* Applies to `desktop="sheet"` only. The dialog is always modal, and so is
|
|
102
|
+
* the phone drawer: at 390 the rail covers most of the page, and a page
|
|
103
|
+
* that stays live under a sheet the thumb cannot see past is a trap.
|
|
104
|
+
*/
|
|
105
|
+
modal?: boolean;
|
|
106
|
+
/**
|
|
107
|
+
* Where the wide container takes over from the drawer. `md` (768, the
|
|
108
|
+
* default) for most overlays; `lg` (1024) where the page beside the rail
|
|
109
|
+
* needs the room — the floor plan's rails switch there.
|
|
110
|
+
*/
|
|
111
|
+
wideFrom?: "md" | "lg";
|
|
77
112
|
/** Classes for the panel itself — the dialog box or the drawer sheet. */
|
|
78
113
|
className?: string;
|
|
79
114
|
/** Classes for the scrolling body. */
|
|
@@ -82,7 +117,9 @@ export type ResponsiveOverlayProps = {
|
|
|
82
117
|
|
|
83
118
|
/**
|
|
84
119
|
* ResponsiveOverlay — one overlay authored once: a `Drawer` under md, a
|
|
85
|
-
* `Dialog` from md
|
|
120
|
+
* `Dialog` from md — or, with `desktop="sheet"`, an inset right rail that can
|
|
121
|
+
* leave the page live (`modal={false}`), switching at md or at lg
|
|
122
|
+
* (`wideFrom`).
|
|
86
123
|
*
|
|
87
124
|
* The shared-table board draws the same chrome on T1, T3, T5, T6 and T8
|
|
88
125
|
* (16-shared-table.html) and the two widths never disagree about it: the
|
|
@@ -117,38 +154,33 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
|
|
|
117
154
|
footer,
|
|
118
155
|
closeLabel = "Close",
|
|
119
156
|
size = "lg",
|
|
157
|
+
desktop = "dialog",
|
|
158
|
+
modal = true,
|
|
159
|
+
wideFrom = "md",
|
|
120
160
|
className,
|
|
121
161
|
bodyClassName,
|
|
122
162
|
},
|
|
123
163
|
ref
|
|
124
164
|
) => {
|
|
125
|
-
const isWide = useMediaQuery(MD_MEDIA_QUERY);
|
|
165
|
+
const isWide = useMediaQuery(wideFrom === "lg" ? DESKTOP_MEDIA_QUERY : MD_MEDIA_QUERY);
|
|
166
|
+
const container: "drawer" | "dialog" | "sheet" = isWide ? desktop : "drawer";
|
|
126
167
|
const titleId = React.useId();
|
|
127
168
|
const subtitleId = React.useId();
|
|
128
169
|
useFocusReturn(open);
|
|
129
170
|
|
|
171
|
+
const Title = CONTAINER_TITLE[container];
|
|
172
|
+
const Description = CONTAINER_DESCRIPTION[container];
|
|
173
|
+
|
|
130
174
|
const header = (
|
|
131
175
|
<div data-slot="responsive-overlay-header" className="flex items-start gap-3 px-5 pt-5">
|
|
132
176
|
<div className="min-w-0 flex-1">
|
|
133
|
-
{
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
</DialogTitle>
|
|
137
|
-
) : (
|
|
138
|
-
<DrawerTitle id={titleId} className="text-[19px] font-bold leading-tight">
|
|
139
|
-
{title}
|
|
140
|
-
</DrawerTitle>
|
|
141
|
-
)}
|
|
177
|
+
<Title id={titleId} className="text-[19px] font-bold leading-tight">
|
|
178
|
+
{title}
|
|
179
|
+
</Title>
|
|
142
180
|
{subtitle ? (
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
</DialogDescription>
|
|
147
|
-
) : (
|
|
148
|
-
<DrawerDescription id={subtitleId} className="mt-1 text-[13px] text-muted-foreground">
|
|
149
|
-
{subtitle}
|
|
150
|
-
</DrawerDescription>
|
|
151
|
-
)
|
|
181
|
+
<Description id={subtitleId} className="mt-1 text-[13px] text-muted-foreground">
|
|
182
|
+
{subtitle}
|
|
183
|
+
</Description>
|
|
152
184
|
) : null}
|
|
153
185
|
</div>
|
|
154
186
|
<button
|
|
@@ -185,7 +217,40 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
|
|
|
185
217
|
</div>
|
|
186
218
|
) : null;
|
|
187
219
|
|
|
188
|
-
if (
|
|
220
|
+
if (container === "sheet") {
|
|
221
|
+
return (
|
|
222
|
+
<Sheet open={open} onOpenChange={onOpenChange} modal={modal}>
|
|
223
|
+
<SheetContent
|
|
224
|
+
ref={ref}
|
|
225
|
+
side="right"
|
|
226
|
+
attachment="inset"
|
|
227
|
+
hideClose
|
|
228
|
+
overlayClassName="bg-scrim-overlay backdrop-blur-none"
|
|
229
|
+
aria-labelledby={titleId}
|
|
230
|
+
aria-describedby={subtitle ? subtitleId : undefined}
|
|
231
|
+
data-slot="responsive-overlay-sheet"
|
|
232
|
+
data-modal={modal ? "true" : "false"}
|
|
233
|
+
// A working rail must survive the page being used: a click on the
|
|
234
|
+
// floor behind it is the user choosing the next table, not
|
|
235
|
+
// dismissing the rail. Radix's non-modal dialog closes on any
|
|
236
|
+
// outside interaction unless told otherwise.
|
|
237
|
+
// Spread, not `={modal ? undefined : …}`: the admin compiles this
|
|
238
|
+
// source under exactOptionalPropertyTypes.
|
|
239
|
+
{...(modal ? {} : { onInteractOutside: (event: Event) => event.preventDefault() })}
|
|
240
|
+
className={cn(
|
|
241
|
+
"flex w-[420px] max-w-[calc(100vw-1.5rem)] flex-col gap-0 bg-card p-0",
|
|
242
|
+
className
|
|
243
|
+
)}
|
|
244
|
+
>
|
|
245
|
+
{header}
|
|
246
|
+
{body}
|
|
247
|
+
{foot}
|
|
248
|
+
</SheetContent>
|
|
249
|
+
</Sheet>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (container === "dialog") {
|
|
189
254
|
return (
|
|
190
255
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
191
256
|
<DialogContent
|
|
@@ -120,7 +120,7 @@ export function SearchableSelect({
|
|
|
120
120
|
e.stopPropagation();
|
|
121
121
|
onValueChange(undefined);
|
|
122
122
|
}}
|
|
123
|
-
className="absolute right-9 top-1/2 -translate-y-1/2 inline-flex size-6 items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-
|
|
123
|
+
className="absolute right-9 top-1/2 -translate-y-1/2 inline-flex size-6 items-center justify-center rounded-full text-muted-foreground hover:bg-accent-subtle hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 transition-colors"
|
|
124
124
|
>
|
|
125
125
|
<X className="size-3.5" />
|
|
126
126
|
</button>
|
|
@@ -126,14 +126,14 @@ const SelectItem = React.forwardRef<
|
|
|
126
126
|
ref={ref}
|
|
127
127
|
data-slot="select-item"
|
|
128
128
|
className={cn(
|
|
129
|
-
"relative flex w-full cursor-default select-none items-center rounded-md py-2 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-accent data-[highlighted]:text-
|
|
129
|
+
"relative flex w-full cursor-default select-none items-center rounded-md py-2 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-accent-subtle data-[highlighted]:text-foreground data-[state=checked]:font-semibold",
|
|
130
130
|
className
|
|
131
131
|
)}
|
|
132
132
|
{...props}
|
|
133
133
|
>
|
|
134
134
|
<span className="absolute left-2 flex size-3.5 items-center justify-center">
|
|
135
135
|
<SelectPrimitive.ItemIndicator>
|
|
136
|
-
<Check className="size-4" />
|
|
136
|
+
<Check className="size-4 text-accent" />
|
|
137
137
|
</SelectPrimitive.ItemIndicator>
|
|
138
138
|
</span>
|
|
139
139
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
|
@@ -46,7 +46,7 @@ const SelectionBar = React.forwardRef<HTMLDivElement, SelectionBarProps>(
|
|
|
46
46
|
type="button"
|
|
47
47
|
aria-label="Clear selection"
|
|
48
48
|
onClick={onClear}
|
|
49
|
-
className="inline-flex size-6 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-
|
|
49
|
+
className="inline-flex size-6 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent-subtle hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
50
50
|
>
|
|
51
51
|
<X className="size-4" />
|
|
52
52
|
</button>
|
package/src/components/sheet.tsx
CHANGED
|
@@ -103,14 +103,24 @@ interface SheetContentProps
|
|
|
103
103
|
* buttons in the same corner.
|
|
104
104
|
*/
|
|
105
105
|
hideClose?: boolean;
|
|
106
|
+
/**
|
|
107
|
+
* Classes for the scrim. The default `bg-black/50` is kept for every
|
|
108
|
+
* existing caller; pass `bg-scrim-overlay` for the themed dim, as `Drawer`
|
|
109
|
+
* and `Dialog` already allow. A non-modal sheet renders no scrim at all.
|
|
110
|
+
*/
|
|
111
|
+
overlayClassName?: string;
|
|
106
112
|
}
|
|
107
113
|
|
|
108
114
|
const SheetContent = React.forwardRef<
|
|
109
115
|
React.ElementRef<typeof SheetPrimitive.Content>,
|
|
110
116
|
SheetContentProps
|
|
111
|
-
>(
|
|
117
|
+
>(
|
|
118
|
+
(
|
|
119
|
+
{ side = "right", attachment = "edge", className, children, hideClose, overlayClassName, ...props },
|
|
120
|
+
ref
|
|
121
|
+
) => (
|
|
112
122
|
<SheetPortal>
|
|
113
|
-
<SheetOverlay />
|
|
123
|
+
<SheetOverlay className={overlayClassName} />
|
|
114
124
|
<SheetPrimitive.Content
|
|
115
125
|
ref={ref}
|
|
116
126
|
className={cn(sheetVariants({ side, attachment }), className)}
|
|
@@ -127,7 +137,8 @@ const SheetContent = React.forwardRef<
|
|
|
127
137
|
)}
|
|
128
138
|
</SheetPrimitive.Content>
|
|
129
139
|
</SheetPortal>
|
|
130
|
-
)
|
|
140
|
+
)
|
|
141
|
+
);
|
|
131
142
|
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
|
132
143
|
|
|
133
144
|
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
|
@@ -31,6 +31,29 @@ export const Pill: Story = {
|
|
|
31
31
|
),
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* A count after each label — the booking strip (today/03-d0). `null` is a
|
|
36
|
+
* count that did not load and draws a dash (today/04-d3, "Queue —").
|
|
37
|
+
*/
|
|
38
|
+
export const WithCounts: Story = {
|
|
39
|
+
render: () => (
|
|
40
|
+
<Tabs defaultValue="today" className="w-[520px]">
|
|
41
|
+
<TabsList variant="underline">
|
|
42
|
+
<TabsTrigger value="floor">Floor</TabsTrigger>
|
|
43
|
+
<TabsTrigger value="today" count={3}>
|
|
44
|
+
Today
|
|
45
|
+
</TabsTrigger>
|
|
46
|
+
<TabsTrigger value="calendar" count={5}>
|
|
47
|
+
Calendar
|
|
48
|
+
</TabsTrigger>
|
|
49
|
+
<TabsTrigger value="queue" count={null} countLabel="count unavailable">
|
|
50
|
+
Queue
|
|
51
|
+
</TabsTrigger>
|
|
52
|
+
</TabsList>
|
|
53
|
+
</Tabs>
|
|
54
|
+
),
|
|
55
|
+
};
|
|
56
|
+
|
|
34
57
|
export const Underline: Story = {
|
|
35
58
|
render: () => (
|
|
36
59
|
<Tabs defaultValue="overview" className="w-[400px]">
|
|
@@ -85,6 +85,70 @@ describe("Tabs", () => {
|
|
|
85
85
|
expect(onValueChange).toHaveBeenCalledWith("b");
|
|
86
86
|
});
|
|
87
87
|
|
|
88
|
+
it("draws a count after the label and reads it as part of the name", () => {
|
|
89
|
+
render(
|
|
90
|
+
<Tabs defaultValue="today">
|
|
91
|
+
<TabsList variant="underline">
|
|
92
|
+
<TabsTrigger value="today" count={3}>
|
|
93
|
+
Today
|
|
94
|
+
</TabsTrigger>
|
|
95
|
+
<TabsTrigger value="floor">Floor</TabsTrigger>
|
|
96
|
+
</TabsList>
|
|
97
|
+
</Tabs>
|
|
98
|
+
);
|
|
99
|
+
const today = screen.getByRole("tab", { name: "Today 3" });
|
|
100
|
+
const count = today.querySelector('[data-slot="tabs-trigger-count"]');
|
|
101
|
+
expect(count).toHaveTextContent("3");
|
|
102
|
+
expect(count).toHaveClass("tabular-nums");
|
|
103
|
+
expect(
|
|
104
|
+
screen.getByRole("tab", { name: "Floor" }).querySelector('[data-slot="tabs-trigger-count"]')
|
|
105
|
+
).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("draws an unknown count as a dash that a screen reader skips", () => {
|
|
109
|
+
render(
|
|
110
|
+
<Tabs defaultValue="queue">
|
|
111
|
+
<TabsList variant="underline">
|
|
112
|
+
<TabsTrigger value="queue" count={null}>
|
|
113
|
+
Queue
|
|
114
|
+
</TabsTrigger>
|
|
115
|
+
</TabsList>
|
|
116
|
+
</Tabs>
|
|
117
|
+
);
|
|
118
|
+
const queue = screen.getByRole("tab", { name: "Queue" });
|
|
119
|
+
expect(queue.querySelector('[data-slot="tabs-trigger-count"]')).toHaveTextContent("—");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("reads the injected count label in place of the figure", () => {
|
|
123
|
+
render(
|
|
124
|
+
<Tabs defaultValue="queue">
|
|
125
|
+
<TabsList variant="underline">
|
|
126
|
+
<TabsTrigger value="queue" count={2} countLabel="2 waiting">
|
|
127
|
+
Queue
|
|
128
|
+
</TabsTrigger>
|
|
129
|
+
<TabsTrigger value="calendar" count={null} countLabel="count unavailable">
|
|
130
|
+
Calendar
|
|
131
|
+
</TabsTrigger>
|
|
132
|
+
</TabsList>
|
|
133
|
+
</Tabs>
|
|
134
|
+
);
|
|
135
|
+
expect(screen.getByRole("tab", { name: "Queue 2 waiting" })).toBeInTheDocument();
|
|
136
|
+
expect(screen.getByRole("tab", { name: "Calendar count unavailable" })).toBeInTheDocument();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("draws a zero count, not nothing", () => {
|
|
140
|
+
render(
|
|
141
|
+
<Tabs defaultValue="queue">
|
|
142
|
+
<TabsList>
|
|
143
|
+
<TabsTrigger value="queue" count={0}>
|
|
144
|
+
Queue
|
|
145
|
+
</TabsTrigger>
|
|
146
|
+
</TabsList>
|
|
147
|
+
</Tabs>
|
|
148
|
+
);
|
|
149
|
+
expect(screen.getByRole("tab", { name: "Queue 0" })).toBeInTheDocument();
|
|
150
|
+
});
|
|
151
|
+
|
|
88
152
|
it("supports disabled triggers", async () => {
|
|
89
153
|
const user = userEvent.setup();
|
|
90
154
|
render(
|
package/src/components/tabs.tsx
CHANGED
|
@@ -79,10 +79,27 @@ const TabsList = React.forwardRef<
|
|
|
79
79
|
});
|
|
80
80
|
TabsList.displayName = TabsPrimitive.List.displayName;
|
|
81
81
|
|
|
82
|
+
type TabsTriggerProps = React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> & {
|
|
83
|
+
/**
|
|
84
|
+
* A count after the label — "Today 3", "Queue 2" (today/03-d0). `null`
|
|
85
|
+
* means the count is unknown (still loading, or its read failed) and draws
|
|
86
|
+
* an em dash, "Queue —" (today/04-d3); `undefined` draws nothing.
|
|
87
|
+
* Tabular figures, so the strip does not shift as a count ticks.
|
|
88
|
+
*/
|
|
89
|
+
count?: number | null;
|
|
90
|
+
/**
|
|
91
|
+
* What a screen reader hears for the count, in place of the bare figure —
|
|
92
|
+
* "3 waiting", "count unavailable". Injected, like every label here: the
|
|
93
|
+
* design system holds no copy. Without it the figure is read as is and the
|
|
94
|
+
* dash is silent.
|
|
95
|
+
*/
|
|
96
|
+
countLabel?: string;
|
|
97
|
+
};
|
|
98
|
+
|
|
82
99
|
const TabsTrigger = React.forwardRef<
|
|
83
100
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
|
84
|
-
|
|
85
|
-
>(({ className, children, ...props }, ref) => {
|
|
101
|
+
TabsTriggerProps
|
|
102
|
+
>(({ className, children, count, countLabel, ...props }, ref) => {
|
|
86
103
|
const { variant, orientation, indicatorId } = React.useContext(TabsContext);
|
|
87
104
|
// Inspect data-state at render to know whether to render the indicator.
|
|
88
105
|
// Radix sets data-state="active" | "inactive" on the trigger via its primitive.
|
|
@@ -154,7 +171,23 @@ const TabsTrigger = React.forwardRef<
|
|
|
154
171
|
)}
|
|
155
172
|
/>
|
|
156
173
|
)}
|
|
157
|
-
<span className="relative z-10 flex items-center gap-2">
|
|
174
|
+
<span className="relative z-10 flex items-center gap-2">
|
|
175
|
+
{children}
|
|
176
|
+
{count !== undefined ? (
|
|
177
|
+
<>
|
|
178
|
+
{/* A flex row drops a whitespace-only node from the layout, but the
|
|
179
|
+
accessible name keeps it: "Today 3", not "Today3". */}{" "}
|
|
180
|
+
<span
|
|
181
|
+
data-slot="tabs-trigger-count"
|
|
182
|
+
aria-hidden={countLabel !== undefined || count === null ? true : undefined}
|
|
183
|
+
className="tabular-nums"
|
|
184
|
+
>
|
|
185
|
+
{count === null ? "—" : count}
|
|
186
|
+
</span>
|
|
187
|
+
{countLabel !== undefined ? <span className="sr-only"> {countLabel}</span> : null}
|
|
188
|
+
</>
|
|
189
|
+
) : null}
|
|
190
|
+
</span>
|
|
158
191
|
</TabsPrimitive.Trigger>
|
|
159
192
|
);
|
|
160
193
|
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { toggleVariants } from "./toggle";
|
|
4
|
+
|
|
5
|
+
describe("toggleVariants", () => {
|
|
6
|
+
// White on a half-strength terracotta measured 2.2:1. Hover steps the
|
|
7
|
+
// surface instead, as Button's quiet variants do; the on-state keeps the fill.
|
|
8
|
+
it("default and outline tint on hover and keep the accent fill for the on-state", () => {
|
|
9
|
+
for (const variant of ["default", "outline"] as const) {
|
|
10
|
+
const classes = toggleVariants({ variant });
|
|
11
|
+
expect(classes).toContain("hover:bg-accent-subtle");
|
|
12
|
+
expect(classes).toContain("hover:text-foreground");
|
|
13
|
+
expect(classes).not.toContain("hover:bg-accent/50");
|
|
14
|
+
expect(classes).not.toContain("hover:text-accent-foreground");
|
|
15
|
+
expect(classes).toContain("data-[state=on]:bg-accent");
|
|
16
|
+
expect(classes).toContain("data-[state=on]:text-accent-foreground");
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -6,14 +6,20 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|
|
6
6
|
|
|
7
7
|
import { cn } from "../lib/utils";
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Hover is a step of the surface (`--accent-subtle`), as Button's quiet
|
|
11
|
+
* variants do. The shadcn default, `hover:bg-accent/50 hover:text-accent-foreground`,
|
|
12
|
+
* put white type on a half-strength terracotta: 2.2:1. The on-state's
|
|
13
|
+
* `data-[state=on]:` utilities sort after `hover:` and still win while on.
|
|
14
|
+
*/
|
|
9
15
|
const toggleVariants = cva(
|
|
10
|
-
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-semibold hover:bg-accent
|
|
16
|
+
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-semibold hover:bg-accent-subtle hover:text-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 transition-colors aria-invalid:ring-2 aria-invalid:ring-destructive/40 whitespace-nowrap",
|
|
11
17
|
{
|
|
12
18
|
variants: {
|
|
13
19
|
variant: {
|
|
14
20
|
default: "bg-transparent",
|
|
15
21
|
outline:
|
|
16
|
-
"border border-border bg-transparent hover:bg-accent
|
|
22
|
+
"border border-border bg-transparent hover:bg-accent-subtle hover:text-foreground data-[state=on]:border-primary",
|
|
17
23
|
/**
|
|
18
24
|
* One cell of a segmented control: a raised card on the group's
|
|
19
25
|
* muted capsule when on, quiet type when off. The group
|