@bearmenu/ui 0.8.41 → 0.8.43

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 +23 -0
  20. package/dist/components/responsive-overlay.js +57 -34
  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 +62 -0
  37. package/src/components/responsive-overlay.test.tsx +134 -0
  38. package/src/components/responsive-overlay.tsx +66 -7
  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
@@ -32,8 +32,12 @@ const CONTAINER_DESCRIPTION = {
32
32
  * The opener is tracked while the overlay is CLOSED rather than captured when
33
33
  * it opens: by the time an effect could run on the opening render, the dialog
34
34
  * has already mounted and pulled focus into itself.
35
+ *
36
+ * `returnFocus={false}` disables the `requestAnimationFrame` refocus below,
37
+ * but tracking keeps running regardless — cheap, and it means flipping the
38
+ * prop back on mid-lifecycle has an opener ready immediately.
35
39
  */
36
- function useFocusReturn(open: boolean) {
40
+ function useFocusReturn(open: boolean, returnFocus: boolean) {
37
41
  const openerRef = React.useRef<HTMLElement | null>(null);
38
42
  const wasOpen = React.useRef(false);
39
43
 
@@ -48,7 +52,7 @@ function useFocusReturn(open: boolean) {
48
52
  const opener = openerRef.current;
49
53
  // The overlay is still unmounting on this tick, and it takes focus with
50
54
  // it; restoring on the next frame lands after that.
51
- if (opener && opener.isConnected) {
55
+ if (returnFocus && opener && opener.isConnected) {
52
56
  requestAnimationFrame(() => opener.focus());
53
57
  }
54
58
  }
@@ -62,7 +66,7 @@ function useFocusReturn(open: boolean) {
62
66
  }
63
67
  document.addEventListener("focusin", track);
64
68
  return () => document.removeEventListener("focusin", track);
65
- }, [open]);
69
+ }, [open, returnFocus]);
66
70
  }
67
71
 
68
72
  export type ResponsiveOverlayProps = {
@@ -124,6 +128,29 @@ export type ResponsiveOverlayProps = {
124
128
  * keeps today's default in every mode (dialog, sheet, drawer).
125
129
  */
126
130
  initialFocusRef?: React.RefObject<HTMLElement | null>;
131
+ /**
132
+ * Whether closing the overlay returns focus to whatever had it before the
133
+ * overlay opened. Two things do that today: Radix's `onCloseAutoFocus` —
134
+ * the FocusScope's own auto-focus-on-unmount, refocusing the element that
135
+ * was active before the overlay trapped focus — and this component's own
136
+ * `useFocusReturn`, which refocuses that same element a frame later so it
137
+ * survives the overlay's own unmount. `true` (the default) keeps today's
138
+ * behaviour in every mode (dialog, sheet, drawer): the opener gets focus
139
+ * back.
140
+ *
141
+ * Pass `false` when closing hands focus to another surface that takes it
142
+ * itself — a pick-mode bar docking over the header, say. Both paths are
143
+ * disabled and focus stays wherever the consumer puts it (typically inside
144
+ * the same `onOpenChange(false)` handler that closes the overlay).
145
+ */
146
+ returnFocus?: boolean;
147
+ /**
148
+ * A fixed height for the phone drawer instead of sizing to its content —
149
+ * `DrawerContent`'s `snapHeight` (the frames' ~640px table sheet). Applies
150
+ * only when the narrow drawer is showing; the desktop dialog/sheet are
151
+ * unaffected. Omit to keep today's content-sized drawer.
152
+ */
153
+ drawerHeight?: number | string;
127
154
  };
128
155
 
129
156
  /**
@@ -171,12 +198,14 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
171
198
  className,
172
199
  bodyClassName,
173
200
  initialFocusRef,
201
+ returnFocus = true,
202
+ drawerHeight,
174
203
  },
175
204
  ref
176
205
  ) => {
177
206
  const isWide = useMediaQuery(wideFrom === "lg" ? DESKTOP_MEDIA_QUERY : MD_MEDIA_QUERY);
178
207
  const container: "drawer" | "dialog" | "sheet" = isWide ? desktop : "drawer";
179
- useFocusReturn(open);
208
+ useFocusReturn(open, returnFocus);
180
209
 
181
210
  // Radix (and vaul, underneath the drawer) call this before moving focus
182
211
  // themselves; preventing the default there is what stops the first-
@@ -190,6 +219,19 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
190
219
  [initialFocusRef]
191
220
  );
192
221
 
222
+ // Radix's FocusScope calls this on unmount before it runs its own default
223
+ // action — refocusing whatever was active before the scope trapped focus,
224
+ // i.e. the opener. Vaul forwards it straight through to the same Radix
225
+ // primitive underneath the drawer, so one handler covers all three modes.
226
+ // Leaving it a no-op when `returnFocus` is true reproduces today's
227
+ // behaviour exactly: the prop was never passed before this change.
228
+ const handleCloseAutoFocus = React.useCallback(
229
+ (event: Event) => {
230
+ if (!returnFocus) event.preventDefault();
231
+ },
232
+ [returnFocus]
233
+ );
234
+
193
235
  // The name and the description are Radix's own: its `Title` and
194
236
  // `Description` register ids in the dialog context and `Content` points
195
237
  // `aria-labelledby` / `aria-describedby` at them. Passing our own ids
@@ -201,10 +243,19 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
201
243
  const Title = CONTAINER_TITLE[container];
202
244
  const Description = CONTAINER_DESCRIPTION[container];
203
245
 
246
+ // The phone drawer draws a tighter gutter and a smaller title than the
247
+ // desktop dialog/sheet — measured off the booking frames' table sheet
248
+ // (floor/14-w1): 16px, not 20; 22px, not 19. Scoped to `drawer` only, so
249
+ // the desktop containers (already frames-checked elsewhere) don't move.
250
+ const isDrawer = container === "drawer";
251
+
204
252
  const header = (
205
- <div data-slot="responsive-overlay-header" className="flex items-start gap-3 px-5 pt-5">
253
+ <div
254
+ data-slot="responsive-overlay-header"
255
+ className={cn("flex items-start gap-3", isDrawer ? "px-4 pt-4" : "px-5 pt-5")}
256
+ >
206
257
  <div className="min-w-0 flex-1">
207
- <Title className="text-[19px] font-bold leading-tight">
258
+ <Title className={cn("font-bold leading-tight", isDrawer ? "text-[22px]" : "text-[19px]")}>
208
259
  {title}
209
260
  </Title>
210
261
  {subtitle ? (
@@ -232,7 +283,11 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
232
283
  const body = (
233
284
  <div
234
285
  data-slot="responsive-overlay-body"
235
- className={cn("min-h-0 flex-1 overflow-y-auto px-5 py-4", bodyClassName)}
286
+ className={cn(
287
+ "min-h-0 flex-1 overflow-y-auto py-4",
288
+ isDrawer ? "px-4" : "px-5",
289
+ bodyClassName
290
+ )}
236
291
  >
237
292
  {children}
238
293
  </div>
@@ -257,6 +312,7 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
257
312
  hideClose
258
313
  overlayClassName="bg-scrim-overlay backdrop-blur-none"
259
314
  onOpenAutoFocus={handleOpenAutoFocus}
315
+ onCloseAutoFocus={handleCloseAutoFocus}
260
316
  {...describedBy}
261
317
  data-slot="responsive-overlay-sheet"
262
318
  data-modal={modal ? "true" : "false"}
@@ -289,6 +345,7 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
289
345
  showCloseButton={false}
290
346
  overlayClassName="bg-scrim-overlay backdrop-blur-none"
291
347
  onOpenAutoFocus={handleOpenAutoFocus}
348
+ onCloseAutoFocus={handleCloseAutoFocus}
292
349
  {...describedBy}
293
350
  className={cn(
294
351
  // The dialog's own `grid gap-4 p-6` would put air between the
@@ -311,6 +368,8 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
311
368
  ref={ref}
312
369
  overlayClassName="bg-scrim-overlay"
313
370
  onOpenAutoFocus={handleOpenAutoFocus}
371
+ onCloseAutoFocus={handleCloseAutoFocus}
372
+ {...(drawerHeight !== undefined ? { snapHeight: drawerHeight } : {})}
314
373
  {...describedBy}
315
374
  className={cn("flex max-h-[85vh] flex-col p-0", className)}
316
375
  >
@@ -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
  });
@@ -22,12 +22,18 @@ const toggleVariants = cva(
22
22
  "border border-border bg-transparent hover:bg-accent-subtle hover:text-foreground data-[state=on]:border-primary",
23
23
  /**
24
24
  * One cell of a segmented control: a raised card on the group's
25
- * muted capsule when on, quiet type when off. The group
25
+ * muted capsule when on, quiet type when off. This class owns only
26
+ * the cell's layout and text colour — the button itself is the full
27
+ * 44px hit target the accessibility floor asks for. The raised card
28
+ * it draws is smaller (36px, measured off the booking frames'
29
+ * calendar toolbar and the closures scope control), so
30
+ * `SegmentedToggleContent` below carries the paint in an inner span,
31
+ * the same split `MenuPickRow` uses for its add disc. The group
26
32
  * (`ToggleGroup variant="segmented"`) supplies the capsule; the cell
27
33
  * never fills with the accent, which would read as a primary button.
28
34
  */
29
35
  segmented:
30
- "h-10 w-11 rounded-full text-muted-foreground hover:bg-transparent hover:text-foreground data-[state=on]:bg-card data-[state=on]:text-foreground data-[state=on]:shadow-[var(--shadow-card)]",
36
+ "group/segment relative rounded-full text-muted-foreground hover:bg-transparent hover:text-foreground data-[state=on]:bg-transparent data-[state=on]:text-foreground",
31
37
  /**
32
38
  * A choice among a few named options that sit in a wrapping row —
33
39
  * the booking flow's seating preference ("No preference", "Terrace",
@@ -58,6 +64,10 @@ const toggleVariants = cva(
58
64
  compoundVariants: [
59
65
  { variant: "chip", class: "h-auto min-w-0 px-[13px]" },
60
66
  { variant: "tab", class: "h-auto min-w-0 px-3" },
67
+ // The segmented cell's hit target is fixed at the 44px floor
68
+ // regardless of `size` — restated here for the same reason as chip and
69
+ // tab above, so the size variants' `h-*` never wins the merge.
70
+ { variant: "segmented", class: "h-11 px-0" },
61
71
  ],
62
72
  defaultVariants: {
63
73
  variant: "default",
@@ -66,10 +76,25 @@ const toggleVariants = cva(
66
76
  }
67
77
  );
68
78
 
79
+ /**
80
+ * The segmented variant's drawn pill — 36px tall, centred in the cell's 44px
81
+ * hit box. Painted here, on `group-data-[state=on]/segment:`, so the outer
82
+ * button (`toggleVariants({variant: "segmented"})`) can stay unpainted and
83
+ * full height. Shared by `Toggle` and `ToggleGroupItem`.
84
+ */
85
+ function SegmentedToggleContent({ children }: { children?: React.ReactNode }) {
86
+ return (
87
+ <span className="flex h-9 w-full items-center justify-center gap-2 rounded-full text-inherit transition-[background-color,box-shadow] group-data-[state=on]/segment:bg-card group-data-[state=on]/segment:shadow-[var(--shadow-card)]">
88
+ {children}
89
+ </span>
90
+ );
91
+ }
92
+
69
93
  function Toggle({
70
94
  className,
71
95
  variant,
72
96
  size,
97
+ children,
73
98
  ...props
74
99
  }: React.ComponentProps<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>) {
75
100
  return (
@@ -77,8 +102,14 @@ function Toggle({
77
102
  data-slot="toggle"
78
103
  className={cn(toggleVariants({ variant, size, className }))}
79
104
  {...props}
80
- />
105
+ >
106
+ {variant === "segmented" ? (
107
+ <SegmentedToggleContent>{children}</SegmentedToggleContent>
108
+ ) : (
109
+ children
110
+ )}
111
+ </TogglePrimitive.Root>
81
112
  );
82
113
  }
83
114
 
84
- export { Toggle, toggleVariants };
115
+ export { Toggle, toggleVariants, SegmentedToggleContent };
package/src/globals.css CHANGED
@@ -364,7 +364,13 @@ h6 {
364
364
  --floor-error: oklch(0.56 0.2 27);
365
365
  --floor-error-foreground: oklch(0.99 0 0);
366
366
  --floor-offer: var(--primary);
367
- --floor-selected: oklch(0.25 0.05 35);
367
+ /* L raised from 0.25 (== --primary, the offer ring's colour) to 0.35: the
368
+ dark hairline read as a heavy black frame around a selected table. Same
369
+ chroma/hue, so it stays legibly the same family, just lighter — still
370
+ 11.2:1 on --floor-room and 11.5:1 on --card (floor 3:1), and a clear
371
+ lightness step (ΔL 0.10) from --floor-offer/--primary at this hue so the
372
+ two rings stay distinct. */
373
+ --floor-selected: oklch(0.35 0.05 35);
368
374
  --floor-room: oklch(0.99 0.005 75);
369
375
  --floor-wall: oklch(0.33 0.02 45);
370
376
  --floor-fixture: oklch(0.62 0.018 50);