Skip to content

fix(fluid-form-field): associate errors and help text with the input [sc-218609] - #194

Open
mnutt wants to merge 1 commit into
mainfrom
mn/form-field-error-a11y
Open

fix(fluid-form-field): associate errors and help text with the input [sc-218609]#194
mnutt wants to merge 1 commit into
mainfrom
mn/form-field-error-a11y

Conversation

@mnutt

@mnutt mnutt commented Sep 3, 2026

Copy link
Copy Markdown
Member

Problem

FluidFormField is the canonical form component across our Ember apps. It already associates the label with the input (<label for={{this.id}}>), but it rendered help text, error messages, and warning messages with no aria-describedby and no aria-invalid.

The result: when a field is rejected, the reason is conveyed only by red text and a red border. A screen reader user tabbing to the field hears the label and nothing else — they are told to fix an error they were never given. That fails WCAG 3.3.1 Error Identification.

Because this is upstream in @movable/fluid, fixing it here fixes every consuming app at once, the same way the aria-modal fix in #190 did.

Fix

FluidFormField now derives an id for each description it renders (-help-text, -error-messages, -warning-messages) and a describedBy getter that joins only the ones actually present, in visual order. That is threaded to the yielded input alongside the existing hasError, and the controls render:

  • aria-describedby — on the <input> and on the FluidDateInput calendar trigger
  • aria-invalid="true" — driven by the existing hasError, no new plumbing needed

Both sit before ...attributes, so consumers can still override them.

Two deliberate details:

  • describedBy is undefined when there is nothing to describe, so a field with no messages emits no aria-describedby rather than pointing at an element that was never rendered — the same dangling-reference bug the aria-labelledby half of fix(a11y): aria-modal, keyboard-operable ExpandingList, and focus rings [sc-217428] #190 addressed.
  • aria-invalid keys off errors only, not warnings. A warning is not a rejection, and marking the field invalid would misreport it.

Covers all three render paths: <f.Text />, block mode (<f.Text as |t|><t.input /></f.Text>, used for icons and add-ons), and <f.Date />.

Out of scope

No role="alert" or live region on the error list. Errors are announced when focus reaches the field, but not at the moment they appear after a failed submit. That is a real remaining gap, but it is a different change — it risks double-announcement when combined with aria-describedby — and it belongs with the canvas-side work on ModalError, page-level banners, and validation-field.

That canvas remainder (~45 ad-hoc error sites) is only worth scoping once this lands, since FluidFormField will absorb the well-structured forms and leave a smaller, mixed set behind.

Testing

  • 7 new integration tests: error association, help-text association, all three message types at once, the no-messages case, block mode, and the date input
  • Full suite: 103 pass / 1 skip (pre-existing setting the initial focus in fluid-modal) / 0 fail
  • lint-hbs and lint-js clean

Reviewer notes

No API change for consumers — FluidFormField callers get the wiring for free. @describedBy is new on FluidTextInput, FluidTextInput::Input, and FluidDateInput, but it is additive and optional.

The field associated its label but rendered help text, errors and warnings with
no aria-describedby and no aria-invalid, so a rejected field conveyed the reason
by red text alone and a screen reader user reaching it heard only the label.

Give each description an id, point the input at whichever exist, and mark the
input invalid when there are errors. describedBy is undefined when there is
nothing to describe so the reference is never left dangling, and warnings do not
set aria-invalid because a warning is not a rejection.
@mnutt
mnutt requested a review from nicksteffens September 8, 2026 14:29
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.

1 participant