From c36e8fdb1b295abbae627bb33673d631370bf92b Mon Sep 17 00:00:00 2001 From: Duncan Faulkner Date: Mon, 5 Oct 2026 18:55:29 +0100 Subject: [PATCH 1/3] feat(keyboard): ngbr/hover-only-content, and unique selectors for repeated keyboard findings (0.16.0) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 32 +++++ e2e/angular-app/src/app.ts | 3 + package-lock.json | 4 +- package.json | 2 +- src/attribution.ts | 15 ++- src/keyboard/hover-content.ts | 98 ++++++++++++++ src/keyboard/keyboard-scan.ts | 129 ++++++++++++++++++- src/keyboard/tab-sequence.ts | 2 +- src/provider.ts | 3 +- src/report/acr.ts | 1 + src/report/headless.ts | 4 +- src/runner.ts | 4 +- src/testing/acr.spec.ts | 1 + src/testing/e2e-angular.spec.ts | 7 + src/testing/hover-content.spec.ts | 205 ++++++++++++++++++++++++++++++ 15 files changed, 492 insertions(+), 18 deletions(-) create mode 100644 src/keyboard/hover-content.ts create mode 100644 src/testing/hover-content.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 888081e..ea736e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,38 @@ 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 never see that + content. An info icon with a tooltip in a table row is the common case. The + rule looks for: + - a tooltip directive 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 + - an HTML `interestfor` attribute + - an Angular `(mouseenter)`, `(mouseover)`, `(pointerenter)` or + `(pointerover)` listener (dev builds) + - a `title` on an icon with no visible text, reported as minor + + A disabled control with a tooltip 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) and a wrapper around + one. 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. + +### Fixed + +- The overlay now draws each keyboard finding on its own element. Keyboard + findings used a short selector such as `mat-icon.info`. When that matched an + element repeated in every table row, the overlay drew all the findings on the + first match. The selector is now an `:nth-child` path from the nearest unique + `id` when the short form matches more than one element. Findings on a unique + element keep their selector. A baseline that lists a keyboard finding on a + repeated element will show it once as fixed and once as new. + ## 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
+ + ⓘ + `, }) 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..8b2d552 --- /dev/null +++ b/src/keyboard/hover-content.ts @@ -0,0 +1,98 @@ +/** + * 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, resolveListenerEvents } 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; +} + +/** 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]' }, + // PrimeNG + { directives: [], genericDirectives: ['Tooltip'], instanceKeys: ['tooltipPosition'], selector: '[ptooltip]' }, + // ng-bootstrap + { directives: ['NgbTooltip'], selector: '[ngbtooltip]' }, + // ngx-bootstrap + { directives: [], genericDirectives: ['TooltipDirective'], instanceKeys: ['tooltip'], selector: '[tooltip]' }, + // ng-zorro + { directives: ['NzTooltipDirective'], selector: '[nz-tooltip]' }, + // Taiga UI + { directives: ['TuiHint', 'TuiHintDirective', 'TuiHintHover'], selector: '[tuihint]' }, + // @ngneat/helipopper (Tippy.js) + { directives: ['TippyDirective'], selector: '[tp]' }, + // HTML interest invokers + { directives: [], selector: '[interestfor]' }, +]; + +const HOVER_EVENTS = ['mouseenter', 'mouseover', 'pointerenter', 'pointerover']; + +/** Icon-font classes whose text is a ligature or glyph name, not visible text. */ +const ICON_CLASS = /^(mat-icon|material-icons|material-symbols|fa[a-z]?|bi|pi|glyphicon)(-|$)/; + +/** How an element shows content on hover, or null when nothing says it does. */ +export type HoverSignal = 'tooltip' | 'hover-listener' | 'title'; + +function tooltipDirective(element: Element): boolean { + const directives = resolveDirectives(element); + return TOOLTIP_LIBRARIES.some( + (lib) => + directives.some((d) => lib.directives.includes(d.name)) || + directives.some( + (d) => + lib.genericDirectives?.includes(d.name) && + (element.matches(lib.selector) || (lib.instanceKeys ?? []).some((key) => key in d.instance)), + ), + ); +} + +function tooltipMarker(element: Element): boolean { + return TOOLTIP_LIBRARIES.some((lib) => { + try { + return element.matches(lib.selector); + } catch { + return false; // a selector this DOM implementation can't parse + } + }); +} + +/** + * True when `element` shows no text of its own: no text content, an `` or + * ``, or an icon-font element whose text is a ligature (`info`). + */ +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() === ''; +} + +/** + * The strongest hover signal on `element`: a tooltip directive or marker, then + * an Angular hover listener, then a non-empty `title` on an icon. + */ +export function hoverSignal(element: Element): HoverSignal | null { + if (tooltipMarker(element) || tooltipDirective(element)) return 'tooltip'; + const events = resolveListenerEvents(element).map((e) => e.split('.')[0]); + if (events.some((e) => HOVER_EVENTS.includes(e))) return 'hover-listener'; + if ((element.getAttribute('title') ?? '').trim() !== '' && isIconLike(element)) return 'title'; + return null; +} diff --git a/src/keyboard/keyboard-scan.ts b/src/keyboard/keyboard-scan.ts index 6324fd5..5b4aff6 100644 --- a/src/keyboard/keyboard-scan.ts +++ b/src/keyboard/keyboard-scan.ts @@ -7,9 +7,9 @@ * output, overlay, and report as the axe violations — attributed to the owning * component like everything else. * - * Honesty guardrail: these are *heuristics*, not axe rules. `click-without-key` - * and `tab-order-mismatch` are flagged as candidates to verify by hand, never as - * confirmed failures. + * Honesty guardrail: these are *heuristics*, not axe rules. `click-without-key`, + * `hover-only-content` and `tab-order-mismatch` are flagged as candidates to + * verify by hand, never as confirmed failures. */ import { appComponentFromPath, @@ -24,13 +24,16 @@ import { hasFocusableDescendant, hasTabbableDescendant, isHidden, + isDisabled, isNativelyFocusable, isTabbable, tabSequence, visualOrderJumps, } from './tab-sequence.js'; import { findUncontainedModals } from './focus-trap.js'; +import { hoverSignal, type HoverSignal } from './hover-content.js'; import { blockingModalDialog } from '../top-layer.js'; +import { OVERLAY_EXCLUDE_SELECTOR } from '../overlay.js'; export interface KeyboardScanOptions { /** UI-primitive prefixes to walk past during attribution; see the scan options. */ @@ -248,6 +251,21 @@ function isComponentHost(el: Element, ng: ReturnType): boolean { } } +/** + * True when an ancestor of `element` is tabbable (focus lands there, as with an + * icon inside a link) or is in `flagged` (already reported). + */ +function hasTabbableOrFlaggedAncestor( + element: Element, + flagged: ReadonlySet, + isVisible?: (el: Element) => boolean, +): boolean { + for (let el = element.parentElement; el; el = el.parentElement) { + if (flagged.has(el) || isTabbable(el, isVisible)) return true; + } + return false; +} + const ITEM_SELECTOR = [...INTERACTIVE_ROLES].map((role) => `[role="${role}"]`).join(','); /** @@ -284,18 +302,57 @@ function partOfKeyboardOperatedWidget( */ export const RULE_DOCS = 'https://ngbracket.com/tools/a11y-devtools/docs'; -/** A short, human CSS-ish selector for our own findings (not an axe target). */ +/** + * A short, human CSS-ish selector for our own findings (not an axe target). When + * the short form matches more than one element (an icon repeated in every table + * row), it becomes an `:nth-child` path from the nearest unique `id` or ``, + * so the overlay draws each finding on its own element instead of the first match. + */ export function shortSelector(element: Element): string { + const short = shortForm(element); + const doc = element.ownerDocument; + if (!doc || matchCount(doc, short) <= 1) return short; + const steps: string[] = []; + for (let el: Element | null = element; el && el !== doc.body && el !== doc.documentElement; el = el.parentElement) { + if (el !== element && el.id && matchCount(doc, `#${cssEscape(el.id)}`) === 1) { + steps.unshift(`${el.tagName.toLowerCase()}#${cssEscape(el.id)}`); + return steps.join(' > '); + } + const parent = el.parentElement; + const index = parent ? [...parent.children].indexOf(el) + 1 : 1; + steps.unshift(el === element ? `${short}:nth-child(${index})` : `${el.tagName.toLowerCase()}:nth-child(${index})`); + } + steps.unshift('body'); + return steps.join(' > '); +} + +function shortForm(element: Element): string { const tag = element.tagName.toLowerCase(); - if (element.id) return `${tag}#${element.id}`; + if (element.id) return `${tag}#${cssEscape(element.id)}`; const classes = (element.getAttribute('class') ?? '') .trim() .split(/\s+/) .filter(Boolean) - .slice(0, 2); + .slice(0, 2) + .map(cssEscape); return classes.length ? `${tag}.${classes.join('.')}` : tag; } +function matchCount(doc: Document, selector: string): number { + try { + return doc.querySelectorAll(selector).length; + } catch { + return 0; // not a valid selector here: keep the short form + } +} + +/** `CSS.escape` where available (not in every test DOM); else escape the usual suspects. */ +function cssEscape(value: string): string { + const css = (globalThis as { CSS?: { escape?: (v: string) => string } }).CSS; + if (typeof css?.escape === 'function') return css.escape(value); + return value.replace(/^(\d)/, '\\3$1 ').replace(/([^\w-])/g, '\\$1'); +} + /** Truncated outerHTML for a finding's `html`. */ export function shortHtml(element: Element): string { const html = element.outerHTML ?? ''; @@ -317,6 +374,44 @@ export function scanKeyboard( const findings: A11yFinding[] = []; const keyCache = new Map(); const sharedCache: SharedStopCache = new Map(); + /** Elements reported as unreachable controls, so the hover check doesn't repeat them. */ + const unreachable = new Set(); + + const hoverFinding = (element: Element, signal: HoverSignal): A11yFinding => { + const url = `${RULE_DOCS}/hover-only-content`; + if (isNativelyFocusable(element) && isDisabled(element)) { + return make( + element, + 'ngbr/hover-only-content', + 'moderate', + `Keyboard users can't see this disabled control's hover content: a disabled control ` + + `can't take focus, so its tooltip only opens for the mouse. Use aria-disabled="true" ` + + `instead of disabled, or show the reason as text. Heuristic — verify manually.`, + url, + ); + } + if (signal === 'title') { + return make( + element, + 'ngbr/hover-only-content', + 'minor', + `Only mouse users see this icon's title: Tab doesn't reach the icon, so its title ` + + `never shows for keyboard users. Show the text on the page, or put the icon in a ` + + `focusable control with a visible label or tooltip. Heuristic — verify manually.`, + url, + ); + } + const what = signal === 'tooltip' ? 'it has a tooltip' : 'it listens for the mouse moving over it'; + return make( + element, + 'ngbr/hover-only-content', + 'moderate', + `Keyboard users may not see what this shows on hover: ${what}, but Tab doesn't reach ` + + `it. Make it focusable (a '))).toEqual([]); + }); + + it('when the trigger is hidden', () => { + expect(hover(fixture(''))).toEqual([]); + }); + + it('when it wraps something Tab reaches', () => { + expect(hover(fixture(''))).toEqual([]); + }); + + it('when it sits inside something Tab reaches, such as an icon in a link', () => { + expect(hover(fixture('Course open_in_new'))).toEqual([]); + }); + + it('when an ancestor was already reported', () => { + const found = hover(fixture('info')); + expect(found).toHaveLength(1); + expect(found[0].target).toBe('span'); + }); + + it('when it is behind an open modal dialog', () => { + const host = fixture('??'); + const found = scanKeyboard(host, { isModal: (el) => el.hasAttribute('data-modal') }).filter( + (f) => f.id === 'ngbr/hover-only-content', + ); + expect(found.map((f) => f.html)).toEqual(['?']); + }); + + it('when the element is already an unreachable control', () => { + const host = fixture('x'); + const ids = scanKeyboard(host).map((f) => f.id); + expect(ids).toEqual(['ngbr/unreachable-control']); + }); + + it('for the devtools overlay itself', () => { + expect(hover(fixture('
?
'))).toEqual([]); + }); + }); + + it('flags a disabled button with a tooltip, suggesting aria-disabled', () => { + const found = hover(fixture('')); + expect(found).toHaveLength(1); + expect(found[0].help).toContain('aria-disabled="true"'); + }); + + it('flags only the unreachable icons in repeated rows, each on its own element', () => { + const row = (focusable: boolean) => + `Course infoNot set`; + const host = fixture(`${row(true)}${row(false)}${row(false)}
`); + const found = hover(host); + expect(found).toHaveLength(2); + const icons = [...host.querySelectorAll('mat-icon')]; + expect(found.map((f) => document.querySelector(f.target))).toEqual([icons[1], icons[2]]); + }); +}); + +describe('hoverSignal', () => { + it('prefers a tooltip over a title', () => { + const el = fixture('info').firstElementChild!; + expect(hoverSignal(el)).toBe('tooltip'); + }); + + it('returns null with no signal', () => { + expect(hoverSignal(fixture('Text').firstElementChild!)).toBeNull(); + }); +}); + +describe('shortSelector', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('keeps the short form when it is unique', () => { + const el = fixture('
x
').firstElementChild!; + expect(shortSelector(el)).toBe('div#only'); + }); + + it('builds an nth-child path for a repeated element, from the nearest unique id', () => { + const host = fixture('
  • a
  • b
'); + const second = host.querySelectorAll('i')[1]; + const selector = shortSelector(second); + expect(selector).toBe('ul#list > li:nth-child(2) > i.ic:nth-child(1)'); + expect(document.querySelector(selector)).toBe(second); + }); + + it('falls back to a path from body when no ancestor has an id', () => { + const host = fixture('

x

y

'); + const second = host.querySelectorAll('b')[1]; + expect(document.querySelector(shortSelector(second))).toBe(second); + }); +}); From 0f32d4a22698ff46f4866e00108e227e16fe41bc Mon Sep 17 00:00:00 2001 From: Duncan Faulkner Date: Mon, 5 Oct 2026 19:07:03 +0100 Subject: [PATCH 2/3] fix(keyboard): hover-only-content review fixes; overlay locator keeps target stable Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 45 ++++++---- src/keyboard/hover-content.ts | 140 ++++++++++++++++++++++-------- src/keyboard/keyboard-scan.ts | 137 +++++++++++++++++------------ src/overlay.ts | 2 +- src/scan.ts | 6 ++ src/testing/e2e-angular.spec.ts | 4 +- src/testing/hover-content.spec.ts | 99 +++++++++++++++++---- src/testing/overlay.spec.ts | 11 +++ 8 files changed, 312 insertions(+), 132 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea736e3..1c6a687 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,32 +8,39 @@ This project adheres to [Semantic Versioning](https://semver.org/). ### 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 never see that + 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 directive 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 - - an HTML `interestfor` attribute + - 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. A tooltip that is + switched off, or has no text, is skipped. + - an HTML `interestfor` attribute. Moderate. - an Angular `(mouseenter)`, `(mouseover)`, `(pointerenter)` or - `(pointerover)` listener (dev builds) - - a `title` on an icon with no visible text, reported as minor - - A disabled control with a tooltip 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) and a wrapper around - one. 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. + `(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 used a short selector such as `mat-icon.info`. When that matched an - element repeated in every table row, the overlay drew all the findings on the - first match. The selector is now an `:nth-child` path from the nearest unique - `id` when the short form matches more than one element. Findings on a unique - element keep their selector. A baseline that lists a keyboard finding on a - repeated element will show it once as fixed and once as new. + 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 diff --git a/src/keyboard/hover-content.ts b/src/keyboard/hover-content.ts index 8b2d552..276d7c0 100644 --- a/src/keyboard/hover-content.ts +++ b/src/keyboard/hover-content.ts @@ -10,7 +10,7 @@ * too: a host class, or the attribute Angular renders for a static input * (`matTooltip="Info"` → `mattooltip="Info"`). */ -import { resolveDirectives, resolveListenerEvents } from '../attribution.js'; +import { resolveDirectives } from '../attribution.js'; interface TooltipLibrary { /** Directive class names that are unambiguous on their own. */ @@ -21,57 +21,119 @@ interface TooltipLibrary { 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]' }, + { + 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]' }, + { + directives: [], + genericDirectives: ['Tooltip'], + instanceKeys: ['tooltipPosition'], + selector: '[ptooltip]', + textAttribute: 'ptooltip', + isOff: (d) => d['tooltipDisabled'] === true || d['disabled'] === true, + }, // ng-bootstrap - { directives: ['NgbTooltip'], selector: '[ngbtooltip]' }, + { + directives: ['NgbTooltip'], + selector: '[ngbtooltip]', + textAttribute: 'ngbtooltip', + isOff: (d) => d['disableTooltip'] === true || empty(d['ngbTooltip']), + }, // ngx-bootstrap - { directives: [], genericDirectives: ['TooltipDirective'], instanceKeys: ['tooltip'], selector: '[tooltip]' }, + { + 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]' }, // Taiga UI - { directives: ['TuiHint', 'TuiHintDirective', 'TuiHintHover'], selector: '[tuihint]' }, + { directives: ['TuiHint', 'TuiHintDirective', 'TuiHintHover'], selector: '[tuihint]', textAttribute: 'tuihint' }, // @ngneat/helipopper (Tippy.js) - { directives: ['TippyDirective'], selector: '[tp]' }, + { directives: ['TippyDirective'], selector: '[tp]', textAttribute: 'tp', genericMarker: true }, // 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. */ -const ICON_CLASS = /^(mat-icon|material-icons|material-symbols|fa[a-z]?|bi|pi|glyphicon)(-|$)/; +/** + * 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|material-symbols-\w+|fa-[\w-]+|fa[srlbdt]|fa-solid|fa-regular|bi-[\w-]+|pi-[\w-]+|glyphicon-[\w-]+)$/; -/** How an element shows content on hover, or null when nothing says it does. */ +/** How an element shows content on hover. */ export type HoverSignal = 'tooltip' | 'hover-listener' | 'title'; -function tooltipDirective(element: Element): boolean { +/** + * 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); - return TOOLTIP_LIBRARIES.some( - (lib) => - directives.some((d) => lib.directives.includes(d.name)) || - directives.some( - (d) => - lib.genericDirectives?.includes(d.name) && - (element.matches(lib.selector) || (lib.instanceKeys ?? []).some((key) => key in d.instance)), - ), - ); + 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)) return true; + continue; + } + 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; + if (lib.genericMarker && element.tagName.includes('-')) continue; + if (lib.textAttribute && element.hasAttribute(lib.textAttribute) && empty(element.getAttribute(lib.textAttribute))) { + continue; + } + return true; + } + return false; } -function tooltipMarker(element: Element): boolean { - return TOOLTIP_LIBRARIES.some((lib) => { - try { - return element.matches(lib.selector); - } catch { - return false; // a selector this DOM implementation can't parse - } - }); +function safeMatches(element: Element, selector: string): boolean { + try { + return element.matches(selector); + } catch { + return false; + } } /** @@ -85,14 +147,22 @@ export function isIconLike(element: Element): boolean { 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 directive or marker, then - * an Angular hover listener, then a non-empty `title` on an icon. + * 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): HoverSignal | null { - if (tooltipMarker(element) || tooltipDirective(element)) return 'tooltip'; - const events = resolveListenerEvents(element).map((e) => e.split('.')[0]); - if (events.some((e) => HOVER_EVENTS.includes(e))) return 'hover-listener'; - if ((element.getAttribute('title') ?? '').trim() !== '' && isIconLike(element)) return 'title'; +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; } diff --git a/src/keyboard/keyboard-scan.ts b/src/keyboard/keyboard-scan.ts index 5b4aff6..eea9b37 100644 --- a/src/keyboard/keyboard-scan.ts +++ b/src/keyboard/keyboard-scan.ts @@ -302,55 +302,52 @@ function partOfKeyboardOperatedWidget( */ export const RULE_DOCS = 'https://ngbracket.com/tools/a11y-devtools/docs'; -/** - * A short, human CSS-ish selector for our own findings (not an axe target). When - * the short form matches more than one element (an icon repeated in every table - * row), it becomes an `:nth-child` path from the nearest unique `id` or ``, - * so the overlay draws each finding on its own element instead of the first match. - */ +/** A short, human CSS-ish selector for our own findings (not an axe target). */ export function shortSelector(element: Element): string { - const short = shortForm(element); - const doc = element.ownerDocument; - if (!doc || matchCount(doc, short) <= 1) return short; - const steps: string[] = []; - for (let el: Element | null = element; el && el !== doc.body && el !== doc.documentElement; el = el.parentElement) { - if (el !== element && el.id && matchCount(doc, `#${cssEscape(el.id)}`) === 1) { - steps.unshift(`${el.tagName.toLowerCase()}#${cssEscape(el.id)}`); - return steps.join(' > '); - } - const parent = el.parentElement; - const index = parent ? [...parent.children].indexOf(el) + 1 : 1; - steps.unshift(el === element ? `${short}:nth-child(${index})` : `${el.tagName.toLowerCase()}:nth-child(${index})`); - } - steps.unshift('body'); - return steps.join(' > '); -} - -function shortForm(element: Element): string { const tag = element.tagName.toLowerCase(); - if (element.id) return `${tag}#${cssEscape(element.id)}`; + if (element.id) return `${tag}#${element.id}`; const classes = (element.getAttribute('class') ?? '') .trim() .split(/\s+/) .filter(Boolean) - .slice(0, 2) - .map(cssEscape); + .slice(0, 2); return classes.length ? `${tag}.${classes.join('.')}` : tag; } -function matchCount(doc: Document, selector: string): number { - try { - return doc.querySelectorAll(selector).length; - } catch { - return 0; // not a valid selector here: keep the short form +/** + * A selector that matches only `element`, for the overlay to draw the finding + * on. {@link shortSelector} can match many elements (an icon repeated in every + * table row), and the overlay would draw them all on the first. Builds an + * `:nth-child` path upward and stops as soon as it is unique, like axe's own + * targets. Undefined when the short selector is already unique, when `CSS.escape` + * is missing, or for an element in a shadow root. + */ +export function uniqueLocator(element: Element): string | undefined { + const doc = element.ownerDocument; + const escape = (globalThis as { CSS?: { escape?: (v: string) => string } }).CSS?.escape; + if (!doc || typeof escape !== 'function' || element.getRootNode() !== doc) return undefined; + const count = (selector: string) => { + try { + return doc.querySelectorAll(selector).length; + } catch { + return 0; + } + }; + if (count(shortSelector(element)) === 1) return undefined; + const step = (el: Element) => { + if (el.id && count(`#${escape(el.id)}`) === 1) return { text: `#${escape(el.id)}`, anchored: true }; + const classes = [...el.classList].slice(0, 2).map((c) => `.${escape(c)}`).join(''); + const index = el.parentElement ? [...el.parentElement.children].indexOf(el) + 1 : 1; + return { text: `${el.tagName.toLowerCase()}${classes}:nth-child(${index})`, anchored: false }; + }; + const steps: string[] = []; + for (let el: Element | null = element; el && el !== doc.documentElement; el = el.parentElement) { + const { text, anchored } = step(el); + steps.unshift(text); + const selector = steps.join(' > '); + if (anchored || count(selector) === 1) return selector; } -} - -/** `CSS.escape` where available (not in every test DOM); else escape the usual suspects. */ -function cssEscape(value: string): string { - const css = (globalThis as { CSS?: { escape?: (v: string) => string } }).CSS; - if (typeof css?.escape === 'function') return css.escape(value); - return value.replace(/^(\d)/, '\\3$1 ').replace(/([^\w-])/g, '\\$1'); + return undefined; } /** Truncated outerHTML for a finding's `html`. */ @@ -361,9 +358,9 @@ export function shortHtml(element: Element): string { /** * Scan `root` for keyboard-operability problems and return them as findings. - * Runs entirely in the page (needs the live `window.ng` for listeners and - * attribution), so it's a no-op — empty result — in production where that global - * is absent. + * Runs entirely in the page. Listener-based checks and attribution need the + * live `window.ng`, so in production only the markup-based checks (roles, tooltip + * markers, icon titles, tab order) report anything. */ export function scanKeyboard( root: ParentNode = document, @@ -376,17 +373,31 @@ 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(); + /** Angular listener names per element, read once and shared by both passes. */ + const listenerEvents = new Map(); + const eventsOf = (el: Element): string[] => { + let events = listenerEvents.get(el); + if (!events) listenerEvents.set(el, (events = resolveListenerEvents(el))); + return events; + }; const hoverFinding = (element: Element, signal: HoverSignal): A11yFinding => { const url = `${RULE_DOCS}/hover-only-content`; - if (isNativelyFocusable(element) && isDisabled(element)) { + // The disabled control itself, or a wrapper around one (Angular Material's + // documented way to give a disabled button a tooltip). + const disabled = + isNativelyFocusable(element) && isDisabled(element) + ? element + : [...element.querySelectorAll('button, input, select, textarea')].find(isDisabled); + if (disabled) { return make( element, 'ngbr/hover-only-content', - 'moderate', - `Keyboard users can't see this disabled control's hover content: a disabled control ` + - `can't take focus, so its tooltip only opens for the mouse. Use aria-disabled="true" ` + - `instead of disabled, or show the reason as text. Heuristic — verify manually.`, + signal === 'tooltip' ? 'moderate' : 'minor', + `Keyboard users may not see this disabled control's hover content: a disabled ` + + `control can't take focus, so its tooltip only opens for the mouse. Use ` + + `aria-disabled="true" instead of disabled, or show the reason as text. ` + + `Heuristic — verify manually.`, url, ); } @@ -395,19 +406,29 @@ export function scanKeyboard( element, 'ngbr/hover-only-content', 'minor', - `Only mouse users see this icon's title: Tab doesn't reach the icon, so its title ` + - `never shows for keyboard users. Show the text on the page, or put the icon in a ` + + `Only mouse users see this icon's title: Tab doesn't reach the icon, so keyboard ` + + `users can't show its title. Show the text on the page, or put the icon in a ` + `focusable control with a visible label or tooltip. Heuristic — verify manually.`, url, ); } - const what = signal === 'tooltip' ? 'it has a tooltip' : 'it listens for the mouse moving over it'; + if (signal === 'hover-listener') { + return make( + element, + 'ngbr/hover-only-content', + 'minor', + `Keyboard users may not see what this shows on hover: it listens for the mouse ` + + `moving over it, but Tab doesn't reach it. If it shows content, make it focusable ` + + `and show the content on focus too, or show it on the page. Heuristic — verify manually.`, + url, + ); + } return make( element, 'ngbr/hover-only-content', 'moderate', - `Keyboard users may not see what this shows on hover: ${what}, but Tab doesn't reach ` + - `it. Make it focusable (a ')); expect(found).toHaveLength(1); + expect(found[0].impact).toBe('moderate'); + expect(found[0].help).toContain('aria-disabled="true"'); + }); + + it('gives the disabled advice for a tooltip on a wrapper around a disabled button', () => { + const found = hover(fixture('')); + expect(found).toHaveLength(1); + expect(found[0].target).toBe('span'); expect(found[0].help).toContain('aria-disabled="true"'); }); @@ -164,42 +223,46 @@ describe('ngbr/hover-only-content', () => { const found = hover(host); expect(found).toHaveLength(2); const icons = [...host.querySelectorAll('mat-icon')]; - expect(found.map((f) => document.querySelector(f.target))).toEqual([icons[1], icons[2]]); + expect(found.map((f) => document.querySelector(f.locator ?? f.target))).toEqual([icons[1], icons[2]]); + // The target stays the short form, so a baseline keyed on it doesn't change. + expect(found.map((f) => f.target)).toEqual(['mat-icon.info', 'mat-icon.info']); }); }); describe('hoverSignal', () => { it('prefers a tooltip over a title', () => { const el = fixture('info').firstElementChild!; - expect(hoverSignal(el)).toBe('tooltip'); + expect(hoverSignal(el, [])).toBe('tooltip'); }); it('returns null with no signal', () => { - expect(hoverSignal(fixture('Text').firstElementChild!)).toBeNull(); + expect(hoverSignal(fixture('Text').firstElementChild!, [])).toBeNull(); }); }); -describe('shortSelector', () => { +describe('uniqueLocator', () => { afterEach(() => { document.body.innerHTML = ''; }); - it('keeps the short form when it is unique', () => { + it('is undefined when the short selector is already unique', () => { const el = fixture('
x
').firstElementChild!; expect(shortSelector(el)).toBe('div#only'); + expect(uniqueLocator(el)).toBeUndefined(); }); - it('builds an nth-child path for a repeated element, from the nearest unique id', () => { - const host = fixture('
  • a
  • b
'); - const second = host.querySelectorAll('i')[1]; - const selector = shortSelector(second); - expect(selector).toBe('ul#list > li:nth-child(2) > i.ic:nth-child(1)'); + it('stops as soon as the path is unique', () => { + const host = fixture('
  • a
  • b
c'); + const second = host.querySelectorAll('li i')[1]; + const selector = uniqueLocator(second)!; + expect(selector).toBe('li:nth-child(2) > i.ic:nth-child(1)'); + expect(document.querySelectorAll(selector)).toHaveLength(1); expect(document.querySelector(selector)).toBe(second); }); - it('falls back to a path from body when no ancestor has an id', () => { - const host = fixture('

x

y

'); + it('escapes ids and classes that aren’t valid as written', () => { + const host = fixture('

x

y

'); const second = host.querySelectorAll('b')[1]; - expect(document.querySelector(shortSelector(second))).toBe(second); + expect(document.querySelector(uniqueLocator(second)!)).toBe(second); }); }); diff --git a/src/testing/overlay.spec.ts b/src/testing/overlay.spec.ts index a619959..5c89d12 100644 --- a/src/testing/overlay.spec.ts +++ b/src/testing/overlay.spec.ts @@ -91,6 +91,17 @@ describe('createOverlay', () => { expect(box.style.height).toBe('30px'); }); + it('finds the node by locator when there is one, not by the shared target', () => { + targetEl('first', { top: 10 }); + const second = targetEl('second', { top: 200 }); + second.className = 'info'; + document.getElementById('first')!.className = 'info'; + overlay.render([finding({ target: 'div.info', locator: '#second' })]); + + const box = overlayRoot()!.querySelector('[data-impact]') as HTMLElement; + expect(box.style.top).toBe('200px'); + }); + it('colours the box by impact and labels it with the component', () => { targetEl('target'); overlay.render([finding({ impact: 'serious', component: 'NavBarComponent' })]); From cf4b47151147a508cf473fa94a95d6518dd84972 Mon Sep 17 00:00:00 2001 From: Duncan Faulkner Date: Mon, 5 Oct 2026 19:16:15 +0100 Subject: [PATCH 3/3] fix(keyboard): hover-only-content second review fixes Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +++-- src/keyboard/hover-content.ts | 33 ++++++++++++++------ src/keyboard/keyboard-scan.ts | 13 ++++++-- src/testing/hover-content.spec.ts | 51 +++++++++++++++++++++++++++++-- 4 files changed, 87 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c6a687..a815eaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,11 @@ This project adheres to [Semantic Versioning](https://semver.org/). 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. A tooltip that is - switched off, or has no text, is skipped. + 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 diff --git a/src/keyboard/hover-content.ts b/src/keyboard/hover-content.ts index 276d7c0..cb4baa8 100644 --- a/src/keyboard/hover-content.ts +++ b/src/keyboard/hover-content.ts @@ -67,11 +67,21 @@ const TOOLTIP_LIBRARIES: readonly TooltipLibrary[] = [ isOff: (d) => d['isDisabled'] === true || empty(d['tooltip']), }, // ng-zorro - { directives: ['NzTooltipDirective'], selector: '[nz-tooltip]' }, + { + 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 }, + { + directives: ['TippyDirective'], + selector: '[tp]', + textAttribute: 'tp', + genericMarker: true, + isOff: (d) => d['tpIsEnabled'] === false, + }, // HTML interest invokers { directives: [], selector: '[interestfor]' }, ]; @@ -85,7 +95,7 @@ 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|material-symbols-\w+|fa-[\w-]+|fa[srlbdt]|fa-solid|fa-regular|bi-[\w-]+|pi-[\w-]+|glyphicon-[\w-]+)$/; +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'; @@ -113,21 +123,26 @@ function hasTooltip(element: Element): boolean { (safeMatches(element, lib.selector) || (lib.instanceKeys ?? []).some((key) => key in d.instance))), ); if (found) { - if (!lib.isOff?.(found.instance as Record)) return true; - continue; + 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; - if (lib.genericMarker && element.tagName.includes('-')) continue; - if (lib.textAttribute && element.hasAttribute(lib.textAttribute) && empty(element.getAttribute(lib.textAttribute))) { - 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); diff --git a/src/keyboard/keyboard-scan.ts b/src/keyboard/keyboard-scan.ts index eea9b37..8149f9f 100644 --- a/src/keyboard/keyboard-scan.ts +++ b/src/keyboard/keyboard-scan.ts @@ -333,7 +333,8 @@ export function uniqueLocator(element: Element): string | undefined { return 0; } }; - if (count(shortSelector(element)) === 1) return undefined; + const short = shortSelector(element); + if (count(short) === 1 && doc.querySelector(short) === element) return undefined; const step = (el: Element) => { if (el.id && count(`#${escape(el.id)}`) === 1) return { text: `#${escape(el.id)}`, anchored: true }; const classes = [...el.classList].slice(0, 2).map((c) => `.${escape(c)}`).join(''); @@ -384,11 +385,17 @@ export function scanKeyboard( const hoverFinding = (element: Element, signal: HoverSignal): A11yFinding => { const url = `${RULE_DOCS}/hover-only-content`; // The disabled control itself, or a wrapper around one (Angular Material's - // documented way to give a disabled button a tooltip). + // documented way to give a disabled button a tooltip): a tooltip on any + // wrapper, or other hover content on a wrapper whose only child is the control. + const only = element.children.length === 1 ? element.children[0] : null; const disabled = isNativelyFocusable(element) && isDisabled(element) ? element - : [...element.querySelectorAll('button, input, select, textarea')].find(isDisabled); + : signal === 'tooltip' + ? [...element.querySelectorAll('button, input, select, textarea')].find(isDisabled) + : only && isNativelyFocusable(only) && isDisabled(only) + ? only + : undefined; if (disabled) { return make( element, diff --git a/src/testing/hover-content.spec.ts b/src/testing/hover-content.spec.ts index 60c7772..845fcaa 100644 --- a/src/testing/hover-content.spec.ts +++ b/src/testing/hover-content.spec.ts @@ -124,8 +124,43 @@ describe('ngbr/hover-only-content', () => { }); }); - it('ignores a generic [tooltip] or [tp] attribute on a custom element', () => { - expect(hover(fixture(''))).toEqual([]); + it('ignores a generic [tooltip] or [tp] attribute on a registered web component', () => { + customElements.define('hc-chart', class extends HTMLElement {}); + customElements.define('hc-avatar', class extends HTMLElement {}); + expect(hover(fixture(''))).toEqual([]); + }); + + it('still counts the [tooltip] marker on an Angular component host', () => { + expect(hover(fixture(''))).toHaveLength(1); + }); + + it('skips an empty or switched-off tooltip in a dev build for every library with a text input', () => { + const host = fixture('????'); + const [taiga, tippyEmpty, tippyOff, zorro] = [...host.children]; + const dirs = new Map([ + [taiga, [directive('TuiHint')]], + [tippyEmpty, [directive('TippyDirective')]], + [tippyOff, [directive('TippyDirective', { tpIsEnabled: false })]], + [zorro, [directive('NzTooltipDirective', { nzTooltipTitle: '' })]], + ]); + withNg(() => expect(hover(host)).toEqual([]), new Map(), dirs); + }); + + it('gives a hover-listener row with a disabled button inside the generic message, not the disabled one', () => { + const host = fixture('
Row
'); + const row = host.querySelector('tr')!; + withNg(() => { + const found = hover(host); + expect(found).toHaveLength(1); + expect(found[0].help).not.toContain('aria-disabled'); + }, new Map([[row, ['mouseenter']]])); + }); + + it('gives the disabled message to a hover-listener wrapper whose only child is a disabled button', () => { + const host = fixture(''); + withNg(() => { + expect(hover(host)[0].help).toContain('aria-disabled'); + }, new Map([[host.firstElementChild!, ['mouseenter']]])); }); describe('title', () => { @@ -147,9 +182,13 @@ describe('ngbr/hover-only-content', () => { }); it('treats icon-font ligatures as icons', () => { - const host = fixture('infoInfo'); + const host = fixture( + 'infoInfoschedulex', + ); expect(isIconLike(host.children[0])).toBe(true); expect(isIconLike(host.children[1])).toBe(false); + expect(isIconLike(host.children[2])).toBe(true); + expect(isIconLike(host.children[3])).toBe(true); }); }); @@ -260,6 +299,12 @@ describe('uniqueLocator', () => { expect(document.querySelector(selector)).toBe(second); }); + it('gives a locator when the unescaped short selector matches a different element', () => { + const host = fixture('
decoy
real
'); + const real = host.children[1]; + expect(document.querySelector(uniqueLocator(real)!)).toBe(real); + }); + it('escapes ids and classes that aren’t valid as written', () => { const host = fixture('

x

y

'); const second = host.querySelectorAll('b')[1];