UNPKG

chrome-devtools-frontend

Version:
184 lines (172 loc) 5.16 kB
// Copyright 2023 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import {type Selector, type DeepSelector} from './Selector.js'; export interface AccessibilityBindings { getAccessibleName(node: Node): string; getAccessibleRole(node: Node): string; } class ARIASelectorComputer { #bindings: AccessibilityBindings; constructor(bindings: AccessibilityBindings) { this.#bindings = bindings; } // Takes a path consisting of element names and roles and makes sure that // every element resolves to a single result. If it does, the selector is added // to the chain of selectors. #computeUniqueARIASelectorForElements = ( elements: {name: string, role: string}[], queryByRoleOnly: boolean, ): DeepSelector|undefined => { const selectors: string[] = []; let parent: Element|Document = document; for (const element of elements) { let result = this.#queryA11yTreeOneByName(parent, element.name); if (result) { selectors.push(element.name); parent = result; continue; } if (queryByRoleOnly) { result = this.#queryA11yTreeOneByRole(parent, element.role); if (result) { selectors.push(`[role="${element.role}"]`); parent = result; continue; } } result = this.#queryA11yTreeOneByNameAndRole( parent, element.name, element.role, ); if (result) { selectors.push(`${element.name}[role="${element.role}"]`); parent = result; continue; } return; } return selectors; }; #queryA11yTreeOneByName = ( parent: Element|Document, name?: string, ): Element|null => { if (!name) { return null; } const result = this.#queryA11yTree(parent, name); if (result.length !== 1) { return null; } return result[0]; }; #queryA11yTreeOneByRole = ( parent: Element|Document, role?: string, ): Element|null => { if (!role) { return null; } const result = this.#queryA11yTree(parent, undefined, role); if (result.length !== 1) { return null; } return result[0]; }; #queryA11yTreeOneByNameAndRole = ( parent: Element|Document, name?: string, role?: string, ): Element|null => { if (!role || !name) { return null; } const result = this.#queryA11yTree(parent, name, role); if (result.length !== 1) { return null; } return result[0]; }; // Queries the DOM tree for elements with matching accessibility name and role. // It attempts to mimic https://chromedevtools.github.io/devtools-protocol/tot/Accessibility/#method-queryAXTree. #queryA11yTree = ( parent: Element|Document, name?: string, role?: string, ): Element[] => { const result: Element[] = []; if (!name && !role) { throw new Error('Both role and name are empty'); } const shouldMatchName = Boolean(name); const shouldMatchRole = Boolean(role); const collect = (root: Element|ShadowRoot): void => { const iter = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); do { const currentNode = iter.currentNode as HTMLElement; if (currentNode.shadowRoot) { collect(currentNode.shadowRoot); } if (currentNode instanceof ShadowRoot) { continue; } if (shouldMatchName && this.#bindings.getAccessibleName(currentNode) !== name) { continue; } if (shouldMatchRole && this.#bindings.getAccessibleRole(currentNode) !== role) { continue; } result.push(currentNode); } while (iter.nextNode()); }; collect(parent instanceof Document ? document.documentElement : parent); return result; }; compute = (node: Node): Selector|undefined => { let selector: Selector|undefined; let current: Node|null = node; const elements: {name: string, role: string}[] = []; while (current) { const role = this.#bindings.getAccessibleRole(current); const name = this.#bindings.getAccessibleName(current); if (!role && !name) { if (current === node) { break; } } else { elements.unshift({name, role}); selector = this.#computeUniqueARIASelectorForElements( elements, current !== node, ); if (selector) { break; } if (current !== node) { elements.shift(); } } current = current.parentNode; if (current instanceof ShadowRoot) { current = current.host; } } return selector; }; } /** * Computes the ARIA selector for a node. * * @param node - The node to compute. * @returns The computed CSS selector. * * @internal */ export const computeARIASelector = ( node: Node, bindings: AccessibilityBindings, ): Selector|undefined => { return new ARIASelectorComputer(bindings).compute(node); };