diff --git a/CHANGELOG.md b/CHANGELOG.md index c310bba..5425492 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,34 @@ All notable changes to `@ngbracket/a11y-devtools` are documented here. This project adheres to [Semantic Versioning](https://semver.org/). +## 0.18.0 + +### Added + +- Report mode can list each route's tab order (`--walkthrough`, or + `walkthrough: true` in `scanPages`). Every tab stop gets a line with its + computed role, accessible name, states and owning component, for example + `button "Save" (disabled) — SaveBarComponent`. A stop with no name reads + `(no accessible name)`, and a positive `tabindex` is flagged. The list is in + the JSON, Markdown and HTML reports, under a note that it is a computed + approximation and not the output of any one screen reader. With + `--color-scheme both` it runs on the light pass only. It stops at 300 stops + per page; the report gives the real total when it is cut short. +- The pill shows a count badge: the issues on the page that pass the menu's + filters, coloured by the most severe of them (green at 0). The switch gets the + count in words as its description, for example "5 issues of 14 on this page + match the filters". +- The pill menu has a **Show component** filter that lists the components with + issues on the current page, each with its count at the chosen severity. + Choosing one draws only that component's highlights and updates the badge. + The choice isn't saved, and it goes back to all components when the path + changes. A query-string change (`?page=2`) keeps it. + +### Changed + +- The keyboard-layer screenshot in the README is new: the demo's Keyboard + section with the severity filter at "Serious and above". + ## 0.17.0 ### Added diff --git a/README.md b/README.md index a518651..ef7f680 100644 --- a/README.md +++ b/README.md @@ -60,11 +60,11 @@ export const appConfig: ApplicationConfig = { }; ``` -![Keyboard & Focus Mode over the demo page: numbered tab-order badges (badge 1 in -orange flags a positive tabindex) joined by a connector path, severity-coloured -finding highlights labelled with their owning component (including the ngbr/* -keyboard findings), and the Focus preview card showing the focused button's -computed role, name and states.](./demo/keyboard-layer-2026-09.png) +![Keyboard & Focus Mode over the demo page's Keyboard section: numbered tab-order +badges joined by a dashed path (badge 1 in orange flags a positive tabindex), two +serious keyboard findings labelled with their owning component, the Focus preview +card showing the focused Menu button's computed role, name and states, and the a11y +pill in the corner with a red 5, the count of issues that pass the severity filter.](./demo/keyboard-layer-2026-10.png) In CI (`--serve` starts `ng serve`, waits for it, and stops it after the scan): @@ -116,12 +116,6 @@ ever becomes reachable through a static import. ## Roadmap -### Planned - -- A headless "linear walkthrough": the tab sequence as a reading list per route in - report mode. The Focus preview is overlay-only for now. -- Per-component filtering and a violation-count badge. - ### Done - Component attribution: the nearest app-owned component, skipping UI primitives. @@ -144,3 +138,5 @@ ever becomes reachable through a static import. - Report mode can start the dev server (`--serve`). - ACR evaluation worksheet by WCAG 2.2 A/AA criterion (`--format acr`). - Re-render a saved JSON report in any format without scanning (`--from`). +- Issue-count badge on the pill and a per-component filter in its menu. +- Tab order walkthrough in report mode (`--walkthrough`). diff --git a/bin/ngbr-a11y-report.mjs b/bin/ngbr-a11y-report.mjs index d825f73..805ebdc 100755 --- a/bin/ngbr-a11y-report.mjs +++ b/bin/ngbr-a11y-report.mjs @@ -61,6 +61,9 @@ Options: --focus-traps Also walk each route with real Tab presses to find keyboard traps — focus that cycles inside part of the page and never moves on (ngbr/focus-trap). Runs after the scan + --walkthrough Also list each route's tab order with every stop's computed + role, name, states and component (an approximation, not + any one screen reader's output) --color-scheme light | dark | both — scan with the browser emulating that prefers-color-scheme (default light). "both" scans each route twice; dark pages list only issues light doesn't have @@ -99,6 +102,7 @@ function parseArgs(argv) { case '--no-skip-primitives': opts.frameworkPrefixes = []; break; case '--keyboard': opts.keyboard = true; break; case '--focus-traps': opts.focusTraps = true; break; + case '--walkthrough': opts.walkthrough = true; break; case '--color-scheme': opts.colorScheme = next(); break; case '--dark-class': opts.darkClass = next(); break; case '--dark-attribute': opts.darkAttribute = next(); break; @@ -117,7 +121,7 @@ const opts = parseArgs(process.argv.slice(2)); const SCAN_ONLY = { base: '--base', serve: '--serve', serveTimeout: '--serve-timeout', routes: '--route', tags: '--tags', wait: '--wait', frameworkPrefixes: '--framework-prefixes / --no-skip-primitives', keyboard: '--keyboard', - focusTraps: '--focus-traps', colorScheme: '--color-scheme', darkClass: '--dark-class', + focusTraps: '--focus-traps', walkthrough: '--walkthrough', colorScheme: '--color-scheme', darkClass: '--dark-class', darkAttribute: '--dark-attribute', headed: '--headed', }; if (opts.from && !opts.help) { @@ -245,6 +249,7 @@ if (!report) { frameworkPrefixes: opts.frameworkPrefixes, keyboard: opts.keyboard, focusTraps: opts.focusTraps, + walkthrough: opts.walkthrough, colorScheme: opts.colorScheme, darkClass: opts.darkClass, darkAttribute, diff --git a/demo/keyboard-layer-2026-09.png b/demo/keyboard-layer-2026-09.png deleted file mode 100644 index 81f8116..0000000 Binary files a/demo/keyboard-layer-2026-09.png and /dev/null differ diff --git a/demo/keyboard-layer-2026-10.png b/demo/keyboard-layer-2026-10.png new file mode 100644 index 0000000..a02aa1f Binary files /dev/null and b/demo/keyboard-layer-2026-10.png differ diff --git a/package-lock.json b/package-lock.json index a6ddc04..42e4367 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ngbracket/a11y-devtools", - "version": "0.17.0", + "version": "0.18.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ngbracket/a11y-devtools", - "version": "0.17.0", + "version": "0.18.0", "license": "MIT", "dependencies": { "axe-core": "^4.13.0" diff --git a/package.json b/package.json index cc5f243..f80e6d0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ngbracket/a11y-devtools", - "version": "0.17.0", + "version": "0.18.0", "description": "Finds accessibility problems in Angular apps and names the component behind each one. A dev overlay, or headless reports for CI.", "license": "MIT", "author": "Duncan Faulkner", diff --git a/src/inpage.ts b/src/inpage.ts index ccb31c6..50d5739 100644 --- a/src/inpage.ts +++ b/src/inpage.ts @@ -1,5 +1,6 @@ import type { RunOptions as AxeRunOptions } from 'axe-core'; import { createFocusWalkProbe } from './keyboard/focus-walk.js'; +import { linearWalkthrough, type Walkthrough } from './keyboard/walkthrough.js'; import { scan, type A11yFinding, type ScanOptions } from './scan.js'; /** @@ -25,3 +26,8 @@ const run: InPageScan = (options, scanOptions) => scan(document, options, scanOp (globalThis as unknown as { __ngbA11yCreateFocusProbe: (prefixes?: readonly string[]) => ReturnType; }).__ngbA11yCreateFocusProbe = (prefixes) => createFocusWalkProbe(document, prefixes); + +// The linear walkthrough: the tab order with each stop's computed role and name. +(globalThis as unknown as { + __ngbA11yWalkthrough: (prefixes?: readonly string[]) => Promise; +}).__ngbA11yWalkthrough = (prefixes) => linearWalkthrough(document, { frameworkPrefixes: prefixes }); diff --git a/src/keyboard/accname.ts b/src/keyboard/accname.ts index 9655cb9..b04b713 100644 --- a/src/keyboard/accname.ts +++ b/src/keyboard/accname.ts @@ -53,17 +53,33 @@ function loadAxe(): Promise { * built — e.g. a scan is mid-flight — so a focus-follow caller degrades quietly. */ export async function describeElement(element: Element): Promise { + return (await describeElements([element]))[0]; +} + +/** + * {@link describeElement} for many elements of one document, building axe's + * tree once instead of once per element (a page's whole tab order, say). + */ +export async function describeElements(elements: readonly Element[]): Promise { + if (elements.length === 0) return []; const axe = await loadAxe(); - const doc = element.ownerDocument ?? document; - let role: string | null = null; - let name = ''; + const doc = elements[0].ownerDocument ?? document; + const computed: { role: string | null; name: string }[] = elements.map(() => ({ role: null, name: '' })); let didSetup = false; try { axe.setup(doc); didSetup = true; - const vnode = axe.utils.getNodeFromTree(element); - role = axe.commons.aria.getRole(element) ?? null; - name = vnode ? (axe.commons.text.accessibleTextVirtual(vnode) ?? '') : ''; + elements.forEach((element, i) => { + try { + const vnode = axe.utils.getNodeFromTree(element); + computed[i] = { + role: axe.commons.aria.getRole(element) ?? null, + name: vnode ? (axe.commons.text.accessibleTextVirtual(vnode) ?? '') : '', + }; + } catch { + // Leave this one at role=null / name=''. + } + }); } catch { // Leave role=null / name='' — axe couldn't build a tree (e.g. run in flight). } finally { @@ -77,7 +93,11 @@ export async function describeElement(element: Element): Promise } } } - return { role, name, description: accessibleDescription(element), states: ariaStates(element) }; + return elements.map((element, i) => ({ + ...computed[i], + description: accessibleDescription(element), + states: ariaStates(element), + })); } /** The accessible description: `aria-describedby` targets' text, else the `title`. */ diff --git a/src/keyboard/walkthrough.ts b/src/keyboard/walkthrough.ts new file mode 100644 index 0000000..4e457ce --- /dev/null +++ b/src/keyboard/walkthrough.ts @@ -0,0 +1,60 @@ +/** + * The linear walkthrough: the page's tab order as a reading list, each stop with + * its computed role, name and states and the component that rendered it. It's + * the report-mode form of the overlay's tab-order path and Focus preview. + * + * Like the Focus preview, this is a *computed approximation* (see `accname.ts`), + * never what any one screen reader says. + */ +import { describeElements } from './accname.js'; +import { tabSequence, type TabSequenceOptions } from './tab-sequence.js'; + +/** One tab stop in the walkthrough. Plain data, so it survives JSON. */ +export interface WalkthroughStep { + /** 1-based position in the tab order. */ + order: number; + /** Computed ARIA role, or null when none resolves. */ + role: string | null; + /** Computed accessible name; '' when it has none. */ + name: string; + /** Computed accessible description, or ''. */ + description: string; + /** State labels, e.g. `['expanded', 'required']`. */ + states: string[]; + /** Tag name of the element, e.g. `button`. */ + tag: string; + /** True when a positive `tabindex` puts this stop out of DOM order. */ + positive: boolean; + /** Owning app component, or null. */ + component: string | null; +} + +/** Steps past this many are dropped; the report says how many there were. */ +export const WALKTHROUGH_MAX_STEPS = 300; + +export interface Walkthrough { + steps: WalkthroughStep[]; + /** Tab stops on the page. More than `steps.length` when the list was capped. */ + total: number; +} + +/** Walk the tab order of `root` and describe each stop. */ +export async function linearWalkthrough( + root: ParentNode = document, + options: Pick = {}, +): Promise { + const stops = tabSequence(root, options); + const kept = stops.slice(0, WALKTHROUGH_MAX_STEPS); + const described = await describeElements(kept.map((s) => s.element)); + const steps = kept.map((stop, i) => ({ + order: stop.order, + role: described[i].role, + name: described[i].name.replace(/\s+/g, ' ').trim(), + description: described[i].description, + states: described[i].states, + tag: stop.element.tagName.toLowerCase(), + positive: stop.positive, + component: stop.component, + })); + return { steps, total: stops.length }; +} diff --git a/src/overlay.ts b/src/overlay.ts index f9e70e3..2f63304 100644 --- a/src/overlay.ts +++ b/src/overlay.ts @@ -40,7 +40,7 @@ const IMPACT_COLOR: Record | 'none', string> = { * Label fill per impact, behind white 11px text: at least 4.5:1 with white, in * the same hue as the border, so the labels pass a contrast check themselves. */ -const IMPACT_LABEL_FILL: Record | 'none', string> = { +export const IMPACT_LABEL_FILL: Record | 'none', string> = { critical: '#d32029', serious: '#b45309', moderate: '#8a6d00', diff --git a/src/pill-menu.ts b/src/pill-menu.ts index 8715754..1b24c6e 100644 --- a/src/pill-menu.ts +++ b/src/pill-menu.ts @@ -6,6 +6,8 @@ export interface PillMenuOptions { onChange: (settings: DevtoolsSettings) => void; /** Download the report for the pages visited so far. */ onDownload: () => void; + /** Show only one component's issues (`null`: every component). */ + onComponentChange?: (component: string | null) => void; /** Open the panel above the button (pill at the bottom) or below it. */ opensUp: boolean; /** Align the panel's left edge (pill on the left) or right edge. */ @@ -19,6 +21,10 @@ export interface PillMenuStatus { shown: number; /** Pages recorded for the report. */ pages: number; + /** Components with issues on this page, most issues first. */ + components?: { name: string; count: number }[]; + /** The component filter in force, or null for every component. */ + component?: string | null; } export interface PillMenu { @@ -143,7 +149,7 @@ export function createPillMenu(doc: Document, options: PillMenuOptions): PillMen select.append(opt); } select.value = settings.minImpact; - Object.assign(select.style, { + const selectStyle = { width: '100%', minHeight: '28px', marginBottom: '10px', @@ -152,9 +158,43 @@ export function createPillMenu(doc: Document, options: PillMenuOptions): PillMen background: '#2a2e37', border: '1px solid rgba(255,255,255,0.3)', borderRadius: '4px', - }); + }; + Object.assign(select.style, selectStyle); select.addEventListener('change', () => update({ minImpact: select.value as MinImpact })); + // Per-component filter. Its options follow the components on the current page. + const componentLabel = doc.createElement('label'); + componentLabel.textContent = 'Show component'; + Object.assign(componentLabel.style, { display: 'block', fontWeight: '700', marginBottom: '4px' }); + const componentSelect = doc.createElement('select'); + componentSelect.id = `${id}-component`; + componentLabel.htmlFor = componentSelect.id; + Object.assign(componentSelect.style, selectStyle); + componentSelect.addEventListener('change', () => options.onComponentChange?.(componentSelect.value || null)); + let componentOptionsKey = ''; + + /** Rebuild the options only when they change, so an open select isn't reset under the user. */ + function setComponentOptions(components: { name: string; count: number }[], selected: string | null): void { + const list = [...components]; + // Keep a chosen component listed after its issues are fixed, so the filter stays visible. + if (selected && !list.some((c) => c.name === selected)) list.push({ name: selected, count: 0 }); + const key = JSON.stringify([list, selected]); + if (key === componentOptionsKey) return; + componentOptionsKey = key; + const all = doc.createElement('option'); + all.value = ''; + all.textContent = 'All components'; + const opts = list.map(({ name, count }) => { + const opt = doc.createElement('option'); + opt.value = name; + opt.textContent = `${name} (${count})`; + return opt; + }); + componentSelect.replaceChildren(all, ...opts); + componentSelect.value = selected ?? ''; + } + setComponentOptions([], null); + const download = doc.createElement('button'); download.type = 'button'; download.textContent = 'Download report'; @@ -172,7 +212,8 @@ export function createPillMenu(doc: Document, options: PillMenuOptions): PillMen const pagesNote = doc.createElement('p'); Object.assign(pagesNote.style, { margin: '4px 0 0', fontSize: '12px', color: muted }); - panel.append(status, fieldset, severityLabel, select, download, pagesNote); + const componentControls = options.onComponentChange ? [componentLabel, componentSelect] : []; + panel.append(status, fieldset, severityLabel, select, ...componentControls, download, pagesNote); function update(patch: Partial): void { settings = { ...settings, ...patch }; @@ -203,7 +244,7 @@ export function createPillMenu(doc: Document, options: PillMenuOptions): PillMen doc.addEventListener('pointerdown', onPointerDown, true); // Inline styles can't do :focus-visible; draw the rings from focus events. - for (const el of [button, download, select]) { + for (const el of [button, download, select, componentSelect]) { el.addEventListener('focus', () => { if (el.matches(':focus-visible')) { el.style.outline = '2px solid #7ab8ff'; @@ -218,7 +259,8 @@ export function createPillMenu(doc: Document, options: PillMenuOptions): PillMen return { button, panel, - setStatus({ found, shown, pages }) { + setStatus({ found, shown, pages, components, component }) { + if (components) setComponentOptions(components, component ?? null); const issues = `${found} ${found === 1 ? 'issue' : 'issues'} on this page`; status.textContent = shown === found ? issues : `${issues}, ${shown} shown`; pagesNote.textContent = `Covers ${pages} ${pages === 1 ? 'page' : 'pages'} visited since the app loaded.`; diff --git a/src/provider.ts b/src/provider.ts index 781b63b..7ded0ae 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -25,9 +25,11 @@ import { type PillPosition, } from './toggle.js'; import { + componentCounts, readStoredSettings, resolveSettings, visibleFindings, + worstImpact, writeStoredSettings, type DevtoolsSettings, type MinImpact, @@ -174,10 +176,25 @@ function devtoolsProviders(options: A11yDevtoolsOptions): EnvironmentProviders { let lastFindings: A11yFinding[] = []; const visited = new Map(); + // Show one component's issues only (null: all). Not remembered, and reset + // on a route change: the components change from page to page. + let componentFilter: string | null = null; + let lastPath: string | undefined; + /** Draw (or clear) each overlay layer from the last scan and the settings. */ function draw(): void { - const shown = settings.highlights ? visibleFindings(lastFindings, settings.minImpact) : []; - pillView?.setStatus({ found: lastFindings.length, shown: shown.length, pages: visited.size }); + const matching = visibleFindings(lastFindings, settings.minImpact, componentFilter); + const shown = settings.highlights ? matching : []; + pillView?.setStatus({ + found: lastFindings.length, + shown: shown.length, + pages: visited.size, + matching: matching.length, + worst: worstImpact(matching), + // Counted at the chosen severity, so an option's count matches the badge. + components: componentCounts(visibleFindings(lastFindings, settings.minImpact)), + component: componentFilter, + }); if (!overlayView) return; if (!enabled) { overlayView.clear(); @@ -251,7 +268,17 @@ function devtoolsProviders(options: A11yDevtoolsOptions): EnvironmentProviders { onToggle: setEnabled, shortcut, position: pill, - menu: overlayView ? { settings, onChange: setSettings, onDownload: downloadReport } : undefined, + menu: overlayView + ? { + settings, + onChange: setSettings, + onDownload: downloadReport, + onComponentChange: (component) => { + componentFilter = component; + draw(); + }, + } + : undefined, }) : undefined; @@ -328,6 +355,9 @@ function devtoolsProviders(options: A11yDevtoolsOptions): EnvironmentProviders { if (!enabled) return; // switched off mid-scan: draw nothing lastFindings = findings; const route = location.pathname + location.search; + // A new path is a new page; a query-string change (?page=2) keeps the filter. + if (lastPath !== undefined && location.pathname !== lastPath) componentFilter = null; + lastPath = location.pathname; // Label by route: a single-page app usually keeps one title on every route. visited.set(route, { label: route, url: location.href, findings }); draw(); diff --git a/src/report/format.ts b/src/report/format.ts index 4107238..6c44a2d 100644 --- a/src/report/format.ts +++ b/src/report/format.ts @@ -1,5 +1,6 @@ import type { A11yFinding, Impact } from '../scan.js'; import type { BaselineDiff } from './baseline.js'; +import type { Walkthrough, WalkthroughStep } from '../keyboard/walkthrough.js'; /** * Pure formatting for report-mode: grouping + Markdown/JSON serialization. Kept @@ -23,12 +24,39 @@ export interface PageReport { * ones that don't also appear in the light pass of the same route. */ darkOnly?: boolean; + /** The tab order with each stop's computed role and name, when the run asked for it. */ + walkthrough?: Walkthrough; } /** One-line explanation for a dark-only page, shared by the Markdown and HTML reports. */ export const DARK_ONLY_NOTE = 'Dark mode: lists only issues that don’t also appear in light mode on this route.'; +/** + * Heading note for the walkthrough, shared by the Markdown and HTML reports. Same + * honesty rule as the overlay's Focus preview: computed, not a screen reader. + */ +export const WALKTHROUGH_NOTE = + 'Each tab stop in order, with its computed role, name and states. A computed approximation, ' + + 'not what any one screen reader announces.'; + +/** One walkthrough step as a single line, e.g. `button "Save" (disabled) — SaveBarComponent`. */ +export function formatStep(step: WalkthroughStep): string { + const role = step.role ?? `<${step.tag}>`; + const name = step.name ? `"${step.name}"` : '(no accessible name)'; + const states = step.states.length ? ` (${step.states.join(', ')})` : ''; + const tabindex = step.positive ? ' [positive tabindex]' : ''; + const component = step.component ? ` — ${step.component}` : ''; + return `${role} ${name}${states}${tabindex}${component}`; +} + +/** "Showing the first 300 of 412 tab stops." when the list was capped, else ''. */ +export function walkthroughCapNote(walkthrough: Walkthrough): string { + return walkthrough.total > walkthrough.steps.length + ? `Showing the first ${walkthrough.steps.length} of ${walkthrough.total} tab stops.` + : ''; +} + /** * Report title, shared by every format. Says "automated scan" on purpose: a file * titled "Accessibility report" is easy to hand to procurement as if it were a @@ -58,6 +86,8 @@ export interface ScanChecks { keyboard: boolean; /** The real-Tab keyboard-trap walk (`ngbr/focus-trap`) ran. */ focusTraps: boolean; + /** Each page lists its tab order with computed roles and names. */ + walkthrough?: boolean; } /** A whole report-mode run across one or more pages. */ @@ -140,6 +170,7 @@ export function toJson(report: ScanReport, diff?: BaselineDiff): string { distinctRules: distinctRuleCount(p.findings), nodeInstances: p.findings.length, findings: p.findings, + ...(p.walkthrough && { walkthrough: p.walkthrough }), })), }, null, @@ -182,10 +213,15 @@ export function parseReport(json: string): ScanReport { ...(p.error !== undefined && { error: p.error }), ...(p.colorScheme && { colorScheme: p.colorScheme }), ...(p.darkOnly && { darkOnly: true }), + ...(isWalkthrough(p.walkthrough) && { walkthrough: p.walkthrough }), })), }; } +function isWalkthrough(w: unknown): w is Walkthrough { + return Array.isArray((w as Walkthrough)?.steps) && typeof (w as Walkthrough).total === 'number'; +} + /** UI primitives and directives a finding came through, for a "via …" note. */ export function viaNames(finding: A11yFinding): string[] { // e.g. an ` + + Help + `, // Healthy: native controls in order, plus an iframe (Tab inside a frame shows // up as repeated focus on the