Skip to content

Commit cab5132

Browse files
rivka-ungarclaude
andcommitted
fix(TextField): associate validation text with input via aria-describedby
The inline validation error rendered in a <span> with no id, and the input's aria-describedby resolved to undefined in the error case, so screen readers could not associate the error with its field (WCAG 3.3.1, 1.3.1). Derive a validation-text id from the existing id prop, set it on the error span, and compose aria-describedby from it plus the existing max-length hint id. Also emit undefined instead of empty-string aria-owns / aria-activedescendant so React omits the invalid empty ARIA attributes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8ebb856 commit cab5132

5 files changed

Lines changed: 94 additions & 55 deletions

File tree

packages/components/text-inputs/src/TextField/TextField.tsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -339,14 +339,16 @@ const TextField = forwardRef(
339339
}, [maxLength, validation, isRequiredAndEmpty, inputValue]);
340340

341341
const hasIcon = iconName || secondaryIconName;
342-
const shouldShowExtraText =
343-
showCharCount || (validation && validation.text) || (isRequiredAndEmpty && requiredErrorText);
342+
const hasValidationText = !!((validation && validation.text) || (isRequiredAndEmpty && requiredErrorText));
343+
const shouldShowExtraText = showCharCount || hasValidationText;
344344
const isSecondary = secondaryIconName === currentStateIconName;
345345
const isPrimary = iconName === currentStateIconName;
346346
const shouldFocusOnPrimaryIcon =
347347
(onIconClick !== NOOP || iconLabel || iconTooltipContent) && inputValue && iconName.length && isPrimary;
348348
const shouldFocusOnSecondaryIcon = (secondaryIconName || secondaryTooltipContent) && isSecondary && !!inputValue;
349349
const allowExceedingMaxLengthTextId = allowExceedingMaxLength ? `${id}-allow-exceeding-max-length-text` : undefined;
350+
const validationTextId = hasValidationText ? `${id}-validation-text` : undefined;
351+
const describedBy = [validationTextId, allowExceedingMaxLengthTextId].filter(Boolean).join(" ") || undefined;
350352

351353
useEffect(() => {
352354
if (!inputRef?.current || !autoFocus) {
@@ -401,10 +403,10 @@ const TextField = forwardRef(
401403
role={searchResultsContainerId && "combobox"} // For voice reader
402404
aria-label={inputAriaLabel || placeholder}
403405
aria-invalid={(validation && validation.status === "error") || isRequiredAndEmpty}
404-
aria-owns={searchResultsContainerId}
405-
aria-activedescendant={activeDescendant}
406+
aria-owns={searchResultsContainerId || undefined}
407+
aria-activedescendant={activeDescendant || undefined}
406408
aria-required={required}
407-
aria-describedby={allowExceedingMaxLengthTextId}
409+
aria-describedby={describedBy}
408410
required={required}
409411
tabIndex={tabIndex}
410412
dir={dir}
@@ -473,8 +475,8 @@ const TextField = forwardRef(
473475
</div>
474476
{shouldShowExtraText && (
475477
<Text type="text2" color="secondary" className={cx(styles.subTextContainer)}>
476-
{((validation && validation.text) || (isRequiredAndEmpty && requiredErrorText)) && (
477-
<span className={cx(styles.subTextContainerStatus)}>
478+
{hasValidationText && (
479+
<span id={validationTextId} className={cx(styles.subTextContainerStatus)}>
478480
{isRequiredAndEmpty ? requiredErrorText : validation.text}
479481
</span>
480482
)}

packages/components/text-inputs/src/TextField/__tests__/TextField.test.tsx

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,62 @@ describe("TextField Tests", () => {
339339
expect(input.value).toBe(value.trim());
340340
});
341341

342+
describe("validation text association", () => {
343+
it("should point aria-describedby at the rendered validation text element", () => {
344+
const { container } = render(
345+
<TextField onChange={onChangeStub} id="described" validation={{ status: "error", text: "Invalid Email" }} />
346+
);
347+
const input = container.querySelector("#described");
348+
349+
expect(input.getAttribute("aria-describedby")).toBe("described-validation-text");
350+
expect(container.querySelector("#described-validation-text").textContent).toBe("Invalid Email");
351+
});
352+
353+
it("should associate the required error text once the field is blurred while empty", () => {
354+
const { container } = render(
355+
<TextField onChange={onChangeStub} id="described" required requiredErrorText="This field is required" />
356+
);
357+
const input = container.querySelector("#described");
358+
act(() => {
359+
fireEvent.blur(input, { target: { value: "" } });
360+
});
361+
362+
expect(input.getAttribute("aria-describedby")).toBe("described-validation-text");
363+
expect(container.querySelector("#described-validation-text").textContent).toBe("This field is required");
364+
});
365+
366+
it("should compose the validation text id with the max length hint id", () => {
367+
const { container } = render(
368+
<TextField
369+
onChange={onChangeStub}
370+
id="described"
371+
showCharCount
372+
maxLength={5}
373+
allowExceedingMaxLength
374+
validation={{ status: "error", text: "Invalid Email" }}
375+
/>
376+
);
377+
378+
expect(container.querySelector("#described").getAttribute("aria-describedby")).toBe(
379+
"described-validation-text described-allow-exceeding-max-length-text"
380+
);
381+
});
382+
383+
it("should not set aria-describedby when there is no validation text", () => {
384+
const { container } = render(<TextField onChange={onChangeStub} id="described" />);
385+
386+
expect(container.querySelector("#described").hasAttribute("aria-describedby")).toBe(false);
387+
});
388+
389+
it("should omit aria-owns and aria-activedescendant when they are empty", () => {
390+
const { container } = render(<TextField onChange={onChangeStub} id="described" />);
391+
const input = container.querySelector("#described");
392+
393+
expect(input.hasAttribute("aria-owns")).toBe(false);
394+
expect(input.hasAttribute("aria-activedescendant")).toBe(false);
395+
});
396+
});
397+
342398
describe("controlled", () => {
343399
it("should call onChange with the new value when controlled is true", () => {
344400
const handleChange = vi.fn();

packages/components/text-inputs/src/TextField/__tests__/__snapshots__/TextField.snapshot.test.tsx.snap

Lines changed: 2 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,8 @@ exports[`TextField renders correctly > when disabled 1`] = `
1313
className="inputWrapper wrapperSizeSmall"
1414
>
1515
<input
16-
aria-activedescendant=""
1716
aria-invalid={false}
1817
aria-label=""
19-
aria-owns=""
2018
aria-required={false}
2119
autoComplete="off"
2220
className="input"
@@ -54,10 +52,8 @@ exports[`TextField renders correctly > when loading 1`] = `
5452
className="inputWrapper wrapperSizeSmall"
5553
>
5654
<input
57-
aria-activedescendant=""
5855
aria-invalid={false}
5956
aria-label=""
60-
aria-owns=""
6157
aria-required={false}
6258
autoComplete="off"
6359
className="input"
@@ -124,10 +120,8 @@ exports[`TextField renders correctly > when readonly 1`] = `
124120
className="inputWrapper wrapperSizeSmall"
125121
>
126122
<input
127-
aria-activedescendant=""
128123
aria-invalid={false}
129124
aria-label=""
130-
aria-owns=""
131125
aria-required={false}
132126
autoComplete="off"
133127
className="input readOnly"
@@ -165,10 +159,8 @@ exports[`TextField renders correctly > when required 1`] = `
165159
className="inputWrapper wrapperSizeSmall"
166160
>
167161
<input
168-
aria-activedescendant=""
169162
aria-invalid={false}
170163
aria-label=""
171-
aria-owns=""
172164
aria-required={true}
173165
autoComplete="off"
174166
className="input"
@@ -206,10 +198,8 @@ exports[`TextField renders correctly > with another type 1`] = `
206198
className="inputWrapper wrapperSizeSmall"
207199
>
208200
<input
209-
aria-activedescendant=""
210201
aria-invalid={false}
211202
aria-label=""
212-
aria-owns=""
213203
aria-required={false}
214204
autoComplete="off"
215205
className="input"
@@ -247,10 +237,8 @@ exports[`TextField renders correctly > with className 1`] = `
247237
className="inputWrapper wrapperSizeSmall"
248238
>
249239
<input
250-
aria-activedescendant=""
251240
aria-invalid={false}
252241
aria-label=""
253-
aria-owns=""
254242
aria-required={false}
255243
autoComplete="off"
256244
className="testClassName input"
@@ -288,10 +276,8 @@ exports[`TextField renders correctly > with date type 1`] = `
288276
className="inputWrapper wrapperSizeSmall"
289277
>
290278
<input
291-
aria-activedescendant=""
292279
aria-invalid={false}
293280
aria-label=""
294-
aria-owns=""
295281
aria-required={false}
296282
autoComplete="off"
297283
className="input"
@@ -329,10 +315,8 @@ exports[`TextField renders correctly > with date-time type 1`] = `
329315
className="inputWrapper wrapperSizeSmall"
330316
>
331317
<input
332-
aria-activedescendant=""
333318
aria-invalid={false}
334319
aria-label=""
335-
aria-owns=""
336320
aria-required={false}
337321
autoComplete="off"
338322
className="input"
@@ -370,10 +354,8 @@ exports[`TextField renders correctly > with email type 1`] = `
370354
className="inputWrapper wrapperSizeSmall"
371355
>
372356
<input
373-
aria-activedescendant=""
374357
aria-invalid={false}
375358
aria-label=""
376-
aria-owns=""
377359
aria-required={false}
378360
autoComplete="off"
379361
className="input"
@@ -411,10 +393,8 @@ exports[`TextField renders correctly > with icon 1`] = `
411393
className="inputWrapper wrapperSizeSmall"
412394
>
413395
<input
414-
aria-activedescendant=""
415396
aria-invalid={false}
416397
aria-label=""
417-
aria-owns=""
418398
aria-required={false}
419399
autoComplete="off"
420400
className="input inputHasIcon"
@@ -472,10 +452,8 @@ exports[`TextField renders correctly > with iconLabel and secondaryIconLabel 1`]
472452
className="inputWrapper wrapperSizeSmall"
473453
>
474454
<input
475-
aria-activedescendant=""
476455
aria-invalid={false}
477456
aria-label=""
478-
aria-owns=""
479457
aria-required={false}
480458
autoComplete="off"
481459
className="input"
@@ -513,10 +491,8 @@ exports[`TextField renders correctly > with id 1`] = `
513491
className="inputWrapper wrapperSizeSmall"
514492
>
515493
<input
516-
aria-activedescendant=""
517494
aria-invalid={false}
518495
aria-label=""
519-
aria-owns=""
520496
aria-required={false}
521497
autoComplete="off"
522498
className="input"
@@ -554,10 +530,8 @@ exports[`TextField renders correctly > with labelIconName 1`] = `
554530
className="inputWrapper wrapperSizeSmall"
555531
>
556532
<input
557-
aria-activedescendant=""
558533
aria-invalid={false}
559534
aria-label=""
560-
aria-owns=""
561535
aria-required={false}
562536
autoComplete="off"
563537
className="input"
@@ -595,10 +569,8 @@ exports[`TextField renders correctly > with large size 1`] = `
595569
className="inputWrapper wrapperSizeLarge"
596570
>
597571
<input
598-
aria-activedescendant=""
599572
aria-invalid={false}
600573
aria-label=""
601-
aria-owns=""
602574
aria-required={false}
603575
autoComplete="off"
604576
className="input"
@@ -636,10 +608,8 @@ exports[`TextField renders correctly > with placeholder 1`] = `
636608
className="inputWrapper wrapperSizeSmall"
637609
>
638610
<input
639-
aria-activedescendant=""
640611
aria-invalid={false}
641612
aria-label="placeholder"
642-
aria-owns=""
643613
aria-required={false}
644614
autoComplete="off"
645615
className="input"
@@ -677,10 +647,8 @@ exports[`TextField renders correctly > with role 1`] = `
677647
className="inputWrapper wrapperSizeSmall"
678648
>
679649
<input
680-
aria-activedescendant=""
681650
aria-invalid={false}
682651
aria-label=""
683-
aria-owns=""
684652
aria-required={false}
685653
autoComplete="off"
686654
className="input"
@@ -718,10 +686,8 @@ exports[`TextField renders correctly > with secondaryIconName 1`] = `
718686
className="inputWrapper wrapperSizeSmall"
719687
>
720688
<input
721-
aria-activedescendant=""
722689
aria-invalid={false}
723690
aria-label=""
724-
aria-owns=""
725691
aria-required={false}
726692
autoComplete="off"
727693
className="input inputHasIcon"
@@ -779,10 +745,8 @@ exports[`TextField renders correctly > with showCharCount 1`] = `
779745
className="inputWrapper wrapperSizeSmall"
780746
>
781747
<input
782-
aria-activedescendant=""
783748
aria-invalid={false}
784749
aria-label=""
785-
aria-owns=""
786750
aria-required={false}
787751
autoComplete="off"
788752
className="input"
@@ -838,10 +802,8 @@ exports[`TextField renders correctly > with tel type 1`] = `
838802
className="inputWrapper wrapperSizeSmall"
839803
>
840804
<input
841-
aria-activedescendant=""
842805
aria-invalid={false}
843806
aria-label=""
844-
aria-owns=""
845807
aria-required={false}
846808
autoComplete="off"
847809
className="input"
@@ -879,10 +841,8 @@ exports[`TextField renders correctly > with url type 1`] = `
879841
className="inputWrapper wrapperSizeSmall"
880842
>
881843
<input
882-
aria-activedescendant=""
883844
aria-invalid={false}
884845
aria-label=""
885-
aria-owns=""
886846
aria-required={false}
887847
autoComplete="off"
888848
className="input"
@@ -920,10 +880,9 @@ exports[`TextField renders correctly > with validation 1`] = `
920880
className="inputWrapper wrapperSizeSmall inputErrorValidation"
921881
>
922882
<input
923-
aria-activedescendant=""
883+
aria-describedby="input-validation-text"
924884
aria-invalid={true}
925885
aria-label=""
926-
aria-owns=""
927886
aria-required={false}
928887
autoComplete="off"
929888
className="input"
@@ -950,6 +909,7 @@ exports[`TextField renders correctly > with validation 1`] = `
950909
>
951910
<span
952911
className="subTextContainerStatus"
912+
id="input-validation-text"
953913
>
954914
error
955915
</span>
@@ -971,10 +931,8 @@ exports[`TextField renders correctly > with value 1`] = `
971931
className="inputWrapper wrapperSizeSmall"
972932
>
973933
<input
974-
aria-activedescendant=""
975934
aria-invalid={false}
976935
aria-label=""
977-
aria-owns=""
978936
aria-required={false}
979937
autoComplete="off"
980938
className="input"
@@ -1012,10 +970,8 @@ exports[`TextField renders correctly > with wrapperClassName 1`] = `
1012970
className="inputWrapper wrapperSizeSmall"
1013971
>
1014972
<input
1015-
aria-activedescendant=""
1016973
aria-invalid={false}
1017974
aria-label=""
1018-
aria-owns=""
1019975
aria-required={false}
1020976
autoComplete="off"
1021977
className="input"

packages/docs/src/pages/components/TextField/TextField.mdx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,10 @@ import { TextField } from "@vibe/core";
4343
clear to all users.
4444
</>,
4545
<>
46-
When using <code>title</code> or validation text, you must also provide an <code>id</code>. This is crucial, as it
47-
allows screen readers to correctly associate the input with its label and description.
46+
When using <code>title</code> or validation text, you must also provide a unique <code>id</code>. This is crucial,
47+
as the label is associated through it and the validation text is exposed to screen readers as{" "}
48+
<code>aria-describedby="&#123;id&#125;-validation-text"</code>. Two fields sharing an <code>id</code> on the same
49+
page produce duplicate description targets.
4850
</>,
4951
<>
5052
For required fields, use the <code>required</code> prop to ensure proper screen reader announcements and native

0 commit comments

Comments
 (0)