feat(getByLabel): support aria-labelledby (#19456)

Testing library also treats them equally.

Fixes #19284.
This commit is contained in:
Dmitry Gozman 2022-12-14 13:51:05 -08:00 committed by GitHub
parent e7b8554342
commit a27f1f744f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 48 additions and 10 deletions

View File

@ -29,7 +29,7 @@ import type { CSSComplexSelectorList } from '../isomorphic/cssParser';
import { generateSelector } from './selectorGenerator'; import { generateSelector } from './selectorGenerator';
import type * as channels from '@protocol/channels'; import type * as channels from '@protocol/channels';
import { Highlight } from './highlight'; import { Highlight } from './highlight';
import { getAriaCheckedStrict, getAriaDisabled, getAriaRole, getElementAccessibleName } from './roleUtils'; import { getAriaCheckedStrict, getAriaDisabled, getAriaLabelledByElements, getAriaRole, getElementAccessibleName } from './roleUtils';
import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils'; import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils';
import { asLocator } from '../isomorphic/locatorGenerators'; import { asLocator } from '../isomorphic/locatorGenerators';
import type { Language } from '../isomorphic/locatorGenerators'; import type { Language } from '../isomorphic/locatorGenerators';
@ -296,14 +296,13 @@ export class InjectedScript {
return { return {
queryAll: (root: SelectorRoot, selector: string): Element[] => { queryAll: (root: SelectorRoot, selector: string): Element[] => {
const { matcher } = createTextMatcher(selector, true); const { matcher } = createTextMatcher(selector, true);
const result: Element[] = []; const allElements = this._evaluator._queryCSS({ scope: root as Document | Element, pierceShadow: true }, '*');
const labels = this._evaluator._queryCSS({ scope: root as Document | Element, pierceShadow: true }, 'label') as HTMLLabelElement[]; return allElements.filter(element => {
for (const label of labels) { let labels: Element[] | NodeListOf<Element> | null | undefined = getAriaLabelledByElements(element);
const control = label.control; if (labels === null)
if (control && matcher(elementText(this._evaluator._cacheText, label))) labels = (element as HTMLInputElement).labels;
result.push(control); return !!labels && [...labels].some(label => matcher(elementText(this._evaluator._cacheText, label)));
} });
return result;
} }
}; };
} }

View File

@ -309,6 +309,13 @@ function getPseudoContent(pseudoStyle: CSSStyleDeclaration | undefined) {
return ''; return '';
} }
export function getAriaLabelledByElements(element: Element): Element[] | null {
const ref = element.getAttribute('aria-labelledby');
if (ref === null)
return null;
return getIdRefs(element, ref);
}
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
@ -360,7 +367,7 @@ function getElementAccessibleNameInternal(element: Element, options: AccessibleN
// step 2b. // step 2b.
if (options.embeddedInLabelledBy === 'none') { if (options.embeddedInLabelledBy === 'none') {
const refs = getIdRefs(element, element.getAttribute('aria-labelledby')); const refs = getAriaLabelledByElements(element) || [];
const accessibleName = refs.map(ref => getElementAccessibleNameInternal(ref, { const accessibleName = refs.map(ref => getElementAccessibleNameInternal(ref, {
...options, ...options,
embeddedInLabelledBy: 'self', embeddedInLabelledBy: 'self',

View File

@ -80,6 +80,38 @@ it('getByLabel should work with nested elements', async ({ page }) => {
expect(await page.getByLabel(/last name/).elementHandles()).toEqual([]); expect(await page.getByLabel(/last name/).elementHandles()).toEqual([]);
}); });
it('getByLabel should work with multiply-labelled input', async ({ page }) => {
await page.setContent(`<label for=target>Name</label><input id=target type=text><label for=target>First or Last</label>`);
expect(await page.getByLabel('Name').evaluate(e => e.id)).toBe('target');
expect(await page.getByLabel('First or Last').evaluate(e => e.id)).toBe('target');
});
it('getByLabel should work with ancestor label and multiple controls', async ({ page }) => {
await page.setContent(`<label>Name<button id=target>Click me</button><input type=text></label>`);
expect(await page.getByLabel('Name').evaluate(e => e.id)).toBe('target');
});
it('getByLabel should work with ancestor label and for', async ({ page }) => {
await page.setContent(`
<label for=target>Name<input type=text id=nontarget></label>
<input type=text id=target>
`);
expect(await page.getByLabel('Name').evaluate(e => e.id)).toBe('target');
});
it('getByLabel should work with aria-labelledby', async ({ page }) => {
await page.setContent(`<label id=name-label>Name</label><button aria-labelledby=name-label>Click me</button>`);
expect(await page.getByLabel('Name').evaluate(e => e.textContent)).toBe('Click me');
});
it('getByLabel should prioritize aria-labelledby over native label', async ({ page }) => {
await page.setContent(`
<label id=name-label>Name</label>
<label>Wrong<button aria-labelledby=name-label>Click me</button></label>
`);
expect(await page.getByLabel('Name').evaluate(e => e.textContent)).toBe('Click me');
});
it('getByPlaceholder should work', async ({ page }) => { it('getByPlaceholder should work', async ({ page }) => {
await page.setContent(`<div> await page.setContent(`<div>
<input placeholder='Hello'> <input placeholder='Hello'>