fix(role): embedded control inside the target element (#30403)
According to the spec, such controls should use the native value as long as they have "aria-label". The relevant spec section is 2D. However, there is an open issue that claims this should always apply, and all browsers and wpt test actually do that: https://github.com/w3c/accname/issues/64. Fixes #28848.
This commit is contained in:
parent
b72e3a3eba
commit
984182bd53
|
|
@ -433,7 +433,10 @@ function getElementAccessibleNameInternal(element: Element, options: AccessibleN
|
||||||
|
|
||||||
const labelledBy = getAriaLabelledByElements(element);
|
const labelledBy = getAriaLabelledByElements(element);
|
||||||
|
|
||||||
// step 2b.
|
// step 2b. LabelledBy:
|
||||||
|
// Otherwise, if the current node has an aria-labelledby attribute that contains
|
||||||
|
// at least one valid IDREF, and the current node is not already part of an ongoing
|
||||||
|
// aria-labelledby or aria-describedby traversal, process its IDREFs in the order they occur...
|
||||||
if (!options.embeddedInLabelledBy) {
|
if (!options.embeddedInLabelledBy) {
|
||||||
const accessibleName = (labelledBy || []).map(ref => getElementAccessibleNameInternal(ref, {
|
const accessibleName = (labelledBy || []).map(ref => getElementAccessibleNameInternal(ref, {
|
||||||
...options,
|
...options,
|
||||||
|
|
@ -448,9 +451,15 @@ function getElementAccessibleNameInternal(element: Element, options: AccessibleN
|
||||||
|
|
||||||
const role = getAriaRole(element) || '';
|
const role = getAriaRole(element) || '';
|
||||||
|
|
||||||
// step 2c.
|
// step 2c:
|
||||||
// TODO: should we check embeddedInLabel here?
|
// if the current node is a control embedded within the label (e.g. any element directly referenced by aria-labelledby) for another widget...
|
||||||
if (!!options.embeddedInLabel || !!options.embeddedInLabelledBy) {
|
//
|
||||||
|
// also step 2d "skip to rule Embedded Control" section:
|
||||||
|
// If traversal of the current node is due to recursion and the current node is an embedded control...
|
||||||
|
// Note this is not strictly by the spec, because spec only applies this logic when "aria-label" is present.
|
||||||
|
// However, browsers and and wpt test name_heading-combobox-focusable-alternative-manual.html follow this behavior,
|
||||||
|
// and there is an issue filed for this: https://github.com/w3c/accname/issues/64
|
||||||
|
if (!!options.embeddedInLabel || !!options.embeddedInLabelledBy || options.embeddedInTargetElement === 'descendant') {
|
||||||
const isOwnLabel = [...(element as (HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement)).labels || []].includes(element as any);
|
const isOwnLabel = [...(element as (HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement)).labels || []].includes(element as any);
|
||||||
const isOwnLabelledBy = (labelledBy || []).includes(element);
|
const isOwnLabelledBy = (labelledBy || []).includes(element);
|
||||||
if (!isOwnLabel && !isOwnLabelledBy) {
|
if (!isOwnLabel && !isOwnLabelledBy) {
|
||||||
|
|
@ -471,6 +480,12 @@ function getElementAccessibleNameInternal(element: Element, options: AccessibleN
|
||||||
const listbox = role === 'combobox' ? queryInAriaOwned(element, '*').find(e => getAriaRole(e) === 'listbox') : element;
|
const listbox = role === 'combobox' ? queryInAriaOwned(element, '*').find(e => getAriaRole(e) === 'listbox') : element;
|
||||||
selectedOptions = listbox ? queryInAriaOwned(listbox, '[aria-selected="true"]').filter(e => getAriaRole(e) === 'option') : [];
|
selectedOptions = listbox ? queryInAriaOwned(listbox, '[aria-selected="true"]').filter(e => getAriaRole(e) === 'option') : [];
|
||||||
}
|
}
|
||||||
|
if (!selectedOptions.length && element.tagName === 'INPUT') {
|
||||||
|
// SPEC DIFFERENCE:
|
||||||
|
// This fallback is not explicitly mentioned in the spec, but all browsers and
|
||||||
|
// wpt test name_heading-combobox-focusable-alternative-manual.html do this.
|
||||||
|
return (element as HTMLInputElement).value;
|
||||||
|
}
|
||||||
return selectedOptions.map(option => getElementAccessibleNameInternal(option, childOptions)).join(' ');
|
return selectedOptions.map(option => getElementAccessibleNameInternal(option, childOptions)).join(' ');
|
||||||
}
|
}
|
||||||
if (['progressbar', 'scrollbar', 'slider', 'spinbutton', 'meter'].includes(role)) {
|
if (['progressbar', 'scrollbar', 'slider', 'spinbutton', 'meter'].includes(role)) {
|
||||||
|
|
|
||||||
|
|
@ -186,7 +186,13 @@ module.exports = [
|
||||||
'<input type="text" id="tb1"></div>' +
|
'<input type="text" id="tb1"></div>' +
|
||||||
'<label for="tb1">My form input</label>',
|
'<label for="tb1">My form input</label>',
|
||||||
target: '#target',
|
target: '#target',
|
||||||
accessibleText: 'My form input',
|
// accessibleText: 'My form input',
|
||||||
|
// All browsers and the spec (kind of) agree that input inside the target element should
|
||||||
|
// use it's value as an "embedded control", rather than a label.
|
||||||
|
// From the spec:
|
||||||
|
// If traversal of the current node is due to recursion and the current node
|
||||||
|
// is an embedded control, ignore aria-label and skip to rule Embedded Control.
|
||||||
|
accessibleText: '',
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,6 @@ const ranges = [
|
||||||
for (let range = 0; range <= ranges.length; range++) {
|
for (let range = 0; range <= ranges.length; range++) {
|
||||||
test('wpt accname #' + range, async ({ page, asset, server, browserName }) => {
|
test('wpt accname #' + range, async ({ page, asset, server, browserName }) => {
|
||||||
const skipped = [
|
const skipped = [
|
||||||
// Spec clearly says to only use control's value when embedded in a label (step 2C).
|
|
||||||
'name_heading-combobox-focusable-alternative-manual.html',
|
|
||||||
// This test expects ::before + title + ::after, which is neither 2F nor 2I.
|
// This test expects ::before + title + ::after, which is neither 2F nor 2I.
|
||||||
'name_test_case_659-manual.html',
|
'name_test_case_659-manual.html',
|
||||||
// This test expects ::before + title + ::after, which is neither 2F nor 2I.
|
// This test expects ::before + title + ::after, which is neither 2F nor 2I.
|
||||||
|
|
@ -307,6 +305,8 @@ test('display:contents should be visible when contents are visible', async ({ pa
|
||||||
});
|
});
|
||||||
|
|
||||||
test('label/labelled-by aria-hidden with descendants', async ({ page }) => {
|
test('label/labelled-by aria-hidden with descendants', async ({ page }) => {
|
||||||
|
test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/29796' });
|
||||||
|
|
||||||
await page.setContent(`
|
await page.setContent(`
|
||||||
<body>
|
<body>
|
||||||
<div id="case1">
|
<div id="case1">
|
||||||
|
|
@ -326,6 +326,51 @@ test('label/labelled-by aria-hidden with descendants', async ({ page }) => {
|
||||||
expect.soft(await getNameAndRole(page, '#case2 button')).toEqual({ role: 'button', name: 'Label2' });
|
expect.soft(await getNameAndRole(page, '#case2 button')).toEqual({ role: 'button', name: 'Label2' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('own aria-label concatenated with aria-labelledby', async ({ page }) => {
|
||||||
|
// This is taken from https://w3c.github.io/accname/#example-5-0
|
||||||
|
|
||||||
|
await page.setContent(`
|
||||||
|
<h1>Files</h1>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a id="file_row1" href="./files/Documentation.pdf">Documentation.pdf</a>
|
||||||
|
<span role="button" tabindex="0" id="del_row1" aria-label="Delete" aria-labelledby="del_row1 file_row1"></span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a id="file_row2" href="./files/HolidayLetter.pdf">HolidayLetter.pdf</a>
|
||||||
|
<span role="button" tabindex="0" id="del_row2" aria-label="Delete" aria-labelledby="del_row2 file_row2"></span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
`);
|
||||||
|
expect.soft(await getNameAndRole(page, '#del_row1')).toEqual({ role: 'button', name: 'Delete Documentation.pdf' });
|
||||||
|
expect.soft(await getNameAndRole(page, '#del_row2')).toEqual({ role: 'button', name: 'Delete HolidayLetter.pdf' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('control embedded in a label', async ({ page }) => {
|
||||||
|
test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/28848' });
|
||||||
|
|
||||||
|
await page.setContent(`
|
||||||
|
<label for="flash">
|
||||||
|
<input type="checkbox" id="flash">
|
||||||
|
Flash the screen <span tabindex="0" role="textbox" aria-label="number of times" contenteditable>5</span> times.
|
||||||
|
</label>
|
||||||
|
`);
|
||||||
|
expect.soft(await getNameAndRole(page, 'input')).toEqual({ role: 'checkbox', name: 'Flash the screen 5 times.' });
|
||||||
|
expect.soft(await getNameAndRole(page, 'span')).toEqual({ role: 'textbox', name: 'number of times' });
|
||||||
|
expect.soft(await getNameAndRole(page, 'label')).toEqual({ role: null, name: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('control embedded in a target element', async ({ page }) => {
|
||||||
|
test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/28848' });
|
||||||
|
|
||||||
|
await page.setContent(`
|
||||||
|
<h1>
|
||||||
|
<input type="text" value="Foo bar">
|
||||||
|
</h1>
|
||||||
|
`);
|
||||||
|
expect.soft(await getNameAndRole(page, 'h1')).toEqual({ role: 'heading', name: 'Foo bar' });
|
||||||
|
});
|
||||||
|
|
||||||
function toArray(x: any): any[] {
|
function toArray(x: any): any[] {
|
||||||
return Array.isArray(x) ? x : [x];
|
return Array.isArray(x) ? x : [x];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue