@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.
Files changed (76) hide show
  1. package/dist/chunk-EGU6EG73.js +39 -0
  2. package/dist/{chunk-NIAL4ZU2.js → chunk-FQAIYKAS.js} +45 -8
  3. package/dist/{chunk-TVEIUTBQ.js → chunk-IQIJCMG2.js} +1 -1
  4. package/dist/{chunk-6W25S5CF.js → chunk-KGXTBTCJ.js} +1 -1
  5. package/dist/chunk-LAEVMNJN.js +135 -0
  6. package/dist/{chunk-VCRXL7H5.js → chunk-PGQXVJXW.js} +2 -2
  7. package/dist/{chunk-F3XK34DV.js → chunk-TB2K5JN3.js} +2 -2
  8. package/dist/{chunk-XNBDACL7.js → chunk-ZUBBMLBD.js} +1 -1
  9. package/dist/components/badge.d.ts +8 -2
  10. package/dist/components/badge.js +1 -1
  11. package/dist/components/banner.d.ts +17 -2
  12. package/dist/components/banner.js +10 -7
  13. package/dist/components/booking-row.js +1 -1
  14. package/dist/components/button-group.js +3 -39
  15. package/dist/components/calendar.js +1 -1
  16. package/dist/components/combobox.js +3 -3
  17. package/dist/components/command.js +2 -2
  18. package/dist/components/concept-convergence.js +1 -1
  19. package/dist/components/dialog.js +1 -1
  20. package/dist/components/dropdown-menu.js +6 -6
  21. package/dist/components/filter-category-row.js +1 -1
  22. package/dist/components/input-group.js +1 -1
  23. package/dist/components/menu-item.js +3 -3
  24. package/dist/components/multi-select-combobox.js +10 -5
  25. package/dist/components/multi-select-menu.js +1 -1
  26. package/dist/components/pan-zoom-viewport.d.ts +137 -0
  27. package/dist/components/pan-zoom-viewport.js +531 -0
  28. package/dist/components/place-card.js +2 -2
  29. package/dist/components/place-details-mobile.js +1 -1
  30. package/dist/components/responsive-overlay.d.ts +29 -1
  31. package/dist/components/responsive-overlay.js +46 -7
  32. package/dist/components/searchable-select.js +3 -3
  33. package/dist/components/select.js +2 -2
  34. package/dist/components/selection-bar.js +1 -1
  35. package/dist/components/sheet.d.ts +6 -0
  36. package/dist/components/sheet.js +3 -133
  37. package/dist/components/signature-chip.js +2 -2
  38. package/dist/components/tabs.d.ts +16 -1
  39. package/dist/components/tabs.js +21 -3
  40. package/dist/components/toggle-group.js +1 -1
  41. package/dist/components/toggle.d.ts +6 -0
  42. package/dist/components/toggle.js +1 -1
  43. package/dist/components/top-ranking-entry.js +3 -3
  44. package/package.json +1 -1
  45. package/src/components/badge.stories.tsx +32 -1
  46. package/src/components/badge.test.tsx +45 -0
  47. package/src/components/badge.tsx +41 -3
  48. package/src/components/banner.stories.tsx +32 -0
  49. package/src/components/banner.test.tsx +58 -0
  50. package/src/components/banner.tsx +28 -4
  51. package/src/components/calendar.tsx +1 -1
  52. package/src/components/combobox.tsx +1 -1
  53. package/src/components/command.test.tsx +10 -0
  54. package/src/components/command.tsx +1 -1
  55. package/src/components/dialog.tsx +1 -1
  56. package/src/components/dropdown-menu.test.tsx +31 -0
  57. package/src/components/dropdown-menu.tsx +6 -6
  58. package/src/components/input-group.tsx +1 -1
  59. package/src/components/multi-select-combobox.tsx +7 -1
  60. package/src/components/multi-select-menu.tsx +1 -1
  61. package/src/components/pan-zoom-viewport.stories.tsx +134 -0
  62. package/src/components/pan-zoom-viewport.test.tsx +381 -0
  63. package/src/components/pan-zoom-viewport.tsx +727 -0
  64. package/src/components/responsive-overlay.stories.tsx +61 -0
  65. package/src/components/responsive-overlay.test.tsx +104 -0
  66. package/src/components/responsive-overlay.tsx +87 -22
  67. package/src/components/searchable-select.tsx +1 -1
  68. package/src/components/select.tsx +2 -2
  69. package/src/components/selection-bar.tsx +1 -1
  70. package/src/components/sheet.tsx +14 -3
  71. package/src/components/tabs.stories.tsx +23 -0
  72. package/src/components/tabs.test.tsx +64 -0
  73. package/src/components/tabs.tsx +36 -3
  74. package/src/components/toggle.test.tsx +19 -0
  75. package/src/components/toggle.tsx +8 -2
  76. 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
- {isWide ? (
134
- <DialogTitle id={titleId} className="text-[19px] font-bold leading-tight">
135
- {title}
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
- isWide ? (
144
- <DialogDescription id={subtitleId} className="mt-1 text-[13px] text-muted-foreground">
145
- {subtitle}
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 (isWide) {
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-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 transition-colors"
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-accent-foreground data-[state=checked]:font-semibold",
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-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
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>
@@ -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
- >(({ side = "right", attachment = "edge", className, children, hideClose, ...props }, ref) => (
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(
@@ -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
- React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
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">{children}</span>
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/50 hover:text-accent-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",
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/50 hover:text-accent-foreground data-[state=on]:border-primary",
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