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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.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",
Expand Down
31 changes: 29 additions & 2 deletions src/keyboard/keyboard-scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<Element>();
/** Checkbox and radio hit areas, skipped by the hover check too. */
const hitAreas = new Set<Element>();
/** Angular listener names per element, read once and shared by both passes. */
const listenerEvents = new Map<Element, string[]>();
const eventsOf = (el: Element): string[] => {
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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<Element>(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<Element>([...unreachable, ...hitAreas]);
for (const element of root.querySelectorAll('*')) {
if (modal && !modal.contains(element)) continue;
if (hoverFlagged.has(element) || element.closest(OVERLAY_EXCLUDE_SELECTOR)) continue;
Expand Down
74 changes: 74 additions & 0 deletions src/testing/keyboard-scan.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`
<div class="mdc-checkbox">
<div id="touch" class="mat-mdc-checkbox-touch-target" ${touchAttrs}></div>
<input type="checkbox" class="mdc-checkbox__native-control" id="terms" ${inputAttrs} />
<div class="mdc-checkbox__ripple" aria-hidden="true"></div>
</div>
<label for="terms">I accept the terms</label>`);

it('is not reported for a checkbox', () => {
const host = checkbox();
const listeners = new Map<Element, string[]>([[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<Element, string[]>([[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<Element, string[]>([[host.querySelector('#touch')!, ['click']]]);
withNg(listeners, new Map(), () => expect(unreachable(host)).toEqual([]));
});

it('is not reported for a radio button', () => {
const host = fixture(`
<div class="mdc-radio">
<div id="touch" class="mat-mdc-radio-touch-target" aria-hidden="true"></div>
<input type="radio" class="mdc-radio__native-control" name="size" id="small" />
</div>
<label for="small">Small</label>`);
const listeners = new Map<Element, string[]>([[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(`
<div><span id="text">Edit</span><input type="checkbox" /></div>
<div><span id="role" role="button"></span><input type="checkbox" /></div>
<div><span id="alone"></span><span>no control</span></div>
<div><span id="hidden-input"></span><input type="hidden" /></div>`);
const listeners = new Map<Element, string[]>(
['#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(`
<div class="field"><input type="text" /><span id="clear" aria-hidden="true"></span></div>
<div class="bar"><button>Save</button><span id="close" aria-hidden="true"></span></div>`);
const listeners = new Map<Element, string[]>(
['#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<Element, string[]>([[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(`<div><div role="button" tabindex="0">A</div><div id="lone" role="button" tabindex="-1">B</div></div>`);
withNg(new Map(), new Map(), () => expect(unreachable(host)).toEqual(['div#lone']));
Expand Down
Loading