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
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,46 @@
All notable changes to `@ngbracket/a11y-devtools` are documented here.
This project adheres to [Semantic Versioning](https://semver.org/).

## 0.17.0

### Added

- New keyboard rule `ngbr/drag-without-keyboard`. It reports a list whose items
move by drag and drop when the tool finds no keyboard way to move them.
Angular CDK drag and drop has no keyboard support of its own, so a list of
rows with `cdkDragHandle` icons is mouse-only unless the app adds one. The
rule finds drag items by the `cdk-drag` class inside a `cdk-drop-list`, and
by `draggable="true"` when a parent holds two or more, in any build. It
reports each list once. It skips a list with a tab stop in or next to it
whose name says it moves an item ("Move up", "Reorder", an arrow, a Material
`arrow_upward` icon). Serious when the keyboard can't reach a drag handle of a
CDK list; moderate for a native `draggable` list. A handle or item with a
`tabindex` counts as reachable when a drag list on the page has a tab stop,
as in a board where one card is the tab stop and the arrow keys move to the
rest. Moderate in a dev build when a handle is reachable but no Angular key
listener sits on the handle, its item, the list, or the elements up to and
including the component that holds the list. Maps to WCAG 2.1.1 and
2.5.7 in the ACR worksheet.

### Fixed

- The tab-order layer now hides the page's tab stops while an Angular Material
dialog is open. Material 22 opens `mat-dialog` with `aria-modal="false"` by
default and hides the page from screen readers with `aria-hidden`. The
scanner only looked for the CDK focus trap around `aria-modal="true"`
elements, so it missed the trap and numbered the page's stops under the
dialog as well as the dialog's own. It now finds the trap around any
`mat-dialog-container`, `.cdk-dialog-container` or dialog role. Report mode
also no longer counts Tab cycling inside a default `mat-dialog` as a
`ngbr/focus-trap` finding.
- The scanner now recognises the focus traps in PrimeNG, ngx-bootstrap and
ng-bootstrap dialogs. PrimeNG and ngx-bootstrap put their sentinels inside
the dialog, as its first and last tab stops. ng-bootstrap's modal and
offcanvas trap Tab with a script and no sentinels, so they're recognised by
element name. Before, the tab-order layer showed the page's stops under these
dialogs, and `ngbr/modal-focus-not-contained` reported a leak that wasn't
there.

## 0.16.0

### Added
Expand Down
47 changes: 44 additions & 3 deletions e2e/angular-app/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
* E2E spec can check that findings are attributed to the right component
* through the real `window.ng` debug API.
*/
import { Component, Directive } from '@angular/core';
import { Dialog } from '@angular/cdk/dialog';
import { afterNextRender, Component, Directive, inject } from '@angular/core';

const PIXEL = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';

Expand Down Expand Up @@ -57,6 +58,17 @@ export class FancyDirective {}
<!-- Hover-only content: a hand-built tooltip and an icon title Tab can't reach. -->
<span class="hover-hint" (mouseenter)="noop()" (mouseleave)="noop()">ⓘ</span>
<span class="title-icon" title="Mandatory"></span>
<!-- Drag and drop (CDK's classes, written by hand): handles Tab can't reach, and a
handle that moves its item with the arrow keys. -->
<ul class="cdk-drop-list drag-mouse-only">
<li class="cdk-drag"><span class="cdk-drag-handle">⠿</span>One</li>
<li class="cdk-drag"><span class="cdk-drag-handle">⠿</span>Two</li>
</ul>
<ul class="cdk-drop-list drag-keys">
<li class="cdk-drag">
<button class="cdk-drag-handle" aria-label="Move One" (keydown.arrowUp)="noop()">⠿</button>One
</li>
</ul>
`,
})
export class HomePageComponent {
Expand All @@ -71,14 +83,39 @@ export class HomePageComponent {
})
export class SettingsPageComponent {}

/** The content of the real CDK dialog that DialogPageComponent opens. */
@Component({
selector: 'app-dialog-content',
template: `<h2>Edit level</h2><input class="dialog-name" aria-label="Name" /><button class="dialog-save">Save</button>`,
})
export class DialogContentComponent {}

/**
* Opens a real Angular CDK dialog on load. CDK and Material dialogs default to
* aria-modal="false" and put their focus-trap anchors beside the container, so
* this checks the tab-order layer shows only the dialog's stops.
*/
@Component({
selector: 'app-dialog-page',
template: `<h1>Dialog</h1><button class="behind-dialog">Behind the dialog</button>`,
})
export class DialogPageComponent {
private readonly dialog = inject(Dialog);
constructor() {
afterNextRender(() => this.dialog.open(DialogContentComponent));
}
}

@Component({
selector: 'app-root',
imports: [HeaderComponent, HomePageComponent, SettingsPageComponent],
imports: [HeaderComponent, HomePageComponent, SettingsPageComponent, DialogPageComponent],
template: `
<app-header />
<main>
@if (page === 'settings') {
<app-settings-page />
} @else if (page === 'dialog') {
<app-dialog-page />
} @else {
<app-home-page />
}
Expand All @@ -87,5 +124,9 @@ export class SettingsPageComponent {}
})
export class AppComponent {
// No router needed: the route picks the page.
page = location.pathname.startsWith('/settings') ? 'settings' : 'home';
page = location.pathname.startsWith('/settings')
? 'settings'
: location.pathname.startsWith('/dialog')
? 'dialog'
: 'home';
}
55 changes: 53 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngbracket/a11y-devtools",
"version": "0.16.0",
"version": "0.17.0",
"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 Expand Up @@ -74,10 +74,12 @@
"@analogjs/vite-plugin-angular": "^2.8.0",
"@analogjs/vitest-angular": "^2.8.0",
"@angular/build": "^22.2.1",
"@angular/cdk": "^22.2.1",
"@angular/common": "^22.2.1",
"@angular/compiler": "^22.2.1",
"@angular/compiler-cli": "^22.2.1",
"@angular/core": "^22.2.1",
"@angular/forms": "^22.2.1",
"@angular/platform-browser": "^22.2.1",
"@angular/platform-browser-dynamic": "^22.2.1",
"@emnapi/core": "^1.11.3",
Expand Down
112 changes: 112 additions & 0 deletions src/keyboard/drag-drop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* What marks a list whose items move by drag and drop, and what counts as a
* keyboard way to move them. The keyboard scan reports such a list when it finds
* no keyboard way (`ngbr/drag-without-keyboard`): Angular CDK drag-drop has no
* keyboard support of its own, so a CDK list is mouse-only unless the app adds it.
*
* Drag items are recognised by DOM markers that are there in production builds
* too: the `cdk-drag` class that `cdkDrag` puts on its host (only inside a
* `cdk-drop-list`; a free-standing `cdkDrag`, such as a draggable dialog title,
* moves something around the screen and is left out), and an explicit
* `draggable="true"` (native HTML drag and drop, ngx-drag-drop's `dndDraggable`).
*/

/** A list of drag items under one container. */
export interface DragGroup {
/** The drop list, or the items' parent for native drag and drop. */
container: Element;
/** The items that can be dragged now (not disabled, not hidden). */
items: Element[];
/** True for CDK drag and drop; false for native `draggable="true"`. */
cdk: boolean;
}

const DRAG_ITEM_SELECTOR = '.cdk-drop-list .cdk-drag, [draggable="true"]';

/** Something that is, or holds, a drag list. */
const DRAG_LIST_SELECTOR = '.cdk-drop-list, [draggable="true"]';

/**
* Names of controls that move an item: "Move up", "Move to top", "Reorder",
* arrow glyphs, and the Material icon ligatures for up and down arrows. Words
* such as "up" or "top" count only as the whole name, so "Sign up" or "Back to
* top" elsewhere near the list doesn't count.
*/
const MOVE_NAME =
/\b(move|reorder)\b|[↑↓⬆⬇▲▼]|\b(arrow_upward|arrow_downward|keyboard_arrow_up|keyboard_arrow_down)\b|^\s*(up|down|top|bottom|earlier|later)\s*$/i;

const CONTROL_SELECTOR = 'button, a[href], [role="button"], [role="menuitem"]';

/**
* The drag lists in `root`, one per container. CDK items group by their
* `cdk-drop-list`; native ones by their parent, and only when there are at least
* two (one draggable on its own is more often an image or file chip to drag
* out of the page than a list to reorder). A disabled list or item (CDK's
* `cdk-drop-list-disabled` / `cdk-drag-disabled`) and CDK's drag preview and
* placeholder are left out.
*/
export function dragGroups(root: ParentNode, isHidden: (el: Element) => boolean): DragGroup[] {
const groups = new Map<Element, Element[]>();
for (const item of root.querySelectorAll(DRAG_ITEM_SELECTOR)) {
if (item.matches('.cdk-drag-disabled, .cdk-drag-preview, .cdk-drag-placeholder')) continue;
// A native draggable inside a CDK item (an image or link in a card) moves with the item.
if (!item.classList.contains('cdk-drag') && item.parentElement?.closest('.cdk-drag')) continue;
// From the parent: an element can be both an item and a drop list (nested lists).
const container = item.classList.contains('cdk-drag')
? item.parentElement?.closest('.cdk-drop-list')
: item.parentElement;
if (!container || container.classList.contains('cdk-drop-list-disabled')) continue;
if (isHidden(item)) continue;
const items = groups.get(container) ?? [];
items.push(item);
groups.set(container, items);
}
return [...groups]
.map(([container, items]) => ({ container, items, cdk: items[0].classList.contains('cdk-drag') }))
.filter((group) => group.cdk || group.items.length >= 2);
}

/**
* Where a keyboard user would start a move for `item`: its CDK drag handles that
* belong to it (not to a nested drag item), or the item itself.
*/
export function dragHandles(item: Element): Element[] {
const handles = [...item.querySelectorAll('.cdk-drag-handle')].filter(
(handle) => handle.closest('.cdk-drag, [draggable="true"]') === item,
);
return handles.length > 0 ? handles : [item];
}

/** A rough accessible name for a control: aria-label, title, then its text. */
function roughName(element: Element): string {
const labelledBy = (element.getAttribute('aria-labelledby') ?? '')
.split(/\s+/)
.map((id) => (id ? element.ownerDocument?.getElementById(id)?.textContent ?? '' : ''))
.join(' ');
return [element.getAttribute('aria-label'), labelledBy, element.getAttribute('title'), element.textContent]
.filter(Boolean)
.join(' ');
}

/**
* True when there's a control near the list whose name says it moves an item
* ("Move up", "↓", an `arrow_upward` icon): in the list, or in an element next
* to it such as a toolbar above it. A sibling that holds another drag list is
* left out, so one list's Move buttons don't vouch for the list beside it. A
* drag handle doesn't count, even one named "Reorder": whether it works from the
* keyboard depends on its key handling, which the scan checks separately.
* `isTabbable` decides whether the keyboard can reach it.
*/
export function hasMoveControl(container: Element, isTabbable: (el: Element) => boolean): boolean {
const siblings = [...(container.parentElement?.children ?? [])].filter(
(el) => el !== container && !el.matches(DRAG_LIST_SELECTOR) && !el.querySelector(DRAG_LIST_SELECTOR),
);
for (const scope of [container, ...siblings]) {
const controls = [...(scope.matches(CONTROL_SELECTOR) ? [scope] : []), ...scope.querySelectorAll(CONTROL_SELECTOR)];
for (const control of controls) {
if (control.closest('.cdk-drag-handle')) continue;
if (MOVE_NAME.test(roughName(control)) && isTabbable(control)) return true;
}
}
return false;
}
5 changes: 4 additions & 1 deletion src/keyboard/focus-walk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,10 @@ function deepActiveElement(doc: Document): Element | null {
/** True when focus cycling among `elements` is an open modal containing focus (fine). */
function containedByOpenModal(elements: readonly Element[]): boolean {
const modalOf = (el: Element): Element | null => {
const aria = el.closest('[aria-modal="true"]');
// CDK / Material dialog containers trap focus by design, even with
// aria-modal="false" (Material's default). This trusts the container, so a
// hand-built .cdk-dialog-container with no trap that leaks focus isn't flagged.
const aria = el.closest('[aria-modal="true"], mat-dialog-container, .cdk-dialog-container');
if (aria) return aria;
try {
return el.closest('dialog:modal');
Expand Down
Loading
Loading