fix(web): repair invalid ARIA and make search/tag pickers real comboboxes - #155
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ce8eb21 to
54b24ad
Compare
An accessibility audit found a set of verified ARIA correctness defects in apps/web — prohibited attributes on roleless elements, listboxes owning non-option children, dangling references, and toggle/progress state withheld from assistive tech. Record the scope up front so the code commits that follow have a reviewed target, and so the deliberately-excluded audit categories are on the record rather than looking like oversights. specs/behaviors/app-shell.md already requires keyboard-navigable dropdowns, so this plan needs no spec change — it brings code into conformance. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The widget was invalid ARIA on every axis: aria-expanded/aria-controls sat on
an implicit searchbox, the popup claimed role="listbox" while owning a bare
<p> and unroled <div>s, and every option hardcoded aria-selected={false}.
Worse, the results were unreachable — Tab blurred the input and a 150ms
setTimeout unmounted the dropdown before anything could be clicked, so only
Enter and Escape ever did anything.
specs/behaviors/app-shell.md already requires "All dropdowns are
keyboard-navigable", so this is conformance work, not new behavior.
Rebuilt to the combobox-with-listbox pattern: focus stays on the input and the
active option is pointed at with aria-activedescendant, which removes the blur
race structurally for keyboard users. The popup swallows mousedown so a
pointer click cannot blur the input either — hence the timeout is deleted
rather than retuned. Group headers become role="group" + aria-labelledby and
the status text moves outside the listbox, so the listbox owns only groups and
options; "See all results" becomes its final option.
Options stay <a href> (option is an allowed role for a[href]) with
tabIndex={-1}, keeping middle-click and "open in new tab" alive, while plain
activation routes through useNavigate() instead of reloading the whole SPA.
The hardcoded id="search-results-dropdown" becomes useId()-derived — it was
duplicated whenever the desktop and mobile-sheet instances both rendered. The
print stylesheet's hook moves to [data-search-dropdown] so app-shell.md's
Print section stays true.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
role="listbox" sat on a <ul> whose <li> children carried no role and wrapped <button>s, the driving Input had no combobox ARIA at all, there was no Escape or arrow handling, and the `label` prop rendered a <Label> associated with nothing — so ProjectEdit, ProfileEdit and PostHelpWantedModal each shipped several unlabelled inputs. Same pattern as SearchBox: useId() ties the Label to the input, the <li> become the interactive role="option" targets driven by aria-activedescendant, and arrows/Enter/Escape work. Enter still falls back to the historical exact-match then first-match then create-tag chain when no option is active, and Backspace-removes-last is untouched, so existing muscle memory survives. The popup no longer renders when the only reason to show it was a query that cannot produce an option — that previously left an empty listbox on screen. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Each of these either lies to assistive tech or withholds state that sighted users get for free. Grouped into one commit because they are all one-line attribute repairs with no shared behavior change. Prohibited attributes on roleless elements (aria-label needs a role to attach to, so today it is simply dropped): PersonAvatar's initials span gains role="img"; StageProgressBar's wrapper loses its aria-label in favour of real role="progressbar" semantics on the bar that actually encodes the percentage. Announcements that never fire: the LoginPlaceholder and AccountClaim spinners put aria-live + aria-label on an empty roleless div, which announces nothing — now role="status" with sr-only text and the spinner hidden. TopProgressBar was permanently exposed (it only fades via opacity), so every page read out a finished "Page loading" bar; it is hidden from AT while idle. Names that fight their labels: NetworkErrorBanner's button reads "Retry" but was named "Dismiss error" (SC 2.5.3), and ConnectGitHubBanner duplicated its own visible "Dismiss" text. Both aria-labels go. Pagination's page buttons were named only "3"; the filter chips on ProjectsIndex and HelpWantedIndex never said they remove the filter. State conveyed by styling alone: TagChip and the Home activity filters get aria-pressed, matching StageFilterRow. Also: StageBadge's tooltip triggers were non-focusable, so the stage description was hover-only; the sessions table's <th>s had no scope; the skip link — the one control that exists purely for keyboard users — ended its class list with focus:outline-none; and ManageMembersModal's inline role field was labelled only by its placeholder. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
aria-invalid was set on several controls but the error <p> beneath was never
referenced, so a screen-reader user heard "invalid" with no way to reach the
reason — the one piece of information the error exists to convey. Every
audited error message now carries an ${id}-error id and its control an
aria-describedby, applied the same way everywhere: conditional, present only
while the error is.
Fields covered: AddMemberModal, ProjectEdit (5), ProjectBuzzNew (4),
PostHelpWantedModal, TagEditModal (2), ProfileEdit (2) and MarkdownEditor.
Where a field showed an error without aria-invalid, that is set too so the
pair stays consistent.
Two related labelling fixes on the same screens. ProjectEdit's debounced slug
check ("Checking…" / "✓ Available" / "✗ Taken") was never announced, so a
non-sighted author could submit a slug already known to be taken; it becomes a
role="status" the input describes. ProfileEdit's "Avatar" Label pointed at
nothing and its file input had no id — the wrapping <label> is now a div so it
cannot compete for the accessible name.
MarkdownEditor also drops aria-live from its preview pane: the preview is the
whole document re-rendered on every debounce, so the live region read the
entire text back on each pause in typing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixing the Label-in-Name violation on NetworkErrorBanner turned up a deeper disagreement: the button says "Retry", app-shell.md prescribes "[Retry]", and the handler only calls clearError(). Spec and label agree; the code does not. That is a behavior decision, not an ARIA repair, so it does not belong in this plan's scope — but it should not evaporate either, and the aria-label removed here was the only remaining trace of what the button really does. Record it as a follow-up needing its own spec decision so nobody later "fixes" it by quietly renaming whichever side is easiest to reach. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With showLabel the nested StageBadge is already a focusable trigger for the same tooltip, so the wrapper's tabIndex added a second, redundant stop right next to it. The wrapper now joins the tab order only in the bar-only variant, where it is the sole way to reach the stage description. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The header search is now an ARIA combobox (PR CodeForPhilly#155), so Enter no longer unconditionally submits: with a highlighted result it navigates there, and the arrow keys move the highlight. Record that so the spec and the widget agree. The 5xx banner's Retry button had only ever dismissed; the spec, the label and the code disagreed. Retry now re-fetches active queries, and when the failing call has nothing to re-issue the button reads Dismiss, so the visible label is always accurate. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr
Review follow-ups on the combobox rewrite: - Drop the Home/End branches. In an editable combobox those keys move the text caret (APG); hijacking them for the listbox surprised anyone editing the query. - activate() now blurs the input, so focus does not stay parked in an emptied combobox after choosing a result. The Enter fallback reuses seeAllUrl + activate() instead of repeating the same four steps. - Options track the pointer with a guarded onMouseMove instead of onMouseEnter: results arriving under a stationary pointer would otherwise steal the highlight from a keyboard user. - The inline (mobile-sheet) instance renders its results in-flow with a smaller cap rather than absolutely positioned, so on short phones the list can no longer hang below the sheet's fixed-height viewport. AppHeader's test queries the sheet search as a combobox now, and gains a case for clicking a result closing the sheet (via CodeForPhilly#154's location-key mechanism). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr
Two interaction gaps in the combobox rewrite:
- After a mouse selection the input kept focus (the listbox swallowed
mousedown), so onFocus could never fire again and clicking the input
a second time did nothing. The input now opens on click as well.
- Tab-out never closed the list; only a document-level mousedown did.
Replace that listener with an onBlur on the container that closes when
relatedTarget is outside it. Options and the listbox get tabIndex={-1}
so a click inside keeps focus within the container, which also lets
the mousedown swallow go (it blocked scrollbar dragging in Firefox).
selectOption refocuses the input so the next tag can be typed.
Also: options highlight on guarded onMouseMove rather than onMouseEnter
(async option arrival under a still pointer no longer steals the
keyboard highlight), and the repeated setOpen(false)/setActiveIndex(-1)
pair is a close() helper. Two tests cover the mouse-select/reopen and
focus-out paths.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr
- TagChip emitted aria-pressed on every onClick chip, so the filter
removal chips announced as "toggle button, not pressed". Only emit it
when a caller actually passes `active`.
- StageBadge and StageProgressBar had gained tabIndex={0} to make the
tooltip keyboard-reachable, which added a roleless focus stop per
project card and opened two nested tooltips at once on the detail
page. Expose the description non-visually instead: an sr-only span in
the badge, and aria-describedby on the progressbar. No focus stop.
- PersonAvatar's inner span is role="img" with the person's name, so the
wrapping Link's aria-label read the name twice. Let the link take its
name from content.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr
The 5xx banner's only button said "Retry" but just dismissed. PR CodeForPhilly#155 removed the aria-label that admitted as much, leaving a button whose name and behaviour still disagreed. showError now takes an optional retry callback. The query client passes one that re-fetches every active query, so Retry re-issues whatever is on screen and then clears the banner. The header typeahead reports errors with no callback (it re-runs on the next keystroke), and in that case the button reads "Dismiss" — the visible label always says what the button does, per the amended specs/behaviors/app-shell.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr
Several screen tests (ProjectEdit, ExpressInterestModal, …) pass in isolation but exceed vitest's 5 s default when the whole web suite runs under CI-like load. The individual 20 s overrides in the new combobox tests were papering over the same thing; a suite-level 15 s is the honest number. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr
Check the validation boxes actually verified (new combobox tests, the audited aria-label/aria-invalid sets, and the full gate: type-check, lint, api 434/434, web 114/114, shared 75/75), fill in the closeout Notes (mousedown-swallow vs focus-out decision, hardcoded error ids kept, the post-CodeForPhilly#154 review pass, and that the mobile-sheet layout was reasoned rather than screenshotted), and convert the prose Follow-ups into taxonomy shapes: CodeForPhilly#164 (useId-derived form error ids) and CodeForPhilly#165 (shared combobox hook) filed and linked; the Retry contradiction is resolved in this PR. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr
54b24ad to
908d4e1
Compare
|
Thanks @heyoub — this is a careful pass and the combobox rewrites are a real improvement. I rebased the branch onto What changed and why
Validation: One thing I could not do here: a headless screenshot of the mobile sheet with results open (no data repo in the review environment). The in-flow change is reasoned through in the plan Notes; if you have a phone-sized viewport handy, a quick look would be welcome. 🤖 Generated with Claude Code |
The accessibility audit's third bucket — findings with one obviously-correct fix each and no design decision attached. Recording it as a plan before touching code so the DAG carries the scope, the PR stack it sits on (CodeForPhilly#154 + CodeForPhilly#155), and its relationship to issue CodeForPhilly#156, which holds the design-decision findings that are deliberately NOT implemented here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An ARIA-correctness pass over
apps/web, scoped to verified defects: markup that is invalid per the ARIA spec (prohibited attributes, illegal listbox ownership, dangling references) or that withholds state assistive technology needs. No spec changes —specs/behaviors/app-shell.mdalready requires "All dropdowns are keyboard-navigable"; this PR brings the code into conformance. Plan record:plans/aria-correctness.md. No visual redesigns: out-of-scope-by-choice items (heading hierarchy, color contrast,document.title, motion controls, etc.) are listed in the plan's follow-ups instead of bundled here.Headline changes
SearchBoxrebuilt as an APG combobox. It previously putaria-expanded/aria-controlson a plain search input, had arole="listbox"owning bare<p>/<div>s, hardcodedaria-selected={false}, and its results were keyboard-unreachable (Tab blurred the input and a 150 ms timeout unmounted the popup). Now:role="combobox"+aria-activedescendant, valid listbox ownership (status text outside, groups labelled), Arrow/Home/End/Enter/Escape, and the blur race replaced by swallowingmousedownon the popup. Options stay<a href>(middle-click works) but plain activation routes throughuseNavigate()— clicking a result no longer full-page-reloads the SPA. The print stylesheet's hook moved from the old hardcoded id (which duplicated when both header and sheet search rendered) to[data-search-dropdown].TagPickerrebuilt the same way — it drove a popup with no combobox ARIA at all,role="listbox"on a<ul>of unroled<li><button>s, no Escape/arrows, and itslabelprop was never associated with the input (affects ProjectEdit, ProfileEdit, PostHelpWantedModal). Also fixes a latent empty-popup render whenallowCreatewas set but the query wasn't a valid slug.aria-invalidwas set in many forms but the error text was never referenced; every audited error now has an id and a conditionalaria-describedby(AddMemberModal, ProjectEdit ×5, ProjectBuzzNew ×4, PostHelpWantedModal, TagEditModal ×2, ProfileEdit ×2, MarkdownEditor). ProjectEdit's slug-availability status is now announced.Smaller repairs
Prohibited
aria-labelon roleless elements (PersonAvatar →role="img", StageBadge bar → realrole="progressbar", auth skeletons, spinner wrappers →role="status"+sr-onlytext); label-in-name mismatch on the network banner's Retry button; the always-mountedTopProgressBarhidden from AT when idle;aria-pressedon toggle filters (TagChip, Home activity row — matching the existingStageFilterRowidiom); redundant labels removed;aria-controlson the login disclosure;Page Nnames in Pagination;scope="col"on the sessions table; tooltip-only stage descriptions now keyboard-reachable; the skip link's suppressed focus ring restored; avatar file input labelled.Notes for reviewers
app-shell.mdprescribes) but its handler only dismisses. Recorded in the plan's follow-ups — it needs its own spec decision.SearchBox.test.tsxandTagPicker.test.tsx(keyboard paths, attribute contracts) — 11 tests.Validation
type-checkandlintclean; web tests 100/100 (25 files), shared 75/75. Noapps/api,specs/, orcomponents/ui/files touched. This PR is independent of #154 (verified: clean merge in either order).🤖 Generated with Claude Code