mirror of
https://github.com/microsoft/playwright.git
synced 2025-01-06 03:16:17 +03:00
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:
parent
a98abbdda9
commit
103ec90751
@ -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]';
|
||||
|
||||
// https://www.w3.org/TR/wai-aria-1.2/#global_states
|
||||
const kGlobalAriaAttributes = [
|
||||
'aria-atomic',
|
||||
'aria-busy',
|
||||
'aria-controls',
|
||||
'aria-current',
|
||||
'aria-describedby',
|
||||
'aria-details',
|
||||
'aria-disabled',
|
||||
'aria-dropeffect',
|
||||
'aria-errormessage',
|
||||
'aria-flowto',
|
||||
'aria-grabbed',
|
||||
'aria-haspopup',
|
||||
'aria-hidden',
|
||||
'aria-invalid',
|
||||
'aria-keyshortcuts',
|
||||
'aria-label',
|
||||
'aria-labelledby',
|
||||
'aria-live',
|
||||
'aria-owns',
|
||||
'aria-relevant',
|
||||
'aria-roledescription',
|
||||
];
|
||||
const kGlobalAriaAttributes = new Map<string, Set<string> | undefined>([
|
||||
['aria-atomic', undefined],
|
||||
['aria-busy', undefined],
|
||||
['aria-controls', undefined],
|
||||
['aria-current', undefined],
|
||||
['aria-describedby', undefined],
|
||||
['aria-details', undefined],
|
||||
// Global use deprecated in ARIA 1.2
|
||||
// ['aria-disabled', undefined],
|
||||
['aria-dropeffect', undefined],
|
||||
// Global use deprecated in ARIA 1.2
|
||||
// ['aria-errormessage', undefined],
|
||||
['aria-flowto', undefined],
|
||||
['aria-grabbed', undefined],
|
||||
// Global use deprecated in ARIA 1.2
|
||||
// ['aria-haspopup', undefined],
|
||||
['aria-hidden', undefined],
|
||||
// Global use deprecated in ARIA 1.2
|
||||
// ['aria-invalid', undefined],
|
||||
['aria-keyshortcuts', undefined],
|
||||
['aria-label', new Set(['caption', 'code', 'deletion', 'emphasis', 'generic', 'insertion', 'paragraph', 'presentation', 'strong', 'subscript', 'superscript'])],
|
||||
['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) {
|
||||
return kGlobalAriaAttributes.some(a => e.hasAttribute(a));
|
||||
function hasGlobalAriaAttribute(element: Element, forRole?: string | null) {
|
||||
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
|
||||
@ -87,7 +115,7 @@ const kImplicitRoleByTagName: { [tagName: string]: (e: Element) => string | null
|
||||
'HEADER': (e: Element) => closestCrossShadow(e, kAncestorPreventingLandmark) ? null : 'banner',
|
||||
'HR': () => 'separator',
|
||||
'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) => {
|
||||
const type = (e as HTMLInputElement).type.toLowerCase();
|
||||
if (type === 'search')
|
||||
@ -185,7 +213,7 @@ function getImplicitAriaRole(element: Element): string | null {
|
||||
if (!parents || !parent || !parents.includes(parent.tagName))
|
||||
break;
|
||||
const parentExplicitRole = getExplicitAriaRole(parent);
|
||||
if ((parentExplicitRole === 'none' || parentExplicitRole === 'presentation') && !hasPresentationConflictResolution(parent))
|
||||
if ((parentExplicitRole === 'none' || parentExplicitRole === 'presentation') && !hasPresentationConflictResolution(parent, parentExplicitRole))
|
||||
return parentExplicitRole;
|
||||
ancestor = parent;
|
||||
}
|
||||
@ -212,18 +240,20 @@ function getExplicitAriaRole(element: Element): string | 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
|
||||
// TODO: this should include "|| focusable" check.
|
||||
return !hasGlobalAriaAttribute(element);
|
||||
return hasGlobalAriaAttribute(element, role) || isFocusable(element);
|
||||
}
|
||||
|
||||
export function getAriaRole(element: Element): string | null {
|
||||
const explicitRole = getExplicitAriaRole(element);
|
||||
if (!explicitRole)
|
||||
return getImplicitAriaRole(element);
|
||||
if ((explicitRole === 'none' || explicitRole === 'presentation') && hasPresentationConflictResolution(element))
|
||||
return getImplicitAriaRole(element);
|
||||
if (explicitRole === 'none' || explicitRole === 'presentation') {
|
||||
const implicitRole = getImplicitAriaRole(element);
|
||||
if (hasPresentationConflictResolution(element, implicitRole))
|
||||
return implicitRole;
|
||||
}
|
||||
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 function getAriaDisabled(element: Element): boolean {
|
||||
// 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.
|
||||
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);
|
||||
if (isNativeFormControl && (element.hasAttribute('disabled') || belongsToDisabledFieldSet(element)))
|
||||
return true;
|
||||
return hasExplicitAriaDisabled(element);
|
||||
return isNativeFormControl && (element.hasAttribute('disabled') || belongsToDisabledFieldSet(element));
|
||||
}
|
||||
|
||||
function belongsToDisabledFieldSet(element: Element | null): boolean {
|
||||
|
@ -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' });
|
||||
});
|
||||
|
||||
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[] {
|
||||
return Array.isArray(x) ? x : [x];
|
||||
}
|
||||
|
@ -301,11 +301,17 @@ it.describe('selector generator', () => {
|
||||
expect(await generate(page, 'input')).toBe('internal:attr=[placeholder=\"foobar\"i]');
|
||||
});
|
||||
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"]');
|
||||
});
|
||||
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"]');
|
||||
});
|
||||
});
|
||||
@ -398,7 +404,7 @@ it.describe('selector generator', () => {
|
||||
});
|
||||
|
||||
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 => {
|
||||
delete window.CSS.escape;
|
||||
button.setAttribute('name', '-tricky\u0001name');
|
||||
|
Loading…
Reference in New Issue
Block a user