@bearmenu/ui 0.8.35 → 0.8.36
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-6W25S5CF.js → chunk-KGXTBTCJ.js} +1 -1
- package/dist/chunk-LAEVMNJN.js +135 -0
- 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/button.d.ts +2 -2
- package/dist/components/command.d.ts +3 -3
- package/dist/components/concept-convergence.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 +8 -3
- 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 +45 -6
- 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/spinner.d.ts +2 -2
- package/dist/components/tabs.d.ts +16 -1
- package/dist/components/tabs.js +21 -3
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/text.d.ts +2 -2
- 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/multi-select-combobox.tsx +7 -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/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
|
@@ -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
|
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
|
});
|