| status | done | ||
|---|---|---|---|
| depends | |||
| specs |
|
||
| issues |
|
||
| pr | 155 |
An accessibility audit of apps/web turned up a set of verified ARIA
correctness defects — markup that is invalid per the ARIA spec (prohibited
attributes, illegal role ownership, dangling references) or that withholds
state from assistive technology that sighted users get visually. This plan
fixes that class of defect only.
The two headline items are the site search box and the tag picker: both claim
role="listbox" today while owning non-option children, and neither is
operable from the keyboard. specs/behaviors/app-shell.md → Accessibility
already requires "All dropdowns are keyboard-navigable", so this is code
brought into conformance with an existing spec. Review surfaced two places
where the spec's own wording lagged the widget (Enter's behaviour with a
highlighted result; what the error banner's Retry does) — those were amended
in the spec first, then the code followed.
Out of scope by deliberate choice: heading hierarchy, colour contrast,
document.title, motion/pause controls, breadcrumbs, target="_blank" cues,
toolbar semantics, repeated button names, CardTitle semantics. See
Follow-ups.
- app-shell.md — Accessibility section, partially: "All dropdowns are keyboard-navigable" now holds for the header search box and the tag picker. The remaining bullets (logo link, skip link, mobile sheet focus trap) were already satisfied; the skip link's focus ring is restored here.
apps/web/src/components/SearchBox.tsx was invalid on every axis: it put
aria-expanded/aria-controls on an implicit searchbox, gave the popup
role="listbox" while it owned a bare <p> and unroled <div>s, hardcoded
aria-selected={false} on every option, and was unreachable by keyboard — Tab
blurred the input and a 150 ms setTimeout unmounted the popup, so only Enter
and Escape ever worked.
Rebuilt to the ARIA APG combobox-with-listbox pattern:
- Input carries
role="combobox",aria-expanded,aria-controls,aria-autocomplete="list", andaria-activedescendant. - Group headers become
role="group"+aria-labelledby→ arole="presentation"header element, so the listbox owns only groups and options. - "Searching…" / "No results" moves out of the listbox into a sibling
role="status"region. - "See all results" becomes the final option in the listbox.
- ArrowDown/ArrowUp (wrapping), Enter, Escape; focus never leaves the input
while the popup is open, so the blur race is structurally gone for keyboard
users. Home/End are left to the text caret (APG: editable combobox). The
popup swallows
mousedownso a pointer click cannot blur the input either — the 150 ms timeout is deleted rather than tuned. Activating a result blurs the input so focus returns to the page. - Options track the pointer with a guarded
onMouseMove, notonMouseEnter, so results arriving under a stationary pointer cannot steal a keyboard user's highlight. - The inline (mobile-sheet) instance renders results in-flow with a smaller height cap; absolutely positioned, they hung below the sheet's fixed-height viewport on short phones.
- Options stay
<a href>(valid:optionis an allowed role fora[href]) withtabIndex={-1}, so middle-click / "open in new tab" still work, while a plain click is intercepted and routed throughuseNavigate()instead of doing a full-page reload. - The popup's hardcoded
id="search-results-dropdown"is replaced byuseId()-derived ids. That id was duplicated whenever both the desktop and the mobile-sheet instance rendered; the print stylesheet's hook moves to[data-search-dropdown]to keepspecs/behaviors/app-shell.md→ Print true.
apps/web/src/components/TagPicker.tsx had role="listbox" on a <ul> whose
<li>s carried no role and wrapped <button>s, no combobox ARIA on the
driving input, no Escape/arrow handling, and a label prop that rendered a
<Label> associated with nothing (affecting ProjectEdit, ProfileEdit and
PostHelpWantedModal). Reworked to the same pattern: useId() ties <Label htmlFor> to the input; <li role="option"> become the interactive targets;
ArrowDown/ArrowUp/Escape/Enter. Enter falls back to the existing
exact-match → first-match → create-tag chain when no option is active, and
Backspace-removes-last is preserved. The list closes on focus-out (container
onBlur + relatedTarget check, options tabIndex={-1}) rather than via a
document-level mousedown listener, and the input reopens it on click since a
mouse selection leaves focus in place.
| File | Defect | Fix |
|---|---|---|
PersonAvatar.tsx |
aria-label on a roleless <span> (prohibited) |
role="img" |
StageBadge.tsx |
aria-label on a roleless <div>; progress conveyed by width only; stage description tooltip-only |
role="progressbar" + value attrs; description exposed via sr-only text / aria-describedby (no extra tab stop) |
LoginPlaceholder.tsx, AccountClaim.tsx |
aria-live + aria-label on an empty roleless div announces nothing |
role="status" + sr-only text, spinner aria-hidden |
NetworkErrorBanner.tsx |
button reads "Retry", aria-label says "Dismiss error" (SC 2.5.3), and the handler only dismissed |
drop the aria-label; showError takes a retry callback (query client re-fetches active queries); label reads "Dismiss" when there is none |
TagChip.tsx |
aria-pressed on every clickable chip made filter-removal chips announce as toggles |
emit it only when active is passed |
PersonAvatar.tsx |
Link aria-label duplicated the inner role="img" name |
drop it; the link is named by content |
TopProgressBar.tsx |
progressbar permanently exposed at 100% | aria-hidden when idle |
TagChip.tsx, Home.tsx |
toggle state conveyed visually only | aria-pressed |
ConnectGitHubBanner.tsx |
aria-label duplicates visible text |
removed |
MarkdownEditor.tsx |
aria-live re-announces the whole preview each debounce; error text unassociated |
drop aria-live; aria-describedby |
ManageMembersModal.tsx |
placeholder-only labelling | aria-label="Role" |
ProfileEdit.tsx |
"Avatar" <Label> labels nothing |
htmlFor → file input id |
ProjectsIndex.tsx, HelpWantedIndex.tsx |
filter chips don't say they remove | aria-label="Remove filter: …" |
Pagination.tsx |
page buttons named only "3" | aria-label="Page 3" |
Account.tsx |
sessions table headers have no scope | scope="col" |
AppShell.tsx |
skip link ends with focus:outline-none |
class removed |
ProjectEdit.tsx |
async slug availability never announced | role="status" + aria-describedby |
aria-invalid was set in several forms but the error text was never
programmatically associated, so a screen-reader user hears "invalid" with no
reason. Every error <p> gains an ${id}-error id and its control gains a
conditional aria-describedby, applied uniformly across AddMemberModal,
ProjectEdit (5 fields), ProjectBuzzNew (4), PostHelpWantedModal,
TagEditModal (2) and ProfileEdit (2).
-
SearchBoxexposesrole="combobox"witharia-expanded,aria-controls,aria-autocomplete="list"; the listbox owns only groups/options; status text sits outside it. -
SearchBoxkeyboard: type → ArrowDown movesaria-activedescendant→ Enter navigates to the active option; Escape closes. -
TagPickerlabel is associated with its input; ArrowDown + Enter selects an option; Escape closes; Backspace-removes-last still works. - New tests
apps/web/tests/SearchBox.test.tsxandapps/web/tests/TagPicker.test.tsxcover the above. - No
aria-labelremains on a roleless generic element in the audited set. - Every audited
aria-invalidcontrol references its error text viaaria-describedby. -
npm run -w packages/shared build && npm run type-check && npm run lint && npm testclean.
- Medium, contained to two widgets. The SearchBox and TagPicker rewrites change interaction, not just attributes. Both are covered by new focused tests; both keep their existing Tailwind classes so the visual design is unchanged.
- The rest is attribute-level and mechanical.
- Mousedown swallow vs. focus-out.
SearchBoxkeeps theonMouseDown={preventDefault}on its popup and closes on input blur — the options are<a href>s and focus should stay in the input until a result is chosen.TagPickerwent the other way after review: containeronBlurwith arelatedTargetcheck,tabIndex={-1}on the listbox and options, and no swallow, because the swallow blocked scrollbar dragging in Firefox and kept the input focused so a second click could never reopen the list. The two widgets now differ here; see the combobox-hook follow-up. - Hardcoded error ids kept. The
aria-describedbywiring in §4 uses literal ids (title-error,slug-error, …). They collide when a screen and a modal that share one are mounted together. Left as-is to keep this PR attribute-level; tracked as a follow-up. - Review pass (post-#154 rebase). Home/End removed from
SearchBox(APG: editable combobox leaves them to the caret);activate()blurs the input; option highlight moves on guardedonMouseMove; inline results render in-flow so the mobile sheet can scroll them;TagChiponly emitsaria-pressedwhenactiveis passed;StageBadge/StageProgressBarlost theirtabIndex={0}in favour ofsr-only/aria-describedbydescriptions;PersonAvatar's link label dropped (double announcement); the error banner's Retry now re-fetches active queries and reads "Dismiss" when there is nothing to retry.specs/behaviors/app-shell.mdwas amended first for the Enter/arrow-key contract and the Retry semantics.apps/webtestTimeoutraised to 15 s — several screen tests exceeded 5 s under full-suite load. - Mobile-sheet layout was reasoned, not screenshotted. No API/data repo
was available in the review environment for a headless check; the in-flow
change follows from the sheet being a fixed-height flex column whose nav
has
min-h-0 overflow-y-auto, so an in-flow, self-scrolling results box (max-h-64) stays inside the viewport while the nav yields height. - The AppHeader test from #154 queried the sheet search as a
searchbox; it is acomboboxnow, and a second case covers clicking a result closing the sheet via #154'slocation.keymechanism.
- Issue #164 —
form error ids should derive from
useId(); the hardcodedtitle-error/slug-errorids collide when a screen and a modal are mounted together. - Issue #165 —
extract a shared combobox hook for
SearchBox/TagPicker; the two hand-rolled copies have already diverged on close-on-blur handling. - The 5xx banner's "Retry" spec↔code contradiction recorded at the original closeout was resolved in review: spec amended first, a real retry wired (re-fetch active queries), and the label reads "Dismiss" when nothing can be retried. Nothing outstanding.
- Tracked as: the wider audit categories deliberately excluded here — heading
hierarchy, colour contrast,
document.title, motion/pause controls, breadcrumbs,target="_blank"cues, toolbar semantics, repeated button names,CardTitlesemantics. The coordinator holds the full audit report; these want their own triage and plan.