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

## 0.16.0

### Added

- New keyboard rule `ngbr/hover-only-content`. It reports an element that shows
content on hover when Tab can't reach it, so keyboard users may never see that
content. An info icon with a tooltip in a table row is the common case. The
rule looks for:
- a tooltip from Angular Material, PrimeNG, ng-bootstrap, ngx-bootstrap,
ng-zorro, Taiga UI or helipopper, by its directive class in a dev build or
by its attribute or host class in any build. Moderate. An empty tooltip
attribute is skipped in every build. In a dev build, a tooltip switched off
through its directive is skipped too (Material, PrimeNG, ng-bootstrap,
ngx-bootstrap, ng-zorro and helipopper), and so is Material's
`matTooltipDisabled` in any build.
- an HTML `interestfor` attribute. Moderate.
- an Angular `(mouseenter)`, `(mouseover)`, `(pointerenter)` or
`(pointerover)` listener (dev builds). Minor, since such a listener can also
do something else, such as highlight a row.
- a `title` on an icon with no visible text, unless it repeats the icon's
`alt` or `aria-label`. Minor.

A disabled control with a tooltip, or a wrapper around one, gets its own
message: a disabled control can't take focus, so use `aria-disabled="true"`
instead. The rule skips an element inside something Tab reaches (an icon in a
link), a wrapper around something Tab reaches, and anything inside an
element already reported. It runs with the keyboard layer and maps to WCAG
2.1.1 in the ACR worksheet. Like the other keyboard rules, it is a heuristic
to check by hand.

If you gate CI on a baseline, findings from this rule show as new after you
upgrade. Update the baseline once you've checked them.

### Fixed

- The overlay now draws each keyboard finding on its own element. Keyboard
findings use a short selector such as `mat-icon.info`, and when that matched
an element repeated in every table row, the overlay drew all the findings on
the first match. Findings now carry a `locator` as well: a selector that
matches only that element, which the overlay uses. `target` is unchanged, so
baselines are unaffected.

## 0.15.8

### Fixed
Expand Down
3 changes: 3 additions & 0 deletions e2e/angular-app/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ export class FancyDirective {}
<div role="tab" tabindex="0">One</div>
<div class="half-built-tab" role="tab" tabindex="-1">Two</div>
</div>
<!-- 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>
`,
})
export class HomePageComponent {
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.15.8",
"version": "0.16.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
15 changes: 12 additions & 3 deletions src/attribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,15 @@ export function resolveOwningComponentName(
* can't see. Empty when the debug global is absent or the node has none.
*/
export function resolveDirectiveNames(node: Element): string[] {
return resolveDirectives(node).map((d) => d.name);
}

/**
* The directive instances on `node` with their names, for checks that need to
* look at an instance (e.g. to tell PrimeNG's `Tooltip` from another library's
* class of the same name). Empty when the debug global is absent.
*/
export function resolveDirectives(node: Element): { name: string; instance: object }[] {
const ng = ngDebug();
if (!ng?.getDirectives) return [];
let directives: unknown[];
Expand All @@ -168,10 +177,10 @@ export function resolveDirectiveNames(node: Element): string[] {
} catch {
return []; // node isn't part of a live view
}
const names: string[] = [];
const found: { name: string; instance: object }[] = [];
for (const directive of directives) {
const name = nameOf(directive);
if (name) names.push(name);
if (name) found.push({ name, instance: directive as object });
}
return names;
return found;
}
183 changes: 183 additions & 0 deletions src/keyboard/hover-content.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/**
* What marks an element as showing content on hover: a tooltip directive from a
* known library, an Angular hover listener, or a native `title` on an icon. The
* keyboard scan reports such an element when Tab can't reach it, since keyboard
* users then never see that content (`ngbr/hover-only-content`).
*
* Tooltip libraries attach their mouse listeners with `addEventListener`, which
* Angular's `getListeners` can't see, so each library is recognised by its
* directive class (dev builds) or by a DOM marker that's there in production
* too: a host class, or the attribute Angular renders for a static input
* (`matTooltip="Info"` → `mattooltip="Info"`).
*/
import { resolveDirectives } from '../attribution.js';

interface TooltipLibrary {
/** Directive class names that are unambiguous on their own. */
directives: readonly string[];
/** Common class names that count only with a marker or one of `instanceKeys`. */
genericDirectives?: readonly string[];
/** Properties a generic directive's instance has in this library. */
instanceKeys?: readonly string[];
/** DOM markers, present in production builds too. */
selector: string;
/** A marker attribute whose value is the tooltip text: empty means no tooltip. */
textAttribute?: string;
/** A marker that a web component could use for something else: skip it on custom elements. */
genericMarker?: boolean;
/** The tooltip is switched off, or has no text (dev builds, from the directive instance). */
isOff?: (instance: Record<string, unknown>) => boolean;
}

const empty = (value: unknown) => value == null || String(value).trim() === '';

/** Tooltip directives we recognise. Popovers are left out: most open on click. */
const TOOLTIP_LIBRARIES: readonly TooltipLibrary[] = [
// Angular Material
{
directives: ['MatTooltip'],
selector: '.mat-mdc-tooltip-trigger, [mattooltip]',
textAttribute: 'mattooltip',
isOff: (d) => d['disabled'] === true || empty(d['message']),
},
// PrimeNG
{
directives: [],
genericDirectives: ['Tooltip'],
instanceKeys: ['tooltipPosition'],
selector: '[ptooltip]',
textAttribute: 'ptooltip',
isOff: (d) => d['tooltipDisabled'] === true || d['disabled'] === true,
},
// ng-bootstrap
{
directives: ['NgbTooltip'],
selector: '[ngbtooltip]',
textAttribute: 'ngbtooltip',
isOff: (d) => d['disableTooltip'] === true || empty(d['ngbTooltip']),
},
// ngx-bootstrap
{
directives: [],
genericDirectives: ['TooltipDirective'],
instanceKeys: ['tooltip'],
selector: '[tooltip]',
textAttribute: 'tooltip',
genericMarker: true,
isOff: (d) => d['isDisabled'] === true || empty(d['tooltip']),
},
// ng-zorro
{
directives: ['NzTooltipDirective'],
selector: '[nz-tooltip]',
isOff: (d) => 'nzTooltipTitle' in d && empty(d['nzTooltipTitle']),
},
// Taiga UI
{ directives: ['TuiHint', 'TuiHintDirective', 'TuiHintHover'], selector: '[tuihint]', textAttribute: 'tuihint' },
// @ngneat/helipopper (Tippy.js)
{
directives: ['TippyDirective'],
selector: '[tp]',
textAttribute: 'tp',
genericMarker: true,
isOff: (d) => d['tpIsEnabled'] === false,
},
// HTML interest invokers
{ directives: [], selector: '[interestfor]' },
];

/** Every marker in one selector, so most elements cost a single `matches()`. */
const ANY_MARKER = TOOLTIP_LIBRARIES.map((lib) => lib.selector).join(', ');

const HOVER_EVENTS = ['mouseenter', 'mouseover', 'pointerenter', 'pointerover'];

/**
* Icon-font classes whose text is a ligature or glyph name, not visible text:
* Material Icons and Symbols, Font Awesome, Bootstrap Icons, PrimeIcons, Glyphicons.
*/
const ICON_CLASS = /^(mat-icon|material-icons(-[\w-]+)?|material-symbols-\w+|fa-[\w-]+|fa[srlbdt]|fa-solid|fa-regular|bi-[\w-]+|pi-[\w-]+|glyphicon-[\w-]+)$/;

/** How an element shows content on hover. */
export type HoverSignal = 'tooltip' | 'hover-listener' | 'title';

/**
* True when a tooltip from a known library is on `element` and switched on. A
* dev build answers from the directive instance; otherwise from the markers.
*/
function hasTooltip(element: Element): boolean {
let marked = false;
try {
marked = element.matches(ANY_MARKER);
} catch {
marked = false; // a selector this DOM implementation can't parse
}
// Material marks a tooltip that's switched off; this works in production too.
if (marked && element.classList.contains('mat-mdc-tooltip-disabled')) return false;

const directives = resolveDirectives(element);
for (const lib of TOOLTIP_LIBRARIES) {
const found = directives.find(
(d) =>
lib.directives.includes(d.name) ||
(lib.genericDirectives?.includes(d.name) &&
(safeMatches(element, lib.selector) || (lib.instanceKeys ?? []).some((key) => key in d.instance))),
);
if (found) {
if (lib.isOff?.(found.instance as Record<string, unknown>) || emptyTextAttribute(element, lib)) continue;
return true;
}
if (!marked || !safeMatches(element, lib.selector)) continue;
// A marker with no directive: production, or something else using the attribute.
if (directives.length > 0 && lib.directives.length + (lib.genericDirectives?.length ?? 0) > 0) continue;
// A registered web component may use the attribute for something else.
// Angular component hosts aren't registered custom elements, so they still count.
if (lib.genericMarker && globalThis.customElements?.get(element.localName) !== undefined) continue;
if (emptyTextAttribute(element, lib)) continue;
return true;
}
return false;
}

/** The library's text attribute is on `element` but empty: no tooltip text. */
function emptyTextAttribute(element: Element, lib: TooltipLibrary): boolean {
return !!lib.textAttribute && element.hasAttribute(lib.textAttribute) && empty(element.getAttribute(lib.textAttribute));
}

function safeMatches(element: Element, selector: string): boolean {
try {
return element.matches(selector);
} catch {
return false;
}
}

/**
* True when `element` shows no text of its own: no text content, an `<svg>` or
* `<img>`, or an icon-font element whose text is a ligature (`<mat-icon>info</mat-icon>`).
*/
export function isIconLike(element: Element): boolean {
const tag = element.tagName.toLowerCase();
if (tag === 'svg' || tag === 'img' || tag === 'mat-icon') return true;
if ([...element.classList].some((c) => ICON_CLASS.test(c))) return true;
return (element.textContent ?? '').trim() === '';
}

/** A `title` that adds nothing to the element's `alt` or `aria-label`. */
function titleRepeatsName(element: Element, title: string): boolean {
const norm = (v: string | null) => (v ?? '').trim().toLowerCase();
return [element.getAttribute('alt'), element.getAttribute('aria-label')].some((n) => norm(n) === norm(title));
}

/**
* The strongest hover signal on `element`: a tooltip that's switched on, then an
* Angular hover listener, then a `title` on an icon. `events` are the Angular
* listener names on it (see `resolveListenerEvents`), passed in so a scan reads
* them once.
*/
export function hoverSignal(element: Element, events: readonly string[]): HoverSignal | null {
if (hasTooltip(element)) return 'tooltip';
if (events.some((e) => HOVER_EVENTS.includes(e.split('.')[0]))) return 'hover-listener';
const title = (element.getAttribute('title') ?? '').trim();
if (title !== '' && isIconLike(element) && !titleRepeatsName(element, title)) return 'title';
return null;
}
Loading
Loading