fix(role): update allowsNameFromContent to closer align with blink/gecko (#19692)

This commit is contained in:
Dmitry Gozman 2022-12-27 09:06:46 -08:00 committed by GitHub
parent 7f5cd0aa8b
commit 0087bfac23
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 94 additions and 67 deletions

View file

@ -316,6 +316,19 @@ export function getAriaLabelledByElements(element: Element): Element[] | null {
return getIdRefs(element, ref);
}
function allowsNameFromContent(role: string, targetDescendant: boolean) {
// SPEC: https://w3c.github.io/aria/#namefromcontent
//
// Note: there is a spec proposal https://github.com/w3c/aria/issues/1821 that
// is roughly aligned with what Chrome/Firefox do, and we follow that.
//
// See chromium implementation here:
// https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/modules/accessibility/ax_object.cc;l=6338;drc=3decef66bc4c08b142a19db9628e9efe68973e64;bpv=0;bpt=1
const alwaysAllowsNameFromContent = ['button', 'cell', 'checkbox', 'columnheader', 'gridcell', 'heading', 'link', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'row', 'rowheader', 'switch', 'tab', 'tooltip', 'treeitem'].includes(role);
const descendantAllowsNameFromContent = targetDescendant && ['', 'caption', 'code', 'contentinfo', 'definition', 'deletion', 'emphasis', 'insertion', 'list', 'listitem', 'mark', 'none', 'paragraph', 'presentation', 'region', 'row', 'rowgroup', 'section', 'strong', 'subscript', 'superscript', 'table', 'term', 'time'].includes(role);
return alwaysAllowsNameFromContent || descendantAllowsNameFromContent;
}
export function getElementAccessibleName(element: Element, includeHidden: boolean, hiddenCache: Map<Element, boolean>): string {
// https://w3c.github.io/accname/#computation-steps
@ -581,9 +594,9 @@ function getElementAccessibleNameInternal(element: Element, options: AccessibleN
}
// step 2f + step 2h.
// https://w3c.github.io/aria/#namefromcontent
const allowsNameFromContent = ['button', 'cell', 'checkbox', 'columnheader', 'gridcell', 'heading', 'link', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'row', 'rowheader', 'switch', 'tab', 'tooltip', 'treeitem'].includes(role);
if (allowsNameFromContent || options.embeddedInLabelledBy !== 'none' || options.embeddedInLabel !== 'none' || options.embeddedInTextAlternativeElement || options.embeddedInTargetElement === 'descendant') {
if (allowsNameFromContent(role, options.embeddedInTargetElement === 'descendant') ||
options.embeddedInLabelledBy !== 'none' || options.embeddedInLabel !== 'none' ||
options.embeddedInTextAlternativeElement) {
options.visitedElements.add(element);
const tokens: string[] = [];
const visit = (node: Node, skipSlotted: boolean) => {

View file

@ -169,7 +169,10 @@ module.exports = [
'<label for="t1">HTML Label</label>' +
'<input type="text" id="t2" aria-labelledby="t2label">',
target: '#t2label',
accessibleText: 'This is This is a label of everything',
// accessibleText: 'This is This is a label of everything',
// Chrome and axe-core disagree, we follow Chrome and spec proposal
// https://github.com/w3c/aria/issues/1821.
accessibleText: 'This is This is a label of',
},
{

View file

@ -84,7 +84,7 @@ for (let range = 0; range <= ranges.length; range++) {
return result;
});
for (const { selector, expected, received } of result)
expect(received, `checking "${selector}"`).toBe(expected);
expect.soft(received, `checking "${selector}" in ${testFile}`).toBe(expected);
});
}
});
@ -107,7 +107,7 @@ test('axe-core implicit-role', async ({ page, asset, server }) => {
throw new Error(`Unable to resolve "${selector}"`);
return (window as any).__injectedScript.getAriaRole(element);
}, testCase.target);
expect(received, `checking ${JSON.stringify(testCase)}`).toBe(testCase.role);
expect.soft(received, `checking ${JSON.stringify(testCase)}`).toBe(testCase.role);
});
}
});
@ -141,11 +141,82 @@ test('axe-core accessible-text', async ({ page, asset, server }) => {
return injected.getElementAccessibleName(element);
});
}, targets);
expect(received, `checking ${JSON.stringify(testCase)}`).toEqual(expected);
expect.soft(received, `checking ${JSON.stringify(testCase)}`).toEqual(expected);
});
}
});
test('accessible name with slots', async ({ page }) => {
// Text "foo" is assigned to the slot, should not be used twice.
await page.setContent(`
<button><div>foo</div></button>
<script>
(() => {
const container = document.querySelector('div');
const shadow = container.attachShadow({ mode: 'open' });
const slot = document.createElement('slot');
shadow.appendChild(slot);
})();
</script>
`);
const name1 = await page.$eval('button', e => (window as any).__injectedScript.getElementAccessibleName(e));
expect.soft(name1).toBe('foo');
// Text "foo" is assigned to the slot, should be used instead of slot content.
await page.setContent(`
<div>foo</div>
<script>
(() => {
const container = document.querySelector('div');
const shadow = container.attachShadow({ mode: 'open' });
const button = document.createElement('button');
shadow.appendChild(button);
const slot = document.createElement('slot');
button.appendChild(slot);
const span = document.createElement('span');
span.textContent = 'pre';
slot.appendChild(span);
})();
</script>
`);
const name2 = await page.$eval('button', e => (window as any).__injectedScript.getElementAccessibleName(e));
expect.soft(name2).toBe('foo');
// Nothing is assigned to the slot, should use slot content.
await page.setContent(`
<div></div>
<script>
(() => {
const container = document.querySelector('div');
const shadow = container.attachShadow({ mode: 'open' });
const button = document.createElement('button');
shadow.appendChild(button);
const slot = document.createElement('slot');
button.appendChild(slot);
const span = document.createElement('span');
span.textContent = 'pre';
slot.appendChild(span);
})();
</script>
`);
const name3 = await page.$eval('button', e => (window as any).__injectedScript.getElementAccessibleName(e));
expect.soft(name3).toBe('pre');
});
test('accessible name nested treeitem', async ({ page }) => {
await page.setContent(`
<div role=treeitem id=target>
<span>Top-level</span>
<div role=group>
<div role=treeitem><span>Nested 1</span></div>
<div role=treeitem><span>Nested 2</span></div>
</div>
</div>
`);
const name = await page.$eval('#target', e => (window as any).__injectedScript.getElementAccessibleName(e));
expect.soft(name).toBe('Top-level');
});
function toArray(x: any): any[] {
return Array.isArray(x) ? x : [x];
}

View file

@ -423,63 +423,3 @@ test('errors', async ({ page }) => {
const e8 = await page.$('role=treeitem[expanded="none"]').catch(e => e);
expect(e8.message).toContain(`"expanded" must be one of true, false`);
});
test('should detect accessible name with slots', async ({ page }) => {
// Text "foo" is assigned to the slot, should not be used twice.
await page.setContent(`
<button><div>foo</div></button>
<script>
(() => {
const container = document.querySelector('div');
const shadow = container.attachShadow({ mode: 'open' });
const slot = document.createElement('slot');
shadow.appendChild(slot);
})();
</script>
`);
expect(await page.locator(`role=button[name="foo"]`).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([
`<button><div>foo</div></button>`,
]);
// Text "foo" is assigned to the slot, should be used instead of slot content.
await page.setContent(`
<div>foo</div>
<script>
(() => {
const container = document.querySelector('div');
const shadow = container.attachShadow({ mode: 'open' });
const button = document.createElement('button');
shadow.appendChild(button);
const slot = document.createElement('slot');
button.appendChild(slot);
const span = document.createElement('span');
span.textContent = 'pre';
slot.appendChild(span);
})();
</script>
`);
expect(await page.locator(`role=button[name="foo"]`).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([
`<button><slot><span>pre</span></slot></button>`,
]);
// Nothing is assigned to the slot, should use slot content.
await page.setContent(`
<div></div>
<script>
(() => {
const container = document.querySelector('div');
const shadow = container.attachShadow({ mode: 'open' });
const button = document.createElement('button');
shadow.appendChild(button);
const slot = document.createElement('slot');
button.appendChild(slot);
const span = document.createElement('span');
span.textContent = 'pre';
slot.appendChild(span);
})();
</script>
`);
expect(await page.locator(`role=button[name="pre"]`).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([
`<button><slot><span>pre</span></slot></button>`,
]);
});