@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,340 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cloneElement,
|
|
3
|
+
forwardRef,
|
|
4
|
+
useCallback,
|
|
5
|
+
useId,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useState,
|
|
9
|
+
type ComponentPropsWithoutRef,
|
|
10
|
+
type ElementRef,
|
|
11
|
+
type HTMLAttributes,
|
|
12
|
+
type ReactElement,
|
|
13
|
+
} from "react";
|
|
14
|
+
import { Slot } from "@radix-ui/react-slot";
|
|
15
|
+
import { cn } from "../../lib/cn";
|
|
16
|
+
import { hasRenderableContent } from "../../lib/has-renderable-content";
|
|
17
|
+
import { Label } from "../label";
|
|
18
|
+
import { FieldContext, useFieldContext, type FieldContextValue } from "./field-context";
|
|
19
|
+
|
|
20
|
+
// ── FieldRoot ─────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export interface FieldRootProps extends HTMLAttributes<HTMLDivElement> {
|
|
23
|
+
/**
|
|
24
|
+
* Whether the field is currently invalid. Drives `aria-invalid` on every
|
|
25
|
+
* `FieldControl` and error styling on `FieldLabel` — independent of
|
|
26
|
+
* whether a `FieldError` with content is actually mounted, so a caller can
|
|
27
|
+
* mark the control invalid before its message has settled.
|
|
28
|
+
*/
|
|
29
|
+
invalid?: boolean;
|
|
30
|
+
/** Whether the field is required. Drives `aria-required` on every `FieldControl`. */
|
|
31
|
+
required?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Compound-anatomy field: `FieldRoot` owns id generation and `aria-describedby`
|
|
36
|
+
* composition; `FieldLabel`/`FieldControl`/`FieldDescription`/`FieldError` read
|
|
37
|
+
* that state from context and can be composed in ANY order/layout — including
|
|
38
|
+
* more than one `FieldControl` in one row (e.g. first/last name) or a
|
|
39
|
+
* `FieldDescription` placed before the control. Mirrors the same accessibility
|
|
40
|
+
* wiring `FieldRow` (`../field-row`) already validated (id/`aria-describedby`/
|
|
41
|
+
* `aria-invalid`/`role="alert"`), adapted to a shared lifted-state context so
|
|
42
|
+
* it holds across independently-composed parts; `FieldRow` remains the
|
|
43
|
+
* convenience wrapper for the common single-control case and is unaffected by
|
|
44
|
+
* this addition (#43).
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```tsx
|
|
48
|
+
* <FieldRoot invalid={!!error} required>
|
|
49
|
+
* <FieldLabel>{label}</FieldLabel>
|
|
50
|
+
* <FieldControl><Input /></FieldControl>
|
|
51
|
+
* <FieldDescription>{helpText}</FieldDescription>
|
|
52
|
+
* <FieldError>{error}</FieldError>
|
|
53
|
+
* </FieldRoot>
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
export const FieldRoot = forwardRef<HTMLDivElement, FieldRootProps>(function FieldRoot(
|
|
57
|
+
{ invalid = false, required = false, className, children, ...props },
|
|
58
|
+
ref,
|
|
59
|
+
) {
|
|
60
|
+
// Each `FieldDescription`/`FieldError` INSTANCE generates and registers its
|
|
61
|
+
// own id (see below) — two lists, not one shared slot per part type, so
|
|
62
|
+
// more than one of either part under one `FieldRoot` gets distinct ids
|
|
63
|
+
// instead of colliding, and unmounting one instance only ever removes that
|
|
64
|
+
// instance's own id from its own list (never a sibling's).
|
|
65
|
+
const [descriptionIds, setDescriptionIds] = useState<readonly string[]>([]);
|
|
66
|
+
const registerDescription = useCallback((descId: string) => {
|
|
67
|
+
setDescriptionIds((prev) => (prev.includes(descId) ? prev : [...prev, descId]));
|
|
68
|
+
}, []);
|
|
69
|
+
const unregisterDescription = useCallback((descId: string) => {
|
|
70
|
+
setDescriptionIds((prev) => (prev.includes(descId) ? prev.filter((x) => x !== descId) : prev));
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
const [errorIds, setErrorIds] = useState<readonly string[]>([]);
|
|
74
|
+
const registerError = useCallback((errId: string) => {
|
|
75
|
+
setErrorIds((prev) => (prev.includes(errId) ? prev : [...prev, errId]));
|
|
76
|
+
}, []);
|
|
77
|
+
const unregisterError = useCallback((errId: string) => {
|
|
78
|
+
setErrorIds((prev) => (prev.includes(errId) ? prev.filter((x) => x !== errId) : prev));
|
|
79
|
+
}, []);
|
|
80
|
+
|
|
81
|
+
// Fixed semantic order — every description before every error — regardless
|
|
82
|
+
// of DOM order or registration order, so "description placed before the
|
|
83
|
+
// control" reorders the VISIBLE layout without reordering the announced
|
|
84
|
+
// description, and a second description/error is appended rather than
|
|
85
|
+
// replacing the first.
|
|
86
|
+
const describedBy = useMemo(() => {
|
|
87
|
+
const ids = [...descriptionIds, ...errorIds];
|
|
88
|
+
return ids.length > 0 ? ids.join(" ") : undefined;
|
|
89
|
+
}, [descriptionIds, errorIds]);
|
|
90
|
+
|
|
91
|
+
// `FieldLabel`'s `htmlFor` can only point to ONE control, so it binds to
|
|
92
|
+
// whichever `FieldControl` registers FIRST (JSX/mount order) — the common
|
|
93
|
+
// single-control case gets this for free; a multi-control row (e.g.
|
|
94
|
+
// first/last name) still labels the first field, and every additional
|
|
95
|
+
// control needs its own explicit `id` the same way any two form controls
|
|
96
|
+
// would (no new coordination prop invented for this).
|
|
97
|
+
const [controlOrder, setControlOrder] = useState<readonly string[]>([]);
|
|
98
|
+
const registerControl = useCallback((controlId: string) => {
|
|
99
|
+
setControlOrder((prev) => (prev.includes(controlId) ? prev : [...prev, controlId]));
|
|
100
|
+
}, []);
|
|
101
|
+
const unregisterControl = useCallback((controlId: string) => {
|
|
102
|
+
setControlOrder((prev) =>
|
|
103
|
+
prev.includes(controlId) ? prev.filter((x) => x !== controlId) : prev,
|
|
104
|
+
);
|
|
105
|
+
}, []);
|
|
106
|
+
const labelFor = controlOrder[0];
|
|
107
|
+
|
|
108
|
+
const value = useMemo<FieldContextValue>(
|
|
109
|
+
() => ({
|
|
110
|
+
labelFor,
|
|
111
|
+
registerControl,
|
|
112
|
+
unregisterControl,
|
|
113
|
+
invalid,
|
|
114
|
+
required,
|
|
115
|
+
describedBy,
|
|
116
|
+
registerDescription,
|
|
117
|
+
unregisterDescription,
|
|
118
|
+
registerError,
|
|
119
|
+
unregisterError,
|
|
120
|
+
}),
|
|
121
|
+
[
|
|
122
|
+
labelFor,
|
|
123
|
+
registerControl,
|
|
124
|
+
unregisterControl,
|
|
125
|
+
invalid,
|
|
126
|
+
required,
|
|
127
|
+
describedBy,
|
|
128
|
+
registerDescription,
|
|
129
|
+
unregisterDescription,
|
|
130
|
+
registerError,
|
|
131
|
+
unregisterError,
|
|
132
|
+
],
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<FieldContext.Provider value={value}>
|
|
137
|
+
<div ref={ref} data-slot="field" className={cn("space-y-2", className)} {...props}>
|
|
138
|
+
{children}
|
|
139
|
+
</div>
|
|
140
|
+
</FieldContext.Provider>
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ── FieldLabel ────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
export const FieldLabel = forwardRef<
|
|
147
|
+
ElementRef<typeof Label>,
|
|
148
|
+
ComponentPropsWithoutRef<typeof Label>
|
|
149
|
+
>(function FieldLabel({ className, ...props }, ref) {
|
|
150
|
+
const { labelFor, invalid } = useFieldContext("FieldLabel");
|
|
151
|
+
return (
|
|
152
|
+
<Label
|
|
153
|
+
ref={ref}
|
|
154
|
+
htmlFor={labelFor}
|
|
155
|
+
data-slot="field-label"
|
|
156
|
+
className={cn(
|
|
157
|
+
"transition-colors duration-fast ease-standard",
|
|
158
|
+
invalid && "text-destructive-text",
|
|
159
|
+
className,
|
|
160
|
+
)}
|
|
161
|
+
{...props}
|
|
162
|
+
/>
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ── FieldControl ──────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
export interface FieldControlProps {
|
|
169
|
+
/**
|
|
170
|
+
* The single field control (`Input`, `Textarea`, `Select`, …). Receives
|
|
171
|
+
* `id`/`aria-describedby`/`aria-invalid`/`aria-required` via a Radix
|
|
172
|
+
* `Slot` — must be a single element that forwards those props to a real
|
|
173
|
+
* form control. Compose more than one `FieldControl` inside one
|
|
174
|
+
* `FieldRoot` for a multi-control row (each gets its own independent id;
|
|
175
|
+
* give every control but the first its own explicit `id`, the same way
|
|
176
|
+
* you would for any two form controls that must not collide). `FieldLabel`
|
|
177
|
+
* can only associate with ONE control (the first to mount), so give every
|
|
178
|
+
* OTHER control in the row its own `aria-label` — a shared visual label
|
|
179
|
+
* plus a placeholder is not a real accessible name for the rest.
|
|
180
|
+
*/
|
|
181
|
+
children: ReactElement<{
|
|
182
|
+
id?: string;
|
|
183
|
+
"aria-describedby"?: string;
|
|
184
|
+
}>;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export const FieldControl = forwardRef<ElementRef<typeof Slot>, FieldControlProps>(
|
|
188
|
+
function FieldControl({ children }, ref) {
|
|
189
|
+
const { invalid, required, describedBy, registerControl, unregisterControl } =
|
|
190
|
+
useFieldContext("FieldControl");
|
|
191
|
+
// Independent per-instance fallback id — guarantees uniqueness with no
|
|
192
|
+
// coordination needed when more than one `FieldControl` shares a
|
|
193
|
+
// `FieldRoot` (e.g. a first/last-name row).
|
|
194
|
+
const generatedId = useId();
|
|
195
|
+
|
|
196
|
+
// Same "child wins, but COMPOSE rather than discard" merge FieldRow uses:
|
|
197
|
+
// Radix `Slot` merges child props OVER slot props, so a control that
|
|
198
|
+
// already carries its own `id`/`aria-describedby` must not silently lose
|
|
199
|
+
// the label association / description-error wiring.
|
|
200
|
+
const control = children;
|
|
201
|
+
const childProps = control.props;
|
|
202
|
+
const id = childProps.id ?? generatedId;
|
|
203
|
+
const composedDescribedBy =
|
|
204
|
+
[childProps["aria-describedby"] ?? null, describedBy ?? null].filter(Boolean).join(" ") ||
|
|
205
|
+
undefined;
|
|
206
|
+
|
|
207
|
+
useLayoutEffect(() => {
|
|
208
|
+
registerControl(id);
|
|
209
|
+
return () => unregisterControl(id);
|
|
210
|
+
}, [id, registerControl, unregisterControl]);
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<Slot
|
|
214
|
+
ref={ref}
|
|
215
|
+
id={id}
|
|
216
|
+
aria-describedby={composedDescribedBy}
|
|
217
|
+
aria-invalid={invalid}
|
|
218
|
+
aria-required={required || undefined}
|
|
219
|
+
data-slot="field-control"
|
|
220
|
+
>
|
|
221
|
+
{cloneElement(control, { id, "aria-describedby": composedDescribedBy })}
|
|
222
|
+
</Slot>
|
|
223
|
+
);
|
|
224
|
+
},
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
// ── FieldDescription ──────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Optional field help text. Renders nothing — and registers no
|
|
231
|
+
* `aria-describedby` reference — when `children` is falsy (`false`/`0`/`""`/
|
|
232
|
+
* `null`/`undefined`), an empty array, or an array containing only falsy
|
|
233
|
+
* values (`{list.map(...)}` on an empty list; `[a && "x", b && "y"]` with
|
|
234
|
+
* both false) — matching `FieldRow`'s `description ? … : null` convention:
|
|
235
|
+
* `{hint && <FieldDescription>{hint}</FieldDescription>}` is the supported
|
|
236
|
+
* way to express "no description".
|
|
237
|
+
*
|
|
238
|
+
* **Known limit:** a child that is itself a COMPONENT that renders nothing
|
|
239
|
+
* (returns `null`/an empty fragment) is not knowable from the element
|
|
240
|
+
* before render, so it still produces an empty paragraph that
|
|
241
|
+
* `aria-describedby` points at. `FieldRow` and the wider React ecosystem
|
|
242
|
+
* share this limit — pass a falsy child instead of a component that may
|
|
243
|
+
* render nothing.
|
|
244
|
+
*/
|
|
245
|
+
export const FieldDescription = forwardRef<
|
|
246
|
+
HTMLParagraphElement,
|
|
247
|
+
HTMLAttributes<HTMLParagraphElement>
|
|
248
|
+
>(function FieldDescription({ className, children, id, ...props }, ref) {
|
|
249
|
+
const { registerDescription, unregisterDescription } = useFieldContext("FieldDescription");
|
|
250
|
+
// Own id per INSTANCE — two `FieldDescription`s under one `FieldRoot` (a hint
|
|
251
|
+
// above the control and a hint below it) must not collide on one shared id.
|
|
252
|
+
const instanceId = useId();
|
|
253
|
+
const generatedId = `${instanceId}-description`;
|
|
254
|
+
// Caller-supplied `id` wins — same "child wins" resolution `FieldControl`
|
|
255
|
+
// already uses (`childProps.id ?? generatedId`). Resolving ONE effective id
|
|
256
|
+
// up front, and destructuring `id` out of `...props` above, means the value
|
|
257
|
+
// registered into `aria-describedby` and the value actually rendered on the
|
|
258
|
+
// `<p>` can never diverge (PR #97 review finding 3).
|
|
259
|
+
const descriptionId = id ?? generatedId;
|
|
260
|
+
// Same falsy-content convention `FieldRow` uses (`description ? … : null`)
|
|
261
|
+
// — see `hasRenderableContent` (`../../lib/has-renderable-content`) for
|
|
262
|
+
// the full rationale, shared with `FieldError` below and `FieldRow`.
|
|
263
|
+
const hasContent = hasRenderableContent(children);
|
|
264
|
+
|
|
265
|
+
useLayoutEffect(() => {
|
|
266
|
+
if (!hasContent) return undefined;
|
|
267
|
+
registerDescription(descriptionId);
|
|
268
|
+
return () => unregisterDescription(descriptionId);
|
|
269
|
+
}, [hasContent, descriptionId, registerDescription, unregisterDescription]);
|
|
270
|
+
|
|
271
|
+
if (!hasContent) return null;
|
|
272
|
+
|
|
273
|
+
return (
|
|
274
|
+
<p
|
|
275
|
+
ref={ref}
|
|
276
|
+
id={descriptionId}
|
|
277
|
+
data-slot="field-description"
|
|
278
|
+
className={cn("text-body text-muted-foreground", className)}
|
|
279
|
+
{...props}
|
|
280
|
+
>
|
|
281
|
+
{children}
|
|
282
|
+
</p>
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// ── FieldError ────────────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Validation error text, announced via `role="alert"`. Renders nothing —
|
|
290
|
+
* and registers no `aria-describedby` reference — when `children` is falsy
|
|
291
|
+
* (`false`/`0`/`""`/`null`/`undefined`), an empty array, or an array
|
|
292
|
+
* containing only falsy values (`{errors.map(...)}` on an empty array is
|
|
293
|
+
* exactly how a real form renders "no errors") — matching `FieldRow`'s
|
|
294
|
+
* `error ? … : null` convention: `{error && <FieldError>{error}</FieldError>}`
|
|
295
|
+
* is the supported way to express "no error".
|
|
296
|
+
*
|
|
297
|
+
* **Known limit:** a child that is itself a COMPONENT that renders nothing
|
|
298
|
+
* (returns `null`/an empty fragment) is not knowable from the element
|
|
299
|
+
* before render, so it still produces an empty `role="alert"` element that
|
|
300
|
+
* `aria-describedby` points at (a screen reader announces an empty alert).
|
|
301
|
+
* `FieldRow` and the wider React ecosystem share this limit — pass a falsy
|
|
302
|
+
* child instead of a component that may render nothing.
|
|
303
|
+
*/
|
|
304
|
+
export const FieldError = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
|
|
305
|
+
function FieldError({ className, children, id, ...props }, ref) {
|
|
306
|
+
const { registerError, unregisterError } = useFieldContext("FieldError");
|
|
307
|
+
// Own id per INSTANCE — same reasoning as `FieldDescription`.
|
|
308
|
+
const instanceId = useId();
|
|
309
|
+
const generatedId = `${instanceId}-error`;
|
|
310
|
+
// Caller-supplied `id` wins — same resolution as `FieldDescription`/
|
|
311
|
+
// `FieldControl` (PR #97 review finding 3: this had the identical
|
|
312
|
+
// ordering bug as `FieldDescription`).
|
|
313
|
+
const errorId = id ?? generatedId;
|
|
314
|
+
// Same falsy-content convention as `FieldDescription`/`FieldRow`'s
|
|
315
|
+
// `error ? … : null` — see `hasRenderableContent`
|
|
316
|
+
// (`../../lib/has-renderable-content`) for the full rationale.
|
|
317
|
+
const hasContent = hasRenderableContent(children);
|
|
318
|
+
|
|
319
|
+
useLayoutEffect(() => {
|
|
320
|
+
if (!hasContent) return undefined;
|
|
321
|
+
registerError(errorId);
|
|
322
|
+
return () => unregisterError(errorId);
|
|
323
|
+
}, [hasContent, errorId, registerError, unregisterError]);
|
|
324
|
+
|
|
325
|
+
if (!hasContent) return null;
|
|
326
|
+
|
|
327
|
+
return (
|
|
328
|
+
<p
|
|
329
|
+
ref={ref}
|
|
330
|
+
id={errorId}
|
|
331
|
+
role="alert"
|
|
332
|
+
data-slot="field-error"
|
|
333
|
+
className={cn("text-body font-medium text-destructive-text", className)}
|
|
334
|
+
{...props}
|
|
335
|
+
>
|
|
336
|
+
{children}
|
|
337
|
+
</p>
|
|
338
|
+
);
|
|
339
|
+
},
|
|
340
|
+
);
|
|
@@ -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
|
});
|