chore: support basic aria attributes (#33182)

This commit is contained in:
Pavel Feldman 2024-10-18 20:18:18 -07:00 committed by GitHub
parent b1fb4f16a7
commit 64bf1bc107
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 236 additions and 129 deletions

View File

@ -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 { role, name } = parseKey(key);
const value = object[key];
node.children = node.children || [];
if (role === 'text') {
node.children.push(valueOrRegex(value));
continue;
}
if (typeof value === 'string') {
node.children.push({ role, name, children: [valueOrRegex(value)] });
continue;
}
const childNode = { role, name };
node.children.push(childNode);
populateNode(childNode, value);
}
}
}
function parseKey(key: string) {
const match = key.match(/^([a-z]+)(?:\s+(?:"([^"]*)"|\/([^\/]*)\/))?$/);
if (!match)
throw new Error(`Invalid key ${key}`);
const role = match[1];
if (role && role !== 'text' && !allRoles.includes(role))
throw new Error(`Invalid role ${role}`);
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 normalizeWhitespace = (text: string) => {
return text.replace(/[\r\n\s\t]+/g, ' ').trim();
};
const valueOrRegex = (value: string): string | RegExp => {
return value.startsWith('/') && value.endsWith('/') ? new RegExp(value.slice(1, -1)) : normalizeWhitespace(value);
};
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;
}
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 normalizeWhitespace(text: string) {
return text.replace(/[\r\n\s\t]+/g, ' ').trim();
}
function valueOrRegex(value: string): string | RegExp {
return value.startsWith('/') && value.endsWith('/') ? new RegExp(value.slice(1, -1)) : normalizeWhitespace(value);
}

View File

@ -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 AriaNode = {
role: string;
name?: string;
type AriaProps = {
checked?: boolean | 'mixed';
disabled?: boolean;
expanded?: boolean | 'none',
level?: number,
pressed?: boolean | 'mixed';
selected?: boolean;
};
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;
@ -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);
@ -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)
@ -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));
}
@ -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',
@ -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) };
}
@ -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;
@ -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)) {
@ -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, '');

View File

@ -82,7 +82,7 @@ function isNativelyFocusable(element: Element) {
// https://w3c.github.io/html-aam/#html-element-role-mappings
// https://www.w3.org/TR/html-aria/#docconformance
const kImplicitRoleByTagName: { [tagName: string]: (e: Element) => string | null } = {
const kImplicitRoleByTagName: { [tagName: string]: (e: Element) => AriaRole | null } = {
'A': (e: Element) => {
return e.hasAttribute('href') ? 'link' : null;
},
@ -127,17 +127,8 @@ const kImplicitRoleByTagName: { [tagName: string]: (e: Element) => string | null
return (list && elementSafeTagName(list) === 'DATALIST') ? 'combobox' : 'textbox';
}
if (type === 'hidden')
return '';
return {
'button': 'button',
'checkbox': 'checkbox',
'image': 'button',
'number': 'spinbutton',
'radio': 'radio',
'range': 'slider',
'reset': 'button',
'submit': 'button',
}[type] || 'textbox';
return null;
return inputTypeToRole[type] || 'textbox';
},
'INS': () => 'insertion',
'LI': () => 'listitem',
@ -200,7 +191,7 @@ const kPresentationInheritanceParents: { [tagName: string]: string[] } = {
'TR': ['THEAD', 'TBODY', 'TFOOT', 'TABLE'],
};
function getImplicitAriaRole(element: Element): string | null {
function getImplicitAriaRole(element: Element): AriaRole | null {
const implicitRole = kImplicitRoleByTagName[elementSafeTagName(element)]?.(element) || '';
if (!implicitRole)
return null;
@ -221,23 +212,29 @@ function getImplicitAriaRole(element: Element): string | null {
}
// https://www.w3.org/TR/wai-aria-1.2/#role_definitions
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'
];
// https://www.w3.org/TR/wai-aria-1.2/#abstract_roles
const abstractRoles = ['command', 'composite', 'input', 'landmark', 'range', 'roletype', 'section', 'sectionhead', 'select', 'structure', 'widget', 'window'];
const validRoles = allRoles.filter(role => !abstractRoles.includes(role));
// type AbstractRoles = 'command' | 'composite' | 'input' | 'landmark' | 'range' | 'roletype' | 'section' | 'sectionhead' | 'select' | 'structure' | 'widget' | 'window';
function getExplicitAriaRole(element: Element): string | null {
export type AriaRole = 'alert' | 'alertdialog' | 'application' | 'article' | 'banner' | 'blockquote' | 'button' | 'caption' | 'cell' | 'checkbox' | 'code' | 'columnheader' | 'combobox' |
'complementary' | 'contentinfo' | 'definition' | 'deletion' | 'dialog' | 'directory' | 'document' | 'emphasis' | 'feed' | 'figure' | 'form' | 'generic' | 'grid' |
'gridcell' | 'group' | 'heading' | 'img' | 'insertion' | 'link' | 'list' | 'listbox' | 'listitem' | 'log' | 'main' | 'mark' | 'marquee' | 'math' | 'meter' | 'menu' |
'menubar' | 'menuitem' | 'menuitemcheckbox' | 'menuitemradio' | 'navigation' | 'none' | 'note' | 'option' | 'paragraph' | 'presentation' | 'progressbar' | 'radio' | 'radiogroup' |
'region' | 'row' | 'rowgroup' | 'rowheader' | 'scrollbar' | 'search' | 'searchbox' | 'separator' | 'slider' |
'spinbutton' | 'status' | 'strong' | 'subscript' | 'superscript' | 'switch' | 'tab' | 'table' | 'tablist' | 'tabpanel' | 'term' | 'textbox' | 'time' | 'timer' |
'toolbar' | 'tooltip' | 'tree' | 'treegrid' | 'treeitem';
const validRoles: AriaRole[] = ['alert', 'alertdialog', 'application', 'article', 'banner', 'blockquote', 'button', 'caption', 'cell', 'checkbox', 'code', 'columnheader', 'combobox',
'complementary', 'contentinfo', 'definition', 'deletion', 'dialog', 'directory', 'document', 'emphasis', 'feed', 'figure', 'form', 'generic', 'grid',
'gridcell', 'group', 'heading', 'img', 'insertion', 'link', 'list', 'listbox', 'listitem', 'log', 'main', 'mark', 'marquee', 'math', 'meter', 'menu',
'menubar', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'navigation', 'none', 'note', 'option', 'paragraph', 'presentation', 'progressbar', 'radio', 'radiogroup',
'region', 'row', 'rowgroup', 'rowheader', 'scrollbar', 'search', 'searchbox', 'separator', 'slider',
'spinbutton', 'status', 'strong', 'subscript', 'superscript', 'switch', 'tab', 'table', 'tablist', 'tabpanel', 'term', 'textbox', 'time', 'timer',
'toolbar', 'tooltip', 'tree', 'treegrid', 'treeitem'];
function getExplicitAriaRole(element: Element): AriaRole | null {
// https://www.w3.org/TR/wai-aria-1.2/#document-handling_author-errors_roles
const roles = (element.getAttribute('role') || '').split(' ').map(role => role.trim());
return roles.find(role => validRoles.includes(role)) || null;
return roles.find(role => validRoles.includes(role as any)) as AriaRole || null;
}
function hasPresentationConflictResolution(element: Element, role: string | null) {
@ -245,7 +242,7 @@ function hasPresentationConflictResolution(element: Element, role: string | null
return hasGlobalAriaAttribute(element, role) || isFocusable(element);
}
export function getAriaRole(element: Element): string | null {
export function getAriaRole(element: Element): AriaRole | null {
const explicitRole = getExplicitAriaRole(element);
if (!explicitRole)
return getImplicitAriaRole(element);
@ -994,3 +991,14 @@ export function endAriaCaches() {
cachePseudoContentAfter = undefined;
}
}
const inputTypeToRole: Record<string, AriaRole> = {
'button': 'button',
'checkbox': 'checkbox',
'image': 'button',
'number': 'spinbutton',
'radio': 'radio',
'range': 'slider',
'reset': 'button',
'submit': 'button',
};

View File

@ -40,7 +40,8 @@ async function checkAndMatchSnapshot(locator: Locator, snapshot: string) {
it('should snapshot', async ({ page }) => {
await page.setContent(`<h1>title</h1>`);
await checkAndMatchSnapshot(page.locator('body'), `
- heading "title"
- heading "title":
- level: 1
`);
});
@ -50,8 +51,10 @@ it('should snapshot list', async ({ page }) => {
<h1>title 2</h1>
`);
await checkAndMatchSnapshot(page.locator('body'), `
- heading "title"
- heading "title 2"
- heading "title":
- level: 1
- heading "title 2":
- level: 1
`);
});
@ -91,7 +94,8 @@ it('should allow text nodes', async ({ page }) => {
`);
await checkAndMatchSnapshot(page.locator('body'), `
- heading "Microsoft"
- heading "Microsoft":
- level: 1
- text: Open source projects and samples from Microsoft
`);
});
@ -144,7 +148,8 @@ it('should snapshot integration', async ({ page }) => {
</ul>`);
await checkAndMatchSnapshot(page.locator('body'), `
- heading "Microsoft"
- heading "Microsoft":
- level: 1
- text: Open source projects and samples from Microsoft
- list:
- listitem:

View File

@ -107,6 +107,17 @@ test('details visibility', async ({ page }) => {
`);
});
test('checked state', async ({ page }) => {
await page.setContent(`
<input type='checkbox' checked />
`);
await expect(page.locator('body')).toMatchAriaSnapshot(`
- checkbox:
- checked: true
`);
});
test('integration test', async ({ page }) => {
await page.setContent(`
<h1>Microsoft</h1>
@ -182,11 +193,12 @@ test('expected formatter', async ({ page }) => {
expect(stripAnsi(error.message)).toContain(`
Locator: locator('body')
- Expected - 2
+ Received string + 3
+ Received string + 4
- - heading "todos"
+ - banner:
+ - heading "todos"
- - textbox "Wrong text"
+ - banner:
+ - heading "todos":
+ - level: 1
+ - textbox "What needs to be done?"`);
});