@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
@@ -0,0 +1,342 @@
1
+ "use client";
2
+
3
+ import {
4
+ cloneElement,
5
+ forwardRef,
6
+ useCallback,
7
+ useId,
8
+ useLayoutEffect,
9
+ useMemo,
10
+ useState,
11
+ type ComponentPropsWithoutRef,
12
+ type ElementRef,
13
+ type HTMLAttributes,
14
+ type ReactElement,
15
+ } from "react";
16
+ import { Slot } from "@radix-ui/react-slot";
17
+ import { cn } from "../../lib/cn";
18
+ import { hasRenderableContent } from "../../lib/has-renderable-content";
19
+ import { Label } from "../label";
20
+ import { FieldContext, useFieldContext, type FieldContextValue } from "./field-context";
21
+
22
+ // ── FieldRoot ─────────────────────────────────────────────────────────────
23
+
24
+ export interface FieldRootProps extends HTMLAttributes<HTMLDivElement> {
25
+ /**
26
+ * Whether the field is currently invalid. Drives `aria-invalid` on every
27
+ * `FieldControl` and error styling on `FieldLabel` — independent of
28
+ * whether a `FieldError` with content is actually mounted, so a caller can
29
+ * mark the control invalid before its message has settled.
30
+ */
31
+ invalid?: boolean;
32
+ /** Whether the field is required. Drives `aria-required` on every `FieldControl`. */
33
+ required?: boolean;
34
+ }
35
+
36
+ /**
37
+ * Compound-anatomy field: `FieldRoot` owns id generation and `aria-describedby`
38
+ * composition; `FieldLabel`/`FieldControl`/`FieldDescription`/`FieldError` read
39
+ * that state from context and can be composed in ANY order/layout — including
40
+ * more than one `FieldControl` in one row (e.g. first/last name) or a
41
+ * `FieldDescription` placed before the control. Mirrors the same accessibility
42
+ * wiring `FieldRow` (`../field-row`) already validated (id/`aria-describedby`/
43
+ * `aria-invalid`/`role="alert"`), adapted to a shared lifted-state context so
44
+ * it holds across independently-composed parts; `FieldRow` remains the
45
+ * convenience wrapper for the common single-control case and is unaffected by
46
+ * this addition (#43).
47
+ *
48
+ * @example
49
+ * ```tsx
50
+ * <FieldRoot invalid={!!error} required>
51
+ * <FieldLabel>{label}</FieldLabel>
52
+ * <FieldControl><Input /></FieldControl>
53
+ * <FieldDescription>{helpText}</FieldDescription>
54
+ * <FieldError>{error}</FieldError>
55
+ * </FieldRoot>
56
+ * ```
57
+ */
58
+ export const FieldRoot = forwardRef<HTMLDivElement, FieldRootProps>(function FieldRoot(
59
+ { invalid = false, required = false, className, children, ...props },
60
+ ref,
61
+ ) {
62
+ // Each `FieldDescription`/`FieldError` INSTANCE generates and registers its
63
+ // own id (see below) — two lists, not one shared slot per part type, so
64
+ // more than one of either part under one `FieldRoot` gets distinct ids
65
+ // instead of colliding, and unmounting one instance only ever removes that
66
+ // instance's own id from its own list (never a sibling's).
67
+ const [descriptionIds, setDescriptionIds] = useState<readonly string[]>([]);
68
+ const registerDescription = useCallback((descId: string) => {
69
+ setDescriptionIds((prev) => (prev.includes(descId) ? prev : [...prev, descId]));
70
+ }, []);
71
+ const unregisterDescription = useCallback((descId: string) => {
72
+ setDescriptionIds((prev) => (prev.includes(descId) ? prev.filter((x) => x !== descId) : prev));
73
+ }, []);
74
+
75
+ const [errorIds, setErrorIds] = useState<readonly string[]>([]);
76
+ const registerError = useCallback((errId: string) => {
77
+ setErrorIds((prev) => (prev.includes(errId) ? prev : [...prev, errId]));
78
+ }, []);
79
+ const unregisterError = useCallback((errId: string) => {
80
+ setErrorIds((prev) => (prev.includes(errId) ? prev.filter((x) => x !== errId) : prev));
81
+ }, []);
82
+
83
+ // Fixed semantic order — every description before every error — regardless
84
+ // of DOM order or registration order, so "description placed before the
85
+ // control" reorders the VISIBLE layout without reordering the announced
86
+ // description, and a second description/error is appended rather than
87
+ // replacing the first.
88
+ const describedBy = useMemo(() => {
89
+ const ids = [...descriptionIds, ...errorIds];
90
+ return ids.length > 0 ? ids.join(" ") : undefined;
91
+ }, [descriptionIds, errorIds]);
92
+
93
+ // `FieldLabel`'s `htmlFor` can only point to ONE control, so it binds to
94
+ // whichever `FieldControl` registers FIRST (JSX/mount order) — the common
95
+ // single-control case gets this for free; a multi-control row (e.g.
96
+ // first/last name) still labels the first field, and every additional
97
+ // control needs its own explicit `id` the same way any two form controls
98
+ // would (no new coordination prop invented for this).
99
+ const [controlOrder, setControlOrder] = useState<readonly string[]>([]);
100
+ const registerControl = useCallback((controlId: string) => {
101
+ setControlOrder((prev) => (prev.includes(controlId) ? prev : [...prev, controlId]));
102
+ }, []);
103
+ const unregisterControl = useCallback((controlId: string) => {
104
+ setControlOrder((prev) =>
105
+ prev.includes(controlId) ? prev.filter((x) => x !== controlId) : prev,
106
+ );
107
+ }, []);
108
+ const labelFor = controlOrder[0];
109
+
110
+ const value = useMemo<FieldContextValue>(
111
+ () => ({
112
+ labelFor,
113
+ registerControl,
114
+ unregisterControl,
115
+ invalid,
116
+ required,
117
+ describedBy,
118
+ registerDescription,
119
+ unregisterDescription,
120
+ registerError,
121
+ unregisterError,
122
+ }),
123
+ [
124
+ labelFor,
125
+ registerControl,
126
+ unregisterControl,
127
+ invalid,
128
+ required,
129
+ describedBy,
130
+ registerDescription,
131
+ unregisterDescription,
132
+ registerError,
133
+ unregisterError,
134
+ ],
135
+ );
136
+
137
+ return (
138
+ <FieldContext.Provider value={value}>
139
+ <div ref={ref} data-slot="field" className={cn("space-y-2", className)} {...props}>
140
+ {children}
141
+ </div>
142
+ </FieldContext.Provider>
143
+ );
144
+ });
145
+
146
+ // ── FieldLabel ────────────────────────────────────────────────────────────
147
+
148
+ export const FieldLabel = forwardRef<
149
+ ElementRef<typeof Label>,
150
+ ComponentPropsWithoutRef<typeof Label>
151
+ >(function FieldLabel({ className, ...props }, ref) {
152
+ const { labelFor, invalid } = useFieldContext("FieldLabel");
153
+ return (
154
+ <Label
155
+ ref={ref}
156
+ htmlFor={labelFor}
157
+ data-slot="field-label"
158
+ className={cn(
159
+ "transition-colors duration-fast ease-standard",
160
+ invalid && "text-destructive-text",
161
+ className,
162
+ )}
163
+ {...props}
164
+ />
165
+ );
166
+ });
167
+
168
+ // ── FieldControl ──────────────────────────────────────────────────────────
169
+
170
+ export interface FieldControlProps {
171
+ /**
172
+ * The single field control (`Input`, `Textarea`, `Select`, …). Receives
173
+ * `id`/`aria-describedby`/`aria-invalid`/`aria-required` via a Radix
174
+ * `Slot` — must be a single element that forwards those props to a real
175
+ * form control. Compose more than one `FieldControl` inside one
176
+ * `FieldRoot` for a multi-control row (each gets its own independent id;
177
+ * give every control but the first its own explicit `id`, the same way
178
+ * you would for any two form controls that must not collide). `FieldLabel`
179
+ * can only associate with ONE control (the first to mount), so give every
180
+ * OTHER control in the row its own `aria-label` — a shared visual label
181
+ * plus a placeholder is not a real accessible name for the rest.
182
+ */
183
+ children: ReactElement<{
184
+ id?: string;
185
+ "aria-describedby"?: string;
186
+ }>;
187
+ }
188
+
189
+ export const FieldControl = forwardRef<ElementRef<typeof Slot>, FieldControlProps>(
190
+ function FieldControl({ children }, ref) {
191
+ const { invalid, required, describedBy, registerControl, unregisterControl } =
192
+ useFieldContext("FieldControl");
193
+ // Independent per-instance fallback id — guarantees uniqueness with no
194
+ // coordination needed when more than one `FieldControl` shares a
195
+ // `FieldRoot` (e.g. a first/last-name row).
196
+ const generatedId = useId();
197
+
198
+ // Same "child wins, but COMPOSE rather than discard" merge FieldRow uses:
199
+ // Radix `Slot` merges child props OVER slot props, so a control that
200
+ // already carries its own `id`/`aria-describedby` must not silently lose
201
+ // the label association / description-error wiring.
202
+ const control = children;
203
+ const childProps = control.props;
204
+ const id = childProps.id ?? generatedId;
205
+ const composedDescribedBy =
206
+ [childProps["aria-describedby"] ?? null, describedBy ?? null].filter(Boolean).join(" ") ||
207
+ undefined;
208
+
209
+ useLayoutEffect(() => {
210
+ registerControl(id);
211
+ return () => unregisterControl(id);
212
+ }, [id, registerControl, unregisterControl]);
213
+
214
+ return (
215
+ <Slot
216
+ ref={ref}
217
+ id={id}
218
+ aria-describedby={composedDescribedBy}
219
+ aria-invalid={invalid}
220
+ aria-required={required || undefined}
221
+ data-slot="field-control"
222
+ >
223
+ {cloneElement(control, { id, "aria-describedby": composedDescribedBy })}
224
+ </Slot>
225
+ );
226
+ },
227
+ );
228
+
229
+ // ── FieldDescription ──────────────────────────────────────────────────────
230
+
231
+ /**
232
+ * Optional field help text. Renders nothing — and registers no
233
+ * `aria-describedby` reference — when `children` is falsy (`false`/`0`/`""`/
234
+ * `null`/`undefined`), an empty array, or an array containing only falsy
235
+ * values (`{list.map(...)}` on an empty list; `[a && "x", b && "y"]` with
236
+ * both false) — matching `FieldRow`'s `description ? … : null` convention:
237
+ * `{hint && <FieldDescription>{hint}</FieldDescription>}` is the supported
238
+ * way to express "no description".
239
+ *
240
+ * **Known limit:** a child that is itself a COMPONENT that renders nothing
241
+ * (returns `null`/an empty fragment) is not knowable from the element
242
+ * before render, so it still produces an empty paragraph that
243
+ * `aria-describedby` points at. `FieldRow` and the wider React ecosystem
244
+ * share this limit — pass a falsy child instead of a component that may
245
+ * render nothing.
246
+ */
247
+ export const FieldDescription = forwardRef<
248
+ HTMLParagraphElement,
249
+ HTMLAttributes<HTMLParagraphElement>
250
+ >(function FieldDescription({ className, children, id, ...props }, ref) {
251
+ const { registerDescription, unregisterDescription } = useFieldContext("FieldDescription");
252
+ // Own id per INSTANCE — two `FieldDescription`s under one `FieldRoot` (a hint
253
+ // above the control and a hint below it) must not collide on one shared id.
254
+ const instanceId = useId();
255
+ const generatedId = `${instanceId}-description`;
256
+ // Caller-supplied `id` wins — same "child wins" resolution `FieldControl`
257
+ // already uses (`childProps.id ?? generatedId`). Resolving ONE effective id
258
+ // up front, and destructuring `id` out of `...props` above, means the value
259
+ // registered into `aria-describedby` and the value actually rendered on the
260
+ // `<p>` can never diverge (PR #97 review finding 3).
261
+ const descriptionId = id ?? generatedId;
262
+ // Same falsy-content convention `FieldRow` uses (`description ? … : null`)
263
+ // — see `hasRenderableContent` (`../../lib/has-renderable-content`) for
264
+ // the full rationale, shared with `FieldError` below and `FieldRow`.
265
+ const hasContent = hasRenderableContent(children);
266
+
267
+ useLayoutEffect(() => {
268
+ if (!hasContent) return undefined;
269
+ registerDescription(descriptionId);
270
+ return () => unregisterDescription(descriptionId);
271
+ }, [hasContent, descriptionId, registerDescription, unregisterDescription]);
272
+
273
+ if (!hasContent) return null;
274
+
275
+ return (
276
+ <p
277
+ ref={ref}
278
+ id={descriptionId}
279
+ data-slot="field-description"
280
+ className={cn("text-body text-muted-foreground", className)}
281
+ {...props}
282
+ >
283
+ {children}
284
+ </p>
285
+ );
286
+ });
287
+
288
+ // ── FieldError ────────────────────────────────────────────────────────────
289
+
290
+ /**
291
+ * Validation error text, announced via `role="alert"`. Renders nothing —
292
+ * and registers no `aria-describedby` reference — when `children` is falsy
293
+ * (`false`/`0`/`""`/`null`/`undefined`), an empty array, or an array
294
+ * containing only falsy values (`{errors.map(...)}` on an empty array is
295
+ * exactly how a real form renders "no errors") — matching `FieldRow`'s
296
+ * `error ? … : null` convention: `{error && <FieldError>{error}</FieldError>}`
297
+ * is the supported way to express "no error".
298
+ *
299
+ * **Known limit:** a child that is itself a COMPONENT that renders nothing
300
+ * (returns `null`/an empty fragment) is not knowable from the element
301
+ * before render, so it still produces an empty `role="alert"` element that
302
+ * `aria-describedby` points at (a screen reader announces an empty alert).
303
+ * `FieldRow` and the wider React ecosystem share this limit — pass a falsy
304
+ * child instead of a component that may render nothing.
305
+ */
306
+ export const FieldError = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
307
+ function FieldError({ className, children, id, ...props }, ref) {
308
+ const { registerError, unregisterError } = useFieldContext("FieldError");
309
+ // Own id per INSTANCE — same reasoning as `FieldDescription`.
310
+ const instanceId = useId();
311
+ const generatedId = `${instanceId}-error`;
312
+ // Caller-supplied `id` wins — same resolution as `FieldDescription`/
313
+ // `FieldControl` (PR #97 review finding 3: this had the identical
314
+ // ordering bug as `FieldDescription`).
315
+ const errorId = id ?? generatedId;
316
+ // Same falsy-content convention as `FieldDescription`/`FieldRow`'s
317
+ // `error ? … : null` — see `hasRenderableContent`
318
+ // (`../../lib/has-renderable-content`) for the full rationale.
319
+ const hasContent = hasRenderableContent(children);
320
+
321
+ useLayoutEffect(() => {
322
+ if (!hasContent) return undefined;
323
+ registerError(errorId);
324
+ return () => unregisterError(errorId);
325
+ }, [hasContent, errorId, registerError, unregisterError]);
326
+
327
+ if (!hasContent) return null;
328
+
329
+ return (
330
+ <p
331
+ ref={ref}
332
+ id={errorId}
333
+ role="alert"
334
+ data-slot="field-error"
335
+ className={cn("text-body font-medium text-destructive-text", className)}
336
+ {...props}
337
+ >
338
+ {children}
339
+ </p>
340
+ );
341
+ },
342
+ );
@@ -0,0 +1,9 @@
1
+ export {
2
+ FieldRoot,
3
+ FieldLabel,
4
+ FieldControl,
5
+ FieldDescription,
6
+ FieldError,
7
+ type FieldRootProps,
8
+ type FieldControlProps,
9
+ } from "./field";
@@ -1,3 +1,4 @@
1
+ import { useState } from "react";
1
2
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
3
  import { expect } from "storybook/test";
3
4
  import { Input } from "../input";
@@ -12,7 +13,7 @@ const meta = {
12
13
  docs: {
13
14
  description: {
14
15
  component:
15
- "Label/description/error/aria-describedby wiring for a single field OUTSIDE a react-hook-form context. See Forms/Form for the RHF-bound equivalent.",
16
+ "Label/description/error/aria-describedby wiring for a single field OUTSIDE a react-hook-form context. See Forms/Form for the RHF-bound equivalent.\n\n**Runtime-agnostic (#26).** `FieldRow` never imports `react-hook-form` — it renders entirely from plain `label`/`description`/`error` props, so it works just as readily with a `useState`-controlled field, Formik, Final Form, TanStack Form or a bespoke reducer. `react-hook-form`/`@hookform/resolvers` are optional peers of `@elabs-ai/components-ui`, so a consumer who only uses `FieldRow` never installs or bundles either — the RHF-bound `Form` family lives on the separate `@elabs-ai/components-ui/form` subpath. See the “Driven by external state” story below for a worked, non-RHF example (including the error state), and `docs/CONSUMING.md` for the Formik and plain-`useState` snippets. Reach for the RHF-bound `Form`/`FormField` family (Forms/Form) instead once the field already lives inside a `react-hook-form` `<FormProvider>` — `FieldRow` covers the gap outside it, it does not replace that family.",
16
17
  },
17
18
  },
18
19
  },
@@ -112,3 +113,86 @@ export const WithTextarea: Story = {
112
113
  expect(canvas.getByRole("textbox")).toHaveFocus();
113
114
  },
114
115
  };
116
+
117
+ // A small local email-shape check — deliberately NOT `event.target.checkValidity()`.
118
+ // Verified against real `<input type="email">` constraint validation (#26
119
+ // fix-round-1 finding 2): the native HTML5 email format does NOT require a
120
+ // dot in the domain, so `checkValidity()` treats "jane@invalid" as VALID —
121
+ // it would not reproduce the contradiction this demo exists to guard
122
+ // against. A `local-part@domain.tld` regex is the smallest check that
123
+ // actually agrees with "looks like a real email", so it can't clear the
124
+ // error for "@", "jane@" or "jane@invalid" the way the old
125
+ // `value.includes("@")` predicate did.
126
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
127
+
128
+ /**
129
+ * Stands a plain `useState`-controlled value in for "any runtime" (a bespoke
130
+ * reducer, Formik's `meta.error`, TanStack Form's `field.state.meta.errors`,
131
+ * …) — `FieldRow` only ever reads the `label`/`description`/`error` props it
132
+ * is handed, so the same shape works for all of them. No `FormProvider`/RHF
133
+ * context exists anywhere in this component's tree.
134
+ */
135
+ function EmailField({ initialValue }: { initialValue: string }) {
136
+ const [value, setValue] = useState(initialValue);
137
+ const error = EMAIL_RE.test(value) ? undefined : "Enter a valid email address.";
138
+
139
+ return (
140
+ <div className="w-72">
141
+ <FieldRow label="Email" description="We'll only use this to send receipts." error={error}>
142
+ <Input type="email" value={value} onChange={(event) => setValue(event.target.value)} />
143
+ </FieldRow>
144
+ </div>
145
+ );
146
+ }
147
+
148
+ /**
149
+ * Issue #26's fourth ask: a worked example of `FieldRow` driven by a form
150
+ * runtime that is NOT react-hook-form — this is the story the docs and PR
151
+ * point readers at as "starts already invalid so the `aria-invalid`/
152
+ * `role="alert"` wiring is visible without interaction". Its play function
153
+ * therefore only ASSERTS the error state; it never resolves it, so the
154
+ * canvas a reader inspects (both in the story view and on the autodocs page,
155
+ * where Storybook runs `play` on mount the same way) stays on the
156
+ * documented error state. See "Driven by external state — validity
157
+ * transitions" below for the invalid→valid interaction instead (#26
158
+ * fix-round-1 finding 1 — the previous version of this story cleared its own
159
+ * error inside `play`, so the state the docs claimed to show never actually
160
+ * survived a render).
161
+ */
162
+ export const RuntimeAgnostic: Story = {
163
+ name: "Driven by external state (no react-hook-form)",
164
+ render: () => <EmailField initialValue="not-an-email" />,
165
+ play: async ({ canvas }) => {
166
+ const input = canvas.getByRole("textbox");
167
+ expect(input).toHaveAttribute("aria-invalid", "true");
168
+ const alert = canvas.getByRole("alert");
169
+ expect(alert).toHaveTextContent("Enter a valid email address.");
170
+ expect(input.getAttribute("aria-describedby")?.split(" ")).toContain(alert.id);
171
+ },
172
+ };
173
+
174
+ /**
175
+ * The invalid→valid half of the lifecycle, kept in its own story so the
176
+ * documented error example above (`RuntimeAgnostic`) never settles into the
177
+ * success state on render (#26 fix-round-1 finding 1). Also locks finding
178
+ * 2: "jane@invalid" has an `@` but no valid domain and must NOT clear the
179
+ * error — `value.includes("@")` used to accept it.
180
+ */
181
+ export const RuntimeAgnosticValidation: Story = {
182
+ name: "Driven by external state — validity transitions",
183
+ render: () => <EmailField initialValue="not-an-email" />,
184
+ play: async ({ canvas, userEvent }) => {
185
+ const input = canvas.getByRole("textbox");
186
+ expect(input).toHaveAttribute("aria-invalid", "true");
187
+
188
+ await userEvent.clear(input);
189
+ await userEvent.type(input, "jane@invalid");
190
+ expect(input).toHaveAttribute("aria-invalid", "true");
191
+ expect(canvas.getByRole("alert")).toHaveTextContent("Enter a valid email address.");
192
+
193
+ await userEvent.clear(input);
194
+ await userEvent.type(input, "jane@example.com");
195
+ expect(input).toHaveAttribute("aria-invalid", "false");
196
+ expect(canvas.queryByRole("alert")).toBeNull();
197
+ },
198
+ };
@@ -185,4 +185,149 @@ describe("FieldRow", () => {
185
185
  for (const id of frDescribedBy) expect(document.getElementById(id)).not.toBeNull();
186
186
  expect(frInput).toHaveAttribute("aria-invalid", "true");
187
187
  });
188
+
189
+ // #26 — the runtime-agnostic contract: FieldRow must wire aria-invalid /
190
+ // aria-describedby / role="alert" from PLAIN external state (any form
191
+ // runtime — a bespoke reducer, Formik's `meta.error`, TanStack Form's
192
+ // `field.state.meta.errors`, …), with NO react-hook-form FormProvider
193
+ // mounted anywhere in the tree. This locks the story's documented example:
194
+ // if FieldRow ever grew an accidental RHF coupling (e.g. reading
195
+ // useFormContext internally), this render would throw before any
196
+ // assertion below ran.
197
+ it("wires aria-invalid + aria-describedby from a field driven entirely by external state, with no form-runtime provider mounted", () => {
198
+ function ExternallyControlledField({ error }: { error?: string }) {
199
+ // Stands in for "any form runtime": FieldRow only ever reads the
200
+ // label/description/error props it is handed, whatever produced them.
201
+ return (
202
+ <FieldRow label="Email" error={error}>
203
+ <Input value="" onChange={() => {}} />
204
+ </FieldRow>
205
+ );
206
+ }
207
+
208
+ const { rerender } = render(<ExternallyControlledField />);
209
+ const input = screen.getByRole("textbox");
210
+ expect(input).toHaveAttribute("aria-invalid", "false");
211
+ expect(input).not.toHaveAttribute("aria-describedby");
212
+ expect(screen.queryByRole("alert")).toBeNull();
213
+
214
+ rerender(<ExternallyControlledField error="Enter a valid email address." />);
215
+ const errorNode = screen.getByRole("alert");
216
+ expect(errorNode).toHaveTextContent("Enter a valid email address.");
217
+ expect(input).toHaveAttribute("aria-invalid", "true");
218
+ expect(input.getAttribute("aria-describedby")).toBe(errorNode.id);
219
+ });
220
+
221
+ // #93 (B) — FieldRow used bare truthiness (`error ? … : null`) to gate the
222
+ // alert, the id contribution and aria-invalid, so an empty/all-falsy ARRAY
223
+ // (every array is truthy in JS) still produced a referenced, empty
224
+ // role="alert" and marked the control invalid with nothing to explain why.
225
+ // `Field*` already handles this shape correctly (field.test.tsx) — this
226
+ // locks the same behavior on FieldRow.
227
+ it("renders no error element, no aria-invalid, and no aria-describedby reference for an empty array or an array of only falsy children", () => {
228
+ const { rerender } = render(
229
+ <FieldRow label="Email" error={[]}>
230
+ <Input />
231
+ </FieldRow>,
232
+ );
233
+ let input = screen.getByRole("textbox");
234
+ expect(screen.queryByRole("alert")).toBeNull();
235
+ expect(input).toHaveAttribute("aria-invalid", "false");
236
+ expect(input).not.toHaveAttribute("aria-describedby");
237
+
238
+ rerender(
239
+ <FieldRow label="Email" error={[false, null]}>
240
+ <Input />
241
+ </FieldRow>,
242
+ );
243
+ input = screen.getByRole("textbox");
244
+ expect(screen.queryByRole("alert")).toBeNull();
245
+ expect(input).toHaveAttribute("aria-invalid", "false");
246
+ expect(input).not.toHaveAttribute("aria-describedby");
247
+ });
248
+
249
+ // Mirrors field.test.tsx's round-4 fix: `Children.toArray` drops
250
+ // null/undefined/booleans but KEEPS "" (and 0), so ["", ""] survives
251
+ // toArray with length 2 and must still be treated as no content.
252
+ it("renders no error element for an array of only empty strings", () => {
253
+ render(
254
+ <FieldRow label="Email" error={["", ""]}>
255
+ <Input />
256
+ </FieldRow>,
257
+ );
258
+ const input = screen.getByRole("textbox");
259
+ expect(screen.queryByRole("alert")).toBeNull();
260
+ expect(input).toHaveAttribute("aria-invalid", "false");
261
+ expect(input).not.toHaveAttribute("aria-describedby");
262
+ });
263
+
264
+ it("renders no description element and no aria-describedby reference for an empty array or an array of only falsy children", () => {
265
+ const { container, rerender } = render(
266
+ <FieldRow label="Bio" description={[]}>
267
+ <Input />
268
+ </FieldRow>,
269
+ );
270
+ let input = screen.getByRole("textbox");
271
+ expect(container.querySelector('[data-slot="field-row-description"]')).toBeNull();
272
+ expect(input).not.toHaveAttribute("aria-describedby");
273
+
274
+ rerender(
275
+ <FieldRow label="Bio" description={[false, null]}>
276
+ <Input />
277
+ </FieldRow>,
278
+ );
279
+ input = screen.getByRole("textbox");
280
+ expect(container.querySelector('[data-slot="field-row-description"]')).toBeNull();
281
+ expect(input).not.toHaveAttribute("aria-describedby");
282
+ });
283
+
284
+ it("renders no description element for an array of only empty strings", () => {
285
+ const { container } = render(
286
+ <FieldRow label="Bio" description={["", ""]}>
287
+ <Input />
288
+ </FieldRow>,
289
+ );
290
+ const input = screen.getByRole("textbox");
291
+ expect(container.querySelector('[data-slot="field-row-description"]')).toBeNull();
292
+ expect(input).not.toHaveAttribute("aria-describedby");
293
+ });
294
+
295
+ // Positive control — the fix above must not suppress a real error: a
296
+ // non-empty `error` still renders the alert, the aria-describedby
297
+ // reference and aria-invalid="true".
298
+ it("still renders the alert, the aria-describedby reference and aria-invalid=true for a non-empty error", () => {
299
+ render(
300
+ <FieldRow label="Email" error="Required.">
301
+ <Input />
302
+ </FieldRow>,
303
+ );
304
+ const input = screen.getByRole("textbox");
305
+ const alert = screen.getByRole("alert");
306
+ expect(alert).toHaveTextContent("Required.");
307
+ expect(input).toHaveAttribute("aria-invalid", "true");
308
+ expect(input.getAttribute("aria-describedby")).toBe(alert.id);
309
+ });
310
+
311
+ // #93 (A) — KNOWN LIMIT, not desired behavior — mirrors field.test.tsx's
312
+ // FieldError pin: a value that is itself a component returning `null` is
313
+ // not knowable from the element before render (a React ecosystem-wide
314
+ // limit), so it still produces an empty `role="alert"` referenced by
315
+ // aria-describedby. This test PINS the current, documented-limit behavior
316
+ // so a future change to it is a deliberate decision, not a silent
317
+ // regression — it is not an endorsement. See the "Known limit" note on
318
+ // `FieldRowProps.error`.
319
+ it("[KNOWN LIMIT] still renders an empty, referenced alert when error is a component that renders null", () => {
320
+ function RendersNull() {
321
+ return null;
322
+ }
323
+ render(
324
+ <FieldRow label="Email" error={<RendersNull />}>
325
+ <Input />
326
+ </FieldRow>,
327
+ );
328
+ const input = screen.getByRole("textbox");
329
+ const alert = screen.getByRole("alert");
330
+ expect(alert).toBeEmptyDOMElement();
331
+ expect(input.getAttribute("aria-describedby")).toBe(alert.id);
332
+ });
188
333
  });