fix(role): update allowsNameFromContent to closer align with blink/gecko (#19692)
This commit is contained in:
parent
7f5cd0aa8b
commit
0087bfac23
|
|
@ -316,6 +316,19 @@ export function getAriaLabelledByElements(element: Element): Element[] | null {
|
||||||
return getIdRefs(element, ref);
|
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 {
|
export function getElementAccessibleName(element: Element, includeHidden: boolean, hiddenCache: Map<Element, boolean>): string {
|
||||||
// https://w3c.github.io/accname/#computation-steps
|
// https://w3c.github.io/accname/#computation-steps
|
||||||
|
|
||||||
|
|
@ -581,9 +594,9 @@ function getElementAccessibleNameInternal(element: Element, options: AccessibleN
|
||||||
}
|
}
|
||||||
|
|
||||||
// step 2f + step 2h.
|
// step 2f + step 2h.
|
||||||
// https://w3c.github.io/aria/#namefromcontent
|
if (allowsNameFromContent(role, options.embeddedInTargetElement === 'descendant') ||
|
||||||
const allowsNameFromContent = ['button', 'cell', 'checkbox', 'columnheader', 'gridcell', 'heading', 'link', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'row', 'rowheader', 'switch', 'tab', 'tooltip', 'treeitem'].includes(role);
|
options.embeddedInLabelledBy !== 'none' || options.embeddedInLabel !== 'none' ||
|
||||||
if (allowsNameFromContent || options.embeddedInLabelledBy !== 'none' || options.embeddedInLabel !== 'none' || options.embeddedInTextAlternativeElement || options.embeddedInTargetElement === 'descendant') {
|
options.embeddedInTextAlternativeElement) {
|
||||||
options.visitedElements.add(element);
|
options.visitedElements.add(element);
|
||||||
const tokens: string[] = [];
|
const tokens: string[] = [];
|
||||||
const visit = (node: Node, skipSlotted: boolean) => {
|
const visit = (node: Node, skipSlotted: boolean) => {
|
||||||
|
|
|
||||||
|
|
@ -169,7 +169,10 @@ module.exports = [
|
||||||
'<label for="t1">HTML Label</label>' +
|
'<label for="t1">HTML Label</label>' +
|
||||||
'<input type="text" id="t2" aria-labelledby="t2label">',
|
'<input type="text" id="t2" aria-labelledby="t2label">',
|
||||||
target: '#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',
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ for (let range = 0; range <= ranges.length; range++) {
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
for (const { selector, expected, received } of 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}"`);
|
throw new Error(`Unable to resolve "${selector}"`);
|
||||||
return (window as any).__injectedScript.getAriaRole(element);
|
return (window as any).__injectedScript.getAriaRole(element);
|
||||||
}, testCase.target);
|
}, 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);
|
return injected.getElementAccessibleName(element);
|
||||||
});
|
});
|
||||||
}, targets);
|
}, 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[] {
|
function toArray(x: any): any[] {
|
||||||
return Array.isArray(x) ? x : [x];
|
return Array.isArray(x) ? x : [x];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -423,63 +423,3 @@ test('errors', async ({ page }) => {
|
||||||
const e8 = await page.$('role=treeitem[expanded="none"]').catch(e => e);
|
const e8 = await page.$('role=treeitem[expanded="none"]').catch(e => e);
|
||||||
expect(e8.message).toContain(`"expanded" must be one of true, false`);
|
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>`,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue