Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 7 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down Expand Up @@ -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.
Expand All @@ -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`).
7 changes: 6 additions & 1 deletion bin/ngbr-a11y-report.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <s> 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
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
Binary file removed demo/keyboard-layer-2026-09.png
Binary file not shown.
Binary file added demo/keyboard-layer-2026-10.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
6 changes: 6 additions & 0 deletions src/inpage.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -25,3 +26,8 @@ const run: InPageScan = (options, scanOptions) => scan(document, options, scanOp
(globalThis as unknown as {
__ngbA11yCreateFocusProbe: (prefixes?: readonly string[]) => ReturnType<typeof createFocusWalkProbe>;
}).__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<Walkthrough>;
}).__ngbA11yWalkthrough = (prefixes) => linearWalkthrough(document, { frameworkPrefixes: prefixes });
34 changes: 27 additions & 7 deletions src/keyboard/accname.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,17 +53,33 @@ function loadAxe(): Promise<AxeCommons> {
* built — e.g. a scan is mid-flight — so a focus-follow caller degrades quietly.
*/
export async function describeElement(element: Element): Promise<AxDescription> {
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<AxDescription[]> {
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 {
Expand All @@ -77,7 +93,11 @@ export async function describeElement(element: Element): Promise<AxDescription>
}
}
}
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`. */
Expand Down
60 changes: 60 additions & 0 deletions src/keyboard/walkthrough.ts
Original file line number Diff line number Diff line change
@@ -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<TabSequenceOptions, 'frameworkPrefixes'> = {},
): Promise<Walkthrough> {
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 };
}
2 changes: 1 addition & 1 deletion src/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const IMPACT_COLOR: Record<NonNullable<Impact> | '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<NonNullable<Impact> | 'none', string> = {
export const IMPACT_LABEL_FILL: Record<NonNullable<Impact> | 'none', string> = {
critical: '#d32029',
serious: '#b45309',
moderate: '#8a6d00',
Expand Down
52 changes: 47 additions & 5 deletions src/pill-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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 {
Expand Down Expand Up @@ -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',
Expand All @@ -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';
Expand All @@ -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<DevtoolsSettings>): void {
settings = { ...settings, ...patch };
Expand Down Expand Up @@ -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';
Expand All @@ -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.`;
Expand Down
Loading
Loading