diff --git a/packages/injected/src/ariaSnapshot.ts b/packages/injected/src/ariaSnapshot.ts index 6f472ceec0..9f5b2618c5 100644 --- a/packages/injected/src/ariaSnapshot.ts +++ b/packages/injected/src/ariaSnapshot.ts @@ -29,6 +29,7 @@ export type AriaNode = AriaProps & { children: (AriaNode | string)[]; element: Element; box: Box; + receivesPointerEvents: boolean; props: Record; }; @@ -43,7 +44,7 @@ export function generateAriaTree(rootElement: Element, generation: number, optio const visited = new Set(); const snapshot: AriaSnapshot = { - root: { role: 'fragment', name: '', children: [], element: rootElement, props: {}, box: box(rootElement) }, + root: { role: 'fragment', name: '', children: [], element: rootElement, props: {}, box: box(rootElement), receivesPointerEvents: true }, elements: new Map(), generation, ids: new Map(), @@ -148,7 +149,7 @@ export function generateAriaTree(rootElement: Element, generation: number, optio function toAriaNode(element: Element, options?: { emitGeneric?: boolean }): AriaNode | null { if (element.nodeName === 'IFRAME') - return { role: 'iframe', name: '', children: [], props: {}, element, box: box(element) }; + return { role: 'iframe', name: '', children: [], props: {}, element, box: box(element), receivesPointerEvents: true }; const defaultRole = options?.emitGeneric ? 'generic' : null; const role = roleUtils.getAriaRole(element) ?? defaultRole; @@ -156,7 +157,8 @@ function toAriaNode(element: Element, options?: { emitGeneric?: boolean }): Aria return null; const name = normalizeWhiteSpace(roleUtils.getElementAccessibleName(element, false) || ''); - const result: AriaNode = { role, name, children: [], props: {}, element, box: box(element) }; + const receivesPointerEvents = roleUtils.receivesPointerEvents(element); + const result: AriaNode = { role, name, children: [], props: {}, element, box: box(element), receivesPointerEvents }; if (roleUtils.kAriaCheckedRoles.includes(role)) result.checked = roleUtils.getAriaChecked(element); @@ -406,7 +408,7 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, options?: { mode?: 'r key += ` [pressed]`; if (ariaNode.selected === true) key += ` [selected]`; - if (options?.ref && ariaNode.box.visible) { + if (options?.ref && ariaNode.box.visible && ariaNode.receivesPointerEvents) { const id = ariaSnapshot.ids.get(ariaNode.element); if (id) key += ` [ref=s${ariaSnapshot.generation}e${id}]`; diff --git a/packages/injected/src/roleUtils.ts b/packages/injected/src/roleUtils.ts index bbd7cced2a..9e5d6e4416 100644 --- a/packages/injected/src/roleUtils.ts +++ b/packages/injected/src/roleUtils.ts @@ -1051,6 +1051,40 @@ function getAccessibleNameFromAssociatedLabels(labels: Iterable !!accessibleName).join(' '); } +export function receivesPointerEvents(element: Element): boolean { + const cache = cachePointerEvents!; + let e: Element | undefined = element; + let result: boolean | undefined; + const parents: Element[] = []; + for (; e; e = parentElementOrShadowHost(e!)) { + const cached = cache.get(e); + if (cached !== undefined) { + result = cached; + break; + } + + parents.push(e); + const style = getElementComputedStyle(e); + if (!style) { + result = true; + break; + } + + const value = style.pointerEvents; + if (value) { + result = value !== 'none'; + break; + } + } + + if (result === undefined) + result = true; + + for (const parent of parents) + cache.set(parent, result); + return result; +} + let cacheAccessibleName: Map | undefined; let cacheAccessibleNameHidden: Map | undefined; let cacheAccessibleDescription: Map | undefined; @@ -1059,6 +1093,7 @@ let cacheAccessibleErrorMessage: Map | undefined; let cacheIsHidden: Map | undefined; let cachePseudoContentBefore: Map | undefined; let cachePseudoContentAfter: Map | undefined; +let cachePointerEvents: Map | undefined; let cachesCounter = 0; export function beginAriaCaches() { @@ -1071,6 +1106,7 @@ export function beginAriaCaches() { cacheIsHidden ??= new Map(); cachePseudoContentBefore ??= new Map(); cachePseudoContentAfter ??= new Map(); + cachePointerEvents ??= new Map(); } export function endAriaCaches() { @@ -1083,6 +1119,7 @@ export function endAriaCaches() { cacheIsHidden = undefined; cachePseudoContentBefore = undefined; cachePseudoContentAfter = undefined; + cachePointerEvents = undefined; } } diff --git a/tests/page/page-aria-snapshot.spec.ts b/tests/page/page-aria-snapshot.spec.ts index 00081b7bc8..b1aaaa147c 100644 --- a/tests/page/page-aria-snapshot.spec.ts +++ b/tests/page/page-aria-snapshot.spec.ts @@ -731,7 +731,7 @@ it('ref mode can be used to stitch all frame snapshots', async ({ page, server } `.trim()); }); -it('should not include hidden input elements', async ({ page }) => { +it('should not generate refs for hidden elements', async ({ page }) => { await page.setContent(` @@ -744,6 +744,37 @@ it('should not include hidden input elements', async ({ page }) => { - button "Three" [ref=s1e5]`); }); +it('should not generate refs for elements with pointer-events:none', async ({ page }) => { + await page.setContent(` + +
+ +
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+ `); + + const snapshot = await page.locator('body').ariaSnapshot({ ref: true }); + expect(snapshot).toContain(`- button "no-ref" +- button "with-ref" [ref=s1e5] +- button "with-ref" [ref=s1e8] +- button "with-ref" [ref=s1e11] +- button "no-ref"`); +}); + it('emit generic roles for nodes w/o roles', async ({ page }) => { await page.setContent(`