From 44ba78ec7cf69ac8dadf795e80f090848bbfa7e0 Mon Sep 17 00:00:00 2001 From: Duncan Faulkner Date: Fri, 2 Oct 2026 22:06:54 +0100 Subject: [PATCH 1/4] fix(focus-preview): name the card Focus preview, show it on toggle, wait for scans Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 17 +++++++++ src/overlay.ts | 8 ++-- src/provider.ts | 71 ++++++++++++++++++++++++++---------- src/testing/provider.spec.ts | 36 ++++++++++++++++++ 4 files changed, 109 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85a2047..746b528 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,23 @@ 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 straight away.** Before, nothing + appeared until focus next moved. Now, switching the setting (or the whole + tool) on describes the control that already has focus. 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. + ## 0.15.5 ### Fixed 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..70aeaf5 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,51 @@ 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; + /** 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 + // 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(() => 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); + } + + let currentScan: Promise | undefined; let rescanQueued = false; + + /** Resolves once no scan is running (a queued rescan starts as one ends). */ + async function scansIdle(): Promise { + while (currentScan) 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 +323,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..ca5e3ae 100644 --- a/src/testing/provider.spec.ts +++ b/src/testing/provider.spec.ts @@ -178,6 +178,42 @@ describe('provideA11yDevtools', () => { expect(JSON.parse(localStorage.getItem(SETTINGS_STORAGE_KEY)!)).toEqual({ highlights: true }); }); + it('turning Focus preview on describes the control that was focused before the menu', async () => { + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + provideA11yDevtools({ root: () => host, logger: makeLogger(), debounceMs: 0, overlay: true }), + ], + }); + const fixture = TestBed.createComponent(AppComponent); + host.appendChild(fixture.nativeElement); + await fixture.whenStable(); + + const save = document.createElement('button'); + save.textContent = 'Save'; + host.appendChild(save); + save.focus(); // preview is off (keyboard defaults to false), so no card yet + + const card = (): HTMLElement | undefined => + [...document.querySelectorAll('div')].find((d) => + d.firstElementChild?.textContent?.startsWith('Focus preview — computed approximation'), + ); + expect(card()?.style.display ?? 'none').toBe('none'); + + const menu = document.getElementById( + document.querySelector('button[aria-label="a11y devtools settings"]')!.getAttribute('aria-controls')!, + )!; + await waitFor(() => /1 issue on this page/.test(menu.textContent ?? '')); // first scan done + const toggle = [...menu.querySelectorAll('label')] + .find((l) => l.textContent?.startsWith('Focus preview'))! + .querySelector('input')!; + 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('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. From 01a59a9409c8613a01af8223272dd98192c48795 Mon Sep 17 00:00:00 2001 From: Duncan Faulkner Date: Fri, 2 Oct 2026 22:07:01 +0100 Subject: [PATCH 2/4] chore: 0.15.6 Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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", From 16da46c3bf4b4323bd86d24098fe70c28db02045 Mon Sep 17 00:00:00 2001 From: Duncan Faulkner Date: Fri, 2 Oct 2026 22:11:28 +0100 Subject: [PATCH 3/4] fix(focus-preview): describe only the latest focus; don't tear down a scan's axe tree; tests Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 ++-- src/keyboard/accname.ts | 14 +++-- src/provider.ts | 17 ++++-- src/testing/provider.spec.ts | 109 +++++++++++++++++++++++++---------- 4 files changed, 109 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 746b528..f252c82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,14 +11,17 @@ This project adheres to [Semantic Versioning](https://semver.org/). "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 straight away.** Before, nothing - appeared until focus next moved. Now, switching the setting (or the whole - tool) on describes the control that already has focus. When you toggle it +- **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. + 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 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/provider.ts b/src/provider.ts index 70aeaf5..658dd91 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -279,17 +279,22 @@ function devtoolsProviders(options: A11yDevtoolsOptions): EnvironmentProviders { document.addEventListener('focusin', onFocusIn, true); } + // 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(() => describeElement(el)) + .then(() => (stale() || !el.isConnected ? null : describeElement(el))) .then((desc) => { - if (!enabled || !settings.focusPreview) return; // switched off while describing + if (!desc || stale()) return; // focus moved on, or switched off while waiting overlayView.renderAxPanel({ ...desc, component: resolveOwningComponentName(el, frameworkPrefixes), @@ -302,9 +307,13 @@ function devtoolsProviders(options: A11yDevtoolsOptions): EnvironmentProviders { let currentScan: Promise | undefined; let rescanQueued = false; - /** Resolves once no scan is running (a queued rescan starts as one ends). */ + /** + * 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 { - while (currentScan) await currentScan; + for (let waits = 0; currentScan && waits < 3; waits++) await currentScan; } function scanNow(): void { if (!enabled) return; diff --git a/src/testing/provider.spec.ts b/src/testing/provider.spec.ts index ca5e3ae..7041461 100644 --- a/src/testing/provider.spec.ts +++ b/src/testing/provider.spec.ts @@ -178,40 +178,89 @@ describe('provideA11yDevtools', () => { expect(JSON.parse(localStorage.getItem(SETTINGS_STORAGE_KEY)!)).toEqual({ highlights: true }); }); - it('turning Focus preview on describes the control that was focused before the menu', async () => { - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideA11yDevtools({ root: () => host, logger: makeLogger(), debounceMs: 0, overlay: true }), - ], - }); - const fixture = TestBed.createComponent(AppComponent); - host.appendChild(fixture.nativeElement); - await fixture.whenStable(); - - const save = document.createElement('button'); - save.textContent = 'Save'; - host.appendChild(save); - save.focus(); // preview is off (keyboard defaults to false), so no card yet - + describe('Focus preview', () => { const card = (): HTMLElement | undefined => [...document.querySelectorAll('div')].find((d) => d.firstElementChild?.textContent?.startsWith('Focus preview — computed approximation'), ); - expect(card()?.style.display ?? 'none').toBe('none'); - - const menu = document.getElementById( - document.querySelector('button[aria-label="a11y devtools settings"]')!.getAttribute('aria-controls')!, - )!; - await waitFor(() => /1 issue on this page/.test(menu.textContent ?? '')); // first scan done - const toggle = [...menu.querySelectorAll('label')] - .find((l) => l.textContent?.startsWith('Focus preview'))! - .querySelector('input')!; - 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"'); + 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 () => { From ce211827ea33bc2614b7be3a162f39dba32ba125 Mon Sep 17 00:00:00 2001 From: Duncan Faulkner Date: Fri, 2 Oct 2026 22:13:00 +0100 Subject: [PATCH 4/4] docs(readme): call the card Focus preview Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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