diff --git a/CHANGELOG.md b/CHANGELOG.md index 5425492..3a84751 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to `@ngbracket/a11y-devtools` are documented here. This project adheres to [Semantic Versioning](https://semver.org/). +## 0.18.1 + +### Fixed + +- `ngbr/unreachable-control` no longer reports the touch target in Angular + Material 15 to 21 checkboxes and radio buttons + (`.mat-mdc-checkbox-touch-target`, `.mat-mdc-radio-touch-target`). The + target is an empty element with a click handler that toggles the native + input beside it, and Tab reaches that input. The rule now skips an empty + element with a click listener and no role when its parent holds a checkbox + or radio input. The same element beside a text field or a button is still + reported. Material 22 removed the handler, so apps on 22 were not affected. + ## 0.18.0 ### Added diff --git a/package-lock.json b/package-lock.json index 42e4367..eb2af07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ngbracket/a11y-devtools", - "version": "0.18.0", + "version": "0.18.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ngbracket/a11y-devtools", - "version": "0.18.0", + "version": "0.18.1", "license": "MIT", "dependencies": { "axe-core": "^4.13.0" diff --git a/package.json b/package.json index f80e6d0..38ed540 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ngbracket/a11y-devtools", - "version": "0.18.0", + "version": "0.18.1", "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/keyboard/keyboard-scan.ts b/src/keyboard/keyboard-scan.ts index f49c600..4121c82 100644 --- a/src/keyboard/keyboard-scan.ts +++ b/src/keyboard/keyboard-scan.ts @@ -303,6 +303,25 @@ function partOfKeyboardOperatedWidget( return false; } +/** + * True when `element` is an empty hit area beside a checkbox or radio input: a + * larger click or touch target that toggles the input, which the keyboard + * reaches and operates on its own. Angular Material 15 to 21 draws one in every + * checkbox and radio button (`.mat-mdc-checkbox-touch-target`, + * `.mat-mdc-radio-touch-target`, each with a click handler; only 21 also marks + * it `aria-hidden`). Disabled inputs count too: their hit area does nothing either. + * + * Only checkbox and radio inputs: an empty clickable beside a text field or a + * button (a clear "×" drawn in CSS) is a separate action Tab can't reach, and + * is still reported. + */ +function hitAreaForToggleInput(element: Element): boolean { + if (element.children.length || element.textContent?.trim()) return false; + const parent = element.parentElement; + if (!parent) return false; + return [...parent.children].some((sibling) => sibling.matches('input[type="checkbox" i], input[type="radio" i]')); +} + /** * Each rule's page: what the message means, who it affects, exactly what * triggers it, how to fix and check it, and the WCAG/APG reference. @@ -381,6 +400,8 @@ export function scanKeyboard( const sharedCache: SharedStopCache = new Map(); /** Elements reported as unreachable controls, so the hover check doesn't repeat them. */ const unreachable = new Set(); + /** Checkbox and radio hit areas, skipped by the hover check too. */ + const hitAreas = new Set(); /** Angular listener names per element, read once and shared by both passes. */ const listenerEvents = new Map(); const eventsOf = (el: Element): string[] => { @@ -500,6 +521,11 @@ export function scanKeyboard( // A click-only part of something the keyboard operates is a mouse shortcut. // Only for unreachable elements: one Tab reaches is still checked below. if (!interactiveByRole && partOfKeyboardOperatedWidget(element, isVisible, keyCache, sharedCache)) continue; + // A mouse/touch hit area for the checkbox or radio next to it (Material's touch targets). + if (!interactiveByRole && hitAreaForToggleInput(element)) { + hitAreas.add(element); + continue; + } const within = interactiveByRole ? reachedWithinWidget(element, isVisible, keyCache, sharedCache) : false; if (within === 'reached') continue; if (within === 'no-keys') { @@ -554,8 +580,9 @@ export function scanKeyboard( // Hover-only content: a tooltip, hover listener or icon `title` on something // Tab can't reach, so keyboard users may never see what it shows. - // Seeded with the unreachable controls, so an icon inside one isn't reported again. - const hoverFlagged = new Set(unreachable); + // Seeded with the unreachable controls, so an icon inside one isn't reported + // again, and with the skipped checkbox/radio hit areas. + const hoverFlagged = new Set([...unreachable, ...hitAreas]); for (const element of root.querySelectorAll('*')) { if (modal && !modal.contains(element)) continue; if (hoverFlagged.has(element) || element.closest(OVERLAY_EXCLUDE_SELECTOR)) continue; diff --git a/src/testing/keyboard-scan.spec.ts b/src/testing/keyboard-scan.spec.ts index 4df5843..8375355 100644 --- a/src/testing/keyboard-scan.spec.ts +++ b/src/testing/keyboard-scan.spec.ts @@ -362,6 +362,80 @@ describe('scanKeyboard', () => { }); }); + describe('a hit area beside a checkbox or radio (Angular Material 15-21 touch targets)', () => { + // Markup trimmed from MatCheckbox and MatRadioButton in Material 21.2. Material + // 15 to 20 render the same touch target without aria-hidden. + const checkbox = (inputAttrs = '', touchAttrs = 'aria-hidden="true"') => + fixture(` +
+
+ + +
+ `); + + it('is not reported for a checkbox', () => { + const host = checkbox(); + const listeners = new Map([[host.querySelector('#touch')!, ['click']]]); + withNg(listeners, new Map(), () => expect(unreachable(host)).toEqual([])); + }); + + it('is not reported for a Material 15-20 checkbox (no aria-hidden)', () => { + const host = checkbox('', ''); + const listeners = new Map([[host.querySelector('#touch')!, ['click']]]); + withNg(listeners, new Map(), () => expect(unreachable(host)).toEqual([])); + }); + + it('is not reported for a disabled checkbox', () => { + const host = checkbox('disabled'); + const listeners = new Map([[host.querySelector('#touch')!, ['click']]]); + withNg(listeners, new Map(), () => expect(unreachable(host)).toEqual([])); + }); + + it('is not reported for a radio button', () => { + const host = fixture(` +
+ + +
+ `); + const listeners = new Map([[host.querySelector('#touch')!, ['click']]]); + withNg(listeners, new Map(), () => expect(unreachable(host)).toEqual([])); + }); + + it('is still reported when it has content, has a role, or has no checkbox or radio beside it', () => { + const host = fixture(` +
Edit
+
+
no control
+
`); + const listeners = new Map( + ['#text', '#role', '#alone', '#hidden-input'].map((id) => [host.querySelector(id)!, ['click']]), + ); + withNg(listeners, new Map(), () => + expect(unreachable(host)).toEqual(['span#text', 'span#role', 'span#alone', 'span#hidden-input']), + ); + }); + + it('is still reported beside a text field or a button (a clear "×" drawn in CSS)', () => { + const host = fixture(` +
+
`); + const listeners = new Map( + ['#clear', '#close'].map((id) => [host.querySelector(id)!, ['click']]), + ); + withNg(listeners, new Map(), () => expect(unreachable(host)).toEqual(['span#clear', 'span#close'])); + }); + + it('gets no hover finding either', () => { + const host = checkbox(); + const touch = host.querySelector('#touch')!; + touch.setAttribute('title', 'Toggle'); + const listeners = new Map([[touch, ['click', 'mouseenter']]]); + withNg(listeners, new Map(), () => expect(scanKeyboard(host)).toEqual([])); + }); + }); + it('still flags a tabindex="-1" control that is not in a composite widget', () => { const host = fixture(`
A
B
`); withNg(new Map(), new Map(), () => expect(unreachable(host)).toEqual(['div#lone']));