diff --git a/packages/playwright-core/src/server/injected/roleUtils.ts b/packages/playwright-core/src/server/injected/roleUtils.ts index 56a2833548..312b3431b6 100644 --- a/packages/playwright-core/src/server/injected/roleUtils.ts +++ b/packages/playwright-core/src/server/injected/roleUtils.ts @@ -433,7 +433,10 @@ function getElementAccessibleNameInternal(element: Element, options: AccessibleN 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) { const accessibleName = (labelledBy || []).map(ref => getElementAccessibleNameInternal(ref, { ...options, @@ -448,9 +451,15 @@ function getElementAccessibleNameInternal(element: Element, options: AccessibleN const role = getAriaRole(element) || ''; - // step 2c. - // TODO: should we check embeddedInLabel here? - if (!!options.embeddedInLabel || !!options.embeddedInLabelledBy) { + // step 2c: + // if the current node is a control embedded within the label (e.g. any element directly referenced by aria-labelledby) for another widget... + // + // 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 isOwnLabelledBy = (labelledBy || []).includes(element); 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; 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(' '); } if (['progressbar', 'scrollbar', 'slider', 'spinbutton', 'meter'].includes(role)) { diff --git a/tests/assets/axe-core/accessible-text.js b/tests/assets/axe-core/accessible-text.js index f8d17c1b5e..3d9125700d 100644 --- a/tests/assets/axe-core/accessible-text.js +++ b/tests/assets/axe-core/accessible-text.js @@ -186,7 +186,13 @@ module.exports = [ '' + '', 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: '', }, { diff --git a/tests/library/role-utils.spec.ts b/tests/library/role-utils.spec.ts index e926018233..d09abb15ea 100644 --- a/tests/library/role-utils.spec.ts +++ b/tests/library/role-utils.spec.ts @@ -36,8 +36,6 @@ const ranges = [ for (let range = 0; range <= ranges.length; range++) { test('wpt accname #' + range, async ({ page, asset, server, browserName }) => { 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. 'name_test_case_659-manual.html', // 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.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/29796' }); + await page.setContent(`
@@ -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' }); }); +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(` +

Files

+ + `); + 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(` + + `); + 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(` +

+ +

+ `); + expect.soft(await getNameAndRole(page, 'h1')).toEqual({ role: 'heading', name: 'Foo bar' }); +}); + function toArray(x: any): any[] { return Array.isArray(x) ? x : [x]; }