@elabs-ai/components-ui 4.0.0 → 4.1.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 (223) hide show
  1. package/README.md +10 -8
  2. package/dist/chunk-JKPPTL63.js +48 -0
  3. package/dist/chunk-JKPPTL63.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 +2485 -167
  8. package/dist/index.js +10716 -2706
  9. package/dist/index.js.map +1 -1
  10. package/package.json +21 -7
  11. package/src/_peer-deps.test.ts +59 -0
  12. package/src/blocks-comparison-table.stories.tsx +2 -2
  13. package/src/components/accordion/accordion.tsx +1 -1
  14. package/src/components/advanced-group/advanced-group.tsx +1 -1
  15. package/src/components/alert/alert.tsx +4 -1
  16. package/src/components/app-shell/app-shell.stories.tsx +44 -2
  17. package/src/components/app-shell/app-shell.tsx +35 -2
  18. package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
  19. package/src/components/attribution-panel/attribution-panel.tsx +1 -1
  20. package/src/components/attribution-panel/attributions.generated.ts +81 -77
  21. package/src/components/badge/badge.stories.tsx +8 -0
  22. package/src/components/badge/badge.tsx +1 -1
  23. package/src/components/bento-grid/bento-grid.test.tsx +1 -1
  24. package/src/components/bento-grid/bento-grid.tsx +1 -1
  25. package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
  26. package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
  27. package/src/components/breadcrumb/breadcrumb.tsx +7 -2
  28. package/src/components/button/button.stories.tsx +116 -8
  29. package/src/components/button/button.tsx +1 -1
  30. package/src/components/card/card.stories.tsx +11 -6
  31. package/src/components/card/card.tsx +1 -1
  32. package/src/components/change-review/change-review.stories.tsx +145 -1
  33. package/src/components/change-review/change-review.test.tsx +143 -0
  34. package/src/components/change-review/change-review.tsx +185 -4
  35. package/src/components/checkbox/checkbox.tsx +1 -1
  36. package/src/components/color-picker/color-picker.tsx +6 -5
  37. package/src/components/command/command.stories.tsx +16 -6
  38. package/src/components/command/command.test.tsx +73 -0
  39. package/src/components/command/command.tsx +30 -7
  40. package/src/components/command/index.ts +1 -0
  41. package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
  42. package/src/components/command-trigger/command-trigger.test.tsx +18 -0
  43. package/src/components/command-trigger/command-trigger.tsx +70 -0
  44. package/src/components/command-trigger/index.ts +1 -0
  45. package/src/components/context-rail/context-rail.stories.tsx +285 -0
  46. package/src/components/context-rail/context-rail.test.tsx +263 -0
  47. package/src/components/context-rail/context-rail.tsx +678 -0
  48. package/src/components/context-rail/index.ts +1 -0
  49. package/src/components/copyable-value/copyable-value.tsx +1 -1
  50. package/src/components/dialog/dialog.tsx +2 -2
  51. package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
  52. package/src/components/empty-state/empty-state.test.tsx +26 -0
  53. package/src/components/empty-state/empty-state.tsx +6 -1
  54. package/src/components/expand-dialog/expand-dialog.tsx +1 -2
  55. package/src/components/field/field-context.ts +59 -0
  56. package/src/components/field/field.stories.tsx +142 -0
  57. package/src/components/field/field.test.tsx +415 -0
  58. package/src/components/field/field.tsx +340 -0
  59. package/src/components/field/index.ts +9 -0
  60. package/src/components/field-row/field-row.stories.tsx +85 -1
  61. package/src/components/field-row/field-row.test.tsx +145 -0
  62. package/src/components/field-row/field-row.tsx +48 -6
  63. package/src/components/file-upload/file-upload.stories.tsx +16 -0
  64. package/src/components/file-upload/file-upload.test.tsx +7 -2
  65. package/src/components/file-upload/file-upload.tsx +15 -4
  66. package/src/components/form/form.tsx +5 -2
  67. package/src/components/icon-button/icon-button.test.tsx +1 -1
  68. package/src/components/icon-button/icon-button.tsx +2 -2
  69. package/src/components/input/input.stories.tsx +121 -0
  70. package/src/components/input/input.test.tsx +13 -0
  71. package/src/components/input/input.tsx +2 -2
  72. package/src/components/input-group/input-group.stories.tsx +74 -0
  73. package/src/components/input-group/input-group.tsx +13 -4
  74. package/src/components/input-otp/input-otp.tsx +5 -1
  75. package/src/components/keyboard-shortcuts/index.ts +6 -0
  76. package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
  77. package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
  78. package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
  79. package/src/components/link-preview/link-preview.tsx +1 -1
  80. package/src/components/locale-provider/index.ts +6 -1
  81. package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
  82. package/src/components/locale-provider/locale-provider.test.tsx +257 -1
  83. package/src/components/locale-provider/locale-provider.tsx +63 -8
  84. package/src/components/locale-provider/messages.ts +501 -2
  85. package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
  86. package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
  87. package/src/components/mention-input/mention-input.tsx +8 -38
  88. package/src/components/mention-input/mention-value.ts +8 -2
  89. package/src/components/metric-card/metric-card.stories.tsx +5 -3
  90. package/src/components/metric-card/metric-card.tsx +21 -2
  91. package/src/components/model-picker/model-picker.stories.tsx +63 -3
  92. package/src/components/model-picker/model-picker.test.tsx +63 -0
  93. package/src/components/model-picker/model-picker.tsx +62 -36
  94. package/src/components/nav-main/nav-main.tsx +1 -1
  95. package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
  96. package/src/components/nav-notifications/nav-notifications.tsx +57 -5
  97. package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
  98. package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
  99. package/src/components/navigation-menu/navigation-menu.tsx +189 -12
  100. package/src/components/page-shell/page-shell.stories.tsx +163 -0
  101. package/src/components/page-shell/page-shell.test.tsx +229 -0
  102. package/src/components/page-shell/page-shell.tsx +108 -25
  103. package/src/components/pagination/pagination.test.tsx +90 -0
  104. package/src/components/pagination/pagination.tsx +9 -5
  105. package/src/components/radio-group/radio-group.tsx +1 -1
  106. package/src/components/rating/rating.tsx +1 -1
  107. package/src/components/resizable/resizable.stories.tsx +114 -2
  108. package/src/components/resizable/resizable.tsx +1 -1
  109. package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
  110. package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
  111. package/src/components/revision-timeline/revision-timeline.tsx +46 -3
  112. package/src/components/schema-form/from-json-schema.test.ts +390 -0
  113. package/src/components/schema-form/from-json-schema.ts +375 -0
  114. package/src/components/schema-form/index.ts +81 -0
  115. package/src/components/schema-form/schema-form-spec.ts +698 -0
  116. package/src/components/schema-form/schema-form.stories.tsx +566 -0
  117. package/src/components/schema-form/schema-form.test.tsx +900 -0
  118. package/src/components/schema-form/schema-form.tsx +1545 -0
  119. package/src/components/section-header/section-header.stories.tsx +37 -0
  120. package/src/components/section-header/section-header.tsx +12 -1
  121. package/src/components/select/select.tsx +6 -2
  122. package/src/components/sheet/sheet.tsx +1 -1
  123. package/src/components/side-dock/index.ts +1 -0
  124. package/src/components/side-dock/side-dock.stories.tsx +137 -0
  125. package/src/components/side-dock/side-dock.test.tsx +273 -0
  126. package/src/components/side-dock/side-dock.tsx +502 -0
  127. package/src/components/sidebar/index.ts +1 -0
  128. package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
  129. package/src/components/sidebar/sidebar.stories.tsx +421 -1
  130. package/src/components/sidebar/sidebar.test.tsx +36 -1
  131. package/src/components/sidebar/sidebar.tsx +245 -33
  132. package/src/components/skip-link/index.ts +1 -0
  133. package/src/components/skip-link/skip-link.stories.tsx +106 -0
  134. package/src/components/skip-link/skip-link.test.tsx +20 -0
  135. package/src/components/skip-link/skip-link.tsx +34 -0
  136. package/src/components/slider/slider.tsx +1 -1
  137. package/src/components/split-panel/index.ts +6 -1
  138. package/src/components/split-panel/split-panel.stories.tsx +18 -2
  139. package/src/components/split-panel/split-panel.test.tsx +20 -0
  140. package/src/components/split-panel/split-panel.tsx +28 -8
  141. package/src/components/state-panel/state-panel.stories.tsx +52 -0
  142. package/src/components/state-panel/state-panel.test.tsx +125 -0
  143. package/src/components/state-panel/state-panel.tsx +96 -17
  144. package/src/components/status-badge/status-badge.stories.tsx +8 -0
  145. package/src/components/switch/switch.tsx +1 -1
  146. package/src/components/table/table.stories.tsx +62 -0
  147. package/src/components/table/table.test.tsx +142 -1
  148. package/src/components/table/table.tsx +60 -1
  149. package/src/components/tabs/tabs.tsx +8 -2
  150. package/src/components/tag-input/tag-input.tsx +3 -2
  151. package/src/components/team-switcher/team-switcher.tsx +34 -5
  152. package/src/components/textarea/textarea.tsx +1 -1
  153. package/src/components/theme-switcher/theme-switcher.tsx +6 -2
  154. package/src/components/theme-switcher/use-theme-transition.ts +2 -2
  155. package/src/components/timeline/timeline.stories.tsx +27 -0
  156. package/src/components/timeline/timeline.tsx +11 -1
  157. package/src/components/toggle/toggle.tsx +1 -1
  158. package/src/components/toolbar/toolbar.stories.tsx +10 -2
  159. package/src/components/tree/tree.stories.tsx +2 -2
  160. package/src/components/tree/tree.tsx +14 -9
  161. package/src/components/typography/prose.tsx +1 -1
  162. package/src/components/typography/typography.stories.tsx +228 -0
  163. package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
  164. package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
  165. package/src/components/view-toolbar/view-toolbar.tsx +37 -4
  166. package/src/components/virtual-select/virtual-select.test.tsx +1 -1
  167. package/src/components/wizard/wizard.tsx +1 -1
  168. package/src/components/workspace-picker/index.ts +3 -0
  169. package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
  170. package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
  171. package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
  172. package/src/components/workspace-picker/workspace-picker.tsx +172 -0
  173. package/src/illustrations/empty-list-illustration.tsx +26 -0
  174. package/src/illustrations/error-illustration.tsx +60 -0
  175. package/src/illustrations/first-run-illustration.tsx +27 -0
  176. package/src/illustrations/illustration-base.tsx +108 -0
  177. package/src/illustrations/illustrations.stories.tsx +94 -0
  178. package/src/illustrations/illustrations.test.tsx +98 -0
  179. package/src/illustrations/index.ts +16 -0
  180. package/src/illustrations/no-access-illustration.tsx +45 -0
  181. package/src/illustrations/no-results-illustration.tsx +46 -0
  182. package/src/illustrations/offline-illustration.tsx +37 -0
  183. package/src/illustrations/success-illustration.tsx +40 -0
  184. package/src/index.ts +75 -2
  185. package/src/lib/agent-event-model.test.ts +12 -0
  186. package/src/lib/agent-event-model.ts +42 -0
  187. package/src/lib/approval-option.test.ts +17 -0
  188. package/src/lib/approval-option.ts +53 -0
  189. package/src/lib/check-result.ts +33 -0
  190. package/src/lib/diff-line.test.ts +36 -0
  191. package/src/lib/diff-line.ts +63 -0
  192. package/src/lib/diff-rows.test.ts +76 -0
  193. package/src/lib/diff-rows.ts +92 -0
  194. package/src/lib/format-duration.test.ts +22 -0
  195. package/src/lib/format-duration.ts +30 -0
  196. package/src/lib/has-renderable-content.test.ts +49 -0
  197. package/src/lib/has-renderable-content.ts +32 -0
  198. package/src/lib/operating-mode.test.ts +36 -0
  199. package/src/lib/operating-mode.ts +81 -0
  200. package/src/lib/optional-peer.ts +59 -0
  201. package/src/lib/session-launch.ts +37 -0
  202. package/src/lib/slash-command.test.ts +44 -0
  203. package/src/lib/slash-command.ts +45 -0
  204. package/src/lib/trigger-query.test.ts +94 -0
  205. package/src/lib/trigger-query.ts +88 -0
  206. package/src/lib/use-mobile.ts +12 -6
  207. package/src/motion.stories.tsx +6 -0
  208. package/src/templates-object-detail-hub.stories.tsx +2 -2
  209. package/src/templates-screen-states.stories.tsx +5 -5
  210. package/src/templates-settings.stories.tsx +1 -1
  211. package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
  212. package/src/blocks/sidebar-02/logo.tsx +0 -18
  213. package/src/blocks/sidebar-02/nav-main.tsx +0 -13
  214. package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
  215. package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
  216. package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
  217. package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
  218. package/src/blocks/sidebar-04/mail-context.tsx +0 -29
  219. package/src/blocks/sidebar-04/nav-user.tsx +0 -6
  220. package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
  221. package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
  222. package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
  223. package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
@@ -0,0 +1,415 @@
1
+ import { useState } from "react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { render, screen } from "@testing-library/react";
4
+ import userEvent from "@testing-library/user-event";
5
+ import { Input } from "../input";
6
+ import { FieldRoot, FieldLabel, FieldControl, FieldDescription, FieldError } from "./field";
7
+
8
+ describe("Field compound anatomy", () => {
9
+ // (1) FieldLabel's htmlFor matches the rendered control's id.
10
+ it("associates FieldLabel with FieldControl via htmlFor/id", async () => {
11
+ render(
12
+ <FieldRoot>
13
+ <FieldLabel>Name</FieldLabel>
14
+ <FieldControl>
15
+ <Input />
16
+ </FieldControl>
17
+ </FieldRoot>,
18
+ );
19
+ const input = screen.getByRole("textbox");
20
+ const label = screen.getByText("Name");
21
+ expect(label.tagName).toBe("LABEL");
22
+ expect(label).toHaveAttribute("for", input.id);
23
+ await userEvent.click(label);
24
+ expect(input).toHaveFocus();
25
+ });
26
+
27
+ // (2) aria-describedby lists both description and error ids when both are
28
+ // present, and drops the error id when there is no error.
29
+ it("composes aria-describedby from mounted FieldDescription + FieldError, dropping the error id when absent", () => {
30
+ const { rerender } = render(
31
+ <FieldRoot>
32
+ <FieldLabel>Email</FieldLabel>
33
+ <FieldControl>
34
+ <Input />
35
+ </FieldControl>
36
+ <FieldDescription>We only use this for receipts.</FieldDescription>
37
+ <FieldError>{undefined}</FieldError>
38
+ </FieldRoot>,
39
+ );
40
+ const input = screen.getByRole("textbox");
41
+ let describedBy = input.getAttribute("aria-describedby");
42
+ expect(describedBy).toBeTruthy();
43
+ let ids = describedBy!.split(" ");
44
+ expect(ids).toHaveLength(1);
45
+ expect(screen.getByText("We only use this for receipts.").id).toBe(ids[0]);
46
+ expect(screen.queryByRole("alert")).toBeNull();
47
+
48
+ rerender(
49
+ <FieldRoot>
50
+ <FieldLabel>Email</FieldLabel>
51
+ <FieldControl>
52
+ <Input />
53
+ </FieldControl>
54
+ <FieldDescription>We only use this for receipts.</FieldDescription>
55
+ <FieldError>Enter a valid email address.</FieldError>
56
+ </FieldRoot>,
57
+ );
58
+ describedBy = input.getAttribute("aria-describedby");
59
+ ids = describedBy!.split(" ");
60
+ expect(ids).toHaveLength(2);
61
+ expect(screen.getByText("We only use this for receipts.").id).toBe(ids[0]);
62
+ const alert = screen.getByRole("alert");
63
+ expect(alert).toHaveTextContent("Enter a valid email address.");
64
+ expect(alert.id).toBe(ids[1]);
65
+ });
66
+
67
+ // (3) aria-invalid / aria-required reflect FieldRoot's invalid/required state.
68
+ it("reflects FieldRoot's invalid and required state on the control", () => {
69
+ const { rerender } = render(
70
+ <FieldRoot>
71
+ <FieldLabel>Name</FieldLabel>
72
+ <FieldControl>
73
+ <Input />
74
+ </FieldControl>
75
+ </FieldRoot>,
76
+ );
77
+ const input = screen.getByRole("textbox");
78
+ expect(input).toHaveAttribute("aria-invalid", "false");
79
+ expect(input).not.toHaveAttribute("aria-required");
80
+
81
+ rerender(
82
+ <FieldRoot invalid required>
83
+ <FieldLabel>Name</FieldLabel>
84
+ <FieldControl>
85
+ <Input />
86
+ </FieldControl>
87
+ </FieldRoot>,
88
+ );
89
+ expect(input).toHaveAttribute("aria-invalid", "true");
90
+ expect(input).toHaveAttribute("aria-required", "true");
91
+ });
92
+
93
+ it("supports two independent FieldControls sharing one FieldRoot's aria-describedby wiring", () => {
94
+ render(
95
+ <FieldRoot invalid>
96
+ <FieldLabel>Name</FieldLabel>
97
+ <div className="flex gap-2">
98
+ <FieldControl>
99
+ <Input id="first-name" placeholder="First" />
100
+ </FieldControl>
101
+ <FieldControl>
102
+ <Input id="last-name" placeholder="Last" />
103
+ </FieldControl>
104
+ </div>
105
+ <FieldError>Both names are required.</FieldError>
106
+ </FieldRoot>,
107
+ );
108
+ const first = screen.getByPlaceholderText("First");
109
+ const last = screen.getByPlaceholderText("Last");
110
+ expect(first.id).toBe("first-name");
111
+ expect(last.id).toBe("last-name");
112
+ expect(first.id).not.toBe(last.id);
113
+
114
+ const alert = screen.getByRole("alert");
115
+ expect(first.getAttribute("aria-describedby")).toBe(alert.id);
116
+ expect(last.getAttribute("aria-describedby")).toBe(alert.id);
117
+ expect(first).toHaveAttribute("aria-invalid", "true");
118
+ expect(last).toHaveAttribute("aria-invalid", "true");
119
+ });
120
+
121
+ it("wires aria-describedby correctly regardless of whether FieldDescription is placed before or after FieldControl", () => {
122
+ render(
123
+ <FieldRoot>
124
+ <FieldLabel>Bio</FieldLabel>
125
+ <FieldDescription>Shown on your public profile.</FieldDescription>
126
+ <FieldControl>
127
+ <Input />
128
+ </FieldControl>
129
+ </FieldRoot>,
130
+ );
131
+ const input = screen.getByRole("textbox");
132
+ const description = screen.getByText("Shown on your public profile.");
133
+ expect(input.getAttribute("aria-describedby")).toBe(description.id);
134
+ });
135
+
136
+ it("keeps the label associated when the control supplies its own id (merge-child-wins semantics)", async () => {
137
+ render(
138
+ <FieldRoot>
139
+ <FieldLabel>Email</FieldLabel>
140
+ <FieldControl>
141
+ <Input id="consumer-owned-id" />
142
+ </FieldControl>
143
+ </FieldRoot>,
144
+ );
145
+ const input = screen.getByRole("textbox");
146
+ expect(input.id).toBe("consumer-owned-id");
147
+ expect(screen.getByText("Email")).toHaveAttribute("for", "consumer-owned-id");
148
+ await userEvent.click(screen.getByText("Email"));
149
+ expect(input).toHaveFocus();
150
+ });
151
+
152
+ // PR #97 review finding 3: a caller-supplied `id` on FieldDescription must
153
+ // be the SAME id the layout effect registers — before this fix, the
154
+ // rendered element took the caller's id while the generated id was what
155
+ // got registered, so aria-describedby pointed at an id nothing rendered.
156
+ it("registers the caller-supplied id on FieldDescription so aria-describedby resolves to a real element", () => {
157
+ render(
158
+ <FieldRoot>
159
+ <FieldLabel>Email</FieldLabel>
160
+ <FieldControl>
161
+ <Input />
162
+ </FieldControl>
163
+ <FieldDescription id="my-custom-description-id">
164
+ We only use this for receipts.
165
+ </FieldDescription>
166
+ </FieldRoot>,
167
+ );
168
+ const input = screen.getByRole("textbox");
169
+ const description = screen.getByText("We only use this for receipts.");
170
+ expect(description.id).toBe("my-custom-description-id");
171
+ const describedBy = input.getAttribute("aria-describedby");
172
+ expect(describedBy).toBe("my-custom-description-id");
173
+ expect(document.getElementById(describedBy!)).toBe(description);
174
+ });
175
+
176
+ // Same defect, FieldError side (identical ordering bug per the finding).
177
+ it("registers the caller-supplied id on FieldError so aria-describedby resolves to a real element", () => {
178
+ render(
179
+ <FieldRoot>
180
+ <FieldLabel>Email</FieldLabel>
181
+ <FieldControl>
182
+ <Input />
183
+ </FieldControl>
184
+ <FieldError id="my-custom-error-id">Enter a valid email address.</FieldError>
185
+ </FieldRoot>,
186
+ );
187
+ const input = screen.getByRole("textbox");
188
+ const alert = screen.getByRole("alert");
189
+ expect(alert.id).toBe("my-custom-error-id");
190
+ const describedBy = input.getAttribute("aria-describedby");
191
+ expect(describedBy).toBe("my-custom-error-id");
192
+ expect(document.getElementById(describedBy!)).toBe(alert);
193
+ });
194
+
195
+ // Fix round 1 (validator FAIL on cadac6e): `{condition && "message"}` is the
196
+ // ordinary React idiom for optional content — when `condition` is false,
197
+ // `children` is the boolean `false`, not `undefined`/`null`/`""`. FieldError
198
+ // must render nothing (and register nothing) in that case, exactly like
199
+ // FieldRow's `error ? … : null`, or a screen reader gets an alert with
200
+ // nothing in it and `aria-describedby` points at a dangling target.
201
+ it("renders no alert element (and no aria-describedby reference) when FieldError's children is false", () => {
202
+ render(
203
+ <FieldRoot>
204
+ <FieldLabel>Email</FieldLabel>
205
+ <FieldControl>
206
+ <Input />
207
+ </FieldControl>
208
+ <FieldError>{false}</FieldError>
209
+ </FieldRoot>,
210
+ );
211
+ const input = screen.getByRole("textbox");
212
+ expect(screen.queryByRole("alert")).toBeNull();
213
+ expect(input).not.toHaveAttribute("aria-describedby");
214
+ });
215
+
216
+ // Fix round 1: two `FieldDescription`s under one `FieldRoot` (e.g. a hint
217
+ // above the control and a hint below it) must not collide on one shared id —
218
+ // each needs its own DOM id, and both must be referenced.
219
+ it("gives two FieldDescriptions under one FieldRoot distinct ids, both referenced by aria-describedby", () => {
220
+ render(
221
+ <FieldRoot>
222
+ <FieldLabel>Password</FieldLabel>
223
+ <FieldDescription>Use at least 12 characters.</FieldDescription>
224
+ <FieldControl>
225
+ <Input type="password" />
226
+ </FieldControl>
227
+ <FieldDescription>You can change this later in settings.</FieldDescription>
228
+ </FieldRoot>,
229
+ );
230
+ const input = screen.getByLabelText("Password");
231
+ const first = screen.getByText("Use at least 12 characters.");
232
+ const second = screen.getByText("You can change this later in settings.");
233
+ expect(first.id).toBeTruthy();
234
+ expect(second.id).toBeTruthy();
235
+ expect(first.id).not.toBe(second.id);
236
+ const describedBy = input.getAttribute("aria-describedby")?.split(" ") ?? [];
237
+ expect(describedBy).toEqual(expect.arrayContaining([first.id, second.id]));
238
+ expect(describedBy).toHaveLength(2);
239
+ });
240
+
241
+ // Fix round 1: unmounting one of two same-type parts must not strip the
242
+ // still-mounted sibling's own reference — the registration has to be
243
+ // tracked per INSTANCE, not per part type.
244
+ it("keeps the remaining FieldDescription's reference intact after unmounting a sibling FieldDescription", async () => {
245
+ function Harness() {
246
+ const [showSecond, setShowSecond] = useState(true);
247
+ return (
248
+ <FieldRoot>
249
+ <FieldLabel>Password</FieldLabel>
250
+ <FieldDescription>Use at least 12 characters.</FieldDescription>
251
+ <FieldControl>
252
+ <Input type="password" />
253
+ </FieldControl>
254
+ {showSecond ? (
255
+ <FieldDescription>You can change this later in settings.</FieldDescription>
256
+ ) : null}
257
+ <button type="button" onClick={() => setShowSecond(false)}>
258
+ Hide second hint
259
+ </button>
260
+ </FieldRoot>
261
+ );
262
+ }
263
+ render(<Harness />);
264
+ const input = screen.getByLabelText("Password");
265
+ const first = screen.getByText("Use at least 12 characters.");
266
+ const firstId = first.id;
267
+ expect(input.getAttribute("aria-describedby")?.split(" ")).toHaveLength(2);
268
+
269
+ await userEvent.click(screen.getByRole("button", { name: "Hide second hint" }));
270
+
271
+ expect(screen.getByText("Use at least 12 characters.")).toBe(first);
272
+ expect(input.getAttribute("aria-describedby")).toBe(firstId);
273
+ });
274
+
275
+ // Fix round 3 (validator FAIL on the round-2 commit, then narrowed by the
276
+ // coordinator): `Boolean(children)` cannot see an empty/all-falsy ARRAY,
277
+ // because an array is always truthy — `{errors.map(...)}` on an empty list
278
+ // is exactly how a real form renders "no errors", so this must render
279
+ // nothing, same as the scalar falsy cases already covered above.
280
+ it("renders no error element for an empty array or an array of only falsy children", () => {
281
+ const { rerender } = render(
282
+ <FieldRoot>
283
+ <FieldLabel>Email</FieldLabel>
284
+ <FieldControl>
285
+ <Input />
286
+ </FieldControl>
287
+ <FieldError>{[]}</FieldError>
288
+ </FieldRoot>,
289
+ );
290
+ let input = screen.getByRole("textbox");
291
+ expect(screen.queryByRole("alert")).toBeNull();
292
+ expect(input).not.toHaveAttribute("aria-describedby");
293
+
294
+ rerender(
295
+ <FieldRoot>
296
+ <FieldLabel>Email</FieldLabel>
297
+ <FieldControl>
298
+ <Input />
299
+ </FieldControl>
300
+ <FieldError>{[false, null]}</FieldError>
301
+ </FieldRoot>,
302
+ );
303
+ input = screen.getByRole("textbox");
304
+ expect(screen.queryByRole("alert")).toBeNull();
305
+ expect(input).not.toHaveAttribute("aria-describedby");
306
+ });
307
+
308
+ // Fix round 4 (validator FAIL on the round-3 commit): `Children.toArray`
309
+ // drops `null`/`undefined`/booleans from an array but KEEPS `""` (and `0`),
310
+ // so `["", ""]` survived `toArray` with length 2 and still rendered an
311
+ // empty, `aria-describedby`-referenced alert even though the JSDoc already
312
+ // named `""` as falsy content. This must render nothing, same as every
313
+ // other all-falsy array shape.
314
+ it("renders no error element for an array of only empty strings", () => {
315
+ render(
316
+ <FieldRoot>
317
+ <FieldLabel>Email</FieldLabel>
318
+ <FieldControl>
319
+ <Input />
320
+ </FieldControl>
321
+ <FieldError>{["", ""]}</FieldError>
322
+ </FieldRoot>,
323
+ );
324
+ const input = screen.getByRole("textbox");
325
+ expect(screen.queryByRole("alert")).toBeNull();
326
+ expect(input).not.toHaveAttribute("aria-describedby");
327
+ });
328
+
329
+ it("renders no description element for an empty array or an array of only falsy children", () => {
330
+ const { container, rerender } = render(
331
+ <FieldRoot>
332
+ <FieldLabel>Bio</FieldLabel>
333
+ <FieldControl>
334
+ <Input />
335
+ </FieldControl>
336
+ <FieldDescription>{[false, null]}</FieldDescription>
337
+ </FieldRoot>,
338
+ );
339
+ let input = screen.getByRole("textbox");
340
+ expect(container.querySelector('[data-slot="field-description"]')).toBeNull();
341
+ expect(input).not.toHaveAttribute("aria-describedby");
342
+
343
+ // Same round-4 gap as FieldError: `["", ""]` survives `Children.toArray`
344
+ // (which keeps `""`) and must still be treated as no content.
345
+ rerender(
346
+ <FieldRoot>
347
+ <FieldLabel>Bio</FieldLabel>
348
+ <FieldControl>
349
+ <Input />
350
+ </FieldControl>
351
+ <FieldDescription>{["", ""]}</FieldDescription>
352
+ </FieldRoot>,
353
+ );
354
+ input = screen.getByRole("textbox");
355
+ expect(container.querySelector('[data-slot="field-description"]')).toBeNull();
356
+ expect(input).not.toHaveAttribute("aria-describedby");
357
+ });
358
+
359
+ // KNOWN LIMIT, not desired behavior: a child that is itself a component
360
+ // returning `null` is not knowable from the element before render (React
361
+ // ecosystem-wide limit, shared by FieldRow), so it still produces an empty
362
+ // `role="alert"` referenced by `aria-describedby`. This test PINS the
363
+ // current, documented-limit behavior so a future change to it is a
364
+ // deliberate decision, not a silent regression — it is not an endorsement,
365
+ // see the "Known limit" JSDoc on `FieldError`.
366
+ it("[KNOWN LIMIT] still renders an empty, referenced alert when the child is a component that renders null", () => {
367
+ function RendersNull() {
368
+ return null;
369
+ }
370
+ render(
371
+ <FieldRoot>
372
+ <FieldLabel>Email</FieldLabel>
373
+ <FieldControl>
374
+ <Input />
375
+ </FieldControl>
376
+ <FieldError>
377
+ <RendersNull />
378
+ </FieldError>
379
+ </FieldRoot>,
380
+ );
381
+ const input = screen.getByRole("textbox");
382
+ const alert = screen.getByRole("alert");
383
+ expect(alert).toBeEmptyDOMElement();
384
+ expect(input.getAttribute("aria-describedby")).toBe(alert.id);
385
+ });
386
+
387
+ // #93 (A) — the same KNOWN LIMIT, mirrored on FieldDescription: a child
388
+ // that is itself a component returning `null` is not knowable from the
389
+ // element before render, so it still produces an empty paragraph that
390
+ // `aria-describedby` points at. This test PINS the current,
391
+ // documented-limit behavior so a future change to it is a deliberate
392
+ // decision, not a silent regression — it is not an endorsement, see the
393
+ // "Known limit" JSDoc on `FieldDescription`.
394
+ it("[KNOWN LIMIT] still renders an empty, referenced paragraph when the child is a component that renders null", () => {
395
+ function RendersNull() {
396
+ return null;
397
+ }
398
+ render(
399
+ <FieldRoot>
400
+ <FieldLabel>Email</FieldLabel>
401
+ <FieldControl>
402
+ <Input />
403
+ </FieldControl>
404
+ <FieldDescription>
405
+ <RendersNull />
406
+ </FieldDescription>
407
+ </FieldRoot>,
408
+ );
409
+ const input = screen.getByRole("textbox");
410
+ const description = document.querySelector('[data-slot="field-description"]');
411
+ expect(description).not.toBeNull();
412
+ expect(description).toBeEmptyDOMElement();
413
+ expect(input.getAttribute("aria-describedby")).toBe(description!.id);
414
+ });
415
+ });