@bearmenu/ui 0.8.34 → 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.
Files changed (47) 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-6W25S5CF.js → chunk-KGXTBTCJ.js} +1 -1
  4. package/dist/chunk-LAEVMNJN.js +135 -0
  5. package/dist/{chunk-XNBDACL7.js → chunk-ZUBBMLBD.js} +1 -1
  6. package/dist/components/badge.d.ts +8 -2
  7. package/dist/components/badge.js +1 -1
  8. package/dist/components/banner.d.ts +17 -2
  9. package/dist/components/banner.js +10 -7
  10. package/dist/components/booking-row.d.ts +11 -1
  11. package/dist/components/booking-row.js +4 -1
  12. package/dist/components/button-group.js +3 -39
  13. package/dist/components/concept-convergence.js +1 -1
  14. package/dist/components/filter-category-row.js +1 -1
  15. package/dist/components/menu-item.js +3 -3
  16. package/dist/components/multi-select-combobox.js +8 -3
  17. package/dist/components/pan-zoom-viewport.d.ts +137 -0
  18. package/dist/components/pan-zoom-viewport.js +531 -0
  19. package/dist/components/place-card.js +2 -2
  20. package/dist/components/place-details-mobile.js +1 -1
  21. package/dist/components/responsive-overlay.d.ts +29 -1
  22. package/dist/components/responsive-overlay.js +45 -6
  23. package/dist/components/sheet.d.ts +6 -0
  24. package/dist/components/sheet.js +3 -133
  25. package/dist/components/signature-chip.js +2 -2
  26. package/dist/components/tabs.d.ts +16 -1
  27. package/dist/components/tabs.js +21 -3
  28. package/dist/components/top-ranking-entry.js +3 -3
  29. package/package.json +23 -216
  30. package/src/components/badge.stories.tsx +32 -1
  31. package/src/components/badge.test.tsx +45 -0
  32. package/src/components/badge.tsx +41 -3
  33. package/src/components/banner.stories.tsx +32 -0
  34. package/src/components/banner.test.tsx +58 -0
  35. package/src/components/banner.tsx +28 -4
  36. package/src/components/booking-row.tsx +13 -0
  37. package/src/components/multi-select-combobox.tsx +7 -1
  38. package/src/components/pan-zoom-viewport.stories.tsx +134 -0
  39. package/src/components/pan-zoom-viewport.test.tsx +381 -0
  40. package/src/components/pan-zoom-viewport.tsx +727 -0
  41. package/src/components/responsive-overlay.stories.tsx +61 -0
  42. package/src/components/responsive-overlay.test.tsx +104 -0
  43. package/src/components/responsive-overlay.tsx +87 -22
  44. package/src/components/sheet.tsx +14 -3
  45. package/src/components/tabs.stories.tsx +23 -0
  46. package/src/components/tabs.test.tsx +64 -0
  47. 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
- {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
@@ -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
  });