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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,26 @@
All notable changes to `@ngbracket/a11y-devtools` are documented here.
This project adheres to [Semantic Versioning](https://semver.org/).

## 0.15.6

### Fixed

- **The Focus preview card is now headed "Focus preview".** It used to say
"Accessibility-tree preview", which didn't match the "Focus preview" setting
in the pill menu, so the card and its switch were hard to connect. It is
still labelled a computed approximation.
- **Turning Focus preview on shows the card without waiting for focus to
move.** Before, nothing appeared until focus next moved. Now, switching the
setting on describes the control that already has focus; switching the whole
tool on does the same once its first scan finishes. When you toggle it
from the pill menu, it describes the page control you were on before.
- **The Focus preview no longer shows an empty name while a scan is running.**
axe can't compute an accessible name during its own run, so a control
focused mid-scan showed "(no accessible name)". The preview now waits for the
scan to finish and describes only the latest focus. A `describeElement` call
made during a scan also no longer tears down the scan's axe tree, which had
silently dropped that scan's findings.

## 0.15.5

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Part of the `@ngbracket` Angular tooling family.
- **Dark mode**: scan your dark theme too, whether it follows the OS setting, a
class, an attribute, or custom logic.
- **Keyboard & Focus Mode**: tab-order visualisation, keyboard findings,
accessibility-tree preview, and keyboard-trap detection with real Tab presses.
Focus preview (computed role, name and state), and keyboard-trap detection with real Tab presses.
- **Zero production weight**: a no-op in production; axe-core is never loaded.

## Install
Expand Down
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.15.5",
"version": "0.15.6",
"description": "Dev-only in-app accessibility auditing for Angular that maps each axe violation back to the component that rendered it — the attribution React overlay tools can't do.",
"license": "MIT",
"author": "Duncan Faulkner",
Expand Down
14 changes: 10 additions & 4 deletions src/keyboard/accname.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,18 +57,24 @@ export async function describeElement(element: Element): Promise<AxDescription>
const doc = element.ownerDocument ?? document;
let role: string | null = null;
let 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) ?? '') : '';
} catch {
// Leave role=null / name='' — axe couldn't build a tree (e.g. run in flight).
} finally {
try {
axe.teardown();
} catch {
/* nothing set up */
// Only tear down our own setup: a failed setup means a scan owns axe's tree,
// and tearing that down would silently drop the scan's findings.
if (didSetup) {
try {
axe.teardown();
} catch {
/* nothing set up */
}
}
}
return { role, name, description: accessibleDescription(element), states: ariaStates(element) };
Expand Down
8 changes: 4 additions & 4 deletions src/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export interface A11yOverlay {
*/
renderTabOrder(stops: TabStop[]): void;
/**
* Show the accessibility-tree preview panel for the focused element, or hide it
* Show the Focus preview panel (an accessibility-tree approximation) for the focused element, or hide it
* when passed `null`. Deliberately framed as a *computed approximation* — see
* the panel header — never as verbatim screen-reader output.
*/
Expand Down Expand Up @@ -135,7 +135,7 @@ export function createOverlay(options: OverlayOptions = {}): A11yOverlay {
svg.appendChild(connector);
container.appendChild(svg); // under the badges, which are appended later

// The accessibility-tree preview panel: a fixed card that follows focus. Hidden
// The Focus preview panel: a fixed card that follows focus. Hidden
// until renderAxPanel is called with data. Marked with OVERLAY_ATTR so scans
// never flag the tool's own UI.
const axPanel = doc.createElement('div');
Expand Down Expand Up @@ -292,12 +292,12 @@ export function createOverlay(options: OverlayOptions = {}): A11yOverlay {
return { render, renderTabOrder, renderAxPanel, clear, clearTabOrder, destroy };
}

/** The rows of the accessibility-tree preview panel, honesty header first. */
/** The rows of the Focus preview panel, honesty header first. */
function buildAxPanelContent(doc: Document, data: AxPanelData): HTMLElement[] {
const nodes: HTMLElement[] = [];

const header = doc.createElement('div');
header.textContent = 'Accessibility-tree preview — computed approximation';
header.textContent = 'Focus preview — computed approximation';
Object.assign(header.style, {
fontWeight: '700',
fontSize: '10px',
Expand Down
80 changes: 61 additions & 19 deletions src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,19 +193,36 @@ function devtoolsProviders(options: A11yDevtoolsOptions): EnvironmentProviders {
if (!settings.focusPreview) overlayView.renderAxPanel(null);
}

/**
* On turning the preview (or the tool) on, describe what's already focused —
* or, when focus is in our own pill menu (the toggle was just clicked), the
* page control that had focus before it.
*/
function previewCurrentFocus(): void {
const active = document.activeElement;
const ours = active instanceof Element && active.closest(`[${OVERLAY_ATTR}]`);
previewFocus(ours ? (lastPageFocus?.isConnected ? lastPageFocus : null) : active);
}

const setEnabled = (next: boolean): void => {
if (next === enabled) return;
enabled = next;
writeStoredEnabled(storage, enabled);
pillView?.setEnabled(enabled);
if (enabled) scanNow(); // don't wait for the app's next stable moment
else draw();
if (enabled) {
scanNow(); // don't wait for the app's next stable moment
previewCurrentFocus();
} else {
draw();
}
};

const setSettings = (next: DevtoolsSettings): void => {
const previewTurnedOn = next.focusPreview && !settings.focusPreview;
settings = next;
writeStoredSettings(storage, settings, defaultSettings);
draw();
if (previewTurnedOn) previewCurrentFocus();
};

const downloadReport = (): void => {
Expand Down Expand Up @@ -251,35 +268,60 @@ function devtoolsProviders(options: A11yDevtoolsOptions): EnvironmentProviders {
// the ~2/3 axe can't test — attributed to its component. Needs the visual
// overlay; shown while the Focus preview setting is on.
let onFocusIn: ((event: FocusEvent) => void) | undefined;
// The last page control (not our own UI) that had focus, for previewCurrentFocus.
let lastPageFocus: Element | undefined;
if (overlayView) {
onFocusIn = (event: FocusEvent) => {
if (!enabled || !settings.focusPreview) return;
const el = event.target;
if (!(el instanceof Element) || el.closest(`[${OVERLAY_ATTR}]`)) return; // skip our own UI
describeElement(el)
.then((desc) => {
if (!enabled || !settings.focusPreview) return; // switched off while describing
overlayView.renderAxPanel({
...desc,
component: resolveOwningComponentName(el, frameworkPrefixes),
tag: el.tagName.toLowerCase(),
});
})
.catch(() => undefined);
if (el instanceof Element && !el.closest(`[${OVERLAY_ATTR}]`)) lastPageFocus = el;
previewFocus(el);
};
document.addEventListener('focusin', onFocusIn, true);
}

let scanning = false;
// Bumped on every preview request, so only the latest focus is described.
let previewGen = 0;

/** Show the Focus preview card for `el`, unless it's our own UI or the page body. */
function previewFocus(el: EventTarget | null): void {
if (!overlayView || !enabled || !settings.focusPreview) return;
if (!(el instanceof Element) || el === document.body || el === document.documentElement) return;
if (el.closest(`[${OVERLAY_ATTR}]`)) return; // skip our own UI
const gen = ++previewGen;
const stale = () => gen !== previewGen || !enabled || !settings.focusPreview;
// axe can't build an accname tree while a scan has it set up, so wait
// until no scan is running rather than show an empty name.
scansIdle()
.then(() => (stale() || !el.isConnected ? null : describeElement(el)))
.then((desc) => {
if (!desc || stale()) return; // focus moved on, or switched off while waiting
overlayView.renderAxPanel({
...desc,
component: resolveOwningComponentName(el, frameworkPrefixes),
tag: el.tagName.toLowerCase(),
});
})
.catch(() => undefined);
}

let currentScan: Promise<unknown> | undefined;
let rescanQueued = false;

/**
* Resolves once no scan is running (a queued rescan starts as one ends).
* Gives up after a few back-to-back scans, so an app that keeps re-settling
* can't hold the preview back for ever.
*/
async function scansIdle(): Promise<void> {
for (let waits = 0; currentScan && waits < 3; waits++) await currentScan;
}
function scanNow(): void {
if (!enabled) return;
if (scanning) {
if (currentScan) {
rescanQueued = true; // don't stack scans; run one more when this finishes
return;
}
scanning = true;
runA11yScan(root?.(), { log, logger, axe, frameworkPrefixes, keyboard })
currentScan = runA11yScan(root?.(), { log, logger, axe, frameworkPrefixes, keyboard })
.then((findings) => {
if (!enabled) return; // switched off mid-scan: draw nothing
lastFindings = findings;
Expand All @@ -290,7 +332,7 @@ function devtoolsProviders(options: A11yDevtoolsOptions): EnvironmentProviders {
})
.catch(() => undefined)
.finally(() => {
scanning = false;
currentScan = undefined;
if (rescanQueued) {
rescanQueued = false;
scanNow();
Expand Down
85 changes: 85 additions & 0 deletions src/testing/provider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,91 @@ describe('provideA11yDevtools', () => {
expect(JSON.parse(localStorage.getItem(SETTINGS_STORAGE_KEY)!)).toEqual({ highlights: true });
});

describe('Focus preview', () => {
const card = (): HTMLElement | undefined =>
[...document.querySelectorAll<HTMLElement>('div')].find((d) =>
d.firstElementChild?.textContent?.startsWith('Focus preview — computed approximation'),
);
const menu = (): HTMLElement =>
document.getElementById(
document.querySelector('button[aria-label="a11y devtools settings"]')!.getAttribute('aria-controls')!,
)!;
const previewToggle = (): HTMLInputElement =>
[...menu().querySelectorAll('label')]
.find((l) => l.textContent?.startsWith('Focus preview'))!
.querySelector('input')!;
const shortcut = (): void => {
document.dispatchEvent(
new KeyboardEvent('keydown', { altKey: true, shiftKey: true, key: 'Å', code: 'KeyA', bubbles: true }),
);
};

async function setup(keyboard: boolean): Promise<HTMLButtonElement> {
TestBed.configureTestingModule({
providers: [
provideZonelessChangeDetection(),
provideA11yDevtools({ root: () => host, logger: makeLogger(), debounceMs: 0, overlay: true, keyboard }),
],
});
const fixture = TestBed.createComponent(AppComponent);
host.appendChild(fixture.nativeElement);
await fixture.whenStable();
const save = document.createElement('button');
save.textContent = 'Save';
host.appendChild(save);
return save;
}

it('turning it on describes the control that was focused before the menu', async () => {
const save = await setup(false);
save.focus(); // preview is off (keyboard defaults to false), so no card yet
await wait(50);
expect(card()).toBeUndefined();

await waitFor(() => /1 issue on this page/.test(menu().textContent ?? '')); // first scan done
const toggle = previewToggle();
toggle.focus(); // focus moves into our own menu, as a real click would
toggle.click();

await waitFor(() => card()?.style.display === 'block');
expect(card()!.textContent).toContain('"Save"');
});

it('turning the tool on describes the focused control without waiting for focus to move', async () => {
localStorage.setItem(TOGGLE_STORAGE_KEY, 'off');
const save = await setup(true);
save.focus();
shortcut();

await waitFor(() => card()?.style.display === 'block');
expect(card()!.textContent).toContain('"Save"');
});

it('a control focused mid-scan is described once the scan ends, not with an empty name', async () => {
const save = await setup(true);
// Focus while axe.run owns axe's tree, when describing it would fail.
const axe = (await import('axe-core')).default as unknown as { _tree?: unknown };
await waitFor(() => !!axe._tree, 5000);
save.focus();

await waitFor(() => card()?.style.display === 'block');
expect(card()!.textContent).toContain('"Save"');
expect(card()!.textContent).not.toContain('no accessible name');
});

it('shows nothing when switched off while waiting for a scan', async () => {
const save = await setup(true);
const axe = (await import('axe-core')).default as unknown as { _tree?: unknown };
await waitFor(() => !!axe._tree, 5000); // a scan is in flight
save.focus(); // the preview waits on the scan
previewToggle().click(); // switched off before the scan finishes

await waitFor(() => !axe._tree && /1 issue on this page/.test(menu().textContent ?? ''));
await wait(50);
expect(card()).toBeUndefined();
});
});

it('filters highlights by the minimum severity chosen in the menu', async () => {
// A role="button" that can't be focused is a serious keyboard finding,
// next to the fixture's critical image-alt.
Expand Down
Loading