diff --git a/CHANGELOG.md b/CHANGELOG.md
index 888081e..a815eaf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/e2e/angular-app/src/app.ts b/e2e/angular-app/src/app.ts
index 4ce651c..6101439 100644
--- a/e2e/angular-app/src/app.ts
+++ b/e2e/angular-app/src/app.ts
@@ -54,6 +54,9 @@ export class FancyDirective {}
One
Two
+
+ ⓘ
+
`,
})
export class HomePageComponent {
diff --git a/package-lock.json b/package-lock.json
index 324f9fb..efa27df 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@ngbracket/a11y-devtools",
- "version": "0.15.8",
+ "version": "0.16.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@ngbracket/a11y-devtools",
- "version": "0.15.8",
+ "version": "0.16.0",
"license": "MIT",
"dependencies": {
"axe-core": "^4.13.0"
diff --git a/package.json b/package.json
index 6c825e7..7a5ec84 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/attribution.ts b/src/attribution.ts
index 43a2212..10eb174 100644
--- a/src/attribution.ts
+++ b/src/attribution.ts
@@ -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[];
@@ -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;
}
diff --git a/src/keyboard/hover-content.ts b/src/keyboard/hover-content.ts
new file mode 100644
index 0000000..cb4baa8
--- /dev/null
+++ b/src/keyboard/hover-content.ts
@@ -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) => 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) || 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 `