@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
@@ -1,7 +1,11 @@
1
- import { type ReactNode } from "react";
1
+ "use client";
2
+
3
+ import { type CSSProperties, type ReactNode } from "react";
2
4
  import { CircleAlert } from "lucide-react";
3
5
  import { cva, type VariantProps } from "class-variance-authority";
4
6
  import { cn } from "../../lib/cn";
7
+ import { ILLUSTRATION_ACCENT_VAR } from "../../illustrations/illustration-base";
8
+ import { useLocale } from "../locale-provider";
5
9
 
6
10
  // ─── Variants ────────────────────────────────────────────────────────────────
7
11
 
@@ -14,11 +18,22 @@ export const statePanelVariants = cva(
14
18
  variants: {
15
19
  kind: {
16
20
  empty: "border border-dashed bg-surface",
17
- // error: primary cue is color (destructive bg/border); structural cue
18
- // (border-s-4 + border-border-strong top strip) survives monochrome /
19
- // a monochrome palette so the panel is distinguishable from empty/loading even
20
- // without hue. border-border-strong satisfies WCAG 1.4.11 non-text contrast.
21
- error: "border border-destructive/30 bg-destructive/5 border-s-4 border-s-border-strong",
21
+ // error: the non-color, monochrome-survivable cue is the rail's WIDTH
22
+ // (border-s-4, 4px, vs the 1px hairline on the other three edges) —
23
+ // not its hue (per styling-and-tokens.md's border/border-strong
24
+ // decision test: "if I deleted this line, could a sighted user still
25
+ // tell the two regions apart?" here it's the thickness that answers
26
+ // yes). So the rail's COLOR should stay in the destructive family
27
+ // like the rest of the panel, not fall back to the neutral
28
+ // `border-strong` rung — a 4px grey rail beside three red hairlines
29
+ // read as a CSS-specificity bug, not a deliberate accent (#71).
30
+ // `border-s-destructive` is the FILL rung (styling-and-tokens.md
31
+ // "which status rung a graphical MARK reaches for") — guaranteed
32
+ // >=3:1 against --card/--background in every theme, so it still
33
+ // satisfies the WCAG 1.4.11 non-text-contrast guarantee the old
34
+ // `border-border-strong` comment claimed. The icon (:87-91) and the
35
+ // eyebrow (:148-152 below) are the third and fourth non-color cues.
36
+ error: "border border-destructive/30 bg-destructive/5 border-s-4 border-s-destructive",
22
37
  loading: "",
23
38
  },
24
39
  },
@@ -41,11 +56,29 @@ export interface StatePanelProps extends VariantProps<typeof statePanelVariants>
41
56
  /** Supporting description below the title. */
42
57
  description?: ReactNode;
43
58
  /**
44
- * Icon or illustration shown above the title.
45
- * For `"error"` defaults to a built-in warning triangle.
46
- * For `"loading"` defaults to an animated spinner.
59
+ * Icon shown above the title, clamped to a uniform 40×40 so mismatched
60
+ * glyphs stay tidy. For `"error"` defaults to a built-in warning triangle.
61
+ * For `"loading"` defaults to an animated spinner. Ignored when
62
+ * `illustration` is also provided — see `illustration`.
47
63
  */
48
64
  icon?: ReactNode;
65
+ /**
66
+ * A larger illustration (one of `@elabs-ai/components-ui`'s shipped
67
+ * `*Illustration` components, or a consumer's own `ReactNode`) shown above
68
+ * the title instead of `icon`. Unlike `icon`, it is **not** clamped to
69
+ * 40×40 — an illustration governs its own size (see each illustration's
70
+ * `size` prop, `rem`-based, ~64px–160px). Takes precedence over `icon`
71
+ * when both are given.
72
+ */
73
+ illustration?: ReactNode;
74
+ /**
75
+ * Heading level for the panel's title (#328 convention). Set this to the
76
+ * level that correctly follows whatever heading precedes this panel in
77
+ * the document outline — e.g. `"h3"` inside a `ChartCard` whose own title
78
+ * is an `<h2>`.
79
+ * @default "h3"
80
+ */
81
+ titleAs?: "div" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
49
82
  /** Accessible label for the spinner in loading state. @default "Loading…" */
50
83
  loadingLabel?: string;
51
84
  /** Spinner size (loading kind only). @default "md" */
@@ -91,13 +124,17 @@ export function StatePanel({
91
124
  title,
92
125
  description,
93
126
  icon,
94
- loadingLabel = "Loading…",
127
+ illustration,
128
+ titleAs: Tag = "h3",
129
+ loadingLabel,
95
130
  size = "md",
96
131
  actions,
97
132
  className,
98
133
  }: StatePanelProps) {
134
+ const { t } = useLocale();
99
135
  const isLoading = kind === "loading";
100
136
  const isError = kind === "error";
137
+ const resolvedLoadingLabel = loadingLabel ?? t("loading");
101
138
 
102
139
  // Role: loading → status + live region; error → alert; empty → (none needed).
103
140
  const roleProps = isLoading
@@ -107,9 +144,9 @@ export function StatePanel({
107
144
  : {};
108
145
 
109
146
  // Default title / description fallbacks for error state (mirroring old ErrorState).
110
- const resolvedTitle = title ?? (isError ? "Something went wrong" : undefined);
147
+ const resolvedTitle = title ?? (isError ? t("ui.statePanel.errorTitle") : undefined);
111
148
  const resolvedDescription =
112
- description ?? (isError ? "An unexpected error occurred. Please try again." : undefined);
149
+ description ?? (isError ? t("ui.statePanel.errorDescription") : undefined);
113
150
 
114
151
  // Resolve displayed icon.
115
152
  let resolvedIcon: ReactNode;
@@ -124,17 +161,48 @@ export function StatePanel({
124
161
  return (
125
162
  <div data-kind={kind} className={cn(statePanelVariants({ kind }), className)} {...roleProps}>
126
163
  {/* Error eyebrow: "Error" label shown above the icon so the panel reads
127
- as an error structurally even in monochrome themes. The
128
- eyebrow uses text-destructive which resolves to a drawable
129
- foreground at high decorationcombined with the thick left border from
130
- statePanelVariants it gives two non-color structural cues. */}
164
+ as an error structurally even in monochrome themes. This is running
165
+ TEXT, not a mark, so it takes the ink rung `text-destructive-text`
166
+ (>= 4.5:1, gated in themes-contrast.test.ts) not the fill rung
167
+ `text-destructive`, whose contract is only the 3:1 mark bar (see
168
+ "Which status rung a graphical MARK reaches for", styling-and-tokens.md
169
+ #381). Combined with the thick left border from statePanelVariants it
170
+ gives two non-color structural cues. The icon below deliberately
171
+ KEEPS text-destructive: an icon is a mark, so the fill rung is
172
+ correct there — do not "tidy" it onto -text too. */}
131
173
  {isError && (
132
- <span className="text-xs font-semibold uppercase tracking-widest text-destructive">
133
- Error
174
+ <span className="text-meta font-semibold uppercase tracking-widest text-destructive-text">
175
+ {t("ui.statePanel.errorEyebrow")}
134
176
  </span>
135
177
  )}
136
178
 
137
- {resolvedIcon ? (
179
+ {illustration ? (
180
+ // Illustration wins over `icon` when both are given. No `[&_svg]:size-10`
181
+ // clamp here — an illustration governs its own (rem-based) size, unlike
182
+ // the fixed-size icon slot below. For `kind="error"` this also sets the
183
+ // `--illustration-accent` custom property every illustration's accent
184
+ // ink reads through, so ANY illustration (not just `ErrorIllustration`)
185
+ // retints its accent to `--destructive-text` instead of staying pinned
186
+ // to its default hue inside a red-tinted slot (#24 fix round 1, P0-2).
187
+ // TEXT rung, not the FILL token below: the wrapper's `text-destructive`
188
+ // class already puts the SUBJECT (silhouette) on the fill rung, so an
189
+ // accent override reading the same `--destructive` token collapsed
190
+ // onto it and disappeared (#48 finding 2) — `--destructive-text` is a
191
+ // deliberately distinct, deeper rung (matches the fallback every
192
+ // other illustration's own accent already uses: `--primary-text`,
193
+ // `--success-text`) so the accent stays visible against the retinted
194
+ // silhouette.
195
+ <div
196
+ className={cn(isError ? "text-destructive" : "text-muted-foreground")}
197
+ style={
198
+ isError
199
+ ? ({ [ILLUSTRATION_ACCENT_VAR]: "var(--destructive-text)" } as CSSProperties)
200
+ : undefined
201
+ }
202
+ >
203
+ {illustration}
204
+ </div>
205
+ ) : resolvedIcon ? (
138
206
  // Error icon keeps text-destructive (not muted) so it stays visible even
139
207
  // when rendered at low contrast. In monochrome themes the icon shape
140
208
  // (CircleAlert) carries the cue; the color is supplemental.
@@ -146,19 +214,37 @@ export function StatePanel({
146
214
  ) : null}
147
215
 
148
216
  {(resolvedTitle || resolvedDescription) && (
149
- <div className="space-y-1">
217
+ // A 112px+ illustration (default `size="7rem"`) needs a heavier title
218
+ // to group with, and more air between the artwork and the copy than
219
+ // between the title and its own description — otherwise the caption
220
+ // reads as an afterthought under the artwork instead of one grouped
221
+ // unit (#47). `mt-2` (on top of the root's `gap-3`) only fires on the
222
+ // illustration path; the plain `icon` path (a fixed 40x40 glyph) is
223
+ // untouched. The title/description gap stays the tight `space-y-1`
224
+ // either way — that's the "one visually distinct unit" the two lines
225
+ // read as.
226
+ <div className={cn("space-y-1", illustration && "mt-2")}>
150
227
  {resolvedTitle && (
151
- <h3 className="text-sm font-semibold text-foreground">{resolvedTitle}</h3>
228
+ <Tag
229
+ className={cn(
230
+ "font-semibold text-foreground",
231
+ illustration ? "text-subtitle" : "text-body",
232
+ )}
233
+ >
234
+ {resolvedTitle}
235
+ </Tag>
152
236
  )}
153
237
  {resolvedDescription && (
154
- <p className="mx-auto max-w-sm text-sm text-muted-foreground">{resolvedDescription}</p>
238
+ <p className="mx-auto max-w-sm text-body text-muted-foreground">
239
+ {resolvedDescription}
240
+ </p>
155
241
  )}
156
242
  </div>
157
243
  )}
158
244
 
159
245
  {/* Loading label (visually shown as supporting text; also the live-region content). */}
160
- {isLoading && loadingLabel && (
161
- <span className="text-sm text-muted-foreground">{loadingLabel}</span>
246
+ {isLoading && resolvedLoadingLabel && (
247
+ <span className="text-body text-muted-foreground">{resolvedLoadingLabel}</span>
162
248
  )}
163
249
 
164
250
  {actions ? <div className="mt-1 flex items-center gap-2">{actions}</div> : null}
@@ -6,6 +6,14 @@ const meta = {
6
6
  title: "Core/StatusBadge",
7
7
  component: StatusBadge,
8
8
  tags: ["autodocs"],
9
+ parameters: {
10
+ docs: {
11
+ description: {
12
+ component:
13
+ "The closed seven-state execution-status vocabulary, each state carrying its own icon as well as its own tone so two states are still tellable apart in greyscale. A neutral label or count with no state semantics is `Core/Badge`; see [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs).",
14
+ },
15
+ },
16
+ },
9
17
  args: { status: "complete" },
10
18
  argTypes: {
11
19
  status: {
@@ -9,9 +9,10 @@ export const Switch = forwardRef<
9
9
  return (
10
10
  <SwitchPrimitive.Root
11
11
  ref={ref}
12
+ data-slot="switch"
12
13
  className={cn(
13
14
  "peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors duration-fast ease-standard",
14
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
15
+ "focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
15
16
  "disabled:cursor-not-allowed disabled:opacity-50",
16
17
  "data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
17
18
  className,
@@ -19,6 +20,7 @@ export const Switch = forwardRef<
19
20
  {...props}
20
21
  >
21
22
  <SwitchPrimitive.Thumb
23
+ data-slot="switch-thumb"
22
24
  className={cn(
23
25
  "pointer-events-none block size-4 rounded-full bg-background shadow-xs ring-0 transition-transform duration-fast ease-standard",
24
26
  "data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
@@ -1,4 +1,5 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, waitFor } from "storybook/test";
2
3
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./table";
3
4
  const meta = {
4
5
  title: "Data/Table",
@@ -8,6 +9,9 @@ const meta = {
8
9
  docs: {
9
10
  description: {
10
11
  component:
12
+ "The STATIC table markup you lay out yourself; the interactive grid is " +
13
+ "`Data/DataTable` — see " +
14
+ "[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). " +
11
15
  "Low-level static table primitives (header/body/row/cell styling). For sorting, filtering, " +
12
16
  "pagination, virtualization, row selection and column management, use the TanStack-powered " +
13
17
  "DataTable (see Data/DataTable, @elabs-ai/components-data) instead.",
@@ -51,3 +55,63 @@ export const Default: Story = {
51
55
  </Table>
52
56
  ),
53
57
  };
58
+
59
+ // ─── Scrolling (#366) ───────────────────────────────────────────────────────
60
+
61
+ /**
62
+ * A fixed-height container with more rows than fit — `Table`'s own scroll
63
+ * wrapper is genuinely `overflow-auto` here, and that is exactly what no
64
+ * `Table` story ever exercised before #366: the wrapper's own keyboard
65
+ * affordance (WCAG 2.1.1 / axe `scrollable-region-focusable`) shipped
66
+ * unguarded on this package's own turf, only incidentally covered elsewhere.
67
+ * `Tab` reaches the region, and it carries the accessible name naming it a
68
+ * scrollable region — see `table.test.tsx` for the fitting-table negative.
69
+ */
70
+ export const Scrolling: Story = {
71
+ render: () => (
72
+ // `Table`'s own scroll wrapper has no explicit height of its own — like any
73
+ // block box it grows to fit its content unless something makes it a flex
74
+ // item with a bounded main-axis size. A plain `<div style={{height:160}}>`
75
+ // ancestor does NOT do that (block children ignore a parent's height), so
76
+ // this outer div is a column flex container: its one child (the wrapper,
77
+ // which already carries `overflow-auto`) gets an automatic flex-basis
78
+ // minimum size of 0 per the flexbox spec's overflow carve-out, letting it
79
+ // shrink to the 160px bound instead of growing to its ~750px content.
80
+ <div style={{ height: 160, display: "flex", flexDirection: "column" }}>
81
+ <Table>
82
+ <TableHeader>
83
+ <TableRow>
84
+ <TableHead>Service</TableHead>
85
+ <TableHead>Status</TableHead>
86
+ </TableRow>
87
+ </TableHeader>
88
+ <TableBody>
89
+ {Array.from({ length: 20 }, (_, i) => (
90
+ <TableRow key={i}>
91
+ <TableCell>service-{i}</TableCell>
92
+ <TableCell>{i % 2 === 0 ? "healthy" : "degraded"}</TableCell>
93
+ </TableRow>
94
+ ))}
95
+ </TableBody>
96
+ </Table>
97
+ </div>
98
+ ),
99
+ play: async ({ canvasElement, userEvent }) => {
100
+ const scrollRegion = await waitFor(() => {
101
+ const el = canvasElement.querySelector<HTMLElement>('[data-slot="table-scroll-region"]');
102
+ if (!el || el.scrollHeight <= el.clientHeight) throw new Error("not overflowing yet");
103
+ return el;
104
+ });
105
+ await expect(scrollRegion).toHaveAttribute("tabindex", "0");
106
+ await expect(scrollRegion).toHaveAccessibleName("Table contents, scrollable");
107
+ // A named, non-landmark stop: `group`, never `region` (a landmark per
108
+ // overflowing table collides under axe `landmark-unique`).
109
+ await expect(scrollRegion).toHaveAttribute("role", "group");
110
+
111
+ // The region is the only focusable element the story renders, so a
112
+ // single Tab from nothing focused reaches it — the real WCAG 2.1.1
113
+ // reproduction from the issue ("press Tab from the top").
114
+ await userEvent.tab();
115
+ await waitFor(() => expect(document.activeElement).toBe(scrollRegion));
116
+ },
117
+ };
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { render, screen } from "@testing-library/react";
2
+ import { fireEvent, render, screen } from "@testing-library/react";
3
3
  import { Table, TableBody, TableCell, TableRow } from "./table";
4
+
4
5
  describe("Table", () => {
5
6
  it("renders cells", () => {
6
7
  render(
@@ -15,3 +16,150 @@ describe("Table", () => {
15
16
  expect(screen.getByText("Alpha")).toBeInTheDocument();
16
17
  });
17
18
  });
19
+
20
+ /**
21
+ * jsdom reports 0 for every layout metric, so overflow has to be simulated —
22
+ * same idiom as `packages/data/src/data-table/data-table.test.tsx`. Metrics
23
+ * are (re-)applied and the component's own `onScroll` handler is fired so the
24
+ * re-measurement runs through the real code path, not by poking at state.
25
+ */
26
+ function setScrollMetrics(
27
+ el: HTMLElement,
28
+ { scrollWidth, clientWidth, scrollHeight, clientHeight }: Record<string, number>,
29
+ ) {
30
+ Object.defineProperty(el, "scrollWidth", { configurable: true, value: scrollWidth });
31
+ Object.defineProperty(el, "clientWidth", { configurable: true, value: clientWidth });
32
+ Object.defineProperty(el, "scrollHeight", { configurable: true, value: scrollHeight });
33
+ Object.defineProperty(el, "clientHeight", { configurable: true, value: clientHeight });
34
+ fireEvent.scroll(el);
35
+ }
36
+
37
+ function scrollRegionOf(container: HTMLElement): HTMLElement {
38
+ const el = container.querySelector<HTMLElement>('[data-slot="table-scroll-region"]');
39
+ if (!el) throw new Error('no [data-slot="table-scroll-region"] in the rendered output');
40
+ return el;
41
+ }
42
+
43
+ describe("Table — #366 the scroll wrapper's tab stop exists only while it measurably overflows", () => {
44
+ it("adds NO tab stop and NO accessible name to a table that fits its container", () => {
45
+ const { container } = render(
46
+ <Table>
47
+ <TableBody>
48
+ <TableRow>
49
+ <TableCell>Alpha</TableCell>
50
+ </TableRow>
51
+ </TableBody>
52
+ </Table>,
53
+ );
54
+ const scrollRegion = scrollRegionOf(container);
55
+ setScrollMetrics(scrollRegion, {
56
+ scrollWidth: 300,
57
+ clientWidth: 300,
58
+ scrollHeight: 100,
59
+ clientHeight: 100,
60
+ });
61
+ // A table that doesn't scroll must not gain a focus stop that does
62
+ // nothing, nor announce itself as "scrollable" — axe's
63
+ // `scrollable-region-focusable` only fires the other way round, so this
64
+ // is the locking assertion for it.
65
+ expect(scrollRegion).not.toHaveAttribute("tabindex");
66
+ expect(scrollRegion).not.toHaveAttribute("aria-label");
67
+ expect(screen.queryByLabelText("Table contents, scrollable")).toBeNull();
68
+ });
69
+
70
+ it("gains the tab stop + accessible name once the region measurably overflows vertically", () => {
71
+ const { container } = render(
72
+ <Table>
73
+ <TableBody>
74
+ <TableRow>
75
+ <TableCell>Alpha</TableCell>
76
+ </TableRow>
77
+ </TableBody>
78
+ </Table>,
79
+ );
80
+ const scrollRegion = scrollRegionOf(container);
81
+ setScrollMetrics(scrollRegion, {
82
+ scrollWidth: 300,
83
+ clientWidth: 300,
84
+ scrollHeight: 800,
85
+ clientHeight: 200,
86
+ });
87
+ expect(scrollRegion).toHaveAttribute("tabindex", "0");
88
+ expect(screen.getByLabelText("Table contents, scrollable")).toBe(scrollRegion);
89
+ });
90
+
91
+ it("gains the tab stop + accessible name once the region measurably overflows horizontally", () => {
92
+ const { container } = render(
93
+ <Table>
94
+ <TableBody>
95
+ <TableRow>
96
+ <TableCell>Alpha</TableCell>
97
+ </TableRow>
98
+ </TableBody>
99
+ </Table>,
100
+ );
101
+ const scrollRegion = scrollRegionOf(container);
102
+ setScrollMetrics(scrollRegion, {
103
+ scrollWidth: 900,
104
+ clientWidth: 300,
105
+ scrollHeight: 100,
106
+ clientHeight: 100,
107
+ });
108
+ expect(scrollRegion).toHaveAttribute("tabindex", "0");
109
+ expect(screen.getByLabelText("Table contents, scrollable")).toBe(scrollRegion);
110
+ });
111
+
112
+ it("drops the tab stop again when the overflow goes away (e.g. the container grows)", () => {
113
+ const { container } = render(
114
+ <Table>
115
+ <TableBody>
116
+ <TableRow>
117
+ <TableCell>Alpha</TableCell>
118
+ </TableRow>
119
+ </TableBody>
120
+ </Table>,
121
+ );
122
+ const scrollRegion = scrollRegionOf(container);
123
+ setScrollMetrics(scrollRegion, {
124
+ scrollWidth: 300,
125
+ clientWidth: 300,
126
+ scrollHeight: 800,
127
+ clientHeight: 200,
128
+ });
129
+ expect(scrollRegion).toHaveAttribute("tabindex", "0");
130
+ setScrollMetrics(scrollRegion, {
131
+ scrollWidth: 300,
132
+ clientWidth: 300,
133
+ scrollHeight: 800,
134
+ clientHeight: 900,
135
+ });
136
+ expect(scrollRegion).not.toHaveAttribute("tabindex");
137
+ });
138
+
139
+ it('adds role="group" once it overflows — a named, non-landmark stop', () => {
140
+ const { container } = render(
141
+ <Table>
142
+ <TableBody>
143
+ <TableRow>
144
+ <TableCell>Alpha</TableCell>
145
+ </TableRow>
146
+ </TableBody>
147
+ </Table>,
148
+ );
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");
155
+ setScrollMetrics(scrollRegion, {
156
+ scrollWidth: 300,
157
+ clientWidth: 300,
158
+ scrollHeight: 800,
159
+ clientHeight: 200,
160
+ });
161
+ expect(scrollRegion).toHaveAttribute("role", "group");
162
+ expect(screen.queryByRole("region")).toBeNull();
163
+ expect(screen.getByRole("group", { name: "Table contents, scrollable" })).toBe(scrollRegion);
164
+ });
165
+ });
@@ -1,17 +1,86 @@
1
+ "use client";
2
+
1
3
  import {
2
4
  forwardRef,
5
+ useCallback,
6
+ useEffect,
7
+ useRef,
8
+ useState,
3
9
  type HTMLAttributes,
4
10
  type TdHTMLAttributes,
5
11
  type ThHTMLAttributes,
6
12
  } from "react";
7
13
  import { cn } from "../../lib/cn";
14
+ import { useLocale } from "../locale-provider";
8
15
 
16
+ /**
17
+ * `Table`'s own scroll wrapper, gated on MEASURED overflow (#366).
18
+ *
19
+ * `className`/`ref`/`...props` all land on the `<table>` — unchanged, no new
20
+ * prop — so this wrapper is internal plumbing, not a new public surface. It
21
+ * follows `DataTable`'s non-virtualized branch verbatim
22
+ * (`packages/data/src/data-table/data-table.tsx:1658-1695`, #330), the
23
+ * closest precedent: a plain `overflow-auto` box with no pinned columns, no
24
+ * loading overlay and no edge fade to carry along, just the keyboard
25
+ * affordance itself.
26
+ *
27
+ * A table that FITS must stay a byte-identical no-op: no `tabIndex`, no
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).
42
+ */
9
43
  export const Table = forwardRef<HTMLTableElement, HTMLAttributes<HTMLTableElement>>(function Table(
10
44
  { className, ...props },
11
45
  ref,
12
46
  ) {
47
+ const { t } = useLocale();
48
+ const scrollRef = useRef<HTMLDivElement>(null);
49
+ const [scrollOverflows, setScrollOverflows] = useState(false);
50
+
51
+ const updateScrollAffordance = useCallback(() => {
52
+ const el = scrollRef.current;
53
+ if (!el) return;
54
+ // 1px tolerance absorbs sub-pixel layout rounding, which would otherwise
55
+ // report a permanent 0.5px overflow on a table that visually fits.
56
+ setScrollOverflows(
57
+ el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1,
58
+ );
59
+ }, []);
60
+
61
+ useEffect(() => {
62
+ const el = scrollRef.current;
63
+ if (!el) return;
64
+ updateScrollAffordance();
65
+ if (typeof ResizeObserver === "undefined") return;
66
+ // Observe the CONTAINER (viewport changes) and the <table> inside it
67
+ // (content changes its intrinsic size without resizing the container).
68
+ const observer = new ResizeObserver(updateScrollAffordance);
69
+ observer.observe(el);
70
+ if (el.firstElementChild) observer.observe(el.firstElementChild);
71
+ return () => observer.disconnect();
72
+ }, [updateScrollAffordance]);
73
+
13
74
  return (
14
- <div className="relative w-full overflow-auto">
75
+ <div
76
+ ref={scrollRef}
77
+ data-slot="table-scroll-region"
78
+ tabIndex={scrollOverflows ? 0 : undefined}
79
+ role={scrollOverflows ? "group" : undefined}
80
+ aria-label={scrollOverflows ? t("ui.table.scrollRegion") : undefined}
81
+ onScroll={updateScrollAffordance}
82
+ className="relative w-full overflow-auto focus-ring-inset"
83
+ >
15
84
  <table ref={ref} className={cn("w-full caption-bottom text-body", className)} {...props} />
16
85
  </div>
17
86
  );
@@ -79,7 +148,7 @@ export const TableHead = forwardRef<HTMLTableCellElement, ThHTMLAttributes<HTMLT
79
148
  <th
80
149
  ref={ref}
81
150
  className={cn(
82
- "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",
83
152
  className,
84
153
  )}
85
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
+ };