@elabs-ai/components-ui 4.0.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 (339) hide show
  1. package/README.md +10 -8
  2. package/dist/chunk-FCH4ZN6G.js +57 -0
  3. package/dist/chunk-FCH4ZN6G.js.map +1 -0
  4. package/dist/form.d.ts +28 -0
  5. package/dist/form.js +123 -0
  6. package/dist/form.js.map +1 -0
  7. package/dist/index.d.ts +2597 -188
  8. package/dist/index.js +12855 -3578
  9. package/dist/index.js.map +1 -1
  10. package/dist/lib/cn.js +10 -1
  11. package/dist/lib/cn.js.map +1 -1
  12. package/package.json +21 -7
  13. package/src/__contract__/attribution-panel.contract.test.tsx +49 -0
  14. package/src/__contract__/badge.contract.test.tsx +49 -0
  15. package/src/__contract__/bounded-number.contract.test.tsx +49 -0
  16. package/src/__contract__/button.contract.test.tsx +49 -0
  17. package/src/__contract__/color-picker.contract.test.tsx +49 -0
  18. package/src/__contract__/combobox.contract.test.tsx +49 -0
  19. package/src/__contract__/command-trigger.contract.test.tsx +49 -0
  20. package/src/__contract__/copyable-value.contract.test.tsx +49 -0
  21. package/src/__contract__/date-picker.contract.test.tsx +49 -0
  22. package/src/__contract__/date-range-picker.contract.test.tsx +49 -0
  23. package/src/__contract__/empty-state.contract.test.tsx +49 -0
  24. package/src/__contract__/error-state.contract.test.tsx +49 -0
  25. package/src/__contract__/icon-button.contract.test.tsx +49 -0
  26. package/src/__contract__/input.contract.test.tsx +49 -0
  27. package/src/__contract__/key-value-editor.contract.test.tsx +49 -0
  28. package/src/__contract__/list-editor.contract.test.tsx +49 -0
  29. package/src/__contract__/loading-state.contract.test.tsx +49 -0
  30. package/src/__contract__/metric-card.contract.test.tsx +49 -0
  31. package/src/__contract__/model-picker.contract.test.tsx +49 -0
  32. package/src/__contract__/nav-notifications.contract.test.tsx +49 -0
  33. package/src/__contract__/number-input.contract.test.tsx +49 -0
  34. package/src/__contract__/rating.contract.test.tsx +49 -0
  35. package/src/__contract__/schema-form.contract.test.tsx +49 -0
  36. package/src/__contract__/section-header.contract.test.tsx +49 -0
  37. package/src/__contract__/segmented-field.contract.test.tsx +49 -0
  38. package/src/__contract__/slider-number.contract.test.tsx +49 -0
  39. package/src/__contract__/status-badge.contract.test.tsx +49 -0
  40. package/src/__contract__/tag-input.contract.test.tsx +49 -0
  41. package/src/__contract__/text.contract.test.tsx +49 -0
  42. package/src/__contract__/textarea.contract.test.tsx +49 -0
  43. package/src/__contract__/timeline.contract.test.tsx +49 -0
  44. package/src/__contract__/toggle.contract.test.tsx +49 -0
  45. package/src/__contract__/transfer.contract.test.tsx +49 -0
  46. package/src/__contract__/tree-select.contract.test.tsx +49 -0
  47. package/src/__contract__/tree.contract.test.tsx +49 -0
  48. package/src/__contract__/virtual-select.contract.test.tsx +49 -0
  49. package/src/__contract__/workspace-picker.contract.test.tsx +49 -0
  50. package/src/_peer-deps.test.ts +59 -0
  51. package/src/blocks-comparison-table.stories.tsx +2 -2
  52. package/src/components/accordion/accordion.tsx +13 -4
  53. package/src/components/advanced-group/advanced-group.tsx +1 -1
  54. package/src/components/alert/alert.tsx +15 -4
  55. package/src/components/alert-dialog/alert-dialog.test.tsx +32 -0
  56. package/src/components/alert-dialog/alert-dialog.tsx +28 -6
  57. package/src/components/app-shell/app-shell.stories.tsx +44 -2
  58. package/src/components/app-shell/app-shell.test.tsx +99 -0
  59. package/src/components/app-shell/app-shell.tsx +56 -4
  60. package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
  61. package/src/components/attribution-panel/attribution-panel.tsx +3 -1
  62. package/src/components/attribution-panel/attributions.generated.ts +83 -79
  63. package/src/components/avatar/avatar.tsx +4 -1
  64. package/src/components/badge/badge.stories.tsx +8 -0
  65. package/src/components/badge/badge.test.tsx +8 -0
  66. package/src/components/badge/badge.tsx +15 -5
  67. package/src/components/bento-grid/bento-grid.test.tsx +24 -3
  68. package/src/components/bento-grid/bento-grid.tsx +11 -1
  69. package/src/components/bounded-number/bounded-number.test.tsx +1 -1
  70. package/src/components/bounded-number/bounded-number.tsx +78 -75
  71. package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
  72. package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
  73. package/src/components/breadcrumb/breadcrumb.tsx +10 -4
  74. package/src/components/button/button.stories.tsx +116 -8
  75. package/src/components/button/button.tsx +18 -12
  76. package/src/components/button-group/button-group.tsx +1 -1
  77. package/src/components/calendar/calendar.tsx +3 -2
  78. package/src/components/card/card.stories.tsx +11 -6
  79. package/src/components/card/card.test.tsx +24 -2
  80. package/src/components/card/card.tsx +48 -7
  81. package/src/components/carousel/carousel.test.tsx +169 -0
  82. package/src/components/carousel/carousel.tsx +93 -32
  83. package/src/components/change-review/change-review.stories.tsx +145 -1
  84. package/src/components/change-review/change-review.test.tsx +143 -0
  85. package/src/components/change-review/change-review.tsx +214 -24
  86. package/src/components/checkbox/checkbox.tsx +2 -1
  87. package/src/components/collapsible-panel/use-collapsible-panel.ts +0 -3
  88. package/src/components/color-picker/color-picker.tsx +22 -15
  89. package/src/components/combobox/combobox.stories.tsx +10 -0
  90. package/src/components/combobox/combobox.test.tsx +54 -0
  91. package/src/components/combobox/combobox.tsx +25 -11
  92. package/src/components/command/command.stories.tsx +16 -6
  93. package/src/components/command/command.test.tsx +88 -0
  94. package/src/components/command/command.tsx +38 -13
  95. package/src/components/command/index.ts +1 -0
  96. package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
  97. package/src/components/command-trigger/command-trigger.test.tsx +18 -0
  98. package/src/components/command-trigger/command-trigger.tsx +70 -0
  99. package/src/components/command-trigger/index.ts +1 -0
  100. package/src/components/context-menu/context-menu.tsx +16 -7
  101. package/src/components/context-rail/context-rail.stories.tsx +285 -0
  102. package/src/components/context-rail/context-rail.test.tsx +263 -0
  103. package/src/components/context-rail/context-rail.tsx +678 -0
  104. package/src/components/context-rail/index.ts +1 -0
  105. package/src/components/copyable-value/copyable-value.stories.tsx +10 -0
  106. package/src/components/copyable-value/copyable-value.test.tsx +28 -1
  107. package/src/components/copyable-value/copyable-value.tsx +13 -2
  108. package/src/components/date-picker/date-picker.stories.tsx +9 -0
  109. package/src/components/date-picker/date-picker.test.tsx +52 -0
  110. package/src/components/date-picker/date-picker.tsx +13 -15
  111. package/src/components/date-range-picker/date-range-picker.stories.tsx +9 -0
  112. package/src/components/date-range-picker/date-range-picker.test.tsx +76 -1
  113. package/src/components/date-range-picker/date-range-picker.tsx +39 -28
  114. package/src/components/descriptions/descriptions.tsx +1 -1
  115. package/src/components/dialog/dialog.test.tsx +8 -3
  116. package/src/components/dialog/dialog.tsx +14 -6
  117. package/src/components/drawer/drawer.tsx +23 -6
  118. package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
  119. package/src/components/dropdown-menu/dropdown-menu.tsx +4 -4
  120. package/src/components/empty-state/empty-state.test.tsx +26 -0
  121. package/src/components/empty-state/empty-state.tsx +6 -1
  122. package/src/components/expand-dialog/expand-dialog.tsx +1 -2
  123. package/src/components/field/field-context.ts +59 -0
  124. package/src/components/field/field.stories.tsx +142 -0
  125. package/src/components/field/field.test.tsx +415 -0
  126. package/src/components/field/field.tsx +342 -0
  127. package/src/components/field/index.ts +9 -0
  128. package/src/components/field-row/field-row.stories.tsx +85 -1
  129. package/src/components/field-row/field-row.test.tsx +145 -0
  130. package/src/components/field-row/field-row.tsx +50 -6
  131. package/src/components/file-upload/file-upload.stories.tsx +16 -0
  132. package/src/components/file-upload/file-upload.test.tsx +90 -12
  133. package/src/components/file-upload/file-upload.tsx +164 -31
  134. package/src/components/form/form.tsx +8 -3
  135. package/src/components/hover-card/hover-card.tsx +1 -0
  136. package/src/components/icon-button/icon-button.test.tsx +1 -1
  137. package/src/components/icon-button/icon-button.tsx +3 -3
  138. package/src/components/input/input.stories.tsx +121 -0
  139. package/src/components/input/input.test.tsx +13 -0
  140. package/src/components/input/input.tsx +8 -4
  141. package/src/components/input-group/input-group.stories.tsx +74 -0
  142. package/src/components/input-group/input-group.tsx +18 -9
  143. package/src/components/input-otp/input-otp.tsx +8 -2
  144. package/src/components/keyboard-shortcuts/index.ts +6 -0
  145. package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
  146. package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
  147. package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
  148. package/src/components/label/label.tsx +1 -1
  149. package/src/components/link-preview/link-preview.tsx +1 -1
  150. package/src/components/list-editor/list-editor.test.tsx +24 -0
  151. package/src/components/list-editor/list-editor.tsx +126 -52
  152. package/src/components/locale-provider/index.ts +6 -1
  153. package/src/components/locale-provider/locale-keys.test.ts +134 -0
  154. package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
  155. package/src/components/locale-provider/locale-provider.test.tsx +257 -1
  156. package/src/components/locale-provider/locale-provider.tsx +63 -8
  157. package/src/components/locale-provider/messages.ts +822 -2
  158. package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
  159. package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
  160. package/src/components/mention-input/mention-input.stories.tsx +3 -3
  161. package/src/components/mention-input/mention-input.test.tsx +51 -1
  162. package/src/components/mention-input/mention-input.tsx +66 -46
  163. package/src/components/mention-input/mention-value.ts +8 -2
  164. package/src/components/menubar/menubar.tsx +16 -6
  165. package/src/components/metric-card/metric-card.stories.tsx +5 -3
  166. package/src/components/metric-card/metric-card.tsx +21 -2
  167. package/src/components/model-picker/model-picker.stories.tsx +63 -3
  168. package/src/components/model-picker/model-picker.test.tsx +63 -0
  169. package/src/components/model-picker/model-picker.tsx +62 -36
  170. package/src/components/nav-main/nav-main.tsx +8 -4
  171. package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
  172. package/src/components/nav-notifications/nav-notifications.test.tsx +11 -0
  173. package/src/components/nav-notifications/nav-notifications.tsx +67 -9
  174. package/src/components/nav-user/nav-user.tsx +5 -5
  175. package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
  176. package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
  177. package/src/components/navigation-menu/navigation-menu.tsx +204 -15
  178. package/src/components/number-input/number-input.stories.tsx +7 -3
  179. package/src/components/number-input/number-input.test.tsx +47 -0
  180. package/src/components/number-input/number-input.tsx +65 -24
  181. package/src/components/page-shell/page-shell.stories.tsx +163 -0
  182. package/src/components/page-shell/page-shell.test.tsx +229 -0
  183. package/src/components/page-shell/page-shell.tsx +108 -25
  184. package/src/components/pagination/pagination.test.tsx +109 -0
  185. package/src/components/pagination/pagination.tsx +27 -19
  186. package/src/components/popover/popover.tsx +1 -0
  187. package/src/components/progress/progress.tsx +2 -0
  188. package/src/components/radio-group/radio-group.tsx +1 -1
  189. package/src/components/rating/rating.tsx +3 -1
  190. package/src/components/resizable/resizable.stories.tsx +114 -2
  191. package/src/components/resizable/resizable.test.tsx +76 -0
  192. package/src/components/resizable/resizable.tsx +32 -6
  193. package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
  194. package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
  195. package/src/components/revision-timeline/revision-timeline.tsx +53 -5
  196. package/src/components/schema-form/from-json-schema.test.ts +390 -0
  197. package/src/components/schema-form/from-json-schema.ts +375 -0
  198. package/src/components/schema-form/index.ts +81 -0
  199. package/src/components/schema-form/schema-form-spec.ts +698 -0
  200. package/src/components/schema-form/schema-form-store.ts +506 -0
  201. package/src/components/schema-form/schema-form.stories.tsx +566 -0
  202. package/src/components/schema-form/schema-form.test.tsx +1021 -0
  203. package/src/components/schema-form/schema-form.tsx +1381 -0
  204. package/src/components/section-header/section-header.stories.tsx +37 -0
  205. package/src/components/section-header/section-header.tsx +14 -3
  206. package/src/components/select/select.tsx +13 -7
  207. package/src/components/separator/separator.tsx +1 -0
  208. package/src/components/sheet/sheet.test.tsx +39 -0
  209. package/src/components/sheet/sheet.tsx +40 -9
  210. package/src/components/side-dock/index.ts +1 -0
  211. package/src/components/side-dock/side-dock.stories.tsx +137 -0
  212. package/src/components/side-dock/side-dock.test.tsx +299 -0
  213. package/src/components/side-dock/side-dock.tsx +515 -0
  214. package/src/components/sidebar/index.ts +1 -0
  215. package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
  216. package/src/components/sidebar/sidebar.stories.tsx +421 -1
  217. package/src/components/sidebar/sidebar.test.tsx +71 -2
  218. package/src/components/sidebar/sidebar.tsx +264 -38
  219. package/src/components/skeleton/skeleton.tsx +1 -0
  220. package/src/components/skip-link/index.ts +1 -0
  221. package/src/components/skip-link/skip-link.stories.tsx +106 -0
  222. package/src/components/skip-link/skip-link.test.tsx +20 -0
  223. package/src/components/skip-link/skip-link.tsx +34 -0
  224. package/src/components/slider/slider.tsx +1 -1
  225. package/src/components/slider-number/slider-number.stories.tsx +4 -1
  226. package/src/components/slider-number/slider-number.test.tsx +1 -1
  227. package/src/components/sonner/sonner.tsx +2 -0
  228. package/src/components/spinner/spinner.test.tsx +18 -0
  229. package/src/components/spinner/spinner.tsx +14 -4
  230. package/src/components/split-panel/index.ts +6 -1
  231. package/src/components/split-panel/split-panel.stories.tsx +18 -2
  232. package/src/components/split-panel/split-panel.test.tsx +20 -0
  233. package/src/components/split-panel/split-panel.tsx +28 -8
  234. package/src/components/state-panel/state-panel.stories.tsx +52 -0
  235. package/src/components/state-panel/state-panel.test.tsx +125 -0
  236. package/src/components/state-panel/state-panel.tsx +110 -24
  237. package/src/components/status-badge/status-badge.stories.tsx +8 -0
  238. package/src/components/switch/switch.tsx +3 -1
  239. package/src/components/table/table.stories.tsx +64 -0
  240. package/src/components/table/table.test.tsx +149 -1
  241. package/src/components/table/table.tsx +71 -2
  242. package/src/components/tabs/index.ts +10 -1
  243. package/src/components/tabs/tabs.stories.tsx +48 -0
  244. package/src/components/tabs/tabs.test.tsx +67 -0
  245. package/src/components/tabs/tabs.tsx +176 -72
  246. package/src/components/tag-input/tag-input.tsx +6 -3
  247. package/src/components/team-switcher/team-switcher.tsx +35 -6
  248. package/src/components/textarea/textarea.tsx +2 -2
  249. package/src/components/theme-switcher/theme-switcher.stories.tsx +76 -0
  250. package/src/components/theme-switcher/theme-switcher.test.tsx +182 -0
  251. package/src/components/theme-switcher/theme-switcher.tsx +140 -12
  252. package/src/components/theme-switcher/use-theme-transition.ts +2 -2
  253. package/src/components/timeline/timeline.stories.tsx +27 -0
  254. package/src/components/timeline/timeline.tsx +10 -3
  255. package/src/components/toggle/toggle.tsx +7 -2
  256. package/src/components/toggle-group/toggle-group.tsx +2 -0
  257. package/src/components/toolbar/toolbar.stories.tsx +10 -2
  258. package/src/components/tooltip/tooltip.tsx +2 -1
  259. package/src/components/top-nav/top-nav.tsx +1 -1
  260. package/src/components/transfer/transfer.tsx +35 -30
  261. package/src/components/tree/tree.stories.tsx +2 -2
  262. package/src/components/tree/tree.test.tsx +41 -0
  263. package/src/components/tree/tree.tsx +91 -31
  264. package/src/components/tree/use-tree-keyboard.ts +37 -14
  265. package/src/components/tree-select/tree-select.stories.tsx +9 -0
  266. package/src/components/tree-select/tree-select.test.tsx +5 -0
  267. package/src/components/tree-select/tree-select.tsx +11 -14
  268. package/src/components/typography/prose.test.tsx +3 -2
  269. package/src/components/typography/prose.tsx +3 -3
  270. package/src/components/typography/typography.stories.tsx +228 -0
  271. package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
  272. package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
  273. package/src/components/view-toolbar/view-toolbar.tsx +37 -4
  274. package/src/components/virtual-select/virtual-select.stories.tsx +9 -0
  275. package/src/components/virtual-select/virtual-select.test.tsx +29 -3
  276. package/src/components/virtual-select/virtual-select.tsx +56 -18
  277. package/src/components/wizard/wizard.test.tsx +9 -0
  278. package/src/components/wizard/wizard.tsx +13 -5
  279. package/src/components/workspace-picker/index.ts +3 -0
  280. package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
  281. package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
  282. package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
  283. package/src/components/workspace-picker/workspace-picker.tsx +172 -0
  284. package/src/illustrations/empty-list-illustration.tsx +26 -0
  285. package/src/illustrations/error-illustration.tsx +60 -0
  286. package/src/illustrations/first-run-illustration.tsx +27 -0
  287. package/src/illustrations/illustration-base.tsx +108 -0
  288. package/src/illustrations/illustrations.stories.tsx +94 -0
  289. package/src/illustrations/illustrations.test.tsx +98 -0
  290. package/src/illustrations/index.ts +16 -0
  291. package/src/illustrations/no-access-illustration.tsx +45 -0
  292. package/src/illustrations/no-results-illustration.tsx +46 -0
  293. package/src/illustrations/offline-illustration.tsx +37 -0
  294. package/src/illustrations/success-illustration.tsx +40 -0
  295. package/src/index.ts +80 -2
  296. package/src/lib/agent-event-model.test.ts +12 -0
  297. package/src/lib/agent-event-model.ts +42 -0
  298. package/src/lib/approval-option.test.ts +17 -0
  299. package/src/lib/approval-option.ts +53 -0
  300. package/src/lib/check-result.ts +33 -0
  301. package/src/lib/cn.ts +9 -0
  302. package/src/lib/csv.ts +48 -0
  303. package/src/lib/diff-line.test.ts +36 -0
  304. package/src/lib/diff-line.ts +63 -0
  305. package/src/lib/diff-rows.test.ts +76 -0
  306. package/src/lib/diff-rows.ts +94 -0
  307. package/src/lib/format-duration.test.ts +22 -0
  308. package/src/lib/format-duration.ts +30 -0
  309. package/src/lib/has-renderable-content.test.ts +49 -0
  310. package/src/lib/has-renderable-content.ts +32 -0
  311. package/src/lib/merge-refs.ts +2 -0
  312. package/src/lib/operating-mode.test.ts +36 -0
  313. package/src/lib/operating-mode.ts +81 -0
  314. package/src/lib/optional-peer.ts +59 -0
  315. package/src/lib/session-launch.ts +37 -0
  316. package/src/lib/slash-command.test.ts +44 -0
  317. package/src/lib/slash-command.ts +45 -0
  318. package/src/lib/trigger-query.test.ts +94 -0
  319. package/src/lib/trigger-query.ts +88 -0
  320. package/src/lib/use-controllable-state.ts +47 -0
  321. package/src/lib/use-mobile.test.tsx +47 -0
  322. package/src/lib/use-mobile.ts +34 -12
  323. package/src/motion.stories.tsx +6 -0
  324. package/src/templates-object-detail-hub.stories.tsx +2 -2
  325. package/src/templates-screen-states.stories.tsx +5 -5
  326. package/src/templates-settings.stories.tsx +1 -1
  327. package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
  328. package/src/blocks/sidebar-02/logo.tsx +0 -18
  329. package/src/blocks/sidebar-02/nav-main.tsx +0 -13
  330. package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
  331. package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
  332. package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
  333. package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
  334. package/src/blocks/sidebar-04/mail-context.tsx +0 -29
  335. package/src/blocks/sidebar-04/nav-user.tsx +0 -6
  336. package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
  337. package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
  338. package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
  339. package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
@@ -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,85 +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
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
146
- "disabled:pointer-events-none disabled:opacity-50",
147
- "data-[state=active]:bg-surface-elevated data-[state=active]:text-foreground data-[state=active]:shadow-sm",
148
- className,
149
- )}
251
+ ref={ref}
252
+ data-slot="tabs-trigger"
253
+ className={cn(tabsTriggerVariants({ variant }), className)}
150
254
  {...props}
151
255
  />
152
256
  );
@@ -160,7 +264,7 @@ export const TabsContent = forwardRef<
160
264
  <TabsPrimitive.Content
161
265
  ref={ref}
162
266
  className={cn(
163
- "mt-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
267
+ "mt-2 focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
164
268
  "data-[state=active]:animate-in data-[state=active]:fade-in-0 data-[state=active]:slide-in-from-bottom-1 data-[state=active]:[--tw-ease:var(--ease-entrance)]",
165
269
  className,
166
270
  )}
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  // a2ui.exposed: yes
2
4
  import {
3
5
  forwardRef,
@@ -208,7 +210,7 @@ export const TagInput = forwardRef<HTMLDivElement, TagInputProps>(function TagIn
208
210
  }}
209
211
  className={cn(
210
212
  "rounded-full p-0.5 transition-colors",
211
- "hover:bg-secondary-foreground/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
213
+ "hover:bg-secondary-foreground/20 focus-ring",
212
214
  "disabled:pointer-events-none disabled:opacity-50",
213
215
  )}
214
216
  >
@@ -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
  )}
@@ -240,7 +242,8 @@ export const TagInput = forwardRef<HTMLDivElement, TagInputProps>(function TagIn
240
242
  />
241
243
  </InputGroup>
242
244
  {error && (
243
- <p role="alert" className="text-xs text-destructive">
245
+ // #124: running text (the validation message) — ink rung.
246
+ <p role="alert" className="text-meta text-destructive-text">
244
247
  {error}
245
248
  </p>
246
249
  )}
@@ -12,6 +12,7 @@ import {
12
12
  DropdownMenuTrigger,
13
13
  } from "../dropdown-menu";
14
14
  import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from "../sidebar";
15
+ import { useLocale } from "../locale-provider";
15
16
  import { cn } from "../../lib/cn";
16
17
 
17
18
  export type TeamSwitcherTeam = {
@@ -37,6 +38,7 @@ export interface TeamSwitcherProps {
37
38
  */
38
39
  export function TeamSwitcher({ teams, logoClassName, className }: TeamSwitcherProps) {
39
40
  const { isMobile } = useSidebar();
41
+ const { t } = useLocale();
40
42
  const [activeTeam, setActiveTeam] = React.useState(teams[0]);
41
43
  if (!activeTeam) return null;
42
44
  const Logo = activeTeam.logo;
@@ -48,21 +50,44 @@ export function TeamSwitcher({ teams, logoClassName, className }: TeamSwitcherPr
48
50
  <DropdownMenuTrigger asChild>
49
51
  <SidebarMenuButton
50
52
  size="lg"
53
+ // The name is written out rather than left to the button's own
54
+ // contents, because the contents cannot carry it in every state:
55
+ // collapsed to the 48px rail the trigger is one glyph, and the
56
+ // label beside it is either gone or clipped out of view by the
57
+ // button's `overflow-hidden` — either way axe reads a nameless
58
+ // button (`button-name`, critical). The string is exactly the
59
+ // visible label, in reading order, so WCAG 2.5.3 (label in name)
60
+ // holds expanded, where that label IS on screen. Verified by
61
+ // deleting this line and watching three dashboard stories fail
62
+ // `button-name` (critical).
63
+ aria-label={`${activeTeam.name} ${activeTeam.plan}`}
51
64
  className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
52
65
  >
66
+ {/* Collapsed, this row is ONE ICON in a 48px rail and has to sit
67
+ on the same centre line as every nav entry above and below it.
68
+ Measured before this fix: the 32px plate did not fit the
69
+ button's 16px content box, so it overflowed (clipped by the
70
+ button's own `overflow-hidden`) and its glyph landed 8px to the
71
+ trailing side of every other icon in the rail. So the plate
72
+ becomes the glyph's own 16px box — no fill, no radius, nothing
73
+ to overflow — and the label and chevron stand down: at 48px
74
+ there is no room to read them, and they are what crowds the
75
+ plate. Expanded, all three come back unchanged. */}
53
76
  <div
54
77
  className={cn(
55
- "flex aspect-square size-8 items-center justify-center rounded-lg",
78
+ "flex aspect-square size-8 shrink-0 items-center justify-center rounded-lg",
56
79
  logoClassName ?? "bg-sidebar-accent text-sidebar-accent-foreground",
80
+ "group-data-[collapsible=icon]:size-4 group-data-[collapsible=icon]:rounded-none group-data-[collapsible=icon]:bg-transparent group-data-[collapsible=icon]:text-sidebar-foreground",
57
81
  )}
58
82
  >
59
83
  <Logo className="size-4" />
60
84
  </div>
61
- <div className="grid flex-1 text-start text-sm leading-tight">
85
+ <div className="grid flex-1 text-start text-body leading-tight group-data-[collapsible=icon]:hidden">
62
86
  <span className="truncate font-semibold">{activeTeam.name}</span>
63
- <span className="truncate text-xs">{activeTeam.plan}</span>
87
+ <span className="truncate text-meta">{activeTeam.plan}</span>
64
88
  </div>
65
- <ChevronsUpDown className="ms-auto" />
89
+ {/* Decorative, so it simply goes. */}
90
+ <ChevronsUpDown className="ms-auto group-data-[collapsible=icon]:hidden" />
66
91
  </SidebarMenuButton>
67
92
  </DropdownMenuTrigger>
68
93
  <DropdownMenuContent
@@ -71,7 +96,9 @@ export function TeamSwitcher({ teams, logoClassName, className }: TeamSwitcherPr
71
96
  side={isMobile ? "bottom" : "right"}
72
97
  sideOffset={4}
73
98
  >
74
- <DropdownMenuLabel className="text-xs text-muted-foreground">Teams</DropdownMenuLabel>
99
+ <DropdownMenuLabel className="text-meta text-muted-foreground">
100
+ {t("ui.teamSwitcher.label")}
101
+ </DropdownMenuLabel>
75
102
  {teams.map((team, index) => (
76
103
  <DropdownMenuItem
77
104
  key={team.name}
@@ -90,7 +117,9 @@ export function TeamSwitcher({ teams, logoClassName, className }: TeamSwitcherPr
90
117
  <div className="flex size-6 items-center justify-center rounded-md border bg-transparent">
91
118
  <Plus className="size-4" />
92
119
  </div>
93
- <div className="font-medium text-muted-foreground">Add team</div>
120
+ <div className="font-medium text-muted-foreground">
121
+ {t("ui.teamSwitcher.addTeam")}
122
+ </div>
94
123
  </DropdownMenuItem>
95
124
  </DropdownMenuContent>
96
125
  </DropdownMenu>
@@ -11,9 +11,9 @@ 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
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
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",
18
18
  "aria-[invalid=true]:border-destructive aria-[invalid=true]:ring-destructive",
19
19
  className,
@@ -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
+ };