From 30cee80b3e6edf371d3ba36f93f705686139038a Mon Sep 17 00:00:00 2001 From: Heyoub Date: Mon, 24 Aug 2026 11:49:36 -0400 Subject: [PATCH 1/8] chore(plans): add aria-correctness plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- plans/aria-correctness.md | 150 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 plans/aria-correctness.md diff --git a/plans/aria-correctness.md b/plans/aria-correctness.md new file mode 100644 index 0000000..8ff0f95 --- /dev/null +++ b/plans/aria-correctness.md @@ -0,0 +1,150 @@ +--- +status: in-progress +depends: [] +specs: + - specs/behaviors/app-shell.md +issues: [] +--- + +# Plan: repair invalid and missing ARIA across the SPA + +## Scope + +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 — **no spec change is needed**. + +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](#follow-ups). + +## Implements + +- [app-shell.md](../specs/behaviors/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. + +## Approach + +### 1. `SearchBox` — rebuild as an APG combobox + +`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 `

` and unroled `

`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](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/): + +- Input carries `role="combobox"`, `aria-expanded`, `aria-controls`, + `aria-autocomplete="list"`, and `aria-activedescendant`. +- Group headers become `role="group"` + `aria-labelledby` → a + `role="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), Home/End, Enter, Escape; focus never leaves + the input, so the blur race is structurally gone for keyboard users. The + popup swallows `mousedown` so a pointer click cannot blur the input either — + the 150 ms timeout is deleted rather than tuned. +- Options stay `` (valid: `option` is an allowed role for `a[href]`) + with `tabIndex={-1}`, so middle-click / "open in new tab" still work, while + a plain click is intercepted and routed through `useNavigate()` instead of + doing a full-page reload. +- The popup's hardcoded `id="search-results-dropdown"` is replaced by + `useId()`-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 keep `specs/behaviors/app-shell.md` → Print true. + +### 2. `TagPicker` — same combobox pattern + +`apps/web/src/components/TagPicker.tsx` had `role="listbox"` on a ` )}
diff --git a/apps/web/tests/TagPicker.test.tsx b/apps/web/tests/TagPicker.test.tsx new file mode 100644 index 0000000..9a80d15 --- /dev/null +++ b/apps/web/tests/TagPicker.test.tsx @@ -0,0 +1,169 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { renderScreen, mockPaginated } from './test-utils.js'; +import { TagPicker } from '../src/components/TagPicker.js'; + +const TAGS = [ + { + id: 't1', + handle: 'topic.civic-tech', + namespace: 'topic', + slug: 'civic-tech', + title: 'Civic Tech', + projectCount: 3, + personCount: 2, + helpWantedCount: 0, + }, + { + id: 't2', + handle: 'topic.housing', + namespace: 'topic', + slug: 'housing', + title: 'Housing', + projectCount: 1, + personCount: 0, + helpWantedCount: 0, + }, +]; + +/** Drives TagPicker as a real consumer would — controlled `value`. */ +function Harness({ allowCreate = false }: { allowCreate?: boolean }) { + const [value, setValue] = useState([]); + return ( + + ); +} + +async function findCombobox() { + return waitFor(() => screen.getByRole('combobox', { name: 'Topics' })); +} + +describe('TagPicker', () => { + beforeEach(() => { + vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string) => { + if (input.startsWith('/api/tags')) { + return Promise.resolve( + new Response(JSON.stringify(mockPaginated(TAGS)), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + } + return Promise.resolve(new Response(null, { status: 404 })); + }) as typeof fetch); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('associates its label with the combobox input', async () => { + renderScreen(); + + // getByLabelText only resolves if