From e9dda7b6662cb6946d8d0eaa18d0c27c3a6829e2 Mon Sep 17 00:00:00 2001 From: Duncan Faulkner Date: Sun, 18 Oct 2026 10:46:25 +0100 Subject: [PATCH 1/2] fix(overlay): highlight boxes let clicks through to the flagged element A box over a flagged element took every click, so a flagged button couldn't be pressed and a flagged dialog (aria-dialog-name, say) was covered whole: its close button stopped working and the page needed a reload. The box is now pointer-events: none; the label alone takes clicks and scrolls to the element, as the docs already describe. Release 0.18.4. --- CHANGELOG.md | 11 +++++++++++ package-lock.json | 4 ++-- package.json | 2 +- src/overlay.ts | 19 ++++++++++++------- src/testing/e2e-top-layer.spec.ts | 29 +++++++++++++++++++++++++++-- src/testing/overlay.spec.ts | 15 ++++++++++++--- 6 files changed, 65 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 187ea5f..40a32a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to `@ngbracket/a11y-devtools` are documented here. This project adheres to [Semantic Versioning](https://semver.org/). +## 0.18.4 + +### Fixed + +- A flagged element can be clicked while the overlay is on. Each highlight box + took clicks, so a flagged button couldn't be pressed, and a flagged dialog + (for example one with no accessible name) was covered completely: its close + button and every other control inside it stopped working, and the page had + to be reloaded unless Escape closed the dialog. The box now lets clicks + through to the element. Clicking the label still scrolls to the element. + ## 0.18.3 ### Fixed diff --git a/package-lock.json b/package-lock.json index fb31bdb..6e6b81d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ngbracket/a11y-devtools", - "version": "0.18.3", + "version": "0.18.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ngbracket/a11y-devtools", - "version": "0.18.3", + "version": "0.18.4", "license": "MIT", "dependencies": { "axe-core": "^4.13.0" diff --git a/package.json b/package.json index 0f5764f..681662e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ngbracket/a11y-devtools", - "version": "0.18.3", + "version": "0.18.4", "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/overlay.ts b/src/overlay.ts index 2f63304..ef83174 100644 --- a/src/overlay.ts +++ b/src/overlay.ts @@ -123,7 +123,7 @@ export function createOverlay(options: OverlayOptions = {}): A11yOverlay { Object.assign(container.style, { position: 'fixed', inset: '0', - pointerEvents: 'none', // pass clicks through; individual boxes opt back in + pointerEvents: 'none', // pass clicks through; finding labels opt back in zIndex: '2147483646', }); doc.body.appendChild(container); @@ -259,8 +259,7 @@ export function createOverlay(options: OverlayOptions = {}): A11yOverlay { for (const finding of findings) { const target = resolveTarget(doc, finding.locator ?? finding.target); if (!target) continue; // node gone since the scan (e.g. re-rendered) - const box = buildBox(doc, finding); - box.addEventListener('click', () => flashAndScroll(target, box)); + const box = buildBox(doc, finding, () => flashAndScroll(target, box)); container.appendChild(box); positionBox(box, target); highlights.push({ finding, target, box }); @@ -392,22 +391,26 @@ function resolveTarget(doc: Document, target: string): Element | null { } } -function buildBox(doc: Document, finding: A11yFinding): HTMLElement { +/** + * The box lets clicks through to the flagged element, so a flagged button or a + * whole flagged dialog stays usable. Only the label takes clicks. + */ +function buildBox(doc: Document, finding: A11yFinding, onLabelClick: () => void): HTMLElement { const color = colorFor(finding.impact); const box = doc.createElement('div'); box.setAttribute('data-impact', finding.impact ?? 'none'); - box.title = `${finding.id}: ${finding.help}`; Object.assign(box.style, { position: 'fixed', boxSizing: 'border-box', border: `2px solid ${color}`, borderRadius: '2px', background: `${color}1a`, // ~10% alpha - pointerEvents: 'auto', - cursor: 'pointer', + pointerEvents: 'none', }); const label = doc.createElement('span'); + label.title = `${finding.id}: ${finding.help}`; + label.addEventListener('click', onLabelClick); label.textContent = finding.component ? `${finding.component} ยท ${finding.id}` : finding.id; @@ -421,6 +424,8 @@ function buildBox(doc: Document, finding: A11yFinding): HTMLElement { color: '#fff', background: IMPACT_LABEL_FILL[finding.impact ?? 'none'], whiteSpace: 'nowrap', + pointerEvents: 'auto', + cursor: 'pointer', }); box.appendChild(label); return box; diff --git a/src/testing/e2e-top-layer.spec.ts b/src/testing/e2e-top-layer.spec.ts index 9a94fb5..4dd0d81 100644 --- a/src/testing/e2e-top-layer.spec.ts +++ b/src/testing/e2e-top-layer.spec.ts @@ -43,6 +43,8 @@ const PAGE = ` import { createOverlay } from '/dist/overlay.js'; import { createTogglePill } from '/dist/toggle.js'; window.clicks = 0; + window.innerClicks = 0; + document.getElementById('inner').addEventListener('click', () => window.innerClicks++); window.overlay = createOverlay(); window.pill = createTogglePill({ enabled: true, onToggle: () => window.clicks++ }); window.ready = true; @@ -111,8 +113,14 @@ describe.skipIf(!ready)('overlay + pill over the top layer (real Chromium)', () }); await settle(); - // The target is at dialog (40,40) + (100,100): its highlight must be on top. - expect(await topAt(180, 160)).toBe('highlight'); + // The target is at dialog (40,40) + (100,100). Its label sits on top, just + // above it; the box itself lets clicks through to the target. + const label = await page.evaluate(() => { + const r = document.querySelector('[data-impact] > span')!.getBoundingClientRect(); + return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; + }); + expect(await topAt(label.x, label.y)).toBe('highlight'); + expect(await topAt(180, 160)).toBe('target'); const { x, y } = await pillCentre(); expect(await topAt(x, y)).toBe('pill'); @@ -120,6 +128,23 @@ describe.skipIf(!ready)('overlay + pill over the top layer (real Chromium)', () expect(await page.evaluate(() => (window as unknown as { clicks: number }).clicks)).toBe(1); }); + it('a highlight over the whole dialog leaves the controls inside it clickable', async () => { + await page.evaluate(() => { + const w = window as unknown as { overlay: { render(f: unknown[]): void } }; + w.overlay.render([ + { id: 'aria-dialog-name', impact: 'serious', help: 'x', helpUrl: '', component: null, directives: [], target: '#dlg', html: '' }, + ]); + }); + await settle(); + const inner = await page.evaluate(() => { + const r = document.getElementById('inner')!.getBoundingClientRect(); + return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; + }); + expect(await topAt(inner.x, inner.y)).toBe('inner'); + await page.mouse.click(inner.x, inner.y); + expect(await page.evaluate(() => (window as unknown as { innerClicks: number }).innerClicks)).toBe(1); + }); + it('goes back to when the dialog closes', async () => { await page.evaluate(() => (document.getElementById('dlg') as HTMLDialogElement).close()); await settle(); diff --git a/src/testing/overlay.spec.ts b/src/testing/overlay.spec.ts index 5c89d12..43ce18f 100644 --- a/src/testing/overlay.spec.ts +++ b/src/testing/overlay.spec.ts @@ -126,18 +126,27 @@ describe('createOverlay', () => { expect(overlayRoot()!.querySelectorAll('[data-impact]')).toHaveLength(1); }); - it('scrolls the offending node into view when its box is clicked', () => { + it('scrolls the offending node into view when its label is clicked', () => { const el = targetEl('target'); const scrollIntoView = vi.fn(); el.scrollIntoView = scrollIntoView; overlay.render([finding({ target: '#target' })]); - const box = overlayRoot()!.querySelector('[data-impact]') as HTMLElement; - box.click(); + const label = overlayRoot()!.querySelector('[data-impact] > span') as HTMLElement; + label.click(); expect(scrollIntoView).toHaveBeenCalledWith({ block: 'center', behavior: 'smooth' }); }); + it('lets clicks through the box to the flagged element; only the label takes them', () => { + targetEl('target'); + overlay.render([finding({ target: '#target' })]); + const box = overlayRoot()!.querySelector('[data-impact]') as HTMLElement; + const label = box.querySelector('span') as HTMLElement; + expect(box.style.pointerEvents).toBe('none'); + expect(label.style.pointerEvents).toBe('auto'); + }); + it('removes its container on destroy', () => { overlay.destroy(); expect(overlayRoot()).toBeNull(); From dd4f3ed7c8d823d35554daaff723666335e7694a Mon Sep 17 00:00:00 2001 From: Duncan Faulkner Date: Sun, 18 Oct 2026 10:47:18 +0100 Subject: [PATCH 2/2] docs(changelog): 0.18.4 wording: the label still takes clicks --- CHANGELOG.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40a32a6..074a87a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,13 @@ This project adheres to [Semantic Versioning](https://semver.org/). ### Fixed -- A flagged element can be clicked while the overlay is on. Each highlight box - took clicks, so a flagged button couldn't be pressed, and a flagged dialog - (for example one with no accessible name) was covered completely: its close - button and every other control inside it stopped working, and the page had - to be reloaded unless Escape closed the dialog. The box now lets clicks - through to the element. Clicking the label still scrolls to the element. +- Clicks now reach a flagged element while the overlay is on. Each highlight + box took clicks, so a flagged button couldn't be pressed, and a flagged + dialog (for example one with no accessible name) was covered completely: its + close button and every other control inside it stopped working, and the page + had to be reloaded unless Escape closed the dialog. The box now lets clicks + through. Only the small label above the box still takes clicks, and clicking + it scrolls to the element as before. ## 0.18.3