feat(ui): standardize optional-field labels and required semantics (#242) - #275
Conversation
Issue #242: forms marked their optional fields with hand-written label copies ("Title (optional)", "Optional link." hints) and required fields got only an asterisk, which is aria-hidden and therefore told a screen reader nothing. Both are part of the Field contract that the primitive could not express, so every caller got them slightly wrong in its own way. - `Field` grows an `optional` prop that appends "(optional)" inside the `<label>`, where it is part of both the visible text and the accessible name — the convention screen readers announce with the field ("Title (optional)"), rather than a second element beside it that nothing reliably connects to the control. - `fieldContext` now carries `required`, so the asterisk's meaning reaches the actual control: `Input`, `Textarea` and `Select` render `aria-required="true"` when the enclosing `Field` is required. The asterisk itself stays `aria-hidden` — it is decoration; the attribute is the announcement. - Precedence is explicit and pinned by tests: `aria-required` on the control wins, then the HTML `required` attribute, then the wrapper. The value is emitted only when true, so existing plain inputs do not grow a stray `aria-required="false"`. - The space before "(optional)" is its own text node rather than the first character of the span: dom-accessibility-api trims each element child's contribution, so a leading space inside the span disappeared from the accessible name while surviving in `textContent` — the two disagreed ("Nickname (optional)" vs "Nickname(optional)"). The separate node keeps both computations identical. Tests cover the optional label (visible text and accessible name), required winning over optional, the absent mark on optional controls, propagation to all three controls, the HTML-required mirror, and the explicit aria-required override.
Every form that hand-marked optional fields, or left required controls
unmarked, now speaks the shared convention — the boundary between "the
form needs this" and "this is a plus" reads the same on every screen,
and assistive tech is told either way.
Removed in favour of the prop (visible text deliberately unchanged):
- AddCardForm + NoteCard: the "Title (optional)" / "What to call it
(optional)" label copies.
- WizardDetailsStep: Industry's "Optional. " hint prefix (the hint
keeps the actual guidance); Description now marks itself optional.
- AddArrivalStepModal: the same for "How to do it" and "Where to do it"
("Optional link." -> "Link to tool or docs."). "What needs to be
done" now declares the required-ness its submit guard
(`canSubmitCustom`) already enforced.
Migrated outright:
- NewStarterTaskModal bypassed Field entirely — raw `<label>`/`<input>`
pairs, a local inputClasses string and a span-based "(optional)". It
is now four Fields around Input/Textarea, and the competency-keys
guidance moved into Field's hint slot so it gets aria-describedby.
Marked, because the code already enforces it:
- TokenAddForm, AtlassianCredentialAddForm, TokenRotateForm — the
credential fields are already gated by `required` inputs / submit
guards; the asterisk and aria-required now say so.
- ProjectDetailsDrawer: Name required, Description optional, matching
the wizard's semantics for the same fields.
- AccountForm (first/last/email carry `required`), NewAreaForm (submit
disabled while empty), ConfluenceConnectStep (base URL and Space ID
inputs carry `required`).
Left deliberately unchanged: ArrivalStepAuthoring's editor tolerates a
blank title in its save path (there is no guard to mirror), so it gets
no required mark in this PR.
The appended marks change each label's text, so tests that pinned the
old exact strings now match the leading part they intended — /^Name/,
/^Description/, /^Title/, /^Industry/, /^Space ID/, /^Confluence base
URL/, /^Token name/ — same assertions, relaxed where the mark made the
full string ambiguous.
Issue #242 asks for axe coverage of the wizard beyond the details step, including both shapes of the "Add GitHub token" form: inline on a narrow viewport, and as the portalled desktop companion where a second dialog lives outside the wizard's own subtree. - The walkthrough mirrors the main suite's helpers (details -> members -> sources, then into the GitHub detail) so the a11y tests exercise the real navigation instead of a shallow render. - One test asserts the semantics end to end: Name announces aria-required, while Description and Industry are labelled "(optional)". - The inline flow runs on the suite's default narrowest viewport; the companion test flips `mockViewport(true)` so `(min-width: 1280px)` matches and the form renders in CompanionModal. `baseElement` is the whole body, so axe sees both dialogs. The keyboard-focus half of the issue is PR #268's fix (hotfix/token-companion-keyboard-focus) and intentionally stays out of this branch — this PR closes the label and semantics scope only.
Review round on #275 found the sweep had a hole: the inline Rename and Rotate panels in AtlassianCredentialRow wrapped `<Input required>` in a `Field` that never declared `required`. The control still announced itself (`aria-required` comes from the input itself), but the label carried no asterisk — the one place in the tree where the visual mark and the semantics disagreed. - Both Fields ("New name", "New API token") now say `required`, which is exactly what the panels already enforce: the inputs carry the HTML `required` attribute and the row's mutations are submit-driven. - The fix is verified mechanically, not just by eye: a JSX-block sweep over `src/` for "control declares required, enclosing Field does not" now returns zero hits (the regex runs against `=>`-normalised source, since arrow props otherwise truncate the tag match). - A regression test opens both panels and pins the mark plus `aria-required` on the field, so this class of omission cannot return silently for the row.
`mockViewport` swaps the global `matchMedia` out and never restores it — unlike `mockResizableViewport`, which ships a `restore()` for exactly this reason. The wizard a11y suite flips to a desktop viewport for its last test, so without a reset any test appended after it would silently inherit `min-width: true`. Cross-file pollution was never possible (Vitest isolates each test file in a fresh environment; the repo leaves `isolate` at its default), so this is about the file staying order-independent as it grows, not about a bug in the gate. The reset puts the suite default (narrowest) back.
kiranfin
left a comment
There was a problem hiding this comment.
Review
I did not find anything blocking.
🟡 Worth a look
Field requiredonly producesaria-required, never the native HTMLrequiredattribute —src/components/ui/Input.tsx,Select.tsx,Textarea.tsx(theisRequired = ariaRequired ?? rest.required ?? field?.requiredline in each).
Most of the newly-marked-required fields in this PR (ProjectDetailsDrawer"Name",NewAreaForm,ConfluenceConnectStep,TokenAddForm,TokenRotateForm, the threeAtlassianCredentialAddFormfields, bothAtlassianCredentialRowfields,NewStarterTaskModal"Title",AddArrivalStepModal) setrequiredonly onField, not on the wrapped control. That's enough foraria-required, but the underlying<input>/<textarea>never gets the nativerequiredattribute unless the caller also passes it directly (asAccountFormand the Atlassian forms happen to do). Practically this is a no-op here since every one of these forms gates submission through a disabled button rather than native constraint validation, and it's not a regression — none of these controls hadrequiredbefore either. But it's a bit of a trap for the next person:<Field required>visually and semantically (for AT) reads as "this is required," yet it silently does not turn on the browser's own:required/native-validation behavior, which could be surprising for a control used outside a JS-gated form. Worth either a short note inField's doc comment, or havingField'srequiredflow through to the control's nativerequiredtoo, so the two can't drift.
Overall: solid, self-contained accessibility fix with strong test coverage (including two full a11y suites exercising axe against the new required/optional states). I'd merge as-is; item 1 is a documentation/consistency thought for later, not something that should hold this up.
kiranfin's review flagged the one thing the new prop does not do: a `Field`-only `required` never reaches the browser's constraint validation, so the wrapped control gets `aria-required` but no native `required` attribute — no `:required` styling, no "fill out this field" popup. That is deliberate (the app's forms gate their own submits and show inline errors; flipping native validation on from a label-level flag would replace those messages with browser chrome behind the form's back), but nothing in the code said so, which makes it a trap for the next caller who wants the browser to do the validating. - `Field`'s `required` doc now states the boundary and points at the escape hatch: pass `required` to the control itself when the native attribute is wanted. - `fieldContext`'s `required` carries the matching note, since that is where a control author reads it. - A unit test pins the contract rather than trusting the comment: a Field-only mark leaves `input.required === false` (and the attribute off), while a control-level `required` still lands on the element. No behaviour changes — this is the documentation/hardening option from the review, not the flow-through alternative, which would have changed runtime validation semantics across ten forms.
|
Thanks @kiranfin — took the documentation option ( The actual split (measured, repo-wide)
Field-only sites from this PR: exactly four — What changed
Why not flow-through (your option 2)Making Gate re-run after the change: |
What this does
Closes the remaining scope of #242 — Improve optional-field labels and keyboard navigation: forms get one shared, accessible convention for marking optional and required fields, wired through the
Fieldprimitive so no caller can get it wrong per-form.Fieldgainsoptional?: boolean→ renders a styled(optional)inside the label (visible and in the accessible name).fieldContextcarriesrequired→Input/Textarea/Selectsetaria-required="true". The asterisk alone wasaria-hiddendecoration — invisible to screen readers./^Name/, …).Base-branch decision (dev, not #267)
The plan left this open; evidence says base on
dev:feature/311-buddy-onboarding-tutor, 72 files — board/buddy/onboarding/starter-work areas). None of this PR's files appear in its diff.dev; stacking this on it would couple this PR's mergeability to a conflicted branch for no shared-file benefit.Relation to #268
Issue #242's keyboard-navigation half (focus restore after the inline token form closes) is @DavidLeuter's #268 (
hotfix/token-companion-keyboard-focus) — green, awaiting review. This branch deliberately does not absorb it; it closes the label/semantics scope only. Whichever merges first,devends up with both halves of #242.1. Shared primitive (
feat(ui))Field.optional" (optional)"inside the<label>, so it joins the control's accessible name — the convention screen readers announce with the field.fieldContext.requiredaria-hidden; the semantics now travel via context to the control asaria-required.Input/Textarea/Selectaria-required→ HTMLrequired→ enclosingField. Emitted only when true — existing plain inputs do not grow a strayaria-required="false".Gotcha worth knowing: the space before
(optional)is its own text node, not the span's first character —dom-accessibility-apitrims each element child's contribution, so a leading space inside the span vanished from the accessible name while surviving intextContent(Nickname (optional)vsNickname(optional)). The separate node keeps both computations identical, and a unit test pins it.2. Form migrations (
refactor(forms))Removed ad-hoc copies (visible text deliberately unchanged):
AddCardForm,NoteCard—"Title (optional)","What to call it (optional)"label strings.WizardDetailsStep— Industry's"Optional. "hint prefix (the hint keeps the real guidance); Description now marks itself optional.AddArrivalStepModal— same hint-prefix cleanup;"Optional link."→"Link to tool or docs.". "What needs to be done" now declares the required-ness itscanSubmitCustomguard already enforced.Migrated outright:
NewStarterTaskModal— bypassedFieldentirely (raw<label>/<input>pairs, a localinputClassesstring, a span-based "(optional)"). Now fourFields aroundInput/Textarea; the competency-keys guidance moved intoField's hint slot, which getsaria-describedbyfor free.Marked where the code already enforces it:
TokenAddForm,AtlassianCredentialAddForm,TokenRotateForm— credential fields already gated byrequiredinputs / submit guards.ProjectDetailsDrawer— Name required, Description optional, matching the wizard's semantics for the same fields.AccountForm(first/last/email carryrequired),NewAreaForm(submit disabled while empty),ConfluenceConnectStep(base URL + Space ID carryrequired).Deliberately not changed:
ArrivalStepAuthoring— its editor tolerates a blank title in the save path (no guard to mirror), so it gets no required mark.https://… (optional)) — that is guidance text, not a field mark.3. Tests (
test(a11y)+ unit)Field.test.tsx: +7 tests — optional label in visible text and accessible name; required wins over optional; optional controls are not marked required; propagation to all three controls; HTML-requiredmirror; explicitaria-required={false}override.CreateProjectWizard.a11y.test.tsx: +4 tests — sources step axe; inline GitHub token form axe (assertsaria-requiredon both token fields); desktop companion axe viamockViewport(true)— the portalled second dialog is included becausebaseElementis the whole body; a semantics test assertingName→aria-requiredwhileDescription (optional)/Industry (optional)are labelled./^Name/,/^Description/,/^Title/,/^Industry/,/^Space ID/,/^Confluence base URL/,/^Token name/.Verification
npm run tryon Node 22 — full chain, all green:Reviewer notes
(optional)text joining the accessible name is deliberate (Improve optional-field labels and keyboard navigation #242 asks for it); the asterisk stays out of it —aria-requiredcarries that meaning instead.