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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.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",
Expand Down
19 changes: 12 additions & 7 deletions src/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
29 changes: 27 additions & 2 deletions src/testing/e2e-top-layer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ const PAGE = `<!doctype html>
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;
Expand Down Expand Up @@ -111,15 +113,38 @@ 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');
await page.mouse.click(x, y); // a real click: fails if the pill is inert
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 <body> when the dialog closes', async () => {
await page.evaluate(() => (document.getElementById('dlg') as HTMLDialogElement).close());
await settle();
Expand Down
15 changes: 12 additions & 3 deletions src/testing/overlay.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading