Skip to content

fix(TextField): associate validation text with input via aria-describedby - #3508

Merged
rivka-ungar merged 2 commits into
masterfrom
a11y/text-field-validation-text-describedby
Sep 8, 2026
Merged

rivka-ungar merged 2 commits into
masterfrom
a11y/text-field-validation-text-describedby

Conversation

@rivka-ungar

Copy link
Copy Markdown
Contributor

What & why

TextField's inline validation error rendered in a <span> with no id, and the input's aria-describedby resolved to undefined in the error case. Screen reader users could not tell which error belonged to which field.

WCAG: 3.3.1 Error Identification, 1.3.1 Info and Relationships.

Monday task: https://monday.monday.com/boards/18428773755/pulses/12971125245

Changes

  • Extract the duplicated error-text condition into hasValidationText and derive validationTextId = \${id}-validation-text`` from it.
  • Set that id on the error <span> so aria-describedby has a real target.
  • Compose aria-describedby from [validationTextId, allowExceedingMaxLengthTextId] rather than replacing the max-length hint — the char-count description is preserved when both features are used together.
  • Emit undefined instead of empty-string aria-owns / aria-activedescendant so React omits the invalid empty ARIA attributes.
  • Added 5 Vitest cases asserting aria-describedby points at an element that actually exists in the DOM, the required-error-after-blur path, composition with the max-length hint, and absence when there's no validation text.
  • Updated the docs guideline (unique id requirement) and added a "Validation in a form" Storybook story with two error-bearing fields.

Non-breaking

The fix derives the accessible description from content the component already renders, using the existing id prop. No prop added/renamed/retyped, no API change, no change to tab order, focus, or visible styling. Consumers get the fix on a version bump alone.

Snapshot churn is expected

The 23 updated snapshots in TextField.snapshot.test.tsx.snap are the removal of the invalid aria-owns="" / aria-activedescendant="" attributes, plus the two new attributes in the validation case — nothing else.

Note for reviewers

id defaults to the literal string "input", so two id-less TextFields on one page produce duplicate input-validation-text ids. The docs guideline was sharpened to require a unique id rather than hiding this behind a generated one (useId isn't available — the package's React floor is >=16.9.0).

Testing

  • yarn workspace @vibe/text-inputs test → 10 files, 143/143 passed (5 new).
  • yarn workspace @vibe/text-inputs lint → clean, 0 errors / 0 warnings.
  • lerna run build --scope=@vibe/text-inputs --include-dependencies → success.

🤖 Generated with Claude Code

…edby

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>
@rivka-ungar
rivka-ungar requested a review from a team as a code owner September 8, 2026 11:26
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Screen readers can announce wrong errors 🐞 Bug ≡ Correctness
Description
validationTextId is derived from the shared default id value of "input", so every ID-less
instance renders the same validation target and description reference. When two validation-bearing
TextField instances omit id, the second input cannot be uniquely associated with its own message
and may resolve to the first field's error instead.
Code

packages/components/text-inputs/src/TextField/TextField.tsx[R350-351]

+    const validationTextId = hasValidationText ? `${id}-validation-text` : undefined;
+    const describedBy = [validationTextId, allowExceedingMaxLengthTextId].filter(Boolean).join(" ") || undefined;
Evidence
The component defaults every omitted ID to input, derives the new description target directly from
that value, and applies it to both the input reference and rendered span. The updated documentation
itself confirms that shared IDs produce duplicate description targets, so callers using the optional
prop's default cannot obtain a unique association.

packages/components/text-inputs/src/TextField/TextField.tsx[224-244]
packages/components/text-inputs/src/TextField/TextField.tsx[349-351]
packages/components/text-inputs/src/TextField/TextField.tsx[394-409]
packages/components/text-inputs/src/TextField/TextField.tsx[476-480]
packages/docs/src/pages/components/TextField/TextField.mdx[46-49]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Multiple `TextField` instances that omit `id` generate duplicate validation-text IDs, preventing each input from being uniquely associated with its own message.
## Issue Context
The public `id` prop remains optional and defaults to the literal `"input"`. Generate a stable per-instance fallback identifier compatible with the package's supported React versions, use it consistently for the input, label, test IDs, and derived validation IDs, and add coverage for two ID-less validation-bearing fields.
## Fix Focus Areas
- packages/components/text-inputs/src/TextField/TextField.tsx[224-224]
- packages/components/text-inputs/src/TextField/TextField.tsx[349-351]
- packages/components/text-inputs/src/TextField/TextField.tsx[377-409]
- packages/components/text-inputs/src/TextField/TextField.tsx[476-480]
- packages/components/text-inputs/src/TextField/__tests__/TextField.test.tsx[342-396]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can tweak Display preferences with a live preview to see your comment before it ships

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

PR Summary by Qodo

Associate TextField validation messages with inputs

🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 10-20 Minutes

Grey Divider

AI Description

• Associates rendered validation messages with inputs through composed aria-describedby
 references.
• Omits empty ARIA relationships while preserving max-length descriptions.
• Adds accessibility regression tests, usage guidance, and a multi-field validation story.
Diagram

graph TD
  S["Validation state"] --> T["TextField render"] --> I["Input element"] -->|"focus context"| A(["Screen reader"])
  T --> V["Validation text"] -->|"described by ID"| A
  T --> H["Length hint"] -->|"described by ID"| A
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Generate internal field IDs
  • ➕ Prevents duplicate description targets when consumers omit an ID.
  • ➕ Reduces reliance on documentation for accessible multi-field forms.
  • ➖ React useId is unavailable with the package's React 16.9 compatibility floor.
  • ➖ A custom generator introduces SSR, hydration, and instance-stability risks.
  • ➖ Generated identifiers could change existing label and test assumptions.

Recommendation: Keep the PR's derived-ID approach. It fixes the relationship without changing props or supported React versions, preserves the max-length description, and relies on the existing documented requirement for unique field IDs. Internal ID generation should only be reconsidered alongside a React floor upgrade that enables useId.

Files changed (4) +92 / -9

Bug fix (1) +9 / -7
TextField.tsxConnect validation text to the input through ARIA IDs +9/-7

Connect validation text to the input through ARIA IDs

• Derives a validation-text ID when an error message is rendered and composes it with the maximum-length hint in 'aria-describedby'. Empty 'aria-owns' and 'aria-activedescendant' values are now omitted instead of emitted as invalid empty attributes.

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

Tests (1) +56 / -0
TextField.test.tsxCover validation description relationships and empty ARIA attributes +56/-0

Cover validation description relationships and empty ARIA attributes

• Adds regression coverage for explicit validation messages, required errors after blur, composed maximum-length descriptions, absent validation text, and omission of empty ARIA relationships.

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

Documentation (2) +27 / -2
TextField.mdxDocument unique IDs for accessible validation text +4/-2

Document unique IDs for accessible validation text

• Clarifies that fields using titles or validation messages require unique IDs and documents the generated validation-description target.

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

TextField.stories.tsxAdd a multi-field validation form example +23/-0

Add a multi-field validation form example

• Adds a Storybook example demonstrating uniquely identified email and password fields with validation behavior in the same form.

packages/docs/src/pages/components/TextField/TextField.stories.tsx

@qodo-free-for-open-source-projects

Copy link
Copy Markdown
Contributor

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Screen readers can announce wrong errors 🐞 Bug ≡ Correctness
Description
validationTextId is derived from the shared default id value of "input", so every ID-less
instance renders the same validation target and description reference. When two validation-bearing
TextField instances omit id, the second input cannot be uniquely associated with its own message
and may resolve to the first field's error instead.
Code

packages/components/text-inputs/src/TextField/TextField.tsx[R350-351]

+    const validationTextId = hasValidationText ? `${id}-validation-text` : undefined;
+    const describedBy = [validationTextId, allowExceedingMaxLengthTextId].filter(Boolean).join(" ") || undefined;
Evidence
The component defaults every omitted ID to input, derives the new description target directly from
that value, and applies it to both the input reference and rendered span. The updated documentation
itself confirms that shared IDs produce duplicate description targets, so callers using the optional
prop's default cannot obtain a unique association.

packages/components/text-inputs/src/TextField/TextField.tsx[224-244]
packages/components/text-inputs/src/TextField/TextField.tsx[349-351]
packages/components/text-inputs/src/TextField/TextField.tsx[394-409]
packages/components/text-inputs/src/TextField/TextField.tsx[476-480]
packages/docs/src/pages/components/TextField/TextField.mdx[46-49]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Multiple `TextField` instances that omit `id` generate duplicate validation-text IDs, preventing each input from being uniquely associated with its own message.

## Issue Context
The public `id` prop remains optional and defaults to the literal `"input"`. Generate a stable per-instance fallback identifier compatible with the package's supported React versions, use it consistently for the input, label, test IDs, and derived validation IDs, and add coverage for two ID-less validation-bearing fields.

## Fix Focus Areas
- packages/components/text-inputs/src/TextField/TextField.tsx[224-224]
- packages/components/text-inputs/src/TextField/TextField.tsx[349-351]
- packages/components/text-inputs/src/TextField/TextField.tsx[377-409]
- packages/components/text-inputs/src/TextField/TextField.tsx[476-480]
- packages/components/text-inputs/src/TextField/__tests__/TextField.test.tsx[342-396]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
⚠️ Tickets: not configured — ticket URL found in PR but could not be fetched — check ticket provider credentials
Review mode: ⚖️ Balanced: This is a localized accessibility behavior change affecting ARIA relationships and multiple validation/max-length rendering paths, warranting a careful single-pass review.

Grey Divider

Tip of the day
💡 Did you know, you can tweak Display preferences with a live preview to see your comment before it ships

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment on lines +350 to +351
const validationTextId = hasValidationText ? `${id}-validation-text` : undefined;
const describedBy = [validationTextId, allowExceedingMaxLengthTextId].filter(Boolean).join(" ") || undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Screen readers can announce wrong errors 🐞 Bug ≡ Correctness

validationTextId is derived from the shared default id value of "input", so every ID-less
instance renders the same validation target and description reference. When two validation-bearing
TextField instances omit id, the second input cannot be uniquely associated with its own message
and may resolve to the first field's error instead.
Agent Prompt
## Issue description
Multiple `TextField` instances that omit `id` generate duplicate validation-text IDs, preventing each input from being uniquely associated with its own message.

## Issue Context
The public `id` prop remains optional and defaults to the literal `"input"`. Generate a stable per-instance fallback identifier compatible with the package's supported React versions, use it consistently for the input, label, test IDs, and derived validation IDs, and add coverage for two ID-less validation-bearing fields.

## Fix Focus Areas
- packages/components/text-inputs/src/TextField/TextField.tsx[224-224]
- packages/components/text-inputs/src/TextField/TextField.tsx[349-351]
- packages/components/text-inputs/src/TextField/TextField.tsx[377-409]
- packages/components/text-inputs/src/TextField/TextField.tsx[476-480]
- packages/components/text-inputs/src/TextField/__tests__/TextField.test.tsx[342-396]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle Size Analysis

✅ No bundle size changes detected.

Unchanged Components
Component Base PR Diff
@vibe/a11y 5.36KB 5.35KB -7B 🟢
@vibe/accordion 30.28KB 30.31KB +29B 🔺
@vibe/alert-banner 35.25KB 35.17KB -86B 🟢
@vibe/attention-box 37.47KB 37.36KB -121B 🟢
@vibe/avatar-group 55.21KB 55.02KB -189B 🟢
@vibe/avatar 30.14KB 30.09KB -55B 🟢
@vibe/badge 7.75KB 7.75KB -4B 🟢
@vibe/base-list 34.41KB 34.38KB -24B 🟢
@vibe/breadcrumbs 44.62KB 44.51KB -114B 🟢
@vibe/button-group 31.66KB 31.66KB -2B 🟢
@vibe/button 14.89KB 14.84KB -55B 🟢
@vibe/checkbox 30.33KB 30.3KB -32B 🟢
@vibe/chips 38.08KB 38.05KB -28B 🟢
@vibe/clickable 3.63KB 3.59KB -43B 🟢
@vibe/color-picker 37.4KB 37.36KB -37B 🟢
@vibe/combobox 46.74KB 46.67KB -68B 🟢
@vibe/counter 6.92KB 6.89KB -32B 🟢
@vibe/date-picker 75.92KB 75.79KB -138B 🟢
@vibe/dialog 17.69KB 17.59KB -104B 🟢
@vibe/divider 2.82KB 2.81KB -8B 🟢
@vibe/dropdown 58.7KB 58.72KB +21B 🔺
@vibe/editable 30.53KB 30.41KB -121B 🟢
@vibe/empty-state 33.79KB 33.72KB -75B 🟢
@vibe/expand-collapse 29.7KB 29.62KB -83B 🟢
@vibe/formatted-number 3.29KB 3.26KB -27B 🟢
@vibe/icon-button 31.56KB 31.54KB -25B 🟢
@vibe/icon 10.51KB 10.48KB -30B 🟢
@vibe/info 35.24KB 35.25KB +11B 🔺
@vibe/label 31.95KB 31.91KB -45B 🟢
@vibe/layer 412B 412B 0B ➖
@vibe/layout 7.49KB 7.51KB +14B 🔺
@vibe/link 11.47KB 11.46KB -16B 🟢
@vibe/list 39.99KB 39.92KB -70B 🟢
@vibe/loader 3.26KB 3.25KB -14B 🟢
@vibe/menu-button 29.06KB 29.04KB -21B 🟢
@vibe/menu 45.5KB 45.42KB -86B 🟢
@vibe/modal 47.91KB 47.8KB -107B 🟢
@vibe/progress-bars 4.36KB 4.34KB -21B 🟢
@vibe/radio-button 29.4KB 29.35KB -49B 🟢
@vibe/search 33.88KB 33.87KB -4B 🟢
@vibe/skeleton 3.41KB 3.4KB -14B 🟢
@vibe/slider 36.76KB 36.75KB -9B 🟢
@vibe/split-button 32.31KB 32.29KB -20B 🟢
@vibe/table 47.21KB 47.3KB +89B 🔺
@vibe/tabs 30.32KB 30.24KB -84B 🟢
@vibe/text-inputs 39.68KB 39.69KB +7B 🔺
@vibe/text-with-highlight 27.89KB 27.8KB -91B 🟢
@vibe/theme-provider 1.45KB 1.43KB -24B 🟢
@vibe/tipseen 37.31KB 37.23KB -76B 🟢
@vibe/toast 37.14KB 37.01KB -125B 🟢
@vibe/toggle 30.1KB 30.04KB -63B 🟢
@vibe/tooltip 26.78KB 26.73KB -43B 🟢
@vibe/transitions 3.02KB 3KB -23B 🟢
@vibe/typography 28.92KB 28.84KB -86B 🟢
@vibe/virtualized-grid 9.65KB 9.58KB -81B 🟢
@vibe/virtualized-list 9.34KB 9.34KB -4B 🟢
@vibe/wizard 37.67KB 37.66KB -10B 🟢

📊 Summary:

  • Total Base Size: 1.51MB
  • Total PR Size: 1.5MB
  • Total Difference: 2.46KB

@rivka-ungar
rivka-ungar merged commit 8facf2c into master Sep 8, 2026
17 checks passed
@rivka-ungar
rivka-ungar deleted the a11y/text-field-validation-text-describedby branch September 8, 2026 11:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants