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
+ + ⓘ + `, }) 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 `` 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() === ''; +} + +/** 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; +} diff --git a/src/keyboard/keyboard-scan.ts b/src/keyboard/keyboard-scan.ts index 6324fd5..8149f9f 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(','); /** @@ -296,6 +314,43 @@ export function shortSelector(element: Element): string { return classes.length ? `${tag}.${classes.join('.')}` : tag; } +/** + * 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; + } + }; + 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(''); + 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; + } + return undefined; +} + /** Truncated outerHTML for a finding's `html`. */ export function shortHtml(element: Element): string { const html = element.outerHTML ?? ''; @@ -304,9 +359,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, @@ -317,6 +372,74 @@ 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(); + /** 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`; + // The disabled control itself, or a wrapper around one (Angular Material's + // 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 + : signal === 'tooltip' + ? [...element.querySelectorAll('button, input, select, textarea')].find(isDisabled) + : only && isNativelyFocusable(only) && isDisabled(only) + ? only + : undefined; + if (disabled) { + return make( + element, + 'ngbr/hover-only-content', + 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, + ); + } + 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 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, + ); + } + 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 this tooltip: it opens on hover, but Tab doesn't reach ` + + `it. Make it focusable (a '); + withNg(() => { + expect(hover(host)[0].help).toContain('aria-disabled'); + }, new Map([[host.firstElementChild!, ['mouseenter']]])); + }); + + describe('title', () => { + it('flags a title on an icon as minor', () => { + const found = hover(fixture('error')); + expect(found.map((f) => f.impact)).toEqual(['minor', 'minor']); + }); + + it('ignores a title on text', () => { + expect(hover(fixture('WCAG'))).toEqual([]); + }); + + it('ignores a title that repeats the alt text', () => { + expect(hover(fixture('Jane Doe'))).toEqual([]); + }); + + it('does not treat a class that only starts like an icon class as an icon', () => { + expect(hover(fixture('
FAQ
Text
'))).toEqual([]); + }); + + it('treats icon-font ligatures as icons', () => { + 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); + }); + }); + + describe('not flagged', () => { + it('when Tab reaches the trigger', () => { + expect(hover(fixture('?'))).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 it sits inside an element already reported as an unreachable control', () => { + const host = fixture('
info Row
'); + withNg(() => { + expect(scanKeyboard(host).map((f) => f.id)).toEqual(['ngbr/unreachable-control']); + }, new Map([[host.firstElementChild!, ['click']]])); + }); + + 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].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"'); + }); + + 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.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'); + }); + + it('returns null with no signal', () => { + expect(hoverSignal(fixture('Text').firstElementChild!, [])).toBeNull(); + }); +}); + +describe('uniqueLocator', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + 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('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('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]; + 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' })]);