From c3a6f5c8702eb97304aff62c0fd7a1d928f608da Mon Sep 17 00:00:00 2001 From: Duncan Faulkner Date: Wed, 14 Oct 2026 21:03:53 +0100 Subject: [PATCH 1/4] fix(keyboard): don't report Material's checkbox and radio touch targets as unreachable Angular Material 15 to 21 puts an empty, aria-hidden touch target with a click handler beside the native input in every checkbox and radio button. Tab reaches the input, but ngbr/unreachable-control reported the touch target. Skip an empty aria-hidden element with a click listener whose parent holds a native control. Release 0.18.1. --- CHANGELOG.md | 12 ++++++++ package-lock.json | 4 +-- package.json | 2 +- src/keyboard/keyboard-scan.ts | 18 ++++++++++++ src/testing/keyboard-scan.spec.ts | 49 +++++++++++++++++++++++++++++++ 5 files changed, 82 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5425492..db2851c 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.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, `aria-hidden` element with a click handler that forwards + to the native input beside it, and Tab reaches that input. The rule now skips + any empty `aria-hidden` element with a click listener whose parent holds a + native control. Material 22 removed the handler, so apps on 22 never saw this. + ## 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..63f263d 100644 --- a/src/keyboard/keyboard-scan.ts +++ b/src/keyboard/keyboard-scan.ts @@ -303,6 +303,22 @@ function partOfKeyboardOperatedWidget( return false; } +/** + * True when `element` is an empty, `aria-hidden` hit area beside a native + * control: a larger click or touch target that forwards to the control, which + * the keyboard reaches and operates on its own. Angular Material up 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). Disabled controls + * count too: their hit area does nothing either. + */ +function hitAreaForNativeControl(element: Element): boolean { + if (element.getAttribute('aria-hidden') !== 'true') return false; + if (element.children.length || element.textContent?.trim()) return false; + const parent = element.parentElement; + if (!parent) return false; + return [...parent.children].some((sibling) => sibling !== element && isNativelyFocusable(sibling)); +} + /** * 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. @@ -500,6 +516,8 @@ 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 a native control next to it (Material's touch targets). + if (!interactiveByRole && hitAreaForNativeControl(element)) continue; const within = interactiveByRole ? reachedWithinWidget(element, isVisible, keyCache, sharedCache) : false; if (within === 'reached') continue; if (within === 'no-keys') { diff --git a/src/testing/keyboard-scan.spec.ts b/src/testing/keyboard-scan.spec.ts index 4df5843..899d9fd 100644 --- a/src/testing/keyboard-scan.spec.ts +++ b/src/testing/keyboard-scan.spec.ts @@ -362,6 +362,55 @@ describe('scanKeyboard', () => { }); }); + describe('a hit area beside a native control (Angular Material 15-21 touch targets)', () => { + // Markup trimmed from MatCheckbox and MatRadioButton in Material 21.2. + const checkbox = (inputAttrs = '') => + 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 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, is not aria-hidden, or has no native control beside it', () => { + const host = fixture(` +
+
+
no control
+
`); + const listeners = new Map( + ['#text', '#shown', '#alone', '#hidden-input'].map((id) => [host.querySelector(id)!, ['click']]), + ); + withNg(listeners, new Map(), () => + expect(unreachable(host)).toEqual(['span#text', 'span#shown', 'span#alone', 'span#hidden-input']), + ); + }); + }); + 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'])); From 16c8b7571ea88050e0e6c4865da8262ba7f75942 Mon Sep 17 00:00:00 2001 From: Duncan Faulkner Date: Wed, 14 Oct 2026 21:03:57 +0100 Subject: [PATCH 2/4] docs: changelog wording --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db2851c..e062b76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). target is an empty, `aria-hidden` element with a click handler that forwards to the native input beside it, and Tab reaches that input. The rule now skips any empty `aria-hidden` element with a click listener whose parent holds a - native control. Material 22 removed the handler, so apps on 22 never saw this. + native control. Material 22 removed the handler, so apps on 22 were not affected. ## 0.18.0 From 4f9b4f6938b9a2e4bf4d21b62730ac20001a23b0 Mon Sep 17 00:00:00 2001 From: Duncan Faulkner Date: Wed, 14 Oct 2026 21:07:17 +0100 Subject: [PATCH 3/4] fix(keyboard): limit the hit-area skip to checkbox and radio inputs Review: beside a text field or a button, an empty aria-hidden clickable is usually a separate action (a CSS-drawn clear button), so it stays reported. Skipped hit areas are also kept out of the hover check. --- CHANGELOG.md | 5 +++-- src/keyboard/keyboard-scan.ts | 32 ++++++++++++++++++++----------- src/testing/keyboard-scan.spec.ts | 20 ++++++++++++++++++- 3 files changed, 43 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e062b76..b405e0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,9 @@ This project adheres to [Semantic Versioning](https://semver.org/). (`.mat-mdc-checkbox-touch-target`, `.mat-mdc-radio-touch-target`). The target is an empty, `aria-hidden` element with a click handler that forwards to the native input beside it, and Tab reaches that input. The rule now skips - any empty `aria-hidden` element with a click listener whose parent holds a - native control. Material 22 removed the handler, so apps on 22 were not affected. + an empty `aria-hidden` element with a click listener 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 diff --git a/src/keyboard/keyboard-scan.ts b/src/keyboard/keyboard-scan.ts index 63f263d..64e327f 100644 --- a/src/keyboard/keyboard-scan.ts +++ b/src/keyboard/keyboard-scan.ts @@ -304,19 +304,23 @@ function partOfKeyboardOperatedWidget( } /** - * True when `element` is an empty, `aria-hidden` hit area beside a native - * control: a larger click or touch target that forwards to the control, which - * the keyboard reaches and operates on its own. Angular Material up 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). Disabled controls + * True when `element` is an empty, `aria-hidden` 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 up 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). Disabled inputs * count too: their hit area does nothing either. + * + * Only checkbox and radio inputs: an empty `aria-hidden` icon with a click + * handler 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 hitAreaForNativeControl(element: Element): boolean { +function hitAreaForToggleInput(element: Element): boolean { if (element.getAttribute('aria-hidden') !== 'true') return false; if (element.children.length || element.textContent?.trim()) return false; const parent = element.parentElement; if (!parent) return false; - return [...parent.children].some((sibling) => sibling !== element && isNativelyFocusable(sibling)); + return [...parent.children].some((sibling) => sibling.matches('input[type="checkbox" i], input[type="radio" i]')); } /** @@ -397,6 +401,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[] => { @@ -516,8 +522,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 a native control next to it (Material's touch targets). - if (!interactiveByRole && hitAreaForNativeControl(element)) 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') { @@ -572,8 +581,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 899d9fd..123849b 100644 --- a/src/testing/keyboard-scan.spec.ts +++ b/src/testing/keyboard-scan.spec.ts @@ -396,7 +396,7 @@ describe('scanKeyboard', () => { withNg(listeners, new Map(), () => expect(unreachable(host)).toEqual([])); }); - it('is still reported when it has content, is not aria-hidden, or has no native control beside it', () => { + it('is still reported when it has content, is not aria-hidden, or has no checkbox or radio beside it', () => { const host = fixture(`
@@ -409,6 +409,24 @@ describe('scanKeyboard', () => { expect(unreachable(host)).toEqual(['span#text', 'span#shown', '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', () => { From dbe50ba548fa66b1a8318fd6f8ef69e810003cc5 Mon Sep 17 00:00:00 2001 From: Duncan Faulkner Date: Wed, 14 Oct 2026 21:11:12 +0100 Subject: [PATCH 4/4] fix(keyboard): match Material 15-20 touch targets too (no aria-hidden there) Only Material 21 marks the checkbox/radio touch target aria-hidden; 15 to 20 render the same empty element and click handler without it. Being empty, role-less and beside a checkbox or radio input is enough. --- CHANGELOG.md | 10 +++++----- src/keyboard/keyboard-scan.ts | 19 +++++++++---------- src/testing/keyboard-scan.spec.ts | 29 ++++++++++++++++++----------- 3 files changed, 32 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b405e0a..3a84751 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,11 @@ This project adheres to [Semantic Versioning](https://semver.org/). - `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, `aria-hidden` element with a click handler that forwards - to the native input beside it, and Tab reaches that input. The rule now skips - an empty `aria-hidden` element with a click listener 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. + 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 diff --git a/src/keyboard/keyboard-scan.ts b/src/keyboard/keyboard-scan.ts index 64e327f..4121c82 100644 --- a/src/keyboard/keyboard-scan.ts +++ b/src/keyboard/keyboard-scan.ts @@ -304,19 +304,18 @@ function partOfKeyboardOperatedWidget( } /** - * True when `element` is an empty, `aria-hidden` 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 up 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). Disabled inputs - * count too: their hit area does nothing either. + * 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 `aria-hidden` icon with a click - * handler beside a text field or a button (a clear "×" drawn in CSS) is a - * separate action Tab can't reach, and is still reported. + * 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.getAttribute('aria-hidden') !== 'true') return false; if (element.children.length || element.textContent?.trim()) return false; const parent = element.parentElement; if (!parent) return false; diff --git a/src/testing/keyboard-scan.spec.ts b/src/testing/keyboard-scan.spec.ts index 123849b..8375355 100644 --- a/src/testing/keyboard-scan.spec.ts +++ b/src/testing/keyboard-scan.spec.ts @@ -362,12 +362,13 @@ describe('scanKeyboard', () => { }); }); - describe('a hit area beside a native control (Angular Material 15-21 touch targets)', () => { - // Markup trimmed from MatCheckbox and MatRadioButton in Material 21.2. - const checkbox = (inputAttrs = '') => + 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(`
- +
@@ -379,6 +380,12 @@ describe('scanKeyboard', () => { 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']]]); @@ -396,17 +403,17 @@ describe('scanKeyboard', () => { withNg(listeners, new Map(), () => expect(unreachable(host)).toEqual([])); }); - it('is still reported when it has content, is not aria-hidden, or has no checkbox or radio beside it', () => { + it('is still reported when it has content, has a role, or has no checkbox or radio beside it', () => { const host = fixture(` -
-
-
no control
-
`); +
Edit
+
+
no control
+
`); const listeners = new Map( - ['#text', '#shown', '#alone', '#hidden-input'].map((id) => [host.querySelector(id)!, ['click']]), + ['#text', '#role', '#alone', '#hidden-input'].map((id) => [host.querySelector(id)!, ['click']]), ); withNg(listeners, new Map(), () => - expect(unreachable(host)).toEqual(['span#text', 'span#shown', 'span#alone', 'span#hidden-input']), + expect(unreachable(host)).toEqual(['span#text', 'span#role', 'span#alone', 'span#hidden-input']), ); });