2024-10-14 14:07:19 -07:00
|
|
|
/**
|
|
|
|
* Copyright (c) Microsoft Corporation.
|
|
|
|
*
|
|
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
* you may not use this file except in compliance with the License.
|
|
|
|
* You may obtain a copy of the License at
|
|
|
|
*
|
|
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
*
|
|
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
* See the License for the specific language governing permissions and
|
|
|
|
* limitations under the License.
|
|
|
|
*/
|
|
|
|
|
|
|
|
import { escapeWithQuotes } from '@isomorphic/stringUtils';
|
2024-10-18 20:18:18 -07:00
|
|
|
import * as roleUtils from './roleUtils';
|
2024-10-17 17:06:18 -07:00
|
|
|
import { isElementVisible, isElementStyleVisibilityVisible, getElementComputedStyle } from './domUtils';
|
2024-10-18 20:18:18 -07:00
|
|
|
import type { AriaRole } from './roleUtils';
|
2024-10-14 14:07:19 -07:00
|
|
|
|
2024-10-18 20:18:18 -07:00
|
|
|
type AriaProps = {
|
|
|
|
checked?: boolean | 'mixed';
|
|
|
|
disabled?: boolean;
|
2024-10-19 14:23:08 -07:00
|
|
|
expanded?: boolean;
|
|
|
|
level?: number;
|
2024-10-18 20:18:18 -07:00
|
|
|
pressed?: boolean | 'mixed';
|
|
|
|
selected?: boolean;
|
|
|
|
};
|
|
|
|
|
|
|
|
type AriaNode = AriaProps & {
|
|
|
|
role: AriaRole | 'fragment' | 'text';
|
|
|
|
name: string;
|
2024-10-17 17:06:18 -07:00
|
|
|
children: (AriaNode | string)[];
|
2024-10-14 14:07:19 -07:00
|
|
|
};
|
|
|
|
|
2024-10-18 20:18:18 -07:00
|
|
|
export type AriaTemplateNode = AriaProps & {
|
|
|
|
role: AriaRole | 'fragment' | 'text';
|
2024-10-14 14:07:19 -07:00
|
|
|
name?: RegExp | string;
|
|
|
|
children?: (AriaTemplateNode | string | RegExp)[];
|
|
|
|
};
|
|
|
|
|
|
|
|
export function generateAriaTree(rootElement: Element): AriaNode {
|
|
|
|
const visit = (ariaNode: AriaNode, node: Node) => {
|
|
|
|
if (node.nodeType === Node.TEXT_NODE && node.nodeValue) {
|
2024-10-17 17:06:18 -07:00
|
|
|
const text = node.nodeValue;
|
|
|
|
if (text)
|
|
|
|
ariaNode.children.push(node.nodeValue || '');
|
2024-10-14 14:07:19 -07:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (node.nodeType !== Node.ELEMENT_NODE)
|
|
|
|
return;
|
|
|
|
|
|
|
|
const element = node as Element;
|
2024-10-22 16:36:03 -07:00
|
|
|
if (roleUtils.isElementHiddenForAria(element))
|
2024-10-14 14:07:19 -07:00
|
|
|
return;
|
|
|
|
|
|
|
|
const visible = isElementVisible(element);
|
2024-10-14 15:55:21 -07:00
|
|
|
const hasVisibleChildren = isElementStyleVisibilityVisible(element);
|
2024-10-14 14:07:19 -07:00
|
|
|
|
|
|
|
if (!hasVisibleChildren)
|
|
|
|
return;
|
|
|
|
|
|
|
|
if (visible) {
|
|
|
|
const childAriaNode = toAriaNode(element);
|
|
|
|
const isHiddenContainer = childAriaNode && hiddenContainerRoles.has(childAriaNode.ariaNode.role);
|
2024-10-17 17:06:18 -07:00
|
|
|
if (childAriaNode && !isHiddenContainer)
|
2024-10-14 14:07:19 -07:00
|
|
|
ariaNode.children.push(childAriaNode.ariaNode);
|
|
|
|
if (isHiddenContainer || !childAriaNode?.isLeaf)
|
|
|
|
processChildNodes(childAriaNode?.ariaNode || ariaNode, element);
|
|
|
|
} else {
|
|
|
|
processChildNodes(ariaNode, element);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
function processChildNodes(ariaNode: AriaNode, element: Element) {
|
2024-10-17 17:06:18 -07:00
|
|
|
// Surround every element with spaces for the sake of concatenated text nodes.
|
|
|
|
const display = getElementComputedStyle(element)?.display || 'inline';
|
|
|
|
const treatAsBlock = (display !== 'inline' || element.nodeName === 'BR') ? ' ' : '';
|
|
|
|
if (treatAsBlock)
|
|
|
|
ariaNode.children.push(treatAsBlock);
|
|
|
|
|
2024-10-18 20:18:18 -07:00
|
|
|
ariaNode.children.push(roleUtils.getPseudoContent(element, '::before'));
|
2024-10-17 17:06:18 -07:00
|
|
|
const assignedNodes = element.nodeName === 'SLOT' ? (element as HTMLSlotElement).assignedNodes() : [];
|
|
|
|
if (assignedNodes.length) {
|
|
|
|
for (const child of assignedNodes)
|
2024-10-14 14:07:19 -07:00
|
|
|
visit(ariaNode, child);
|
2024-10-17 17:06:18 -07:00
|
|
|
} else {
|
|
|
|
for (let child = element.firstChild; child; child = child.nextSibling) {
|
|
|
|
if (!(child as Element | Text).assignedSlot)
|
|
|
|
visit(ariaNode, child);
|
|
|
|
}
|
|
|
|
if (element.shadowRoot) {
|
|
|
|
for (let child = element.shadowRoot.firstChild; child; child = child.nextSibling)
|
|
|
|
visit(ariaNode, child);
|
|
|
|
}
|
2024-10-14 14:07:19 -07:00
|
|
|
}
|
2024-10-17 17:06:18 -07:00
|
|
|
|
2024-10-18 20:18:18 -07:00
|
|
|
ariaNode.children.push(roleUtils.getPseudoContent(element, '::after'));
|
2024-10-17 17:06:18 -07:00
|
|
|
|
|
|
|
if (treatAsBlock)
|
|
|
|
ariaNode.children.push(treatAsBlock);
|
2024-10-14 14:07:19 -07:00
|
|
|
}
|
|
|
|
|
2024-10-18 20:18:18 -07:00
|
|
|
roleUtils.beginAriaCaches();
|
|
|
|
const ariaRoot: AriaNode = { role: 'fragment', name: '', children: [] };
|
2024-10-14 14:07:19 -07:00
|
|
|
try {
|
|
|
|
visit(ariaRoot, rootElement);
|
|
|
|
} finally {
|
2024-10-18 20:18:18 -07:00
|
|
|
roleUtils.endAriaCaches();
|
2024-10-14 14:07:19 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
normalizeStringChildren(ariaRoot);
|
|
|
|
return ariaRoot;
|
|
|
|
}
|
|
|
|
|
2024-10-18 20:18:18 -07:00
|
|
|
function toAriaNode(element: Element): { ariaNode: AriaNode, isLeaf: boolean } | null {
|
|
|
|
const role = roleUtils.getAriaRole(element);
|
|
|
|
if (!role)
|
|
|
|
return null;
|
|
|
|
|
|
|
|
const name = roleUtils.getElementAccessibleName(element, false) || '';
|
|
|
|
const isLeaf = leafRoles.has(role);
|
|
|
|
const result: AriaNode = { role, name, children: [] };
|
|
|
|
if (isLeaf && !name) {
|
|
|
|
const text = roleUtils.accumulatedElementText(element);
|
|
|
|
if (text)
|
|
|
|
result.children = [text];
|
|
|
|
}
|
|
|
|
|
|
|
|
if (roleUtils.kAriaCheckedRoles.includes(role))
|
|
|
|
result.checked = roleUtils.getAriaChecked(element);
|
|
|
|
|
|
|
|
if (roleUtils.kAriaDisabledRoles.includes(role))
|
|
|
|
result.disabled = roleUtils.getAriaDisabled(element);
|
|
|
|
|
|
|
|
if (roleUtils.kAriaExpandedRoles.includes(role))
|
|
|
|
result.expanded = roleUtils.getAriaExpanded(element);
|
|
|
|
|
|
|
|
if (roleUtils.kAriaLevelRoles.includes(role))
|
|
|
|
result.level = roleUtils.getAriaLevel(element);
|
|
|
|
|
|
|
|
if (roleUtils.kAriaPressedRoles.includes(role))
|
|
|
|
result.pressed = roleUtils.getAriaPressed(element);
|
|
|
|
|
|
|
|
if (roleUtils.kAriaSelectedRoles.includes(role))
|
|
|
|
result.selected = roleUtils.getAriaSelected(element);
|
|
|
|
|
|
|
|
return { isLeaf, ariaNode: result };
|
|
|
|
}
|
|
|
|
|
2024-10-14 14:07:19 -07:00
|
|
|
export function renderedAriaTree(rootElement: Element): string {
|
|
|
|
return renderAriaTree(generateAriaTree(rootElement));
|
|
|
|
}
|
|
|
|
|
|
|
|
function normalizeStringChildren(rootA11yNode: AriaNode) {
|
|
|
|
const flushChildren = (buffer: string[], normalizedChildren: (AriaNode | string)[]) => {
|
|
|
|
if (!buffer.length)
|
|
|
|
return;
|
|
|
|
const text = normalizeWhitespaceWithin(buffer.join('')).trim();
|
|
|
|
if (text)
|
|
|
|
normalizedChildren.push(text);
|
|
|
|
buffer.length = 0;
|
|
|
|
};
|
|
|
|
|
|
|
|
const visit = (ariaNode: AriaNode) => {
|
|
|
|
const normalizedChildren: (AriaNode | string)[] = [];
|
|
|
|
const buffer: string[] = [];
|
|
|
|
for (const child of ariaNode.children || []) {
|
|
|
|
if (typeof child === 'string') {
|
|
|
|
buffer.push(child);
|
|
|
|
} else {
|
|
|
|
flushChildren(buffer, normalizedChildren);
|
|
|
|
visit(child);
|
|
|
|
normalizedChildren.push(child);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
flushChildren(buffer, normalizedChildren);
|
2024-10-17 17:06:18 -07:00
|
|
|
ariaNode.children = normalizedChildren.length ? normalizedChildren : [];
|
2024-10-14 14:07:19 -07:00
|
|
|
};
|
|
|
|
visit(rootA11yNode);
|
|
|
|
}
|
|
|
|
|
|
|
|
const hiddenContainerRoles = new Set(['none', 'presentation']);
|
|
|
|
|
2024-10-18 20:18:18 -07:00
|
|
|
const leafRoles = new Set<AriaRole>([
|
2024-10-14 14:07:19 -07:00
|
|
|
'alert', 'blockquote', 'button', 'caption', 'checkbox', 'code', 'columnheader',
|
|
|
|
'definition', 'deletion', 'emphasis', 'generic', 'heading', 'img', 'insertion',
|
|
|
|
'link', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'meter', 'option',
|
|
|
|
'progressbar', 'radio', 'rowheader', 'scrollbar', 'searchbox', 'separator',
|
|
|
|
'slider', 'spinbutton', 'strong', 'subscript', 'superscript', 'switch', 'tab', 'term',
|
|
|
|
'textbox', 'time', 'tooltip'
|
|
|
|
]);
|
|
|
|
|
2024-10-17 17:06:18 -07:00
|
|
|
const normalizeWhitespaceWithin = (text: string) => text.replace(/[\s\t\r\n]+/g, ' ');
|
2024-10-14 14:07:19 -07:00
|
|
|
|
|
|
|
function matchesText(text: string | undefined, template: RegExp | string | undefined) {
|
|
|
|
if (!template)
|
|
|
|
return true;
|
|
|
|
if (!text)
|
|
|
|
return false;
|
|
|
|
if (typeof template === 'string')
|
|
|
|
return text === template;
|
|
|
|
return !!text.match(template);
|
|
|
|
}
|
|
|
|
|
|
|
|
export function matchesAriaTree(rootElement: Element, template: AriaTemplateNode): { matches: boolean, received: string } {
|
|
|
|
const root = generateAriaTree(rootElement);
|
2024-10-18 20:18:18 -07:00
|
|
|
const matches = matchesNodeDeep(root, template);
|
2024-10-21 21:54:06 -07:00
|
|
|
return { matches, received: renderAriaTree(root, { noText: true }) };
|
2024-10-14 14:07:19 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
function matchesNode(node: AriaNode | string, template: AriaTemplateNode | RegExp | string, depth: number): boolean {
|
|
|
|
if (typeof node === 'string' && (typeof template === 'string' || template instanceof RegExp))
|
|
|
|
return matchesText(node, template);
|
|
|
|
|
|
|
|
if (typeof node === 'object' && typeof template === 'object' && !(template instanceof RegExp)) {
|
2024-10-18 20:18:18 -07:00
|
|
|
if (template.role !== 'fragment' && template.role !== node.role)
|
|
|
|
return false;
|
|
|
|
if (template.checked !== undefined && template.checked !== node.checked)
|
|
|
|
return false;
|
|
|
|
if (template.disabled !== undefined && template.disabled !== node.disabled)
|
|
|
|
return false;
|
|
|
|
if (template.expanded !== undefined && template.expanded !== node.expanded)
|
|
|
|
return false;
|
|
|
|
if (template.level !== undefined && template.level !== node.level)
|
|
|
|
return false;
|
|
|
|
if (template.pressed !== undefined && template.pressed !== node.pressed)
|
|
|
|
return false;
|
|
|
|
if (template.selected !== undefined && template.selected !== node.selected)
|
2024-10-14 14:07:19 -07:00
|
|
|
return false;
|
|
|
|
if (!matchesText(node.name, template.name))
|
|
|
|
return false;
|
|
|
|
if (!containsList(node.children || [], template.children || [], depth))
|
|
|
|
return false;
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
function containsList(children: (AriaNode | string)[], template: (AriaTemplateNode | RegExp | string)[], depth: number): boolean {
|
|
|
|
if (template.length > children.length)
|
|
|
|
return false;
|
|
|
|
const cc = children.slice();
|
|
|
|
const tt = template.slice();
|
|
|
|
for (const t of tt) {
|
|
|
|
let c = cc.shift();
|
|
|
|
while (c) {
|
|
|
|
if (matchesNode(c, t, depth + 1))
|
|
|
|
break;
|
|
|
|
c = cc.shift();
|
|
|
|
}
|
|
|
|
if (!c)
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
2024-10-18 20:18:18 -07:00
|
|
|
function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode): boolean {
|
2024-10-14 14:07:19 -07:00
|
|
|
const results: (AriaNode | string)[] = [];
|
|
|
|
const visit = (node: AriaNode | string): boolean => {
|
|
|
|
if (matchesNode(node, template, 0)) {
|
|
|
|
results.push(node);
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
if (typeof node === 'string')
|
|
|
|
return false;
|
|
|
|
for (const child of node.children || []) {
|
|
|
|
if (visit(child))
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
return false;
|
|
|
|
};
|
|
|
|
visit(root);
|
|
|
|
return !!results.length;
|
|
|
|
}
|
|
|
|
|
2024-10-21 21:54:06 -07:00
|
|
|
export function renderAriaTree(ariaNode: AriaNode, options?: { noText?: boolean }): string {
|
2024-10-14 14:07:19 -07:00
|
|
|
const lines: string[] = [];
|
2024-10-15 13:38:55 -07:00
|
|
|
const visit = (ariaNode: AriaNode | string, indent: string) => {
|
|
|
|
if (typeof ariaNode === 'string') {
|
2024-10-21 21:54:06 -07:00
|
|
|
if (!options?.noText)
|
2024-10-22 16:36:03 -07:00
|
|
|
lines.push(indent + '- text: ' + quoteYamlString(ariaNode));
|
2024-10-15 13:38:55 -07:00
|
|
|
return;
|
|
|
|
}
|
2024-10-14 14:07:19 -07:00
|
|
|
let line = `${indent}- ${ariaNode.role}`;
|
|
|
|
if (ariaNode.name)
|
|
|
|
line += ` ${escapeWithQuotes(ariaNode.name, '"')}`;
|
2024-10-19 14:23:08 -07:00
|
|
|
|
|
|
|
if (ariaNode.checked === 'mixed')
|
|
|
|
line += ` [checked=mixed]`;
|
|
|
|
if (ariaNode.checked === true)
|
|
|
|
line += ` [checked]`;
|
|
|
|
if (ariaNode.disabled)
|
|
|
|
line += ` [disabled]`;
|
|
|
|
if (ariaNode.expanded)
|
|
|
|
line += ` [expanded]`;
|
|
|
|
if (ariaNode.level)
|
|
|
|
line += ` [level=${ariaNode.level}]`;
|
|
|
|
if (ariaNode.pressed === 'mixed')
|
|
|
|
line += ` [pressed=mixed]`;
|
|
|
|
if (ariaNode.pressed === true)
|
|
|
|
line += ` [pressed]`;
|
2024-10-21 21:54:06 -07:00
|
|
|
if (ariaNode.selected === true)
|
|
|
|
line += ` [selected]`;
|
2024-10-19 14:23:08 -07:00
|
|
|
|
|
|
|
const stringValue = !ariaNode.children.length || (ariaNode.children?.length === 1 && typeof ariaNode.children[0] === 'string');
|
2024-10-18 20:18:18 -07:00
|
|
|
if (stringValue) {
|
2024-10-21 21:54:06 -07:00
|
|
|
if (!options?.noText && ariaNode.children.length)
|
2024-10-22 16:36:03 -07:00
|
|
|
line += ': ' + quoteYamlString(ariaNode.children?.[0] as string);
|
2024-10-14 14:07:19 -07:00
|
|
|
lines.push(line);
|
|
|
|
return;
|
|
|
|
}
|
2024-10-18 20:18:18 -07:00
|
|
|
|
|
|
|
lines.push(line + ':');
|
2024-10-15 13:38:55 -07:00
|
|
|
for (const child of ariaNode.children || [])
|
|
|
|
visit(child, indent + ' ');
|
2024-10-14 14:07:19 -07:00
|
|
|
};
|
2024-10-18 20:18:18 -07:00
|
|
|
|
|
|
|
if (ariaNode.role === 'fragment') {
|
2024-10-15 13:38:55 -07:00
|
|
|
// Render fragment.
|
|
|
|
for (const child of ariaNode.children || [])
|
|
|
|
visit(child, '');
|
|
|
|
} else {
|
|
|
|
visit(ariaNode, '');
|
|
|
|
}
|
2024-10-14 14:07:19 -07:00
|
|
|
return lines.join('\n');
|
|
|
|
}
|
|
|
|
|
2024-10-22 16:36:03 -07:00
|
|
|
function quoteYamlString(str: string) {
|
|
|
|
return `"${str
|
|
|
|
.replace(/\\/g, '\\\\')
|
|
|
|
.replace(/"/g, '\\"')
|
|
|
|
.replace(/\n/g, '\\n')
|
|
|
|
.replace(/\r/g, '\\r')}"`;
|
2024-10-14 14:07:19 -07:00
|
|
|
}
|