mirror of
https://github.com/microsoft/playwright.git
synced 2024-11-28 01:15:10 +03:00
chore: support basic aria attributes (#33182)
This commit is contained in:
parent
b1fb4f16a7
commit
64bf1bc107
@ -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 parseKey = (key: string): AriaTemplateNode => {
|
||||
if (!key)
|
||||
return { role: '' };
|
||||
|
||||
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}`);
|
||||
|
||||
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 fragment = yaml.parse(text) as any[];
|
||||
const result: AriaTemplateNode = { role: 'fragment' };
|
||||
populateNode(result, fragment);
|
||||
return result;
|
||||
}
|
||||
|
||||
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 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] as AriaRole | 'text';
|
||||
if (match[2])
|
||||
return { role, name: match[2] };
|
||||
if (match[3])
|
||||
return { role, name: new RegExp(match[3]) };
|
||||
return { role };
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
@ -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, '');
|
||||
|
@ -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',
|
||||
};
|
||||
|
@ -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:
|
||||
|
@ -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?"`);
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user