diff --git a/CHANGELOG.md b/CHANGELOG.md index 85a2047..f252c82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 7b5fe68..30a1cd5 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 1ebb239..23ec5d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ngbracket/a11y-devtools", - "version": "0.15.5", + "version": "0.15.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ngbracket/a11y-devtools", - "version": "0.15.5", + "version": "0.15.6", "license": "MIT", "dependencies": { "axe-core": "^4.13.0" diff --git a/package.json b/package.json index f102c54..295603e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/keyboard/accname.ts b/src/keyboard/accname.ts index c6f7ac7..9655cb9 100644 --- a/src/keyboard/accname.ts +++ b/src/keyboard/accname.ts @@ -57,18 +57,24 @@ export async function describeElement(element: Element): Promise 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) }; diff --git a/src/overlay.ts b/src/overlay.ts index 1a47213..67bb8f9 100644 --- a/src/overlay.ts +++ b/src/overlay.ts @@ -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. */ @@ -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'); @@ -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', diff --git a/src/provider.ts b/src/provider.ts index 2cc6279..658dd91 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -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 => { @@ -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 | 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 { + 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; @@ -290,7 +332,7 @@ function devtoolsProviders(options: A11yDevtoolsOptions): EnvironmentProviders { }) .catch(() => undefined) .finally(() => { - scanning = false; + currentScan = undefined; if (rescanQueued) { rescanQueued = false; scanNow(); diff --git a/src/testing/provider.spec.ts b/src/testing/provider.spec.ts index 193e0d0..7041461 100644 --- a/src/testing/provider.spec.ts +++ b/src/testing/provider.spec.ts @@ -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('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 { + 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.