diff --git a/CHANGELOG.md b/CHANGELOG.md index 187ea5f..074a87a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to `@ngbracket/a11y-devtools` are documented here. This project adheres to [Semantic Versioning](https://semver.org/). +## 0.18.4 + +### Fixed + +- 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 ### 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();