fix(role): align presentation role conflict resolution with the spec (#30408)

See
https://www.w3.org/TR/wai-aria-1.2/#conflict_resolution_presentation_none

Fixes #26809.
This commit is contained in:
Dmitry Gozman 2024-04-18 08:53:31 -07:00 committed by GitHub
parent a98abbdda9
commit 103ec90751
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 88 additions and 39 deletions

View file

@ -24,32 +24,60 @@ function hasExplicitAccessibleName(e: Element) {
const kAncestorPreventingLandmark = 'article:not([role]), aside:not([role]), main:not([role]), nav:not([role]), section:not([role]), [role=article], [role=complementary], [role=main], [role=navigation], [role=region]'; const kAncestorPreventingLandmark = 'article:not([role]), aside:not([role]), main:not([role]), nav:not([role]), section:not([role]), [role=article], [role=complementary], [role=main], [role=navigation], [role=region]';
// https://www.w3.org/TR/wai-aria-1.2/#global_states // https://www.w3.org/TR/wai-aria-1.2/#global_states
const kGlobalAriaAttributes = [ const kGlobalAriaAttributes = new Map<string, Set<string> | undefined>([
'aria-atomic', ['aria-atomic', undefined],
'aria-busy', ['aria-busy', undefined],
'aria-controls', ['aria-controls', undefined],
'aria-current', ['aria-current', undefined],
'aria-describedby', ['aria-describedby', undefined],
'aria-details', ['aria-details', undefined],
'aria-disabled', // Global use deprecated in ARIA 1.2
'aria-dropeffect', // ['aria-disabled', undefined],
'aria-errormessage', ['aria-dropeffect', undefined],
'aria-flowto', // Global use deprecated in ARIA 1.2
'aria-grabbed', // ['aria-errormessage', undefined],
'aria-haspopup', ['aria-flowto', undefined],
'aria-hidden', ['aria-grabbed', undefined],
'aria-invalid', // Global use deprecated in ARIA 1.2
'aria-keyshortcuts', // ['aria-haspopup', undefined],
'aria-label', ['aria-hidden', undefined],
'aria-labelledby', // Global use deprecated in ARIA 1.2
'aria-live', // ['aria-invalid', undefined],
'aria-owns', ['aria-keyshortcuts', undefined],
'aria-relevant', ['aria-label', new Set(['caption', 'code', 'deletion', 'emphasis', 'generic', 'insertion', 'paragraph', 'presentation', 'strong', 'subscript', 'superscript'])],
'aria-roledescription', ['aria-labelledby', new Set(['caption', 'code', 'deletion', 'emphasis', 'generic', 'insertion', 'paragraph', 'presentation', 'strong', 'subscript', 'superscript'])],
]; ['aria-live', undefined],
['aria-owns', undefined],
['aria-relevant', undefined],
['aria-roledescription', new Set(['generic'])],
]);
function hasGlobalAriaAttribute(e: Element) { function hasGlobalAriaAttribute(element: Element, forRole?: string | null) {
return kGlobalAriaAttributes.some(a => e.hasAttribute(a)); return [...kGlobalAriaAttributes].some(([attr, prohibited]) => {
return !prohibited?.has(forRole || '') && element.hasAttribute(attr);
});
}
function hasTabIndex(element: Element) {
return !Number.isNaN(Number(String(element.getAttribute('tabindex'))));
}
function isFocusable(element: Element) {
// TODO:
// - "inert" attribute makes the whole substree not focusable
// - when dialog is open on the page - everything but the dialog is not focusable
return !isNativelyDisabled(element) && (isNativelyFocusable(element) || hasTabIndex(element));
}
function isNativelyFocusable(element: Element) {
const tagName = element.tagName.toUpperCase();
if (['BUTTON', 'DETAILS', 'SELECT', 'SUMMARY', 'TEXTAREA'].includes(tagName))
return true;
if (tagName === 'A' || tagName === 'AREA')
return element.hasAttribute('href');
if (tagName === 'INPUT')
return !(element as HTMLInputElement).hidden;
return false;
} }
// https://w3c.github.io/html-aam/#html-element-role-mappings // https://w3c.github.io/html-aam/#html-element-role-mappings
@ -87,7 +115,7 @@ const kImplicitRoleByTagName: { [tagName: string]: (e: Element) => string | null
'HEADER': (e: Element) => closestCrossShadow(e, kAncestorPreventingLandmark) ? null : 'banner', 'HEADER': (e: Element) => closestCrossShadow(e, kAncestorPreventingLandmark) ? null : 'banner',
'HR': () => 'separator', 'HR': () => 'separator',
'HTML': () => 'document', 'HTML': () => 'document',
'IMG': (e: Element) => (e.getAttribute('alt') === '') && !hasGlobalAriaAttribute(e) && Number.isNaN(Number(String(e.getAttribute('tabindex')))) ? 'presentation' : 'img', 'IMG': (e: Element) => (e.getAttribute('alt') === '') && !hasGlobalAriaAttribute(e) && !hasTabIndex(e) ? 'presentation' : 'img',
'INPUT': (e: Element) => { 'INPUT': (e: Element) => {
const type = (e as HTMLInputElement).type.toLowerCase(); const type = (e as HTMLInputElement).type.toLowerCase();
if (type === 'search') if (type === 'search')
@ -185,7 +213,7 @@ function getImplicitAriaRole(element: Element): string | null {
if (!parents || !parent || !parents.includes(parent.tagName)) if (!parents || !parent || !parents.includes(parent.tagName))
break; break;
const parentExplicitRole = getExplicitAriaRole(parent); const parentExplicitRole = getExplicitAriaRole(parent);
if ((parentExplicitRole === 'none' || parentExplicitRole === 'presentation') && !hasPresentationConflictResolution(parent)) if ((parentExplicitRole === 'none' || parentExplicitRole === 'presentation') && !hasPresentationConflictResolution(parent, parentExplicitRole))
return parentExplicitRole; return parentExplicitRole;
ancestor = parent; ancestor = parent;
} }
@ -212,18 +240,20 @@ function getExplicitAriaRole(element: Element): string | null {
return roles.find(role => validRoles.includes(role)) || null; return roles.find(role => validRoles.includes(role)) || null;
} }
function hasPresentationConflictResolution(element: Element) { function hasPresentationConflictResolution(element: Element, role: string | null) {
// https://www.w3.org/TR/wai-aria-1.2/#conflict_resolution_presentation_none // https://www.w3.org/TR/wai-aria-1.2/#conflict_resolution_presentation_none
// TODO: this should include "|| focusable" check. return hasGlobalAriaAttribute(element, role) || isFocusable(element);
return !hasGlobalAriaAttribute(element);
} }
export function getAriaRole(element: Element): string | null { export function getAriaRole(element: Element): string | null {
const explicitRole = getExplicitAriaRole(element); const explicitRole = getExplicitAriaRole(element);
if (!explicitRole) if (!explicitRole)
return getImplicitAriaRole(element); return getImplicitAriaRole(element);
if ((explicitRole === 'none' || explicitRole === 'presentation') && hasPresentationConflictResolution(element)) if (explicitRole === 'none' || explicitRole === 'presentation') {
return getImplicitAriaRole(element); const implicitRole = getImplicitAriaRole(element);
if (hasPresentationConflictResolution(element, implicitRole))
return implicitRole;
}
return explicitRole; return explicitRole;
} }
@ -824,12 +854,14 @@ export function getAriaLevel(element: Element): number {
export const kAriaDisabledRoles = ['application', 'button', 'composite', 'gridcell', 'group', 'input', 'link', 'menuitem', 'scrollbar', 'separator', 'tab', 'checkbox', 'columnheader', 'combobox', 'grid', 'listbox', 'menu', 'menubar', 'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'radiogroup', 'row', 'rowheader', 'searchbox', 'select', 'slider', 'spinbutton', 'switch', 'tablist', 'textbox', 'toolbar', 'tree', 'treegrid', 'treeitem']; export const kAriaDisabledRoles = ['application', 'button', 'composite', 'gridcell', 'group', 'input', 'link', 'menuitem', 'scrollbar', 'separator', 'tab', 'checkbox', 'columnheader', 'combobox', 'grid', 'listbox', 'menu', 'menubar', 'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'radiogroup', 'row', 'rowheader', 'searchbox', 'select', 'slider', 'spinbutton', 'switch', 'tablist', 'textbox', 'toolbar', 'tree', 'treegrid', 'treeitem'];
export function getAriaDisabled(element: Element): boolean { export function getAriaDisabled(element: Element): boolean {
// https://www.w3.org/TR/wai-aria-1.2/#aria-disabled // https://www.w3.org/TR/wai-aria-1.2/#aria-disabled
// https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings
// Note that aria-disabled applies to all descendants, so we look up the hierarchy. // Note that aria-disabled applies to all descendants, so we look up the hierarchy.
return isNativelyDisabled(element) || hasExplicitAriaDisabled(element);
}
function isNativelyDisabled(element: Element) {
// https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings
const isNativeFormControl = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'OPTION', 'OPTGROUP'].includes(element.tagName); const isNativeFormControl = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'OPTION', 'OPTGROUP'].includes(element.tagName);
if (isNativeFormControl && (element.hasAttribute('disabled') || belongsToDisabledFieldSet(element))) return isNativeFormControl && (element.hasAttribute('disabled') || belongsToDisabledFieldSet(element));
return true;
return hasExplicitAriaDisabled(element);
} }
function belongsToDisabledFieldSet(element: Element | null): boolean { function belongsToDisabledFieldSet(element: Element | null): boolean {

View file

@ -371,6 +371,17 @@ test('control embedded in a target element', async ({ page }) => {
expect.soft(await getNameAndRole(page, 'h1')).toEqual({ role: 'heading', name: 'Foo bar' }); expect.soft(await getNameAndRole(page, 'h1')).toEqual({ role: 'heading', name: 'Foo bar' });
}); });
test('svg role=presentation', async ({ page }) => {
test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/26809' });
await page.setContent(`
<img src="http://example.com/image.png" alt="Code is Poetry." />
<svg viewBox="0 0 100 100" width="16" height="16" xmlns="http://www.w3.org/2000/svg" role="presentation" focusable="false"><circle cx="50" cy="50" r="50"></circle></svg>
`);
expect.soft(await getNameAndRole(page, 'img')).toEqual({ role: 'img', name: 'Code is Poetry.' });
expect.soft(await getNameAndRole(page, 'svg')).toEqual({ role: 'presentation', name: '' });
});
function toArray(x: any): any[] { function toArray(x: any): any[] {
return Array.isArray(x) ? x : [x]; return Array.isArray(x) ? x : [x];
} }

View file

@ -301,11 +301,17 @@ it.describe('selector generator', () => {
expect(await generate(page, 'input')).toBe('internal:attr=[placeholder=\"foobar\"i]'); expect(await generate(page, 'input')).toBe('internal:attr=[placeholder=\"foobar\"i]');
}); });
it('name', async ({ page }) => { it('name', async ({ page }) => {
await page.setContent(`<input role="presentation" aria-hidden="false" name="foobar" type="date"/>`); await page.setContent(`
<input aria-hidden="false" name="foobar" type="date"/>
<div role="textbox"/>content</div>
`);
expect(await generate(page, 'input')).toBe('input[name="foobar"]'); expect(await generate(page, 'input')).toBe('input[name="foobar"]');
}); });
it('type', async ({ page }) => { it('type', async ({ page }) => {
await page.setContent(`<input role="presentation" aria-hidden="false" type="checkbox"/>`); await page.setContent(`
<input aria-hidden="false" type="checkbox"/>
<div role="checkbox"/>content</div>
`);
expect(await generate(page, 'input')).toBe('input[type="checkbox"]'); expect(await generate(page, 'input')).toBe('input[type="checkbox"]');
}); });
}); });
@ -398,7 +404,7 @@ it.describe('selector generator', () => {
}); });
it('should work without CSS.escape', async ({ page }) => { it('should work without CSS.escape', async ({ page }) => {
await page.setContent(`<button role="presentation" aria-hidden="false"></button>`); await page.setContent(`<button aria-hidden="false"></button><div role="button"></div>`);
await page.$eval('button', button => { await page.$eval('button', button => {
delete window.CSS.escape; delete window.CSS.escape;
button.setAttribute('name', '-tricky\u0001name'); button.setAttribute('name', '-tricky\u0001name');