@elabs-ai/components-ui 4.1.0 → 4.2.0

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 (193) hide show
  1. package/dist/{chunk-JKPPTL63.js → chunk-FCH4ZN6G.js} +12 -3
  2. package/dist/chunk-FCH4ZN6G.js.map +1 -0
  3. package/dist/form.js +3 -3
  4. package/dist/form.js.map +1 -1
  5. package/dist/index.d.ts +126 -35
  6. package/dist/index.js +3346 -2079
  7. package/dist/index.js.map +1 -1
  8. package/dist/lib/cn.js +10 -1
  9. package/dist/lib/cn.js.map +1 -1
  10. package/package.json +3 -3
  11. package/src/__contract__/attribution-panel.contract.test.tsx +49 -0
  12. package/src/__contract__/badge.contract.test.tsx +49 -0
  13. package/src/__contract__/bounded-number.contract.test.tsx +49 -0
  14. package/src/__contract__/button.contract.test.tsx +49 -0
  15. package/src/__contract__/color-picker.contract.test.tsx +49 -0
  16. package/src/__contract__/combobox.contract.test.tsx +49 -0
  17. package/src/__contract__/command-trigger.contract.test.tsx +49 -0
  18. package/src/__contract__/copyable-value.contract.test.tsx +49 -0
  19. package/src/__contract__/date-picker.contract.test.tsx +49 -0
  20. package/src/__contract__/date-range-picker.contract.test.tsx +49 -0
  21. package/src/__contract__/empty-state.contract.test.tsx +49 -0
  22. package/src/__contract__/error-state.contract.test.tsx +49 -0
  23. package/src/__contract__/icon-button.contract.test.tsx +49 -0
  24. package/src/__contract__/input.contract.test.tsx +49 -0
  25. package/src/__contract__/key-value-editor.contract.test.tsx +49 -0
  26. package/src/__contract__/list-editor.contract.test.tsx +49 -0
  27. package/src/__contract__/loading-state.contract.test.tsx +49 -0
  28. package/src/__contract__/metric-card.contract.test.tsx +49 -0
  29. package/src/__contract__/model-picker.contract.test.tsx +49 -0
  30. package/src/__contract__/nav-notifications.contract.test.tsx +49 -0
  31. package/src/__contract__/number-input.contract.test.tsx +49 -0
  32. package/src/__contract__/rating.contract.test.tsx +49 -0
  33. package/src/__contract__/schema-form.contract.test.tsx +49 -0
  34. package/src/__contract__/section-header.contract.test.tsx +49 -0
  35. package/src/__contract__/segmented-field.contract.test.tsx +49 -0
  36. package/src/__contract__/slider-number.contract.test.tsx +49 -0
  37. package/src/__contract__/status-badge.contract.test.tsx +49 -0
  38. package/src/__contract__/tag-input.contract.test.tsx +49 -0
  39. package/src/__contract__/text.contract.test.tsx +49 -0
  40. package/src/__contract__/textarea.contract.test.tsx +49 -0
  41. package/src/__contract__/timeline.contract.test.tsx +49 -0
  42. package/src/__contract__/toggle.contract.test.tsx +49 -0
  43. package/src/__contract__/transfer.contract.test.tsx +49 -0
  44. package/src/__contract__/tree-select.contract.test.tsx +49 -0
  45. package/src/__contract__/tree.contract.test.tsx +49 -0
  46. package/src/__contract__/virtual-select.contract.test.tsx +49 -0
  47. package/src/__contract__/workspace-picker.contract.test.tsx +49 -0
  48. package/src/components/accordion/accordion.tsx +13 -4
  49. package/src/components/alert/alert.tsx +11 -3
  50. package/src/components/alert-dialog/alert-dialog.test.tsx +32 -0
  51. package/src/components/alert-dialog/alert-dialog.tsx +28 -6
  52. package/src/components/app-shell/app-shell.test.tsx +99 -0
  53. package/src/components/app-shell/app-shell.tsx +29 -10
  54. package/src/components/attribution-panel/attribution-panel.tsx +2 -0
  55. package/src/components/attribution-panel/attributions.generated.ts +2 -2
  56. package/src/components/avatar/avatar.tsx +4 -1
  57. package/src/components/badge/badge.test.tsx +8 -0
  58. package/src/components/badge/badge.tsx +14 -4
  59. package/src/components/bento-grid/bento-grid.test.tsx +23 -2
  60. package/src/components/bento-grid/bento-grid.tsx +10 -0
  61. package/src/components/bounded-number/bounded-number.test.tsx +1 -1
  62. package/src/components/bounded-number/bounded-number.tsx +78 -75
  63. package/src/components/breadcrumb/breadcrumb.tsx +3 -2
  64. package/src/components/button/button.tsx +18 -12
  65. package/src/components/button-group/button-group.tsx +1 -1
  66. package/src/components/calendar/calendar.tsx +3 -2
  67. package/src/components/card/card.test.tsx +24 -2
  68. package/src/components/card/card.tsx +47 -6
  69. package/src/components/carousel/carousel.test.tsx +169 -0
  70. package/src/components/carousel/carousel.tsx +93 -32
  71. package/src/components/change-review/change-review.tsx +38 -29
  72. package/src/components/checkbox/checkbox.tsx +1 -0
  73. package/src/components/collapsible-panel/use-collapsible-panel.ts +0 -3
  74. package/src/components/color-picker/color-picker.tsx +17 -11
  75. package/src/components/combobox/combobox.stories.tsx +10 -0
  76. package/src/components/combobox/combobox.test.tsx +54 -0
  77. package/src/components/combobox/combobox.tsx +25 -11
  78. package/src/components/command/command.test.tsx +15 -0
  79. package/src/components/command/command.tsx +8 -6
  80. package/src/components/context-menu/context-menu.tsx +16 -7
  81. package/src/components/context-rail/context-rail.tsx +4 -4
  82. package/src/components/copyable-value/copyable-value.stories.tsx +10 -0
  83. package/src/components/copyable-value/copyable-value.test.tsx +28 -1
  84. package/src/components/copyable-value/copyable-value.tsx +12 -1
  85. package/src/components/date-picker/date-picker.stories.tsx +9 -0
  86. package/src/components/date-picker/date-picker.test.tsx +52 -0
  87. package/src/components/date-picker/date-picker.tsx +13 -15
  88. package/src/components/date-range-picker/date-range-picker.stories.tsx +9 -0
  89. package/src/components/date-range-picker/date-range-picker.test.tsx +76 -1
  90. package/src/components/date-range-picker/date-range-picker.tsx +39 -28
  91. package/src/components/descriptions/descriptions.tsx +1 -1
  92. package/src/components/dialog/dialog.test.tsx +8 -3
  93. package/src/components/dialog/dialog.tsx +12 -4
  94. package/src/components/drawer/drawer.tsx +23 -6
  95. package/src/components/dropdown-menu/dropdown-menu.tsx +4 -4
  96. package/src/components/field/field.tsx +2 -0
  97. package/src/components/field-row/field-row.tsx +2 -0
  98. package/src/components/file-upload/file-upload.test.tsx +83 -10
  99. package/src/components/file-upload/file-upload.tsx +150 -28
  100. package/src/components/form/form.tsx +4 -2
  101. package/src/components/hover-card/hover-card.tsx +1 -0
  102. package/src/components/icon-button/icon-button.tsx +1 -1
  103. package/src/components/input/input.tsx +7 -3
  104. package/src/components/input-group/input-group.tsx +6 -6
  105. package/src/components/input-otp/input-otp.tsx +3 -1
  106. package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +2 -2
  107. package/src/components/label/label.tsx +1 -1
  108. package/src/components/list-editor/list-editor.test.tsx +24 -0
  109. package/src/components/list-editor/list-editor.tsx +126 -52
  110. package/src/components/locale-provider/locale-keys.test.ts +134 -0
  111. package/src/components/locale-provider/messages.ts +321 -0
  112. package/src/components/mention-input/mention-input.stories.tsx +3 -3
  113. package/src/components/mention-input/mention-input.test.tsx +51 -1
  114. package/src/components/mention-input/mention-input.tsx +58 -8
  115. package/src/components/menubar/menubar.tsx +16 -6
  116. package/src/components/nav-main/nav-main.tsx +7 -3
  117. package/src/components/nav-notifications/nav-notifications.test.tsx +11 -0
  118. package/src/components/nav-notifications/nav-notifications.tsx +11 -5
  119. package/src/components/nav-user/nav-user.tsx +5 -5
  120. package/src/components/navigation-menu/navigation-menu.tsx +16 -4
  121. package/src/components/number-input/number-input.stories.tsx +7 -3
  122. package/src/components/number-input/number-input.test.tsx +47 -0
  123. package/src/components/number-input/number-input.tsx +65 -24
  124. package/src/components/pagination/pagination.test.tsx +20 -1
  125. package/src/components/pagination/pagination.tsx +18 -14
  126. package/src/components/popover/popover.tsx +1 -0
  127. package/src/components/progress/progress.tsx +2 -0
  128. package/src/components/rating/rating.tsx +2 -0
  129. package/src/components/resizable/resizable.test.tsx +76 -0
  130. package/src/components/resizable/resizable.tsx +31 -5
  131. package/src/components/revision-timeline/revision-timeline.tsx +7 -2
  132. package/src/components/schema-form/schema-form-store.ts +506 -0
  133. package/src/components/schema-form/schema-form.test.tsx +122 -1
  134. package/src/components/schema-form/schema-form.tsx +163 -327
  135. package/src/components/section-header/section-header.tsx +2 -2
  136. package/src/components/select/select.tsx +7 -5
  137. package/src/components/separator/separator.tsx +1 -0
  138. package/src/components/sheet/sheet.test.tsx +39 -0
  139. package/src/components/sheet/sheet.tsx +40 -9
  140. package/src/components/side-dock/side-dock.test.tsx +26 -0
  141. package/src/components/side-dock/side-dock.tsx +15 -2
  142. package/src/components/sidebar/sidebar.test.tsx +35 -1
  143. package/src/components/sidebar/sidebar.tsx +20 -6
  144. package/src/components/skeleton/skeleton.tsx +1 -0
  145. package/src/components/slider-number/slider-number.stories.tsx +4 -1
  146. package/src/components/slider-number/slider-number.test.tsx +1 -1
  147. package/src/components/sonner/sonner.tsx +2 -0
  148. package/src/components/spinner/spinner.test.tsx +18 -0
  149. package/src/components/spinner/spinner.tsx +14 -4
  150. package/src/components/state-panel/state-panel.test.tsx +3 -3
  151. package/src/components/state-panel/state-panel.tsx +15 -8
  152. package/src/components/switch/switch.tsx +2 -0
  153. package/src/components/table/table.stories.tsx +3 -1
  154. package/src/components/table/table.test.tsx +9 -2
  155. package/src/components/table/table.tsx +18 -8
  156. package/src/components/tabs/index.ts +10 -1
  157. package/src/components/tabs/tabs.stories.tsx +48 -0
  158. package/src/components/tabs/tabs.test.tsx +67 -0
  159. package/src/components/tabs/tabs.tsx +175 -77
  160. package/src/components/tag-input/tag-input.tsx +4 -2
  161. package/src/components/team-switcher/team-switcher.tsx +3 -3
  162. package/src/components/textarea/textarea.tsx +1 -1
  163. package/src/components/theme-switcher/theme-switcher.stories.tsx +76 -0
  164. package/src/components/theme-switcher/theme-switcher.test.tsx +182 -0
  165. package/src/components/theme-switcher/theme-switcher.tsx +134 -10
  166. package/src/components/timeline/timeline.tsx +0 -3
  167. package/src/components/toggle/toggle.tsx +7 -2
  168. package/src/components/toggle-group/toggle-group.tsx +2 -0
  169. package/src/components/tooltip/tooltip.tsx +2 -1
  170. package/src/components/top-nav/top-nav.tsx +1 -1
  171. package/src/components/transfer/transfer.tsx +35 -30
  172. package/src/components/tree/tree.test.tsx +41 -0
  173. package/src/components/tree/tree.tsx +77 -22
  174. package/src/components/tree/use-tree-keyboard.ts +37 -14
  175. package/src/components/tree-select/tree-select.stories.tsx +9 -0
  176. package/src/components/tree-select/tree-select.test.tsx +5 -0
  177. package/src/components/tree-select/tree-select.tsx +11 -14
  178. package/src/components/typography/prose.test.tsx +3 -2
  179. package/src/components/typography/prose.tsx +3 -3
  180. package/src/components/virtual-select/virtual-select.stories.tsx +9 -0
  181. package/src/components/virtual-select/virtual-select.test.tsx +28 -2
  182. package/src/components/virtual-select/virtual-select.tsx +56 -18
  183. package/src/components/wizard/wizard.test.tsx +9 -0
  184. package/src/components/wizard/wizard.tsx +12 -4
  185. package/src/index.ts +5 -0
  186. package/src/lib/cn.ts +9 -0
  187. package/src/lib/csv.ts +48 -0
  188. package/src/lib/diff-rows.ts +2 -0
  189. package/src/lib/merge-refs.ts +2 -0
  190. package/src/lib/use-controllable-state.ts +47 -0
  191. package/src/lib/use-mobile.test.tsx +47 -0
  192. package/src/lib/use-mobile.ts +26 -10
  193. package/dist/chunk-JKPPTL63.js.map +0 -1
@@ -136,7 +136,7 @@ describe("Table — #366 the scroll wrapper's tab stop exists only while it meas
136
136
  expect(scrollRegion).not.toHaveAttribute("tabindex");
137
137
  });
138
138
 
139
- it('never adds a redundant role="region" landmark', () => {
139
+ it('adds role="group" once it overflows — a named, non-landmark stop', () => {
140
140
  const { container } = render(
141
141
  <Table>
142
142
  <TableBody>
@@ -147,12 +147,19 @@ describe("Table — #366 the scroll wrapper's tab stop exists only while it meas
147
147
  </Table>,
148
148
  );
149
149
  const scrollRegion = scrollRegionOf(container);
150
+ // `aria-label` on a plain `<div>` (role `generic`) is not guaranteed to
151
+ // produce an accessible name at all, so the overflowing stop needs a
152
+ // naming-capable role — `group`, never the `region` landmark, which would
153
+ // collide (axe `landmark-unique`) as soon as two tables overflow on a page.
154
+ expect(scrollRegion).not.toHaveAttribute("role");
150
155
  setScrollMetrics(scrollRegion, {
151
156
  scrollWidth: 300,
152
157
  clientWidth: 300,
153
158
  scrollHeight: 800,
154
159
  clientHeight: 200,
155
160
  });
156
- expect(scrollRegion).not.toHaveAttribute("role");
161
+ expect(scrollRegion).toHaveAttribute("role", "group");
162
+ expect(screen.queryByRole("region")).toBeNull();
163
+ expect(screen.getByRole("group", { name: "Table contents, scrollable" })).toBe(scrollRegion);
157
164
  });
158
165
  });
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  import {
2
4
  forwardRef,
3
5
  useCallback,
@@ -23,13 +25,20 @@ import { useLocale } from "../locale-provider";
23
25
  * affordance itself.
24
26
  *
25
27
  * A table that FITS must stay a byte-identical no-op: no `tabIndex`, no
26
- * `aria-label`, no ring — an unconditional stop would announce "scrollable"
27
- * on content that never scrolls. Only a table that measurably overflows
28
- * gets the tab stop, its accessible name (WCAG 4.1.2) and the compound focus
29
- * ring (`focus-ring-inset` — the wrapper's own edge is the scroll clip, so an
30
- * outside ring would be cut, `.claude/rules/theming.md`). No `role="region"`:
31
- * both existing precedents (`DataTable`, `SheetTable`) decided against a
32
- * redundant landmark over a real `<table>`, and this wrapper adds none either.
28
+ * `aria-label`, no `role`, no ring — an unconditional stop would announce
29
+ * "scrollable" on content that never scrolls. Only a table that measurably
30
+ * overflows gets the tab stop, its accessible name (WCAG 4.1.2) and the
31
+ * compound focus ring (`focus-ring-inset` — the wrapper's own edge is the
32
+ * scroll clip, so an outside ring would be cut, `.claude/rules/theming.md`).
33
+ *
34
+ * A naming-capable role is REQUIRED alongside that `aria-label`: `aria-label`
35
+ * on a plain `<div>` (role `generic`) is prohibited by ARIA 1.2 and not
36
+ * guaranteed to compute into a name, so `tabIndex={0}` alone would make an
37
+ * UNNAMED stop. That role is `group`, NOT `region`: `region` is a landmark,
38
+ * and every overflowing table on a page would add one more landmark with the
39
+ * same name (axe `landmark-unique`) over a real `<table>` that already has
40
+ * its own semantics. `group` names the stop without joining landmark
41
+ * navigation — the no-redundant-landmark call `DataTable` made first (#330).
33
42
  */
34
43
  export const Table = forwardRef<HTMLTableElement, HTMLAttributes<HTMLTableElement>>(function Table(
35
44
  { className, ...props },
@@ -67,6 +76,7 @@ export const Table = forwardRef<HTMLTableElement, HTMLAttributes<HTMLTableElemen
67
76
  ref={scrollRef}
68
77
  data-slot="table-scroll-region"
69
78
  tabIndex={scrollOverflows ? 0 : undefined}
79
+ role={scrollOverflows ? "group" : undefined}
70
80
  aria-label={scrollOverflows ? t("ui.table.scrollRegion") : undefined}
71
81
  onScroll={updateScrollAffordance}
72
82
  className="relative w-full overflow-auto focus-ring-inset"
@@ -138,7 +148,7 @@ export const TableHead = forwardRef<HTMLTableCellElement, ThHTMLAttributes<HTMLT
138
148
  <th
139
149
  ref={ref}
140
150
  className={cn(
141
- "h-10 px-3 text-start align-middle font-medium text-muted-foreground",
151
+ "h-10 px-3 text-start align-middle font-table-header text-muted-foreground",
142
152
  className,
143
153
  )}
144
154
  {...props}
@@ -1 +1,10 @@
1
- export { Tabs, TabsList, TabsTrigger, TabsContent } from "./tabs";
1
+ export {
2
+ Tabs,
3
+ TabsList,
4
+ TabsTrigger,
5
+ TabsContent,
6
+ tabsListVariants,
7
+ tabsTriggerVariants,
8
+ type TabsListProps,
9
+ type TabsVariant,
10
+ } from "./tabs";
@@ -265,3 +265,51 @@ export const ProgrammaticActivation: Story = {
265
265
  await expect(window.scrollY).toBe(0);
266
266
  },
267
267
  };
268
+
269
+ /**
270
+ * `variant="underline"` — line tabs: a transparent row over a 1px rule, the
271
+ * active tab marked by a 2px `--primary` underline and foreground ink. Label
272
+ * weight follows the theme's `--control-weight`. Keyboard behaviour is Radix's,
273
+ * identical to the segmented default.
274
+ */
275
+ export const Underline: Story = {
276
+ render: () => (
277
+ <Tabs defaultValue="overview" className="w-[28rem]">
278
+ <TabsList variant="underline">
279
+ <TabsTrigger value="overview">Overview</TabsTrigger>
280
+ <TabsTrigger value="activity">Activity</TabsTrigger>
281
+ <TabsTrigger value="settings">Settings</TabsTrigger>
282
+ </TabsList>
283
+ <TabsContent value="overview" className="text-body text-muted-foreground">
284
+ Overview panel.
285
+ </TabsContent>
286
+ <TabsContent value="activity" className="text-body text-muted-foreground">
287
+ Activity panel.
288
+ </TabsContent>
289
+ <TabsContent value="settings" className="text-body text-muted-foreground">
290
+ Settings panel.
291
+ </TabsContent>
292
+ </Tabs>
293
+ ),
294
+ play: async ({ canvas, userEvent }) => {
295
+ const overview = canvas.getByRole("tab", { name: "Overview" });
296
+ const activity = canvas.getByRole("tab", { name: "Activity" });
297
+
298
+ // The underline is a 2px border on every trigger (transparent at rest), so
299
+ // activating a tab never changes its box.
300
+ await expect(getComputedStyle(activity).borderBottomWidth).toBe("2px");
301
+ await expect(getComputedStyle(overview).borderBottomColor).not.toBe(
302
+ getComputedStyle(activity).borderBottomColor,
303
+ );
304
+ const restingHeight = activity.getBoundingClientRect().height;
305
+
306
+ await userEvent.click(overview);
307
+ await userEvent.keyboard("{ArrowRight}");
308
+ await expect(activity).toHaveFocus();
309
+ await expect(activity).toHaveAttribute("aria-selected", "true");
310
+ await expect(activity.getBoundingClientRect().height).toBe(restingHeight);
311
+ // The panel mounts on switch and fades in (gated entrance) — wait for it to settle.
312
+ const panel = await canvas.findByText("Activity panel.");
313
+ await waitFor(() => expect(panel).toBeVisible());
314
+ },
315
+ };
@@ -76,6 +76,73 @@ describe("Tabs", () => {
76
76
  // scroll-into-view/320px-container behavior is layout-dependent and is
77
77
  // covered by the `OverflowScrollable` Storybook interaction test instead
78
78
  // (jsdom doesn't lay out real scroll geometry).
79
+ it("creates one shared observer pair per list, not one per trigger (#387)", () => {
80
+ const originalMutationObserver = globalThis.MutationObserver;
81
+ const originalResizeObserver = globalThis.ResizeObserver;
82
+ let mutationObserverCount = 0;
83
+ let resizeObserverCount = 0;
84
+
85
+ class CountingMutationObserver extends originalMutationObserver {
86
+ constructor(...args: ConstructorParameters<typeof MutationObserver>) {
87
+ super(...args);
88
+ mutationObserverCount += 1;
89
+ }
90
+ }
91
+ class CountingResizeObserver extends originalResizeObserver {
92
+ constructor(...args: ConstructorParameters<typeof ResizeObserver>) {
93
+ super(...args);
94
+ resizeObserverCount += 1;
95
+ }
96
+ }
97
+ globalThis.MutationObserver = CountingMutationObserver as typeof MutationObserver;
98
+ globalThis.ResizeObserver = CountingResizeObserver as typeof ResizeObserver;
99
+
100
+ try {
101
+ render(
102
+ <Tabs defaultValue="a">
103
+ <TabsList>
104
+ <TabsTrigger value="a">A</TabsTrigger>
105
+ <TabsTrigger value="b">B</TabsTrigger>
106
+ <TabsTrigger value="c">C</TabsTrigger>
107
+ <TabsTrigger value="d">D</TabsTrigger>
108
+ </TabsList>
109
+ <TabsContent value="a">Panel A</TabsContent>
110
+ </Tabs>,
111
+ );
112
+ // 4 triggers in the strip. Before the fix, each trigger owned its own
113
+ // MutationObserver AND ResizeObserver (4 of each here — the review's
114
+ // "20 callbacks per resize" was measured on a 20-tab strip).
115
+ const fourTriggerMutationCount = mutationObserverCount;
116
+ const fourTriggerResizeCount = resizeObserverCount;
117
+ expect(fourTriggerResizeCount).toBeGreaterThan(0);
118
+
119
+ mutationObserverCount = 0;
120
+ resizeObserverCount = 0;
121
+ render(
122
+ <Tabs defaultValue="a">
123
+ <TabsList>
124
+ <TabsTrigger value="a">A</TabsTrigger>
125
+ <TabsTrigger value="b">B</TabsTrigger>
126
+ <TabsTrigger value="c">C</TabsTrigger>
127
+ <TabsTrigger value="d">D</TabsTrigger>
128
+ <TabsTrigger value="e">E</TabsTrigger>
129
+ <TabsTrigger value="f">F</TabsTrigger>
130
+ <TabsTrigger value="g">G</TabsTrigger>
131
+ <TabsTrigger value="h">H</TabsTrigger>
132
+ </TabsList>
133
+ <TabsContent value="a">Panel A</TabsContent>
134
+ </Tabs>,
135
+ );
136
+ // The list owns a FIXED number of observers regardless of trigger
137
+ // count — doubling the trigger count must not double it.
138
+ expect(mutationObserverCount).toBe(fourTriggerMutationCount);
139
+ expect(resizeObserverCount).toBe(fourTriggerResizeCount);
140
+ } finally {
141
+ globalThis.MutationObserver = originalMutationObserver;
142
+ globalThis.ResizeObserver = originalResizeObserver;
143
+ }
144
+ });
145
+
79
146
  it("renders TabsList as a bounded scroll container with safe centering", () => {
80
147
  render(
81
148
  <Tabs defaultValue="a">
@@ -1,5 +1,9 @@
1
+ "use client";
2
+
1
3
  import {
4
+ createContext,
2
5
  forwardRef,
6
+ use,
3
7
  useEffect,
4
8
  useMemo,
5
9
  useRef,
@@ -7,6 +11,7 @@ import {
7
11
  type ElementRef,
8
12
  } from "react";
9
13
  import * as TabsPrimitive from "@radix-ui/react-tabs";
14
+ import { cva, type VariantProps } from "class-variance-authority";
10
15
  import { useReducedMotion } from "@elabs-ai/components-tokens";
11
16
  import { cn } from "../../lib/cn";
12
17
  import { mergeRefs } from "../../lib/merge-refs";
@@ -68,91 +73,184 @@ function scrollTriggerIntoStrip(trigger: HTMLElement, behavior: ScrollBehavior)
68
73
  * start-alignment on overflow while staying visually identical when the
69
74
  * strip fits (see #344).
70
75
  */
71
- export const TabsList = forwardRef<
72
- ElementRef<typeof TabsPrimitive.List>,
73
- ComponentPropsWithoutRef<typeof TabsPrimitive.List>
74
- >(function TabsList({ className, ...props }, ref) {
75
- return (
76
- <TabsPrimitive.List
77
- ref={ref}
78
- className={cn(
79
- "inline-flex h-9 max-w-full items-center justify-center-safe overflow-x-auto rounded-lg bg-muted p-1 text-muted-foreground",
80
- className,
81
- )}
82
- {...props}
83
- />
84
- );
85
- });
76
+ export const tabsListVariants = cva(
77
+ "max-w-full items-center overflow-x-auto text-muted-foreground",
78
+ {
79
+ variants: {
80
+ variant: {
81
+ // Recessed pill track with a raised active segment.
82
+ segmented: "inline-flex h-9 justify-center-safe rounded-lg bg-muted p-1",
83
+ // Line tabs: a transparent start-aligned row over a 1px rule; the active
84
+ // tab's 2px underline sits on top of it (see `tabsTriggerVariants`).
85
+ underline: "flex w-full justify-start border-b border-rule",
86
+ },
87
+ },
88
+ defaultVariants: { variant: "segmented" },
89
+ },
90
+ );
91
+
92
+ export const tabsTriggerVariants = cva(
93
+ "inline-flex items-center justify-center whitespace-nowrap text-body font-control transition-colors duration-fast disabled:pointer-events-none disabled:opacity-50",
94
+ {
95
+ variants: {
96
+ variant: {
97
+ segmented: cn(
98
+ "rounded-control px-3 py-1",
99
+ // `ring-offset-1`, not `-2`: the compound indicator's reach is
100
+ // `ring-offset` + 2px ring + 1px contour, and `TabsList` is an
101
+ // `overflow-x-auto` strip with `p-1` (4px). At offset 2 the reach is 5px
102
+ // and the scroll box clips the contour on three sides (#67 fix round 2,
103
+ // measured on `patterns-templates-object-detail-hub--default` tab stop
104
+ // 9); at offset 1 it is exactly 4px and the loop closes.
105
+ "focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
106
+ "data-[state=active]:bg-surface-elevated data-[state=active]:text-foreground data-[state=active]:shadow-sm",
107
+ ),
108
+ underline: cn(
109
+ // The underline is a bottom BORDER inside the fixed height, transparent
110
+ // at rest, so activation never shifts layout. No negative margin to
111
+ // overlap the list's rule: the list scrolls (`overflow-x-auto`), which
112
+ // would clip anything hanging past its padding box. The strip has no
113
+ // padding for an outer ring either, so the indicator is drawn inset.
114
+ "h-12 rounded-none border-b-2 border-transparent px-4 hover:text-foreground",
115
+ "focus-ring-inset",
116
+ "data-[state=active]:border-primary data-[state=active]:text-foreground",
117
+ ),
118
+ },
119
+ },
120
+ defaultVariants: { variant: "segmented" },
121
+ },
122
+ );
123
+
124
+ export type TabsVariant = NonNullable<VariantProps<typeof tabsListVariants>["variant"]>;
125
+
126
+ // The list owns the variant; its triggers read it, so a strip never mixes two.
127
+ const TabsVariantContext = createContext<TabsVariant>("segmented");
128
+
129
+ export interface TabsListProps
130
+ extends
131
+ ComponentPropsWithoutRef<typeof TabsPrimitive.List>,
132
+ VariantProps<typeof tabsListVariants> {}
133
+
134
+ export const TabsList = forwardRef<ElementRef<typeof TabsPrimitive.List>, TabsListProps>(
135
+ function TabsList({ className, variant, ...props }, ref) {
136
+ const resolved = variant ?? "segmented";
137
+ const innerRef = useRef<HTMLDivElement | null>(null);
138
+ const mergedRef = useMemo(() => mergeRefs(ref, innerRef), [ref]);
139
+ // Honours the in-app tri-state motion preference on `ThemeProvider` as well
140
+ // as the OS setting, and degrades to OS-only outside a provider.
141
+ const reducedMotion = useReducedMotion();
142
+
143
+ // Keep the tab that BECOMES active inside the strip's visible bounds.
144
+ //
145
+ // ONE set of observers per LIST, not one per trigger (#387 perf finding:
146
+ // N triggers used to mean N independent `MutationObserver`s (each
147
+ // watching only its own node) plus N independent `ResizeObserver`s (each
148
+ // ALSO watching this same shared strip) — a resize of a 20-tab strip fired
149
+ // 20 redundant callbacks. Watching the list's subtree in one callback
150
+ // gets the same behaviour for the cost of one.
151
+ //
152
+ // Watching attributes rather than reacting to a render is still
153
+ // load-bearing: Radix flips `data-state` on the trigger it renders, and
154
+ // only ITS OWN context consumer re-renders — this component does not, so
155
+ // a render-driven effect would never observe an activation at all
156
+ // (measured: 8 effect runs for 8 triggers at mount, zero on any later
157
+ // activation). A MutationObserver fires on a genuine inactive → active
158
+ // TRANSITION and never at mount, which is what stops a freshly-mounted
159
+ // Tabs from scrolling anything (#344 shipped a version that scrolled the
160
+ // page 900px on every mount).
161
+ useEffect(() => {
162
+ const node = innerRef.current;
163
+ if (!node) return;
164
+
165
+ const scrollActiveIntoView = () => {
166
+ const active = node.querySelector<HTMLElement>(
167
+ '[data-slot="tabs-trigger"][data-state="active"]',
168
+ );
169
+ if (active) scrollTriggerIntoStrip(active, reducedMotion ? "auto" : "smooth");
170
+ };
171
+
172
+ const activationObserver = new MutationObserver((mutations) => {
173
+ for (const mutation of mutations) {
174
+ const el = mutation.target as HTMLElement;
175
+ if (el.getAttribute("data-state") === "active") {
176
+ scrollTriggerIntoStrip(el, reducedMotion ? "auto" : "smooth");
177
+ break;
178
+ }
179
+ }
180
+ });
181
+ activationObserver.observe(node, {
182
+ subtree: true,
183
+ attributes: true,
184
+ attributeFilter: ["data-state"],
185
+ });
186
+
187
+ // A relayout AFTER the scroll invalidates the target it was computed
188
+ // from: a webfont swapping in on a cold load (measured — a
189
+ // mono-everything theme's wider mono face left the last tab 45px
190
+ // outside the strip), or the strip itself being resized. One
191
+ // `ResizeObserver` instance covers both — it watches the strip AND
192
+ // every trigger inside it, not a second instance per trigger.
193
+ let resizeObserver: ResizeObserver | undefined;
194
+ if (typeof ResizeObserver === "function") {
195
+ resizeObserver = new ResizeObserver(scrollActiveIntoView);
196
+ resizeObserver.observe(node);
197
+ for (const trigger of node.querySelectorAll<HTMLElement>('[data-slot="tabs-trigger"]')) {
198
+ resizeObserver.observe(trigger);
199
+ }
200
+ }
201
+
202
+ // A dynamic tab list can add/remove triggers later — keep the single
203
+ // `ResizeObserver`'s target set in sync instead of spinning up a new
204
+ // observer per trigger mount.
205
+ const childListObserver = new MutationObserver((mutations) => {
206
+ if (!resizeObserver) return;
207
+ for (const mutation of mutations) {
208
+ mutation.addedNodes.forEach((added) => {
209
+ if (added instanceof HTMLElement && added.matches('[data-slot="tabs-trigger"]')) {
210
+ resizeObserver!.observe(added);
211
+ }
212
+ });
213
+ mutation.removedNodes.forEach((removed) => {
214
+ if (removed instanceof HTMLElement && removed.matches('[data-slot="tabs-trigger"]')) {
215
+ resizeObserver!.unobserve(removed);
216
+ }
217
+ });
218
+ }
219
+ });
220
+ childListObserver.observe(node, { childList: true, subtree: true });
221
+
222
+ return () => {
223
+ activationObserver.disconnect();
224
+ resizeObserver?.disconnect();
225
+ childListObserver.disconnect();
226
+ };
227
+ }, [reducedMotion]);
228
+
229
+ return (
230
+ <TabsVariantContext.Provider value={resolved}>
231
+ <TabsPrimitive.List
232
+ ref={mergedRef}
233
+ data-slot="tabs-list"
234
+ data-variant={resolved}
235
+ className={cn(tabsListVariants({ variant: resolved }), className)}
236
+ {...props}
237
+ />
238
+ </TabsVariantContext.Provider>
239
+ );
240
+ },
241
+ );
86
242
 
87
243
  export const TabsTrigger = forwardRef<
88
244
  ElementRef<typeof TabsPrimitive.Trigger>,
89
245
  ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
90
246
  >(function TabsTrigger({ className, ...props }, ref) {
91
- const innerRef = useRef<HTMLButtonElement>(null);
92
- const mergedRef = useMemo(() => mergeRefs(ref, innerRef), [ref]);
93
- // Honours the in-app tri-state motion preference on `ThemeProvider` as well
94
- // as the OS setting, and degrades to OS-only outside a provider.
95
- const reducedMotion = useReducedMotion();
96
-
97
- // Keep the tab that BECOMES active inside the strip's visible bounds.
98
- //
99
- // Watching the attribute rather than reacting to a render is load-bearing on
100
- // both counts:
101
- // - Radix flips `data-state` on the trigger it renders INSIDE this wrapper,
102
- // and only its own context consumer re-renders — this component does not.
103
- // A render-driven effect therefore never observes an activation at all
104
- // (measured: 8 effect runs for 8 triggers at mount, zero on any later
105
- // activation), so the scroll only ever ran when the CONSUMER's own state
106
- // changed.
107
- // - A MutationObserver fires on a genuine inactive → active TRANSITION and
108
- // never at mount, which is what stops a freshly-mounted Tabs from
109
- // scrolling anything (#344 shipped a version that scrolled the page 900px
110
- // on every mount).
111
- useEffect(() => {
112
- const node = innerRef.current;
113
- if (!node) return;
114
-
115
- const scrollIfActive = () => {
116
- if (node.getAttribute("data-state") !== "active") return;
117
- scrollTriggerIntoStrip(node, reducedMotion ? "auto" : "smooth");
118
- };
119
-
120
- const observer = new MutationObserver(scrollIfActive);
121
- observer.observe(node, { attributes: true, attributeFilter: ["data-state"] });
122
-
123
- // A relayout AFTER the scroll invalidates the target it was computed from:
124
- // a webfont swapping in on a cold load (measured — a mono-everything theme's
125
- // wider mono face left the last tab 45px outside the strip), or the strip
126
- // being resized. Re-measure whenever the tab or the strip changes size;
127
- // the delta check makes every unaffected pass a no-op.
128
- const resizeObserver =
129
- typeof ResizeObserver === "function" ? new ResizeObserver(scrollIfActive) : undefined;
130
- resizeObserver?.observe(node);
131
- const strip = node.closest<HTMLElement>('[role="tablist"]');
132
- if (strip) resizeObserver?.observe(strip);
133
-
134
- return () => {
135
- observer.disconnect();
136
- resizeObserver?.disconnect();
137
- };
138
- }, [reducedMotion]);
247
+ const variant = use(TabsVariantContext);
139
248
 
140
249
  return (
141
250
  <TabsPrimitive.Trigger
142
- ref={mergedRef}
143
- className={cn(
144
- "inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium transition-colors duration-fast",
145
- // `ring-offset-1`, not `-2`: the compound indicator's reach is
146
- // `ring-offset` + 2px ring + 1px contour, and `TabsList` is an
147
- // `overflow-x-auto` strip with `p-1` (4px). At offset 2 the reach is 5px
148
- // and the scroll box clips the contour on three sides (#67 fix round 2,
149
- // measured on `patterns-templates-object-detail-hub--default` tab stop
150
- // 9); at offset 1 it is exactly 4px and the loop closes.
151
- "focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
152
- "disabled:pointer-events-none disabled:opacity-50",
153
- "data-[state=active]:bg-surface-elevated data-[state=active]:text-foreground data-[state=active]:shadow-sm",
154
- className,
155
- )}
251
+ ref={ref}
252
+ data-slot="tabs-trigger"
253
+ className={cn(tabsTriggerVariants({ variant }), className)}
156
254
  {...props}
157
255
  />
158
256
  );
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  // a2ui.exposed: yes
2
4
  import {
3
5
  forwardRef,
@@ -229,7 +231,7 @@ export const TagInput = forwardRef<HTMLDivElement, TagInputProps>(function TagIn
229
231
  aria-invalid={error ? "true" : undefined}
230
232
  data-slot="input-group-control"
231
233
  className={cn(
232
- "min-w-[6rem] flex-1 bg-transparent text-sm outline-none",
234
+ "min-w-[6rem] flex-1 bg-transparent text-body outline-none",
233
235
  "placeholder:text-muted-foreground",
234
236
  "disabled:cursor-not-allowed disabled:opacity-50",
235
237
  )}
@@ -241,7 +243,7 @@ export const TagInput = forwardRef<HTMLDivElement, TagInputProps>(function TagIn
241
243
  </InputGroup>
242
244
  {error && (
243
245
  // #124: running text (the validation message) — ink rung.
244
- <p role="alert" className="text-xs text-destructive-text">
246
+ <p role="alert" className="text-meta text-destructive-text">
245
247
  {error}
246
248
  </p>
247
249
  )}
@@ -82,9 +82,9 @@ export function TeamSwitcher({ teams, logoClassName, className }: TeamSwitcherPr
82
82
  >
83
83
  <Logo className="size-4" />
84
84
  </div>
85
- <div className="grid flex-1 text-start text-sm leading-tight group-data-[collapsible=icon]:hidden">
85
+ <div className="grid flex-1 text-start text-body leading-tight group-data-[collapsible=icon]:hidden">
86
86
  <span className="truncate font-semibold">{activeTeam.name}</span>
87
- <span className="truncate text-xs">{activeTeam.plan}</span>
87
+ <span className="truncate text-meta">{activeTeam.plan}</span>
88
88
  </div>
89
89
  {/* Decorative, so it simply goes. */}
90
90
  <ChevronsUpDown className="ms-auto group-data-[collapsible=icon]:hidden" />
@@ -96,7 +96,7 @@ export function TeamSwitcher({ teams, logoClassName, className }: TeamSwitcherPr
96
96
  side={isMobile ? "bottom" : "right"}
97
97
  sideOffset={4}
98
98
  >
99
- <DropdownMenuLabel className="text-xs text-muted-foreground">
99
+ <DropdownMenuLabel className="text-meta text-muted-foreground">
100
100
  {t("ui.teamSwitcher.label")}
101
101
  </DropdownMenuLabel>
102
102
  {teams.map((team, index) => (
@@ -11,7 +11,7 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function
11
11
  <textarea
12
12
  ref={ref}
13
13
  className={cn(
14
- "flex min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-[color,background-color,border-color,box-shadow] duration-fast ease-standard",
14
+ "flex min-h-20 w-full rounded-control border border-input bg-input-background px-3 py-2 text-body shadow-input transition-[color,background-color,border-color,box-shadow] duration-fast ease-standard",
15
15
  "placeholder:text-muted-foreground",
16
16
  "focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
17
17
  "disabled:cursor-not-allowed disabled:opacity-50 disabled:border-border",
@@ -144,3 +144,79 @@ export const RestrictedProvider: Story = {
144
144
  await waitFor(() => expect(body.queryByRole("menu")).not.toBeInTheDocument());
145
145
  },
146
146
  };
147
+
148
+ /**
149
+ * Theme families (ADR 0036). Once two or more families declare `family`, the
150
+ * switcher offers a **Theme** group and — for a family with both schemes — a
151
+ * separate **Mode** group (Light / Dark / System). `ocean-*` are the
152
+ * downloadable family in repo-root `themes/ocean/`, whose CSS this Storybook
153
+ * imports.
154
+ */
155
+ const oceanThemes = [
156
+ defineTheme({
157
+ value: "ocean-light",
158
+ label: "Ocean Light",
159
+ dark: false,
160
+ family: "ocean",
161
+ familyLabel: "Ocean",
162
+ }),
163
+ defineTheme({ value: "ocean-dark", label: "Ocean Dark", dark: true, family: "ocean" }),
164
+ ];
165
+ const FAMILY_REGISTRY = [...BUILT_IN_THEME_DEFINITIONS, ...oceanThemes];
166
+
167
+ export const WithThemeFamilies: Story = {
168
+ decorators: [
169
+ (Story) => (
170
+ <ThemeProvider themes={FAMILY_REGISTRY} defaultTheme="light" storageKey={null}>
171
+ <div className="flex min-h-32 items-center justify-center">
172
+ <Story />
173
+ </div>
174
+ </ThemeProvider>
175
+ ),
176
+ ],
177
+ play: async ({ canvas, canvasElement, userEvent }) => {
178
+ const root = canvasElement.ownerDocument.documentElement;
179
+ const body = within(canvasElement.ownerDocument.body);
180
+ await userEvent.click(canvas.getByRole("button", { name: "Theme" }));
181
+ await userEvent.click(await body.findByRole("menuitemradio", { name: "Ocean" }));
182
+ await waitFor(() => expect(root).toHaveAttribute("data-theme", "ocean-light"));
183
+ // The closing menu keeps the canvas aria-hidden until it unmounts; reopen only after.
184
+ await waitFor(() => expect(body.queryByRole("menu")).not.toBeInTheDocument());
185
+ await userEvent.click(await canvas.findByRole("button", { name: "Theme" }));
186
+ await userEvent.click(await body.findByRole("menuitemradio", { name: "Dark" }));
187
+ await waitFor(() => expect(root).toHaveAttribute("data-theme", "ocean-dark"));
188
+ await waitFor(() => expect(body.queryByRole("menu")).not.toBeInTheDocument());
189
+ },
190
+ };
191
+
192
+ /**
193
+ * A family that ships only one scheme shows no Mode group. `Dusk` borrows the
194
+ * reference `dark` block as its only variant.
195
+ */
196
+ const duskTheme = defineTheme({
197
+ value: "dark",
198
+ label: "Dusk",
199
+ dark: true,
200
+ family: "dusk",
201
+ familyLabel: "Dusk (dark only)",
202
+ });
203
+
204
+ export const SingleModeFamily: Story = {
205
+ decorators: [
206
+ (Story) => (
207
+ <ThemeProvider themes={[...oceanThemes, duskTheme]} defaultTheme="dark" storageKey={null}>
208
+ <div className="flex min-h-32 items-center justify-center">
209
+ <Story />
210
+ </div>
211
+ </ThemeProvider>
212
+ ),
213
+ ],
214
+ play: async ({ canvas, canvasElement, userEvent }) => {
215
+ const body = within(canvasElement.ownerDocument.body);
216
+ await userEvent.click(canvas.getByRole("button", { name: "Theme" }));
217
+ await expect(await body.findByRole("menuitemradio", { name: /Dusk/ })).toBeChecked();
218
+ await expect(body.queryByRole("menuitemradio", { name: "Light" })).not.toBeInTheDocument();
219
+ await userEvent.keyboard("{Escape}");
220
+ await waitFor(() => expect(body.queryByRole("menu")).not.toBeInTheDocument());
221
+ },
222
+ };