@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,3 +1,5 @@
1
+ "use client";
2
+
1
3
  import {
2
4
  cloneElement,
3
5
  forwardRef,
@@ -8,16 +10,43 @@ import {
8
10
  } from "react";
9
11
  import { Slot } from "@radix-ui/react-slot";
10
12
  import { cn } from "../../lib/cn";
13
+ import { hasRenderableContent } from "../../lib/has-renderable-content";
11
14
  import { Label } from "../label";
12
15
 
13
16
  export interface FieldRowProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
14
17
  /** Field label — rendered as a real `<label>`, click-to-focus associated with the control. */
15
18
  label: ReactNode;
16
- /** Optional help/description text, always visible (not gated by an error). */
19
+ /**
20
+ * Optional help/description text, always visible (not gated by an error).
21
+ * Renders nothing — and contributes no id to `aria-describedby` — when
22
+ * `description` is falsy (`false`/`0`/`""`/`null`/`undefined`), an empty
23
+ * array, or an array containing only falsy values (`{list.map(...)}` on an
24
+ * empty list; `[a && "x", b && "y"]` with both false).
25
+ *
26
+ * **Known limit:** a value that is itself a COMPONENT that renders nothing
27
+ * (returns `null`/an empty fragment) is not knowable before render, so it
28
+ * still produces an empty paragraph that `aria-describedby` points at.
29
+ * `FieldDescription` and the wider React ecosystem share this limit — pass
30
+ * a falsy value instead of a component that may render nothing, e.g.
31
+ * `description={hasHint ? hintText : undefined}`, never
32
+ * `description={<MaybeRendersNull />}`.
33
+ */
17
34
  description?: ReactNode;
18
35
  /**
19
36
  * Validation error text. When present: sets `aria-invalid` on the control,
20
37
  * is appended to `aria-describedby`, and is announced via `role="alert"`.
38
+ * Renders nothing — and leaves `aria-invalid="false"` — when `error` is
39
+ * falsy (`false`/`0`/`""`/`null`/`undefined`), an empty array, or an array
40
+ * containing only falsy values (`{errors.map(...)}` on an empty list — the
41
+ * idiomatic "no errors" shape).
42
+ *
43
+ * **Known limit:** a value that is itself a COMPONENT that renders nothing
44
+ * (returns `null`/an empty fragment) is not knowable before render, so it
45
+ * still produces an empty `role="alert"` element that `aria-describedby`
46
+ * points at. `FieldError` and the wider React ecosystem share this limit —
47
+ * pass a falsy value instead of a component that may render nothing, e.g.
48
+ * `error={hasError ? message : undefined}`, never
49
+ * `error={<MaybeRendersNull />}`.
21
50
  */
22
51
  error?: ReactNode;
23
52
  /**
@@ -62,11 +91,19 @@ export const FieldRow = forwardRef<HTMLDivElement, FieldRowProps>(function Field
62
91
  const childProps = control.props;
63
92
  const controlId = childProps.id ?? `${id}-control`;
64
93
 
94
+ // Same predicate `FieldDescription`/`FieldError` use (`../field/field.tsx`)
95
+ // — bare truthiness (`description ? … : null`) treats an empty/all-falsy
96
+ // ARRAY as content, since every array is truthy in JS. `errors.map(...)`
97
+ // on an empty list is the idiomatic "no errors" shape, so that gap is a
98
+ // real defect here, not a hypothetical (#93).
99
+ const hasDescription = hasRenderableContent(description);
100
+ const hasError = hasRenderableContent(error);
101
+
65
102
  const describedBy =
66
103
  [
67
104
  childProps["aria-describedby"] ?? null,
68
- description ? descriptionId : null,
69
- error ? errorId : null,
105
+ hasDescription ? descriptionId : null,
106
+ hasError ? errorId : null,
70
107
  ]
71
108
  .filter(Boolean)
72
109
  .join(" ") || undefined;
@@ -86,12 +123,19 @@ export const FieldRow = forwardRef<HTMLDivElement, FieldRowProps>(function Field
86
123
  <Slot
87
124
  id={controlId}
88
125
  aria-describedby={describedBy}
89
- aria-invalid={!!error}
126
+ // `FieldRoot.invalid` (`../field/field.tsx`) is intentionally
127
+ // INDEPENDENT of whether a `FieldError` with content is mounted — a
128
+ // caller can mark the control invalid before its message settles.
129
+ // `FieldRow` has no such prop: `error` is its only invalidity
130
+ // signal, so "no error content" must mean "not invalid" — deriving
131
+ // this from raw `!!error` marked `error={[]}` invalid with nothing
132
+ // to explain why (#93).
133
+ aria-invalid={hasError}
90
134
  data-slot="field-row-control"
91
135
  >
92
136
  {cloneElement(control, { id: controlId, "aria-describedby": describedBy })}
93
137
  </Slot>
94
- {description ? (
138
+ {hasDescription ? (
95
139
  <p
96
140
  id={descriptionId}
97
141
  data-slot="field-row-description"
@@ -100,7 +144,7 @@ export const FieldRow = forwardRef<HTMLDivElement, FieldRowProps>(function Field
100
144
  {description}
101
145
  </p>
102
146
  ) : null}
103
- {error ? (
147
+ {hasError ? (
104
148
  <p
105
149
  id={errorId}
106
150
  role="alert"
@@ -1,5 +1,6 @@
1
1
  import { useState } from "react";
2
2
  import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { expect, userEvent, within } from "storybook/test";
3
4
  import {
4
5
  FileUpload,
5
6
  FileUploadDropzone,
@@ -106,6 +107,21 @@ export const Default: Story = {
106
107
  </FileUpload>
107
108
  </div>
108
109
  ),
110
+ play: async ({ canvasElement }) => {
111
+ const canvas = within(canvasElement);
112
+ // #322 — the dropzone (`focus-ring-within`) is the sole compound focus
113
+ // indicator for this composite control; the inner "Browse files" button
114
+ // must delegate its own indicator to it, never paint a second one.
115
+ const browseButton = canvas.getByRole("button", { name: "Browse files" });
116
+ await userEvent.tab();
117
+ await expect(browseButton).toHaveFocus();
118
+ const dropzone = browseButton.closest('[data-slot="file-upload-dropzone"]');
119
+ await expect(dropzone).not.toBeNull();
120
+ const dropzoneStyle = getComputedStyle(dropzone as HTMLElement);
121
+ const buttonStyle = getComputedStyle(browseButton);
122
+ await expect(dropzoneStyle.outlineStyle).toBe("solid");
123
+ await expect(buttonStyle.outlineStyle).not.toBe("solid");
124
+ },
109
125
  };
110
126
 
111
127
  export const Uncontrolled: Story = {
@@ -15,21 +15,28 @@ function makeUploadFile(name = "test.txt", size = 1024): UploadFile {
15
15
  }
16
16
 
17
17
  describe("FileUpload", () => {
18
- it("renders the dropzone with a label pointing to the hidden input", () => {
18
+ it("default dropzone exposes exactly one control: the Browse files button", () => {
19
19
  render(
20
20
  <FileUpload>
21
21
  <FileUploadDropzone />
22
22
  </FileUpload>,
23
23
  );
24
- // The label wraps the input — label's htmlFor matches input's id
25
- const label = document.querySelector("label[data-slot='file-upload-dropzone']");
26
- expect(label).not.toBeNull();
27
- const inputId = label!.getAttribute("for");
28
- expect(inputId).toBeTruthy();
29
- const input = document.getElementById(inputId!);
30
- expect(input).not.toBeNull();
31
- expect(input!.tagName.toLowerCase()).toBe("input");
32
- expect(input).toHaveAttribute("type", "file");
24
+ const dropzone = document.querySelector("[data-slot='file-upload-dropzone']")!;
25
+ // The zone is a plain drop surface — no role, no tab stop — so the inner
26
+ // button is never nested inside another interactive element (axe
27
+ // `nested-interactive` / `aria-allowed-role`).
28
+ expect(dropzone.tagName.toLowerCase()).toBe("div");
29
+ expect(dropzone).not.toHaveAttribute("role");
30
+ expect(dropzone).not.toHaveAttribute("tabIndex");
31
+ expect(screen.getAllByRole("button")).toHaveLength(1);
32
+
33
+ const input = document.querySelector("input[type='file']") as HTMLInputElement;
34
+ const clickSpy = vi.spyOn(input, "click");
35
+ fireEvent.click(screen.getByRole("button", { name: "Browse files" }));
36
+ // One activation opens the picker once (the zone's click must not re-fire it).
37
+ expect(clickSpy).toHaveBeenCalledTimes(1);
38
+ fireEvent.click(dropzone);
39
+ expect(clickSpy).toHaveBeenCalledTimes(2);
33
40
  });
34
41
 
35
42
  it("hidden file input has sr-only class (visually hidden but real)", () => {
@@ -88,7 +95,11 @@ describe("FileUpload", () => {
88
95
  expect(onFilesChange.mock.calls[0]![0]).toHaveLength(0);
89
96
  });
90
97
 
91
- it("FileUploadList renders role=status and aria-live=polite", () => {
98
+ it("FileUploadList announces via aria-live while keeping its native list role", () => {
99
+ // NOT role="status" — that would override the <ul>'s implicit `list`
100
+ // role and strip `listitem` from every <FileUploadItem> inside it
101
+ // (an axe `listitem` violation). `aria-live="polite"` alone still
102
+ // announces additions/removals to assistive tech.
92
103
  const f = makeUploadFile();
93
104
  render(
94
105
  <FileUpload files={[f]} onFilesChange={() => {}}>
@@ -97,8 +108,9 @@ describe("FileUpload", () => {
97
108
  </FileUploadList>
98
109
  </FileUpload>,
99
110
  );
100
- const list = screen.getByRole("status");
111
+ const list = screen.getByRole("list");
101
112
  expect(list).toHaveAttribute("aria-live", "polite");
113
+ expect(list).not.toHaveAttribute("role");
102
114
  });
103
115
 
104
116
  it("FileUploadList returns null when no files and no children", () => {
@@ -151,6 +163,72 @@ describe("FileUpload", () => {
151
163
  expect(onFilesChange).toHaveBeenCalledWith([]);
152
164
  });
153
165
 
166
+ it("drag-and-drop rejects files that don't match `accept` and reports why", () => {
167
+ const onFilesChange = vi.fn();
168
+ const onFilesRejected = vi.fn();
169
+ render(
170
+ <FileUpload
171
+ files={[]}
172
+ onFilesChange={onFilesChange}
173
+ onFilesRejected={onFilesRejected}
174
+ accept=".png"
175
+ >
176
+ <FileUploadDropzone />
177
+ </FileUpload>,
178
+ );
179
+ const dropzone = document.querySelector("[data-slot='file-upload-dropzone']")!;
180
+ const wrongType = new File(["x"], "notes.txt", { type: "text/plain" });
181
+ fireEvent.drop(dropzone, { dataTransfer: { files: [wrongType] } });
182
+
183
+ // A drop bypasses the native <input accept> filter entirely — without
184
+ // this, a dragged-in .txt would be added even though the picker would
185
+ // never have offered it.
186
+ expect(onFilesChange).toHaveBeenCalledWith([]);
187
+ expect(onFilesRejected).toHaveBeenCalledWith([{ file: wrongType, reason: "accept" }]);
188
+ });
189
+
190
+ it("drag-and-drop of several files when multiple=false keeps only the first, rejects the rest", () => {
191
+ const onFilesChange = vi.fn();
192
+ const onFilesRejected = vi.fn();
193
+ render(
194
+ <FileUpload files={[]} onFilesChange={onFilesChange} onFilesRejected={onFilesRejected}>
195
+ <FileUploadDropzone />
196
+ </FileUpload>,
197
+ );
198
+ const dropzone = document.querySelector("[data-slot='file-upload-dropzone']")!;
199
+ const first = new File(["a"], "a.txt", { type: "text/plain" });
200
+ const second = new File(["b"], "b.txt", { type: "text/plain" });
201
+ fireEvent.drop(dropzone, { dataTransfer: { files: [first, second] } });
202
+
203
+ // `multiple` is only enforced by the browser for the picker dialog, never
204
+ // for a raw DataTransfer drop.
205
+ expect(onFilesChange.mock.calls[0]![0]).toHaveLength(1);
206
+ expect(onFilesChange.mock.calls[0]![0][0].file.name).toBe("a.txt");
207
+ expect(onFilesRejected).toHaveBeenCalledWith([{ file: second, reason: "multiple" }]);
208
+ });
209
+
210
+ it("a custom-children dropzone still opens the picker on Enter/Space (keyboard reachable)", () => {
211
+ render(
212
+ <FileUpload>
213
+ <FileUploadDropzone>Drop it here</FileUploadDropzone>
214
+ </FileUpload>,
215
+ );
216
+ // With custom `children` there is no fallback "Browse files" button —
217
+ // the zone itself must be the one control: a tab stop answering Enter/Space.
218
+ const dropzone = screen.getByRole("button");
219
+ expect(dropzone).toHaveTextContent("Drop it here");
220
+ expect(dropzone).toHaveAttribute("data-slot", "file-upload-dropzone");
221
+ expect(dropzone).toHaveAttribute("tabIndex", "0");
222
+
223
+ const input = document.querySelector("input[type='file']") as HTMLInputElement;
224
+ const clickSpy = vi.spyOn(input, "click");
225
+ fireEvent.keyDown(dropzone, { key: "Enter" });
226
+ expect(clickSpy).toHaveBeenCalledTimes(1);
227
+
228
+ fireEvent.keyDown(dropzone, { key: " " });
229
+ expect(clickSpy).toHaveBeenCalledTimes(2);
230
+ });
231
+
154
232
  it("disabled prop disables the file input and remove buttons", () => {
155
233
  const f = makeUploadFile();
156
234
  render(
@@ -18,6 +18,7 @@ import {
18
18
  import { X, Upload, FileIcon, CheckCircle2, AlertCircle } from "lucide-react";
19
19
  import { cn } from "../../lib/cn";
20
20
  import { Button } from "../button";
21
+ import { useLocale } from "../locale-provider";
21
22
  import { Progress } from "../progress";
22
23
 
23
24
  // ---------------------------------------------------------------------------
@@ -39,6 +40,42 @@ export interface UploadFile {
39
40
  errorMessage?: string;
40
41
  }
41
42
 
43
+ /** Why `addFiles` (picker OR drag-and-drop) declined a file. */
44
+ export type FileRejectionReason = "accept" | "maxSize" | "maxFiles" | "multiple";
45
+
46
+ export interface FileRejection {
47
+ file: File;
48
+ reason: FileRejectionReason;
49
+ }
50
+
51
+ /**
52
+ * Match a file against an `<input accept>`-style pattern list (comma-separated
53
+ * extensions like `.png`, exact MIME types like `image/png`, or wildcards like
54
+ * `image/*`). Drag-and-drop delivers files straight from `DataTransfer` — the
55
+ * browser only enforces `accept` for the native picker dialog, never for a
56
+ * drop — so this has to be re-applied by hand for the drop path to actually
57
+ * "respect `accept`".
58
+ */
59
+ function fileMatchesAccept(file: File, accept: string | undefined): boolean {
60
+ if (!accept) return true;
61
+ const patterns = accept
62
+ .split(",")
63
+ .map((p) => p.trim().toLowerCase())
64
+ .filter(Boolean);
65
+ if (patterns.length === 0) return true;
66
+ const name = file.name.toLowerCase();
67
+ const type = file.type.toLowerCase();
68
+ return patterns.some((pattern) => {
69
+ if (pattern.startsWith(".")) return name.endsWith(pattern);
70
+ if (pattern.endsWith("/*")) return type.startsWith(pattern.slice(0, -1));
71
+ return type === pattern;
72
+ });
73
+ }
74
+
75
+ function makeUploadFileId(file: File): string {
76
+ return `${file.name}-${Date.now()}-${Math.random()}`;
77
+ }
78
+
42
79
  // ---------------------------------------------------------------------------
43
80
  // Context
44
81
  // ---------------------------------------------------------------------------
@@ -58,6 +95,22 @@ interface FileUploadContextValue {
58
95
  inputId: string;
59
96
  }
60
97
 
98
+ /** Read `accept`/`multiple`/`maxSize`/`maxFiles` off a rejection for a default message. */
99
+ export function describeFileRejection(rejection: FileRejection): string {
100
+ switch (rejection.reason) {
101
+ case "accept":
102
+ return `${rejection.file.name} is not an accepted file type.`;
103
+ case "maxSize":
104
+ return `${rejection.file.name} is too large.`;
105
+ case "maxFiles":
106
+ return `${rejection.file.name} was not added — the file limit was reached.`;
107
+ case "multiple":
108
+ return `${rejection.file.name} was not added — only one file is allowed.`;
109
+ default:
110
+ return `${rejection.file.name} was not added.`;
111
+ }
112
+ }
113
+
61
114
  const FileUploadContext = createContext<FileUploadContextValue | null>(null);
62
115
 
63
116
  function useFileUpload(): FileUploadContextValue {
@@ -75,6 +128,12 @@ export interface FileUploadProps extends HTMLAttributes<HTMLDivElement> {
75
128
  files?: UploadFile[];
76
129
  /** Called when files are added or removed. */
77
130
  onFilesChange?: (files: UploadFile[]) => void;
131
+ /**
132
+ * Called with the files a picker selection OR a drag-and-drop declined —
133
+ * wrong type (`accept`), over `maxSize`, over `maxFiles`, or extra files
134
+ * dropped when `multiple` is false — each with its {@link FileRejectionReason}.
135
+ */
136
+ onFilesRejected?: (rejections: FileRejection[]) => void;
78
137
  /** Accepted MIME types / extensions (forwarded to <input accept>). */
79
138
  accept?: string;
80
139
  /** Allow multiple files at once. */
@@ -102,6 +161,7 @@ export const FileUpload = forwardRef<HTMLDivElement, FileUploadProps>(function F
102
161
  {
103
162
  files: filesProp,
104
163
  onFilesChange,
164
+ onFilesRejected,
105
165
  accept,
106
166
  multiple = false,
107
167
  maxSize,
@@ -134,19 +194,45 @@ export const FileUpload = forwardRef<HTMLDivElement, FileUploadProps>(function F
134
194
 
135
195
  const addFiles = useCallback(
136
196
  (incoming: File[]) => {
137
- const toAdd = incoming.filter((f) => {
138
- if (maxSize && f.size > maxSize) return false;
139
- return true;
140
- });
197
+ const rejections: FileRejection[] = [];
198
+
199
+ // A drop can deliver more files than the native picker ever would —
200
+ // `multiple` is only enforced by the browser for the `<input>` dialog,
201
+ // never for `DataTransfer`, so re-apply it here for the drop path too.
202
+ let candidates = incoming;
203
+ if (!multiple && candidates.length > 1) {
204
+ rejections.push(
205
+ ...candidates.slice(1).map((file) => ({ file, reason: "multiple" as const })),
206
+ );
207
+ candidates = candidates.slice(0, 1);
208
+ }
209
+
210
+ const toAdd: File[] = [];
211
+ for (const f of candidates) {
212
+ if (!fileMatchesAccept(f, accept)) {
213
+ rejections.push({ file: f, reason: "accept" });
214
+ continue;
215
+ }
216
+ if (maxSize && f.size > maxSize) {
217
+ rejections.push({ file: f, reason: "maxSize" });
218
+ continue;
219
+ }
220
+ toAdd.push(f);
221
+ }
141
222
 
142
223
  const next = [...files];
143
224
  for (const f of toAdd) {
144
- if (maxFiles && next.length >= maxFiles) break;
145
- next.push({ id: `${f.name}-${Date.now()}-${Math.random()}`, file: f });
225
+ if (maxFiles && next.length >= maxFiles) {
226
+ rejections.push({ file: f, reason: "maxFiles" });
227
+ continue;
228
+ }
229
+ next.push({ id: makeUploadFileId(f), file: f });
146
230
  }
231
+
147
232
  commit(next);
233
+ if (rejections.length > 0) onFilesRejected?.(rejections);
148
234
  },
149
- [files, maxSize, maxFiles, commit],
235
+ [files, accept, multiple, maxSize, maxFiles, commit, onFilesRejected],
150
236
  );
151
237
 
152
238
  const removeFile = useCallback(
@@ -256,30 +342,61 @@ export const FileUpload = forwardRef<HTMLDivElement, FileUploadProps>(function F
256
342
  // FileUploadDropzone
257
343
  // ---------------------------------------------------------------------------
258
344
 
259
- export interface FileUploadDropzoneProps extends HTMLAttributes<HTMLElement> {
260
- /** Label for the visually-hidden file picker link (for SR users). */
345
+ export interface FileUploadDropzoneProps extends HTMLAttributes<HTMLDivElement> {
346
+ /** Label for the default "Browse files" button. */
261
347
  browseLabel?: string;
262
348
  }
263
349
 
264
350
  /**
265
351
  * FileUploadDropzone — the drag-and-drop target.
266
352
  *
267
- * Uses a `<label>` wrapping the hidden `<input>` so the ENTIRE zone is ONE hit
268
- * target — no `<div onClick>`. Keyboard users get the Browse button as a
269
- * complementary path.
353
+ * A plain `<div>` drop surface with exactly ONE keyboard-reachable control:
354
+ *
355
+ * - Default content: a real `<button>` ("Browse files") is the tab stop; the
356
+ * zone itself carries no role and no tab stop, so nothing interactive is
357
+ * nested inside another interactive element.
358
+ * - Custom `children`: there is no inner button, so the zone itself becomes
359
+ * the control (`role="button"`, a tab stop, Enter/Space). Custom children
360
+ * must then be non-interactive content.
361
+ *
362
+ * A pointer click anywhere on the zone also opens the picker — a mouse
363
+ * convenience on top of the keyboard path above, never the only path.
270
364
  */
271
- export const FileUploadDropzone = forwardRef<HTMLElement, FileUploadDropzoneProps>(
365
+ export const FileUploadDropzone = forwardRef<HTMLDivElement, FileUploadDropzoneProps>(
272
366
  function FileUploadDropzone(
273
- { className, children, browseLabel = "Browse files", ...props },
367
+ { className, children, browseLabel, onClick, onKeyDown, ...props },
274
368
  ref,
275
369
  ) {
276
- const { isDragging, disabled, inputId, openPicker } = useFileUpload();
370
+ const { isDragging, disabled, openPicker } = useFileUpload();
371
+ const { t } = useLocale();
372
+ const resolvedBrowseLabel = browseLabel ?? t("ui.fileUpload.browseFiles");
373
+ // With custom children there is no inner button, so the zone owns the
374
+ // one keyboard path itself.
375
+ const isSelfControl = children != null;
277
376
 
278
377
  return (
279
- // label wraps the hidden input — clicking anywhere on the zone opens the picker
280
- <label
281
- ref={ref as React.Ref<HTMLLabelElement>}
282
- htmlFor={inputId}
378
+ <div
379
+ ref={ref}
380
+ role={isSelfControl ? "button" : undefined}
381
+ tabIndex={isSelfControl && !disabled ? 0 : undefined}
382
+ // Also on the default (role-less) zone: it marks the dimmed copy as
383
+ // inactive UI, which WCAG 1.4.3 exempts from contrast — the same
384
+ // exemption a `<label>` of a disabled control gets.
385
+ aria-disabled={disabled || undefined}
386
+ onClick={(e) => {
387
+ onClick?.(e);
388
+ if (disabled || e.defaultPrevented) return;
389
+ openPicker();
390
+ }}
391
+ onKeyDown={(e) => {
392
+ onKeyDown?.(e);
393
+ if (!isSelfControl || disabled || e.defaultPrevented) return;
394
+ if (e.target !== e.currentTarget) return;
395
+ if (e.key === "Enter" || e.key === " ") {
396
+ e.preventDefault();
397
+ openPicker();
398
+ }
399
+ }}
283
400
  data-slot="file-upload-dropzone"
284
401
  data-dragging={isDragging || undefined}
285
402
  data-disabled={disabled || undefined}
@@ -289,7 +406,7 @@ export const FileUploadDropzone = forwardRef<HTMLElement, FileUploadDropzoneProp
289
406
  "hover:border-ring hover:bg-accent/50",
290
407
  "data-[dragging]:border-primary data-[dragging]:bg-primary/5",
291
408
  "data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
292
- "focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background",
409
+ "focus-ring-within focus-within:ring-offset-2 focus-within:ring-offset-background",
293
410
  className,
294
411
  )}
295
412
  {...props}
@@ -300,29 +417,37 @@ export const FileUploadDropzone = forwardRef<HTMLElement, FileUploadDropzoneProp
300
417
  <Upload className="size-5 text-muted-foreground" aria-hidden="true" />
301
418
  </div>
302
419
  <div className="flex flex-col items-center gap-1 text-center">
303
- <p className="text-sm font-medium text-foreground">Drag & drop files here</p>
304
- <p className="text-xs text-muted-foreground">
420
+ <p className="text-body font-medium text-foreground">
421
+ {t("ui.fileUpload.dragDropHere")}
422
+ </p>
423
+ <p className="text-meta text-muted-foreground">
305
424
  or{" "}
306
425
  <button
307
426
  type="button"
308
427
  disabled={disabled}
309
428
  onClick={(e) => {
310
- e.preventDefault();
429
+ // The zone's own click handler opens the picker; stop the
430
+ // bubble so one activation opens it exactly once.
431
+ e.stopPropagation();
311
432
  openPicker();
312
433
  }}
313
434
  className={cn(
314
435
  // #399 — inline text trigger inside a sentence → `-text` rung.
315
- "text-primary-text underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm",
436
+ "text-primary-text underline-offset-2 hover:underline focus-ring rounded-sm",
437
+ // #322 — the dropzone's `focus-ring-within` (above) is the
438
+ // one compound indicator for this composite control; this
439
+ // button delegates rather than painting a second one.
440
+ "focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
316
441
  "disabled:pointer-events-none disabled:opacity-50",
317
442
  )}
318
443
  >
319
- {browseLabel}
444
+ {resolvedBrowseLabel}
320
445
  </button>
321
446
  </p>
322
447
  </div>
323
448
  </>
324
449
  )}
325
- </label>
450
+ </div>
326
451
  );
327
452
  },
328
453
  );
@@ -340,14 +465,19 @@ export type FileUploadListProps = HTMLAttributes<HTMLUListElement>;
340
465
  export const FileUploadList = forwardRef<HTMLUListElement, FileUploadListProps>(
341
466
  function FileUploadList({ className, children, ...props }, ref) {
342
467
  const { files } = useFileUpload();
468
+ const { t } = useLocale();
343
469
  if (files.length === 0 && !children) return null;
344
470
 
345
471
  return (
472
+ // `aria-live="polite"` alone is enough to announce additions/removals —
473
+ // it does NOT require `role="status"`. Adding that role here used to
474
+ // override the `<ul>`'s implicit `list` role, which strips the
475
+ // `listitem` role from every `<FileUploadItem>` (`<li>`) inside it and
476
+ // fails axe's `listitem` rule the moment the list is non-empty.
346
477
  <ul
347
478
  ref={ref}
348
- role="status"
349
479
  aria-live="polite"
350
- aria-label="Selected files"
480
+ aria-label={t("ui.fileUpload.selectedFiles")}
351
481
  data-slot="file-upload-list"
352
482
  className={cn("flex flex-col gap-2", className)}
353
483
  {...props}
@@ -412,10 +542,10 @@ export const FileUploadItem = forwardRef<HTMLLIElement, FileUploadItemProps>(
412
542
  >
413
543
  <div className="flex items-center gap-2">
414
544
  {statusIcon[resolvedStatus]}
415
- <span className="flex-1 min-w-0 truncate text-sm text-foreground">
545
+ <span className="flex-1 min-w-0 truncate text-body text-foreground">
416
546
  {uploadFile.file.name}
417
547
  </span>
418
- <span className="shrink-0 text-xs tabular-nums text-muted-foreground">
548
+ <span className="shrink-0 text-meta tabular-nums text-muted-foreground">
419
549
  {formatFileSize(uploadFile.file.size)}
420
550
  </span>
421
551
  <Button
@@ -434,7 +564,10 @@ export const FileUploadItem = forwardRef<HTMLLIElement, FileUploadItemProps>(
434
564
  <Progress value={resolvedProgress} className="h-1" />
435
565
  )}
436
566
  {isError && resolvedError && (
437
- <p role="alert" className="text-xs text-destructive">
567
+ // #124: this is running text (the rejection reason), so it takes the
568
+ // >=4.5:1 ink rung `text-destructive-text` — not the 3:1 mark rung the
569
+ // `statusIcon.error` glyph above correctly keeps.
570
+ <p role="alert" className="text-meta text-destructive-text">
438
571
  {resolvedError}
439
572
  </p>
440
573
  )}
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  import {
2
4
  createContext,
3
5
  forwardRef,
@@ -80,7 +82,8 @@ export const FormLabel = forwardRef<
80
82
  htmlFor={formItemId}
81
83
  className={cn(
82
84
  "transition-colors duration-fast ease-standard",
83
- error && "text-destructive",
85
+ // #124: the label's own error-state text, not a mark — ink rung.
86
+ error && "text-destructive-text",
84
87
  className,
85
88
  )}
86
89
  {...props}
@@ -113,7 +116,7 @@ export const FormDescription = forwardRef<
113
116
  <p
114
117
  ref={ref}
115
118
  id={formDescriptionId}
116
- className={cn("text-sm text-muted-foreground", className)}
119
+ className={cn("text-body text-muted-foreground", className)}
117
120
  {...props}
118
121
  />
119
122
  );
@@ -129,7 +132,9 @@ export const FormMessage = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLP
129
132
  ref={ref}
130
133
  id={formMessageId}
131
134
  className={cn(
132
- "text-sm font-medium text-destructive animate-in fade-in slide-in-from-top-1 ease-entrance",
135
+ // #124: the error message body is running text ink rung, not the
136
+ // 3:1 mark rung `text-destructive` guarantees.
137
+ "text-body font-medium text-destructive-text animate-in fade-in slide-in-from-top-1 ease-entrance",
133
138
  className,
134
139
  )}
135
140
  {...props}
@@ -12,6 +12,7 @@ export const HoverCardContent = forwardRef<
12
12
  return (
13
13
  <HoverCardPrimitive.Content
14
14
  ref={ref}
15
+ data-slot="hover-card-content"
15
16
  align={align}
16
17
  sideOffset={sideOffset}
17
18
  className={cn(
@@ -59,7 +59,7 @@ describe("IconButton", () => {
59
59
  );
60
60
  const wrapper = document.querySelector('[data-slot="icon-button"]')!;
61
61
  expect(wrapper).toHaveAttribute("tabindex", "0");
62
- expect(wrapper.className).toContain("focus-visible:ring-ring");
62
+ expect(wrapper.className).toContain("focus-ring");
63
63
  await userEvent.tab();
64
64
  expect(wrapper).toHaveFocus();
65
65
  });
@@ -97,12 +97,12 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
97
97
  data-slot="icon-button"
98
98
  tabIndex={disabled ? 0 : undefined}
99
99
  className={cn(
100
- "inline-flex rounded-md",
100
+ "inline-flex rounded-control",
101
101
  // While disabled this span IS the focus target (the button is out
102
102
  // of the tab order), so it must carry the system's focus ring —
103
103
  // without this it falls back to the UA default outline, which is
104
- // a hardcoded browser blue identical in all three themes.
105
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
104
+ // a hardcoded browser blue identical in every theme.
105
+ "focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
106
106
  disabled && "cursor-not-allowed",
107
107
  )}
108
108
  >