@bearmenu/ui 0.8.41 → 0.8.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/{chunk-TB2K5JN3.js → chunk-BFS4M7KD.js} +26 -9
  2. package/dist/{chunk-KGXTBTCJ.js → chunk-CLFE4U5T.js} +1 -1
  3. package/dist/{chunk-NF5UMLSL.js → chunk-FVFAOW36.js} +46 -40
  4. package/dist/{chunk-ZUBBMLBD.js → chunk-RQPUBKFU.js} +1 -1
  5. package/dist/{chunk-FQAIYKAS.js → chunk-WHUG6QEI.js} +8 -2
  6. package/dist/components/badge.d.ts +1 -1
  7. package/dist/components/badge.js +1 -1
  8. package/dist/components/booking-row.js +1 -1
  9. package/dist/components/button.d.ts +2 -2
  10. package/dist/components/command.d.ts +3 -3
  11. package/dist/components/concept-convergence.js +1 -1
  12. package/dist/components/drawer.d.ts +9 -0
  13. package/dist/components/drawer.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 +1 -1
  17. package/dist/components/place-card.js +1 -1
  18. package/dist/components/place-details-mobile.js +1 -1
  19. package/dist/components/responsive-overlay.d.ts +7 -0
  20. package/dist/components/responsive-overlay.js +40 -27
  21. package/dist/components/signature-chip.js +2 -2
  22. package/dist/components/spinner.d.ts +2 -2
  23. package/dist/components/tag.d.ts +2 -2
  24. package/dist/components/text.d.ts +2 -2
  25. package/dist/components/toggle-group.js +9 -5
  26. package/dist/components/toggle.d.ts +11 -2
  27. package/dist/components/toggle.js +1 -1
  28. package/dist/components/top-ranking-entry.js +3 -3
  29. package/package.json +1 -1
  30. package/src/components/badge.stories.tsx +22 -1
  31. package/src/components/badge.test.tsx +37 -0
  32. package/src/components/badge.tsx +8 -2
  33. package/src/components/drawer.stories.tsx +25 -0
  34. package/src/components/drawer.test.tsx +50 -0
  35. package/src/components/drawer.tsx +70 -47
  36. package/src/components/responsive-overlay.stories.tsx +3 -0
  37. package/src/components/responsive-overlay.test.tsx +48 -0
  38. package/src/components/responsive-overlay.tsx +25 -3
  39. package/src/components/toggle-group.stories.tsx +17 -0
  40. package/src/components/toggle-group.test.tsx +65 -0
  41. package/src/components/toggle-group.tsx +13 -5
  42. package/src/components/toggle.test.tsx +50 -1
  43. package/src/components/toggle.tsx +35 -4
  44. package/src/globals.css +7 -1
@@ -1,5 +1,5 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
- import { Armchair, Check, ChevronDown, CircleHelp, Clock, Users } from "lucide-react";
2
+ import { Armchair, Check, ChevronDown, CircleCheck, CircleHelp, Clock, Users } from "lucide-react";
3
3
  import { Avatar, AvatarFallback, AvatarGroup } from "./avatar";
4
4
  import { Badge } from "./badge";
5
5
  import { Button } from "./button";
@@ -91,6 +91,27 @@ export const BookingStatus: Story = {
91
91
  ),
92
92
  };
93
93
 
94
+ /**
95
+ * `size="status"` — the taller pill beside a summary, not the inline row chip
96
+ * `BookingStatus` above draws. Measured off the booking details panel
97
+ * (Bookings Calendar 09): a 24px pill at 12px semibold, icon included.
98
+ */
99
+ export const StatusSize: Story = {
100
+ render: () => (
101
+ <div className="flex flex-wrap items-center gap-2">
102
+ <Badge as="span" variant="confirmed" size="status" icon={<CircleCheck />} className="gap-[5px]">
103
+ Confirmed
104
+ </Badge>
105
+ <Badge as="span" variant="pending" size="status">
106
+ Pending
107
+ </Badge>
108
+ <Badge as="span" variant="completed" size="status">
109
+ Completed
110
+ </Badge>
111
+ </div>
112
+ ),
113
+ };
114
+
94
115
  /**
95
116
  * `assignee` — who an item belongs to, in the shared table's organise view. It
96
117
  * is a control, not a status: the whole pill opens "Who had this?", so it keeps
@@ -42,4 +42,41 @@ describe("Badge", () => {
42
42
  render(<Badge variant="success">Solid</Badge>);
43
43
  expect(screen.getByText("Solid")).toHaveClass("bg-success", "text-success-foreground");
44
44
  });
45
+
46
+ describe('size="status"', () => {
47
+ // Measured off the booking details panel (Bookings Calendar 09): a 24px
48
+ // pill (18px line-height + 3px top/bottom), not the old py-[11px]'s 38px.
49
+ it("draws a 24px pill, not the old 38px one", () => {
50
+ render(<Badge size="status">Confirmed</Badge>);
51
+ const badge = screen.getByText("Confirmed");
52
+ expect(badge).toHaveClass("py-[3px]", "leading-[18px]");
53
+ expect(badge).not.toHaveClass("py-[11px]");
54
+ });
55
+
56
+ it("overrides a colour variant's compact, inline-row type scale", () => {
57
+ // `confirmed` alone is the small 10.5px bold row chip; `status` is the
58
+ // taller 12px semibold scale the booking details header draws, and
59
+ // must win since it is applied after the variant in the merge.
60
+ render(<Badge size="status">Confirmed</Badge>);
61
+ const badge = screen.getByText("Confirmed");
62
+ expect(badge).toHaveClass("text-xs", "font-semibold");
63
+ expect(badge.className).not.toContain("text-[10.5px]");
64
+
65
+ render(
66
+ <Badge variant="confirmed" size="status">
67
+ Confirmed
68
+ </Badge>
69
+ );
70
+ const withVariant = screen.getAllByText("Confirmed")[1];
71
+ expect(withVariant).toHaveClass("text-xs", "font-semibold", "py-[3px]");
72
+ expect(withVariant.className).not.toContain("text-[10.5px]");
73
+ expect(withVariant.className).not.toContain("font-bold");
74
+ });
75
+
76
+ it("leaves the default chip size's compact padding alone", () => {
77
+ render(<Badge variant="confirmed">Confirmed</Badge>);
78
+ const badge = screen.getByText("Confirmed");
79
+ expect(badge).toHaveClass("py-[3px]", "text-[10.5px]", "font-bold");
80
+ });
81
+ });
45
82
  });
@@ -78,11 +78,17 @@ const badgeVariants = cva(
78
78
  /**
79
79
  * `status` is the taller pill the booking design draws beside a
80
80
  * summary — the same ink and radius, more air. `chip` (the default) is
81
- * the inline one that sits in a row of text.
81
+ * the inline one that sits in a row of text. Measured off the booking
82
+ * details panel (Bookings Calendar 09, the header status badge): a 24px
83
+ * pill, not the 38px `py-[11px]` previously drew. Resets text size and
84
+ * weight too, because the colour variants (`confirmed`/`pending`/…)
85
+ * tune those for their compact, inline-with-a-row scale (10.5px bold);
86
+ * `status` is a different scale (12px semibold) and wins the merge by
87
+ * coming after the variant classes.
82
88
  */
83
89
  size: {
84
90
  chip: "",
85
- status: "py-[11px]",
91
+ status: "gap-1 px-2 py-[3px] text-xs leading-[18px] font-semibold",
86
92
  },
87
93
  // v1.0 motion: "live" surface signal — applies `live-pulse` archetype.
88
94
  // Pair with a colored variant (e.g. variant="destructive" live).
@@ -45,3 +45,28 @@ export const Default: Story = {
45
45
  </Drawer>
46
46
  ),
47
47
  };
48
+
49
+ /**
50
+ * `snapHeight` — a fixed sheet instead of one that grows with its content,
51
+ * for a phone drawer the frames draw at a set height (the floor plan's table
52
+ * sheet, ~640px). Opt-in: every other drawer keeps sizing to its content.
53
+ */
54
+ export const FixedHeight: Story = {
55
+ render: () => (
56
+ <Drawer>
57
+ <DrawerTrigger asChild>
58
+ <Button variant="outline">Open table sheet</Button>
59
+ </DrawerTrigger>
60
+ <DrawerContent snapHeight={420}>
61
+ <DrawerHeader>
62
+ <DrawerTitle>T5</DrawerTitle>
63
+ <DrawerDescription>6 seats · Inside</DrawerDescription>
64
+ </DrawerHeader>
65
+ <div className="flex-1 p-4 text-muted-foreground">
66
+ The sheet holds this height regardless of how little content sits
67
+ inside it.
68
+ </div>
69
+ </DrawerContent>
70
+ </Drawer>
71
+ ),
72
+ };
@@ -52,4 +52,54 @@ describe("Drawer", () => {
52
52
  await new Promise((resolve) => requestAnimationFrame(resolve));
53
53
  expect(dialog.contains(document.activeElement)).toBe(false);
54
54
  });
55
+
56
+ // Measured off the booking frames' table sheet (floor/14-w1): a 40×4px
57
+ // handle, not the previous 60×6.
58
+ it("draws the smaller handle by default", () => {
59
+ render(<Harness />);
60
+ const handle = document.querySelector('[data-slot="drawer-content"] > [aria-hidden]');
61
+ expect(handle).toHaveClass("h-1", "w-10");
62
+ expect(handle).not.toHaveClass("h-1.5", "w-[60px]");
63
+ });
64
+
65
+ describe("snapHeight", () => {
66
+ it("is content-sized (no inline height) when omitted", () => {
67
+ render(<Harness />);
68
+ const dialog = screen.getByRole("dialog");
69
+ expect(dialog.style.height).toBe("");
70
+ });
71
+
72
+ it("sets a fixed pixel height from a number", () => {
73
+ render(
74
+ <Drawer open onOpenChange={() => {}}>
75
+ <DrawerContent snapHeight={640}>
76
+ <DrawerTitle>T5</DrawerTitle>
77
+ </DrawerContent>
78
+ </Drawer>
79
+ );
80
+ expect(screen.getByRole("dialog").style.height).toBe("640px");
81
+ });
82
+
83
+ it("passes a string height through as-is", () => {
84
+ render(
85
+ <Drawer open onOpenChange={() => {}}>
86
+ <DrawerContent snapHeight="70vh">
87
+ <DrawerTitle>T5</DrawerTitle>
88
+ </DrawerContent>
89
+ </Drawer>
90
+ );
91
+ expect(screen.getByRole("dialog").style.height).toBe("70vh");
92
+ });
93
+
94
+ it("is ignored on a horizontal (left/right) drawer", () => {
95
+ render(
96
+ <Drawer open onOpenChange={() => {}} direction="right">
97
+ <DrawerContent snapHeight={640}>
98
+ <DrawerTitle>T5</DrawerTitle>
99
+ </DrawerContent>
100
+ </Drawer>
101
+ );
102
+ expect(screen.getByRole("dialog").style.height).toBe("");
103
+ });
104
+ });
55
105
  });
@@ -68,58 +68,81 @@ type DrawerContentProps = React.ComponentPropsWithoutRef<typeof DrawerPrimitive.
68
68
  * `--scrim-overlay` in globals.css, and why it is not `--scrim`).
69
69
  */
70
70
  overlayClassName?: string;
71
+ /**
72
+ * A fixed sheet height instead of sizing to content — the booking frames'
73
+ * ~640px table sheet (floor/14-w1) draws one instead of growing with its
74
+ * content. A number is px; a string is passed through (`"70vh"`). Only
75
+ * meaningful on a vertical (`top`/`bottom`) drawer, and always capped by the
76
+ * shared `max-h-[85vh]` on short viewports. Omit it to keep every existing
77
+ * caller's content-sized sheet — this is opt-in, not a new default.
78
+ */
79
+ snapHeight?: number | string;
71
80
  };
72
81
 
73
82
  const DrawerContent = React.forwardRef<
74
83
  React.ElementRef<typeof DrawerPrimitive.Content>,
75
84
  DrawerContentProps
76
- >(({ className, variant = "flush", showHandle, overlayClassName, children, ...props }, ref) => {
77
- const { direction, modal } = React.useContext(DrawerContext);
78
- const isVertical = direction === "top" || direction === "bottom";
79
- const handleEnabled = showHandle ?? (direction === "bottom");
80
-
81
- const positionClasses =
82
- direction === "bottom"
83
- ? "inset-x-0 bottom-0 flex-col rounded-t-2xl border-t [padding-bottom:max(env(safe-area-inset-bottom),0px)]"
84
- : direction === "top"
85
- ? "inset-x-0 top-0 flex-col rounded-b-2xl border-b [padding-top:max(env(safe-area-inset-top),0px)]"
86
- : direction === "left"
87
- ? "inset-y-0 left-0 h-full w-3/4 max-w-sm flex-col rounded-r-2xl border-r [padding-left:max(env(safe-area-inset-left),0px)]"
88
- : "inset-y-0 right-0 h-full w-3/4 max-w-sm flex-col rounded-l-2xl border-l [padding-right:max(env(safe-area-inset-right),0px)]";
89
-
90
- return (
91
- <DrawerPortal>
92
- <DrawerOverlay className={overlayClassName} />
93
- <DrawerPrimitive.Content
94
- ref={ref}
95
- data-slot="drawer-content"
96
- // Radix hides the page from assistive tech but never says so on the
97
- // dialog itself; `aria-modal` is what tells a screen reader to stay in.
98
- aria-modal={modal ? true : undefined}
99
- className={cn(
100
- "fixed z-[1000] flex border-border bg-card shadow-[var(--shadow-overlay)]",
101
- positionClasses,
102
- // The single height cap for every vertical drawer. Consumers should
103
- // NOT restate it: six app call sites carried an identical
104
- // `!max-h-[90vh]`, so this default governed nothing and changing it
105
- // here would have reached none of them.
106
- isVertical ? "max-h-[85vh]" : "",
107
- variant === "floating" && "m-3 rounded-2xl border",
108
- className
109
- )}
110
- {...props}
111
- >
112
- {handleEnabled && (
113
- <div
114
- aria-hidden
115
- className="mx-auto mt-2 h-1.5 w-[60px] shrink-0 rounded-full bg-muted-foreground/30"
116
- />
117
- )}
118
- {children}
119
- </DrawerPrimitive.Content>
120
- </DrawerPortal>
121
- );
122
- });
85
+ >(
86
+ (
87
+ { className, variant = "flush", showHandle, overlayClassName, snapHeight, style, children, ...props },
88
+ ref
89
+ ) => {
90
+ const { direction, modal } = React.useContext(DrawerContext);
91
+ const isVertical = direction === "top" || direction === "bottom";
92
+ const handleEnabled = showHandle ?? (direction === "bottom");
93
+ const fixedHeight =
94
+ isVertical && snapHeight !== undefined
95
+ ? typeof snapHeight === "number"
96
+ ? `${snapHeight}px`
97
+ : snapHeight
98
+ : undefined;
99
+
100
+ const positionClasses =
101
+ direction === "bottom"
102
+ ? "inset-x-0 bottom-0 flex-col rounded-t-2xl border-t [padding-bottom:max(env(safe-area-inset-bottom),0px)]"
103
+ : direction === "top"
104
+ ? "inset-x-0 top-0 flex-col rounded-b-2xl border-b [padding-top:max(env(safe-area-inset-top),0px)]"
105
+ : direction === "left"
106
+ ? "inset-y-0 left-0 h-full w-3/4 max-w-sm flex-col rounded-r-2xl border-r [padding-left:max(env(safe-area-inset-left),0px)]"
107
+ : "inset-y-0 right-0 h-full w-3/4 max-w-sm flex-col rounded-l-2xl border-l [padding-right:max(env(safe-area-inset-right),0px)]";
108
+
109
+ return (
110
+ <DrawerPortal>
111
+ <DrawerOverlay className={overlayClassName} />
112
+ <DrawerPrimitive.Content
113
+ ref={ref}
114
+ data-slot="drawer-content"
115
+ // Radix hides the page from assistive tech but never says so on the
116
+ // dialog itself; `aria-modal` is what tells a screen reader to stay in.
117
+ aria-modal={modal ? true : undefined}
118
+ style={fixedHeight !== undefined ? { ...style, height: fixedHeight } : style}
119
+ className={cn(
120
+ "fixed z-[1000] flex border-border bg-card shadow-[var(--shadow-overlay)]",
121
+ positionClasses,
122
+ // The single height cap for every vertical drawer, fixed height or
123
+ // not (a `snapHeight` taller than the viewport still backs off to
124
+ // this on a short phone). Consumers should NOT restate it: six app
125
+ // call sites carried an identical `!max-h-[90vh]`, so this default
126
+ // governed nothing and changing it here would have reached none of
127
+ // them.
128
+ isVertical ? "max-h-[85vh]" : "",
129
+ variant === "floating" && "m-3 rounded-2xl border",
130
+ className
131
+ )}
132
+ {...props}
133
+ >
134
+ {handleEnabled && (
135
+ <div
136
+ aria-hidden
137
+ className="mx-auto mt-2 h-1 w-10 shrink-0 rounded-full bg-muted-foreground/30"
138
+ />
139
+ )}
140
+ {children}
141
+ </DrawerPrimitive.Content>
142
+ </DrawerPortal>
143
+ );
144
+ }
145
+ );
123
146
  DrawerContent.displayName = "DrawerContent";
124
147
 
125
148
  const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
@@ -62,6 +62,8 @@ export const AddAnItem: Story = {
62
62
  * floor/14-w1 — a working rail. `desktop="sheet"` with `modal={false}` and
63
63
  * `wideFrom="lg"`: from 1024 the page stays live beside it (click a table and
64
64
  * the rail shows that one instead of closing); under 1024 it is the drawer.
65
+ * `drawerHeight={640}` matches the frame's fixed-height phone sheet — resize
66
+ * the preview under 1024px and open a table to see it hold its height.
65
67
  */
66
68
  export const WorkingRail: Story = {
67
69
  render: () => {
@@ -83,6 +85,7 @@ export const WorkingRail: Story = {
83
85
  desktop="sheet"
84
86
  modal={false}
85
87
  wideFrom="lg"
88
+ drawerHeight={640}
86
89
  closeLabel="Close"
87
90
  >
88
91
  <p className="text-[14px] text-muted-foreground">
@@ -340,6 +340,54 @@ describe("ResponsiveOverlay", () => {
340
340
  });
341
341
  });
342
342
 
343
+ describe("phone drawer chrome", () => {
344
+ // Measured off the booking frames' table sheet (floor/14-w1): 16px
345
+ // gutter and a 22px title on the phone drawer; the desktop dialog/sheet
346
+ // keep their existing 20px / 19px.
347
+ it("tightens the gutter and grows the title on the drawer only", () => {
348
+ open();
349
+ const header = document.querySelector('[data-slot="responsive-overlay-header"]');
350
+ expect(header).toHaveClass("px-4", "pt-4");
351
+ expect(screen.getByText("Add an item")).toHaveClass("text-[22px]");
352
+ const body = document.querySelector('[data-slot="responsive-overlay-body"]');
353
+ expect(body).toHaveClass("px-4");
354
+ });
355
+
356
+ it("leaves the desktop dialog's gutter and title alone", () => {
357
+ setWidth(true);
358
+ open();
359
+ const header = document.querySelector('[data-slot="responsive-overlay-header"]');
360
+ expect(header).toHaveClass("px-5", "pt-5");
361
+ expect(screen.getByText("Add an item")).toHaveClass("text-[19px]");
362
+ });
363
+
364
+ it("leaves the desktop sheet's gutter and title alone", () => {
365
+ setWidth(true);
366
+ open({ desktop: "sheet" });
367
+ const header = document.querySelector('[data-slot="responsive-overlay-header"]');
368
+ expect(header).toHaveClass("px-5", "pt-5");
369
+ expect(screen.getByText("Add an item")).toHaveClass("text-[19px]");
370
+ });
371
+ });
372
+
373
+ describe("drawerHeight", () => {
374
+ it("is content-sized when omitted", () => {
375
+ open();
376
+ expect(screen.getByRole("dialog").style.height).toBe("");
377
+ });
378
+
379
+ it("fixes the drawer's height when given", () => {
380
+ open({ drawerHeight: 640 });
381
+ expect(screen.getByRole("dialog").style.height).toBe("640px");
382
+ });
383
+
384
+ it("does not affect the desktop dialog", () => {
385
+ setWidth(true);
386
+ open({ drawerHeight: 640 });
387
+ expect(screen.getByRole("dialog").style.height).toBe("");
388
+ });
389
+ });
390
+
343
391
  it("stays out of the DOM while closed", () => {
344
392
  render(
345
393
  <ResponsiveOverlay open={false} onOpenChange={() => {}} title="Add an item">
@@ -124,6 +124,13 @@ export type ResponsiveOverlayProps = {
124
124
  * keeps today's default in every mode (dialog, sheet, drawer).
125
125
  */
126
126
  initialFocusRef?: React.RefObject<HTMLElement | null>;
127
+ /**
128
+ * A fixed height for the phone drawer instead of sizing to its content —
129
+ * `DrawerContent`'s `snapHeight` (the frames' ~640px table sheet). Applies
130
+ * only when the narrow drawer is showing; the desktop dialog/sheet are
131
+ * unaffected. Omit to keep today's content-sized drawer.
132
+ */
133
+ drawerHeight?: number | string;
127
134
  };
128
135
 
129
136
  /**
@@ -171,6 +178,7 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
171
178
  className,
172
179
  bodyClassName,
173
180
  initialFocusRef,
181
+ drawerHeight,
174
182
  },
175
183
  ref
176
184
  ) => {
@@ -201,10 +209,19 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
201
209
  const Title = CONTAINER_TITLE[container];
202
210
  const Description = CONTAINER_DESCRIPTION[container];
203
211
 
212
+ // The phone drawer draws a tighter gutter and a smaller title than the
213
+ // desktop dialog/sheet — measured off the booking frames' table sheet
214
+ // (floor/14-w1): 16px, not 20; 22px, not 19. Scoped to `drawer` only, so
215
+ // the desktop containers (already frames-checked elsewhere) don't move.
216
+ const isDrawer = container === "drawer";
217
+
204
218
  const header = (
205
- <div data-slot="responsive-overlay-header" className="flex items-start gap-3 px-5 pt-5">
219
+ <div
220
+ data-slot="responsive-overlay-header"
221
+ className={cn("flex items-start gap-3", isDrawer ? "px-4 pt-4" : "px-5 pt-5")}
222
+ >
206
223
  <div className="min-w-0 flex-1">
207
- <Title className="text-[19px] font-bold leading-tight">
224
+ <Title className={cn("font-bold leading-tight", isDrawer ? "text-[22px]" : "text-[19px]")}>
208
225
  {title}
209
226
  </Title>
210
227
  {subtitle ? (
@@ -232,7 +249,11 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
232
249
  const body = (
233
250
  <div
234
251
  data-slot="responsive-overlay-body"
235
- className={cn("min-h-0 flex-1 overflow-y-auto px-5 py-4", bodyClassName)}
252
+ className={cn(
253
+ "min-h-0 flex-1 overflow-y-auto py-4",
254
+ isDrawer ? "px-4" : "px-5",
255
+ bodyClassName
256
+ )}
236
257
  >
237
258
  {children}
238
259
  </div>
@@ -311,6 +332,7 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
311
332
  ref={ref}
312
333
  overlayClassName="bg-scrim-overlay"
313
334
  onOpenAutoFocus={handleOpenAutoFocus}
335
+ {...(drawerHeight !== undefined ? { snapHeight: drawerHeight } : {})}
314
336
  {...describedBy}
315
337
  className={cn("flex max-h-[85vh] flex-col p-0", className)}
316
338
  >
@@ -95,6 +95,23 @@ export const Segmented: Story = {
95
95
  ),
96
96
  };
97
97
 
98
+ /**
99
+ * Text-label cells — the calendar toolbar's Timeline/List, the reservation
100
+ * sheet's booked-by source, the rules confirmation mode. Each cell is a full
101
+ * 44px hit target (the accessibility floor); the raised card it draws stays
102
+ * the frames' 36px, centred inside.
103
+ */
104
+ export const SegmentedLabels: Story = {
105
+ render: () => (
106
+ <div className="w-[220px]">
107
+ <ToggleGroup type="single" variant="segmented" defaultValue="list" aria-label="View">
108
+ <ToggleGroupItem value="timeline">Timeline</ToggleGroupItem>
109
+ <ToggleGroupItem value="list">List</ToggleGroupItem>
110
+ </ToggleGroup>
111
+ </div>
112
+ ),
113
+ };
114
+
98
115
  /** Choice chips that wrap: the booking flow's seating preference (B2). */
99
116
  export const Chips: Story = {
100
117
  render: () => (
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+
5
+ import { ToggleGroup, ToggleGroupItem } from "./toggle-group";
6
+
7
+ describe("ToggleGroup segmented variant", () => {
8
+ it("gives each cell a 44px hit target with a smaller pill drawn inside it", () => {
9
+ render(
10
+ <ToggleGroup type="single" variant="segmented" value="timeline" aria-label="View">
11
+ <ToggleGroupItem value="timeline">Timeline</ToggleGroupItem>
12
+ <ToggleGroupItem value="list">List</ToggleGroupItem>
13
+ </ToggleGroup>
14
+ );
15
+ const timeline = screen.getByRole("radio", { name: "Timeline" });
16
+ expect(timeline).toHaveClass("h-11");
17
+ const pill = timeline.querySelector('[class*="group-data-[state=on]/segment:bg-card"]');
18
+ expect(pill).not.toBeNull();
19
+ expect(pill).toHaveClass("h-9", "w-full");
20
+ expect(pill).toContainElement(screen.getByText("Timeline"));
21
+ });
22
+
23
+ it("keeps the group's own capsule padding-free — the cell fills its full height", () => {
24
+ render(
25
+ <ToggleGroup type="single" variant="segmented" value="light" aria-label="Theme">
26
+ <ToggleGroupItem value="light">Light</ToggleGroupItem>
27
+ <ToggleGroupItem value="dark">Dark</ToggleGroupItem>
28
+ </ToggleGroup>
29
+ );
30
+ const group = document.querySelector('[data-slot="toggle-group"]');
31
+ expect(group).not.toHaveClass("data-[variant=segmented]:p-0.5");
32
+ expect(group).toHaveClass("data-[variant=segmented]:gap-0.5");
33
+ });
34
+
35
+ it("toggling selection moves the on-state to the newly active cell only", async () => {
36
+ const user = userEvent.setup();
37
+ function Harness() {
38
+ return (
39
+ <ToggleGroup type="single" defaultValue="timeline" variant="segmented" aria-label="View">
40
+ <ToggleGroupItem value="timeline">Timeline</ToggleGroupItem>
41
+ <ToggleGroupItem value="list">List</ToggleGroupItem>
42
+ </ToggleGroup>
43
+ );
44
+ }
45
+ render(<Harness />);
46
+ const timeline = screen.getByRole("radio", { name: "Timeline" });
47
+ const list = screen.getByRole("radio", { name: "List" });
48
+ expect(timeline).toHaveAttribute("data-state", "on");
49
+ expect(list).toHaveAttribute("data-state", "off");
50
+ await user.click(list);
51
+ expect(list).toHaveAttribute("data-state", "on");
52
+ expect(timeline).toHaveAttribute("data-state", "off");
53
+ });
54
+
55
+ it("does not wrap a non-segmented cell's children in a pill", () => {
56
+ render(
57
+ <ToggleGroup type="single" variant="chip" value="a" aria-label="Choice">
58
+ <ToggleGroupItem value="a">No preference</ToggleGroupItem>
59
+ </ToggleGroup>
60
+ );
61
+ const item = screen.getByRole("radio", { name: "No preference" });
62
+ expect(item.querySelector('[class*="group-data-[state=on]/segment"]')).toBeNull();
63
+ expect(item.firstChild?.textContent).toBe("No preference");
64
+ });
65
+ });
@@ -5,7 +5,7 @@ import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
5
5
  import { type VariantProps } from "class-variance-authority";
6
6
 
7
7
  import { cn } from "../lib/utils";
8
- import { toggleVariants } from "./toggle";
8
+ import { SegmentedToggleContent, toggleVariants } from "./toggle";
9
9
 
10
10
  const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants>>({
11
11
  size: "default",
@@ -27,8 +27,12 @@ function ToggleGroup({
27
27
  className={cn(
28
28
  "group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
29
29
  // The segmented control's capsule: muted ground, the on cell a raised
30
- // card inside it (see `toggleVariants.segmented`). 44px tall.
31
- "data-[variant=segmented]:gap-0.5 data-[variant=segmented]:rounded-full data-[variant=segmented]:bg-muted data-[variant=segmented]:p-0.5",
30
+ // card inside it (see `toggleVariants.segmented`). No padding here —
31
+ // each cell is the full 44px capsule height (the hit target), and
32
+ // draws its own smaller 36px card centred inside
33
+ // (`SegmentedToggleContent`), so the capsule stays the 44px the
34
+ // booking frames draw it at.
35
+ "data-[variant=segmented]:gap-0.5 data-[variant=segmented]:rounded-full data-[variant=segmented]:bg-muted",
32
36
  // Chips wrap onto as many rows as they need, 7px apart.
33
37
  "data-[variant=chip]:flex-wrap data-[variant=chip]:gap-[7px] data-[variant=chip]:rounded-none",
34
38
  // The tab bar spans its container: a muted capsule, 4px inset.
@@ -66,7 +70,7 @@ function ToggleGroupItem({
66
70
  }),
67
71
  "min-w-0 flex-1 shrink-0 shadow-none focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
68
72
  resolved === "segmented"
69
- ? "rounded-full px-0 data-[state=on]:shadow-[var(--shadow-card)]"
73
+ ? "rounded-full"
70
74
  : // Tabs and chips size themselves (`toggleVariants`' compounds);
71
75
  // the item's own rounding must not square them off.
72
76
  resolved === "tab"
@@ -79,7 +83,11 @@ function ToggleGroupItem({
79
83
  )}
80
84
  {...props}
81
85
  >
82
- {children}
86
+ {resolved === "segmented" ? (
87
+ <SegmentedToggleContent>{children}</SegmentedToggleContent>
88
+ ) : (
89
+ children
90
+ )}
83
91
  </ToggleGroupPrimitive.Item>
84
92
  );
85
93
  }
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
2
3
 
3
- import { toggleVariants } from "./toggle";
4
+ import { Toggle, toggleVariants } from "./toggle";
4
5
 
5
6
  describe("toggleVariants", () => {
6
7
  // White on a half-strength terracotta measured 2.2:1. Hover steps the
@@ -16,4 +17,52 @@ describe("toggleVariants", () => {
16
17
  expect(classes).toContain("data-[state=on]:text-accent-foreground");
17
18
  }
18
19
  });
20
+
21
+ // The 44px floor: the cell's own box is the hit target, not just the card
22
+ // it draws. `size` must never shrink it back below 44 — that is exactly the
23
+ // merge order chip/tab already guard against. `toggleVariants` alone is
24
+ // pre-merge (real callers run it through `cn`/twMerge), so render it.
25
+ it("holds the segmented cell at the 44px hit-target floor for every size", () => {
26
+ for (const size of ["sm", "default", "lg"] as const) {
27
+ render(
28
+ <Toggle variant="segmented" size={size} aria-label={`toggle-${size}`}>
29
+ X
30
+ </Toggle>
31
+ );
32
+ expect(screen.getByRole("button", { name: `toggle-${size}` })).toHaveClass("h-11");
33
+ }
34
+ });
35
+
36
+ // The cell itself no longer paints the card or the shadow — that moved to
37
+ // `SegmentedToggleContent`'s inner pill, so the outer 44px box stays
38
+ // invisible and only the pill it wraps is 36px and shows the "on" state.
39
+ it("leaves the segmented cell unpainted; the base accent fill is neutralised", () => {
40
+ const classes = toggleVariants({ variant: "segmented" });
41
+ expect(classes).not.toContain("data-[state=on]:bg-card");
42
+ expect(classes).not.toContain("shadow-[var(--shadow-card)]");
43
+ expect(classes).toContain("data-[state=on]:bg-transparent");
44
+ });
45
+ });
46
+
47
+ describe("Toggle", () => {
48
+ it("wraps a segmented toggle's content in the 36px pill", () => {
49
+ render(
50
+ <Toggle variant="segmented" aria-label="Light">
51
+ Light
52
+ </Toggle>
53
+ );
54
+ const button = screen.getByRole("button", { name: "Light" });
55
+ expect(button).toHaveClass("h-11", "group/segment");
56
+ const pill = button.querySelector('[class*="group-data-[state=on]/segment:bg-card"]');
57
+ expect(pill).not.toBeNull();
58
+ expect(pill).toHaveClass("h-9");
59
+ expect(pill).toContainElement(screen.getByText("Light"));
60
+ });
61
+
62
+ it("renders other variants' children directly, with no inner pill", () => {
63
+ render(<Toggle aria-label="Bold">B</Toggle>);
64
+ const button = screen.getByRole("button", { name: "Bold" });
65
+ expect(button.querySelector('[class*="group-data-[state=on]/segment"]')).toBeNull();
66
+ expect(button.firstChild?.textContent).toBe("B");
67
+ });
19
68
  });