Skip to content

fix(web): repair invalid ARIA and make search/tag pickers real comboboxes - #155

Draft
heyoub wants to merge 8 commits into
CodeForPhilly:developfrom
heyoub:fix/aria-correctness
Draft

fix(web): repair invalid ARIA and make search/tag pickers real comboboxes#155
heyoub wants to merge 8 commits into
CodeForPhilly:developfrom
heyoub:fix/aria-correctness

Conversation

@heyoub

@heyoub heyoub commented Aug 24, 2026

Copy link
Copy Markdown

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.md already 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

  • SearchBox rebuilt as an APG combobox. It previously put aria-expanded/aria-controls on a plain search input, had a role="listbox" owning bare <p>/<div>s, hardcoded aria-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 swallowing mousedown on the popup. Options stay <a href> (middle-click works) but plain activation routes through useNavigate() — 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].
  • TagPicker rebuilt 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 its label prop was never associated with the input (affects ProjectEdit, ProfileEdit, PostHelpWantedModal). Also fixes a latent empty-popup render when allowCreate was set but the query wasn't a valid slug.
  • Form errors wired to their fieldsaria-invalid was set in many forms but the error text was never referenced; every audited error now has an id and a conditional aria-describedby (AddMemberModal, ProjectEdit ×5, ProjectBuzzNew ×4, PostHelpWantedModal, TagEditModal ×2, ProfileEdit ×2, MarkdownEditor). ProjectEdit's slug-availability status is now announced.

Smaller repairs

Prohibited aria-label on roleless elements (PersonAvatar → role="img", StageBadge bar → real role="progressbar", auth skeletons, spinner wrappers → role="status" + sr-only text); label-in-name mismatch on the network banner's Retry button; the always-mounted TopProgressBar hidden from AT when idle; aria-pressed on toggle filters (TagChip, Home activity row — matching the existing StageFilterRow idiom); redundant labels removed; aria-controls on the login disclosure; Page N names 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

  • One behavior tension surfaced and deliberately not resolved here: the network-error banner's button says "Retry" (as app-shell.md prescribes) but its handler only dismisses. Recorded in the plan's follow-ups — it needs its own spec decision.
  • New tests: SearchBox.test.tsx and TagPicker.test.tsx (keyboard paths, attribute contracts) — 11 tests.

Validation

type-check and lint clean; web tests 100/100 (25 files), shared 75/75. No apps/api, specs/, or components/ui/ files touched. This PR is independent of #154 (verified: clean merge in either order).

🤖 Generated with Claude Code

heyoub and others added 7 commits August 24, 2026 11:49
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>
heyoub added a commit to heyoub/codeforphilly-ng that referenced this pull request Aug 24, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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