Skip to content

Commit

Permalink
chore: support basic aria attributes (#33182)
Browse files Browse the repository at this point in the history
  • Loading branch information
pavelfeldman authored Oct 19, 2024
1 parent b1fb4f1 commit 64bf1bc
Show file tree
Hide file tree
Showing 5 changed files with 228 additions and 121 deletions.
120 changes: 72 additions & 48 deletions packages/playwright-core/src/server/ariaSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,63 +16,87 @@

import type { AriaTemplateNode } from './injected/ariaSnapshot';
import { yaml } from '../utilsBundle';
import type { AriaRole } from '@injected/roleUtils';

export function parseAriaSnapshot(text: string): AriaTemplateNode {
type YamlNode = Record<string, Array<YamlNode> | string>;
const fragment = yaml.parse(text) as any[];
const result: AriaTemplateNode = { role: 'fragment' };
populateNode(result, fragment);
return result;
}

const parseKey = (key: string): AriaTemplateNode => {
if (!key)
return { role: '' };
function populateNode(node: AriaTemplateNode, container: any[]) {
for (const object of container) {
if (typeof object === 'string') {
const { role, name } = parseKey(object);
node.children = node.children || [];
node.children.push({ role, name });
continue;
}
for (const key of Object.keys(object)) {
if (key === 'checked') {
node.checked = object[key];
continue;
}
if (key === 'disabled') {
node.disabled = object[key];
continue;
}
if (key === 'expanded') {
node.expanded = object[key];
continue;
}
if (key === 'level') {
node.level = object[key];
continue;
}
if (key === 'pressed') {
node.pressed = object[key];
continue;
}
if (key === 'selected') {
node.selected = object[key];
continue;
}

const match = key.match(/^([a-z]+)(?:\s+(?:"([^"]*)"|\/([^\/]*)\/))?$/);
const { role, name } = parseKey(key);
const value = object[key];
node.children = node.children || [];

if (!match)
throw new Error(`Invalid key ${key}`);
if (role === 'text') {
node.children.push(valueOrRegex(value));
continue;
}

const role = match[1];
if (role && role !== 'text' && !allRoles.includes(role))
throw new Error(`Invalid role ${role}`);
if (typeof value === 'string') {
node.children.push({ role, name, children: [valueOrRegex(value)] });
continue;
}

if (match[2])
return { role, name: match[2] };
if (match[3])
return { role, name: new RegExp(match[3]) };
return { role };
};
const childNode = { role, name };
node.children.push(childNode);
populateNode(childNode, value);
}
}
}

const normalizeWhitespace = (text: string) => {
return text.replace(/[\r\n\s\t]+/g, ' ').trim();
};
function parseKey(key: string) {
const match = key.match(/^([a-z]+)(?:\s+(?:"([^"]*)"|\/([^\/]*)\/))?$/);
if (!match)
throw new Error(`Invalid key ${key}`);

const valueOrRegex = (value: string): string | RegExp => {
return value.startsWith('/') && value.endsWith('/') ? new RegExp(value.slice(1, -1)) : normalizeWhitespace(value);
};
const role = match[1] as AriaRole | 'text';
if (match[2])
return { role, name: match[2] };
if (match[3])
return { role, name: new RegExp(match[3]) };
return { role };
}

const convert = (object: YamlNode | string): AriaTemplateNode | RegExp | string => {
const key = typeof object === 'string' ? object : Object.keys(object)[0];
const value = typeof object === 'string' ? undefined : object[key];
const parsed = parseKey(key);
if (parsed.role === 'text') {
if (typeof value !== 'string')
throw new Error(`Generic role must have a text value`);
return valueOrRegex(value as string);
}
if (Array.isArray(value))
parsed.children = value.map(convert);
else if (value)
parsed.children = [valueOrRegex(value)];
return parsed;
};
const fragment = yaml.parse(text) as YamlNode[];
return convert({ '': fragment }) as AriaTemplateNode;
function normalizeWhitespace(text: string) {
return text.replace(/[\r\n\s\t]+/g, ' ').trim();
}

const allRoles = [
'alert', 'alertdialog', 'application', 'article', 'banner', 'blockquote', 'button', 'caption', 'cell', 'checkbox', 'code', 'columnheader', 'combobox', 'command',
'complementary', 'composite', 'contentinfo', 'definition', 'deletion', 'dialog', 'directory', 'document', 'emphasis', 'feed', 'figure', 'form', 'generic', 'grid',
'gridcell', 'group', 'heading', 'img', 'input', 'insertion', 'landmark', 'link', 'list', 'listbox', 'listitem', 'log', 'main', 'marquee', 'math', 'meter', 'menu',
'menubar', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'navigation', 'none', 'note', 'option', 'paragraph', 'presentation', 'progressbar', 'radio', 'radiogroup',
'range', 'region', 'roletype', 'row', 'rowgroup', 'rowheader', 'scrollbar', 'search', 'searchbox', 'section', 'sectionhead', 'select', 'separator', 'slider',
'spinbutton', 'status', 'strong', 'structure', 'subscript', 'superscript', 'switch', 'tab', 'table', 'tablist', 'tabpanel', 'term', 'textbox', 'time', 'timer',
'toolbar', 'tooltip', 'tree', 'treegrid', 'treeitem', 'widget', 'window'
];
function valueOrRegex(value: string): string | RegExp {
return value.startsWith('/') && value.endsWith('/') ? new RegExp(value.slice(1, -1)) : normalizeWhitespace(value);
}
134 changes: 96 additions & 38 deletions packages/playwright-core/src/server/injected/ariaSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,38 +15,32 @@
*/

import { escapeWithQuotes } from '@isomorphic/stringUtils';
import { accumulatedElementText, beginAriaCaches, endAriaCaches, getAriaRole, getElementAccessibleName, getPseudoContent, isElementIgnoredForAria } from './roleUtils';
import * as roleUtils from './roleUtils';
import { isElementVisible, isElementStyleVisibilityVisible, getElementComputedStyle } from './domUtils';
import type { AriaRole } from './roleUtils';

type AriaProps = {
checked?: boolean | 'mixed';
disabled?: boolean;
expanded?: boolean | 'none',
level?: number,
pressed?: boolean | 'mixed';
selected?: boolean;
};

type AriaNode = {
role: string;
name?: string;
type AriaNode = AriaProps & {
role: AriaRole | 'fragment' | 'text';
name: string;
children: (AriaNode | string)[];
};

export type AriaTemplateNode = {
role: string;
export type AriaTemplateNode = AriaProps & {
role: AriaRole | 'fragment' | 'text';
name?: RegExp | string;
children?: (AriaTemplateNode | string | RegExp)[];
};

export function generateAriaTree(rootElement: Element): AriaNode {
const toAriaNode = (element: Element): { ariaNode: AriaNode, isLeaf: boolean } | null => {
const role = getAriaRole(element);
if (!role)
return null;

const name = role ? getElementAccessibleName(element, false) || undefined : undefined;
const isLeaf = leafRoles.has(role);
const result: AriaNode = { role, name, children: [] };
if (isLeaf && !name) {
const text = accumulatedElementText(element);
if (text)
result.children = [text];
}
return { isLeaf, ariaNode: result };
};

const visit = (ariaNode: AriaNode, node: Node) => {
if (node.nodeType === Node.TEXT_NODE && node.nodeValue) {
const text = node.nodeValue;
Expand All @@ -59,7 +53,7 @@ export function generateAriaTree(rootElement: Element): AriaNode {
return;

const element = node as Element;
if (isElementIgnoredForAria(element))
if (roleUtils.isElementIgnoredForAria(element))
return;

const visible = isElementVisible(element);
Expand Down Expand Up @@ -87,7 +81,7 @@ export function generateAriaTree(rootElement: Element): AriaNode {
if (treatAsBlock)
ariaNode.children.push(treatAsBlock);

ariaNode.children.push(getPseudoContent(element, '::before'));
ariaNode.children.push(roleUtils.getPseudoContent(element, '::before'));
const assignedNodes = element.nodeName === 'SLOT' ? (element as HTMLSlotElement).assignedNodes() : [];
if (assignedNodes.length) {
for (const child of assignedNodes)
Expand All @@ -103,24 +97,59 @@ export function generateAriaTree(rootElement: Element): AriaNode {
}
}

ariaNode.children.push(getPseudoContent(element, '::after'));
ariaNode.children.push(roleUtils.getPseudoContent(element, '::after'));

if (treatAsBlock)
ariaNode.children.push(treatAsBlock);
}

beginAriaCaches();
const ariaRoot: AriaNode = { role: '', children: [] };
roleUtils.beginAriaCaches();
const ariaRoot: AriaNode = { role: 'fragment', name: '', children: [] };
try {
visit(ariaRoot, rootElement);
} finally {
endAriaCaches();
roleUtils.endAriaCaches();
}

normalizeStringChildren(ariaRoot);
return ariaRoot;
}

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 };
}

export function renderedAriaTree(rootElement: Element): string {
return renderAriaTree(generateAriaTree(rootElement));
}
Expand Down Expand Up @@ -155,7 +184,7 @@ function normalizeStringChildren(rootA11yNode: AriaNode) {

const hiddenContainerRoles = new Set(['none', 'presentation']);

const leafRoles = new Set([
const leafRoles = new Set<AriaRole>([
'alert', 'blockquote', 'button', 'caption', 'checkbox', 'code', 'columnheader',
'definition', 'deletion', 'emphasis', 'generic', 'heading', 'img', 'insertion',
'link', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'meter', 'option',
Expand All @@ -178,7 +207,7 @@ function matchesText(text: string | undefined, template: RegExp | string | undef

export function matchesAriaTree(rootElement: Element, template: AriaTemplateNode): { matches: boolean, received: string } {
const root = generateAriaTree(rootElement);
const matches = nodeMatches(root, template);
const matches = matchesNodeDeep(root, template);
return { matches, received: renderAriaTree(root) };
}

Expand All @@ -187,7 +216,19 @@ function matchesNode(node: AriaNode | string, template: AriaTemplateNode | RegEx
return matchesText(node, template);

if (typeof node === 'object' && typeof template === 'object' && !(template instanceof RegExp)) {
if (template.role && template.role !== node.role)
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)
return false;
if (!matchesText(node.name, template.name))
return false;
Expand Down Expand Up @@ -216,7 +257,7 @@ function containsList(children: (AriaNode | string)[], template: (AriaTemplateNo
return true;
}

function nodeMatches(root: AriaNode, template: AriaTemplateNode): boolean {
function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode): boolean {
const results: (AriaNode | string)[] = [];
const visit = (node: AriaNode | string): boolean => {
if (matchesNode(node, template, 0)) {
Expand Down Expand Up @@ -245,19 +286,36 @@ export function renderAriaTree(ariaNode: AriaNode): string {
let line = `${indent}- ${ariaNode.role}`;
if (ariaNode.name)
line += ` ${escapeWithQuotes(ariaNode.name, '"')}`;
const noChild = !ariaNode.name && !ariaNode.children?.length;
const oneChild = !ariaNode.name && ariaNode.children?.length === 1 && typeof ariaNode.children[0] === 'string';
if (noChild || oneChild) {
if (oneChild)
const stringValue = !ariaNode.checked
&& !ariaNode.disabled
&& (!ariaNode.expanded || ariaNode.expanded === 'none')
&& !ariaNode.level
&& !ariaNode.pressed
&& !ariaNode.selected
&& (!ariaNode.children.length || (ariaNode.children?.length === 1 && typeof ariaNode.children[0] === 'string'));
if (stringValue) {
if (ariaNode.children.length)
line += ': ' + escapeYamlString(ariaNode.children?.[0] as string);
lines.push(line);
return;
}
lines.push(line + (ariaNode.children.length ? ':' : ''));

lines.push(line + ':');
if (ariaNode.checked)
lines.push(`${indent} - checked: ${ariaNode.checked}`);
if (ariaNode.disabled)
lines.push(`${indent} - disabled: ${ariaNode.disabled}`);
if (ariaNode.expanded && ariaNode.expanded !== 'none')
lines.push(`${indent} - expanded: ${ariaNode.expanded}`);
if (ariaNode.level)
lines.push(`${indent} - level: ${ariaNode.level}`);
if (ariaNode.pressed)
lines.push(`${indent} - pressed: ${ariaNode.pressed}`);
for (const child of ariaNode.children || [])
visit(child, indent + ' ');
};
if (ariaNode.role === '') {

if (ariaNode.role === 'fragment') {
// Render fragment.
for (const child of ariaNode.children || [])
visit(child, '');
Expand Down
Loading

0 comments on commit 64bf1bc

Please sign in to comment.