@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.
- package/README.md +10 -8
- package/dist/chunk-JKPPTL63.js +48 -0
- package/dist/chunk-JKPPTL63.js.map +1 -0
- package/dist/form.d.ts +28 -0
- package/dist/form.js +123 -0
- package/dist/form.js.map +1 -0
- package/dist/index.d.ts +2485 -167
- package/dist/index.js +10716 -2706
- package/dist/index.js.map +1 -1
- package/package.json +21 -7
- package/src/_peer-deps.test.ts +59 -0
- package/src/blocks-comparison-table.stories.tsx +2 -2
- package/src/components/accordion/accordion.tsx +1 -1
- package/src/components/advanced-group/advanced-group.tsx +1 -1
- package/src/components/alert/alert.tsx +4 -1
- package/src/components/app-shell/app-shell.stories.tsx +44 -2
- package/src/components/app-shell/app-shell.tsx +35 -2
- package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
- package/src/components/attribution-panel/attribution-panel.tsx +1 -1
- package/src/components/attribution-panel/attributions.generated.ts +81 -77
- package/src/components/badge/badge.stories.tsx +8 -0
- package/src/components/badge/badge.tsx +1 -1
- package/src/components/bento-grid/bento-grid.test.tsx +1 -1
- package/src/components/bento-grid/bento-grid.tsx +1 -1
- package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
- package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
- package/src/components/breadcrumb/breadcrumb.tsx +7 -2
- package/src/components/button/button.stories.tsx +116 -8
- package/src/components/button/button.tsx +1 -1
- package/src/components/card/card.stories.tsx +11 -6
- package/src/components/card/card.tsx +1 -1
- package/src/components/change-review/change-review.stories.tsx +145 -1
- package/src/components/change-review/change-review.test.tsx +143 -0
- package/src/components/change-review/change-review.tsx +185 -4
- package/src/components/checkbox/checkbox.tsx +1 -1
- package/src/components/color-picker/color-picker.tsx +6 -5
- package/src/components/command/command.stories.tsx +16 -6
- package/src/components/command/command.test.tsx +73 -0
- package/src/components/command/command.tsx +30 -7
- package/src/components/command/index.ts +1 -0
- package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
- package/src/components/command-trigger/command-trigger.test.tsx +18 -0
- package/src/components/command-trigger/command-trigger.tsx +70 -0
- package/src/components/command-trigger/index.ts +1 -0
- package/src/components/context-rail/context-rail.stories.tsx +285 -0
- package/src/components/context-rail/context-rail.test.tsx +263 -0
- package/src/components/context-rail/context-rail.tsx +678 -0
- package/src/components/context-rail/index.ts +1 -0
- package/src/components/copyable-value/copyable-value.tsx +1 -1
- package/src/components/dialog/dialog.tsx +2 -2
- package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
- package/src/components/empty-state/empty-state.test.tsx +26 -0
- package/src/components/empty-state/empty-state.tsx +6 -1
- package/src/components/expand-dialog/expand-dialog.tsx +1 -2
- package/src/components/field/field-context.ts +59 -0
- package/src/components/field/field.stories.tsx +142 -0
- package/src/components/field/field.test.tsx +415 -0
- package/src/components/field/field.tsx +340 -0
- package/src/components/field/index.ts +9 -0
- package/src/components/field-row/field-row.stories.tsx +85 -1
- package/src/components/field-row/field-row.test.tsx +145 -0
- package/src/components/field-row/field-row.tsx +48 -6
- package/src/components/file-upload/file-upload.stories.tsx +16 -0
- package/src/components/file-upload/file-upload.test.tsx +7 -2
- package/src/components/file-upload/file-upload.tsx +15 -4
- package/src/components/form/form.tsx +5 -2
- package/src/components/icon-button/icon-button.test.tsx +1 -1
- package/src/components/icon-button/icon-button.tsx +2 -2
- package/src/components/input/input.stories.tsx +121 -0
- package/src/components/input/input.test.tsx +13 -0
- package/src/components/input/input.tsx +2 -2
- package/src/components/input-group/input-group.stories.tsx +74 -0
- package/src/components/input-group/input-group.tsx +13 -4
- package/src/components/input-otp/input-otp.tsx +5 -1
- package/src/components/keyboard-shortcuts/index.ts +6 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
- package/src/components/link-preview/link-preview.tsx +1 -1
- package/src/components/locale-provider/index.ts +6 -1
- package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
- package/src/components/locale-provider/locale-provider.test.tsx +257 -1
- package/src/components/locale-provider/locale-provider.tsx +63 -8
- package/src/components/locale-provider/messages.ts +501 -2
- package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
- package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
- package/src/components/mention-input/mention-input.tsx +8 -38
- package/src/components/mention-input/mention-value.ts +8 -2
- package/src/components/metric-card/metric-card.stories.tsx +5 -3
- package/src/components/metric-card/metric-card.tsx +21 -2
- package/src/components/model-picker/model-picker.stories.tsx +63 -3
- package/src/components/model-picker/model-picker.test.tsx +63 -0
- package/src/components/model-picker/model-picker.tsx +62 -36
- package/src/components/nav-main/nav-main.tsx +1 -1
- package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
- package/src/components/nav-notifications/nav-notifications.tsx +57 -5
- package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
- package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
- package/src/components/navigation-menu/navigation-menu.tsx +189 -12
- package/src/components/page-shell/page-shell.stories.tsx +163 -0
- package/src/components/page-shell/page-shell.test.tsx +229 -0
- package/src/components/page-shell/page-shell.tsx +108 -25
- package/src/components/pagination/pagination.test.tsx +90 -0
- package/src/components/pagination/pagination.tsx +9 -5
- package/src/components/radio-group/radio-group.tsx +1 -1
- package/src/components/rating/rating.tsx +1 -1
- package/src/components/resizable/resizable.stories.tsx +114 -2
- package/src/components/resizable/resizable.tsx +1 -1
- package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
- package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
- package/src/components/revision-timeline/revision-timeline.tsx +46 -3
- package/src/components/schema-form/from-json-schema.test.ts +390 -0
- package/src/components/schema-form/from-json-schema.ts +375 -0
- package/src/components/schema-form/index.ts +81 -0
- package/src/components/schema-form/schema-form-spec.ts +698 -0
- package/src/components/schema-form/schema-form.stories.tsx +566 -0
- package/src/components/schema-form/schema-form.test.tsx +900 -0
- package/src/components/schema-form/schema-form.tsx +1545 -0
- package/src/components/section-header/section-header.stories.tsx +37 -0
- package/src/components/section-header/section-header.tsx +12 -1
- package/src/components/select/select.tsx +6 -2
- package/src/components/sheet/sheet.tsx +1 -1
- package/src/components/side-dock/index.ts +1 -0
- package/src/components/side-dock/side-dock.stories.tsx +137 -0
- package/src/components/side-dock/side-dock.test.tsx +273 -0
- package/src/components/side-dock/side-dock.tsx +502 -0
- package/src/components/sidebar/index.ts +1 -0
- package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
- package/src/components/sidebar/sidebar.stories.tsx +421 -1
- package/src/components/sidebar/sidebar.test.tsx +36 -1
- package/src/components/sidebar/sidebar.tsx +245 -33
- package/src/components/skip-link/index.ts +1 -0
- package/src/components/skip-link/skip-link.stories.tsx +106 -0
- package/src/components/skip-link/skip-link.test.tsx +20 -0
- package/src/components/skip-link/skip-link.tsx +34 -0
- package/src/components/slider/slider.tsx +1 -1
- package/src/components/split-panel/index.ts +6 -1
- package/src/components/split-panel/split-panel.stories.tsx +18 -2
- package/src/components/split-panel/split-panel.test.tsx +20 -0
- package/src/components/split-panel/split-panel.tsx +28 -8
- package/src/components/state-panel/state-panel.stories.tsx +52 -0
- package/src/components/state-panel/state-panel.test.tsx +125 -0
- package/src/components/state-panel/state-panel.tsx +96 -17
- package/src/components/status-badge/status-badge.stories.tsx +8 -0
- package/src/components/switch/switch.tsx +1 -1
- package/src/components/table/table.stories.tsx +62 -0
- package/src/components/table/table.test.tsx +142 -1
- package/src/components/table/table.tsx +60 -1
- package/src/components/tabs/tabs.tsx +8 -2
- package/src/components/tag-input/tag-input.tsx +3 -2
- package/src/components/team-switcher/team-switcher.tsx +34 -5
- package/src/components/textarea/textarea.tsx +1 -1
- package/src/components/theme-switcher/theme-switcher.tsx +6 -2
- package/src/components/theme-switcher/use-theme-transition.ts +2 -2
- package/src/components/timeline/timeline.stories.tsx +27 -0
- package/src/components/timeline/timeline.tsx +11 -1
- package/src/components/toggle/toggle.tsx +1 -1
- package/src/components/toolbar/toolbar.stories.tsx +10 -2
- package/src/components/tree/tree.stories.tsx +2 -2
- package/src/components/tree/tree.tsx +14 -9
- package/src/components/typography/prose.tsx +1 -1
- package/src/components/typography/typography.stories.tsx +228 -0
- package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
- package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
- package/src/components/view-toolbar/view-toolbar.tsx +37 -4
- package/src/components/virtual-select/virtual-select.test.tsx +1 -1
- package/src/components/wizard/wizard.tsx +1 -1
- package/src/components/workspace-picker/index.ts +3 -0
- package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
- package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
- package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
- package/src/components/workspace-picker/workspace-picker.tsx +172 -0
- package/src/illustrations/empty-list-illustration.tsx +26 -0
- package/src/illustrations/error-illustration.tsx +60 -0
- package/src/illustrations/first-run-illustration.tsx +27 -0
- package/src/illustrations/illustration-base.tsx +108 -0
- package/src/illustrations/illustrations.stories.tsx +94 -0
- package/src/illustrations/illustrations.test.tsx +98 -0
- package/src/illustrations/index.ts +16 -0
- package/src/illustrations/no-access-illustration.tsx +45 -0
- package/src/illustrations/no-results-illustration.tsx +46 -0
- package/src/illustrations/offline-illustration.tsx +37 -0
- package/src/illustrations/success-illustration.tsx +40 -0
- package/src/index.ts +75 -2
- package/src/lib/agent-event-model.test.ts +12 -0
- package/src/lib/agent-event-model.ts +42 -0
- package/src/lib/approval-option.test.ts +17 -0
- package/src/lib/approval-option.ts +53 -0
- package/src/lib/check-result.ts +33 -0
- package/src/lib/diff-line.test.ts +36 -0
- package/src/lib/diff-line.ts +63 -0
- package/src/lib/diff-rows.test.ts +76 -0
- package/src/lib/diff-rows.ts +92 -0
- package/src/lib/format-duration.test.ts +22 -0
- package/src/lib/format-duration.ts +30 -0
- package/src/lib/has-renderable-content.test.ts +49 -0
- package/src/lib/has-renderable-content.ts +32 -0
- package/src/lib/operating-mode.test.ts +36 -0
- package/src/lib/operating-mode.ts +81 -0
- package/src/lib/optional-peer.ts +59 -0
- package/src/lib/session-launch.ts +37 -0
- package/src/lib/slash-command.test.ts +44 -0
- package/src/lib/slash-command.ts +45 -0
- package/src/lib/trigger-query.test.ts +94 -0
- package/src/lib/trigger-query.ts +88 -0
- package/src/lib/use-mobile.ts +12 -6
- package/src/motion.stories.tsx +6 -0
- package/src/templates-object-detail-hub.stories.tsx +2 -2
- package/src/templates-screen-states.stories.tsx +5 -5
- package/src/templates-settings.stories.tsx +1 -1
- package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
- package/src/blocks/sidebar-02/logo.tsx +0 -18
- package/src/blocks/sidebar-02/nav-main.tsx +0 -13
- package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
- package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
- package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
- package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
- package/src/blocks/sidebar-04/mail-context.tsx +0 -29
- package/src/blocks/sidebar-04/nav-user.tsx +0 -6
- package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
- package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
- package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
- 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
|
+
});
|