fix(aria): normalize whitespace in accessible name and description

This makes it easier to work with, both asserting and querying.

Note: this might break some role locators with RegExp names
that were expecting particular whitespace.
This commit is contained in:
Dmitry Gozman 2024-11-14 20:47:47 +00:00
parent f5477d9051
commit 5736d46c87
5 changed files with 26 additions and 20 deletions

View file

@ -147,8 +147,7 @@ function queryRole(scope: SelectorRoot, options: RoleEngineOptions, internal: bo
return;
}
if (options.name !== undefined) {
// Always normalize whitespace in the accessible name.
const accessibleName = normalizeWhiteSpace(getElementAccessibleName(element, !!options.includeHidden));
const accessibleName = getElementAccessibleName(element, !!options.includeHidden);
if (typeof options.name === 'string')
options.name = normalizeWhiteSpace(options.name);
// internal:role assumes that [name="foo"i] also means substring.

View file

@ -16,6 +16,7 @@
import type { AriaRole } from '@isomorphic/ariaSnapshot';
import { closestCrossShadow, elementSafeTagName, enclosingShadowRootOrDocument, getElementComputedStyle, isElementStyleVisibilityVisible, isVisibleTextNode, parentElementOrShadowHost } from './domUtils';
import { normalizeWhiteSpace } from '../../utils/isomorphic/stringUtils';
function hasExplicitAccessibleName(e: Element) {
return e.hasAttribute('aria-label') || e.hasAttribute('aria-labelledby');
@ -420,6 +421,8 @@ export function getElementAccessibleName(element: Element, includeHidden: boolea
}));
}
// Note: we always normalize whitespace in the accessible name to make it easier to work with.
accessibleName = normalizeWhiteSpace(accessibleName);
cache?.set(element, accessibleName);
}
return accessibleName;
@ -452,6 +455,8 @@ export function getElementAccessibleDescription(element: Element, includeHidden:
accessibleDescription = asFlatString(element.getAttribute('title') || '');
}
// Note: we always normalize whitespace in the accessible description to make it easier to work with.
accessibleDescription = normalizeWhiteSpace(accessibleDescription);
cache?.set(element, accessibleDescription);
}
return accessibleDescription;

View file

@ -475,24 +475,9 @@ test('should ignore stylesheet from hidden aria-labelledby subtree', async ({ pa
expect.soft(await getNameAndRole(page, 'input')).toEqual({ role: 'textbox', name: 'hello' });
});
test('should not include hidden pseudo into accessible name', async ({ page }) => {
await page.setContent(`
<style>
span:before {
content: 'world';
display: none;
}
div:after {
content: 'bye';
visibility: hidden;
}
</style>
<a href="http://example.com">
<span>hello</span>
<div>hello</div>
</a>
`);
expect.soft(await getNameAndRole(page, 'a')).toEqual({ role: 'link', name: 'hello hello' });
test('should normalize accessible name', async ({ page }) => {
await page.setContent(`<button>foo&nbsp;bar\nbaz</button>`);
expect.soft(await getNameAndRole(page, 'button')).toEqual({ role: 'button', name: 'foo bar baz' });
});
function toArray(x: any): any[] {

View file

@ -431,6 +431,9 @@ test('toHaveAccessibleName', async ({ page }) => {
await expect(page.locator('div')).toHaveAccessibleName(/ell\w/);
await expect(page.locator('div')).not.toHaveAccessibleName(/hello/);
await expect(page.locator('div')).toHaveAccessibleName(/hello/, { ignoreCase: true });
await page.setContent(`<button>foo&nbsp;bar\nbaz</button>`);
await expect(page.locator('button')).toHaveAccessibleName('foo bar baz');
});
test('toHaveAccessibleDescription', async ({ page }) => {
@ -443,6 +446,12 @@ test('toHaveAccessibleDescription', async ({ page }) => {
await expect(page.locator('div')).toHaveAccessibleDescription(/ell\w/);
await expect(page.locator('div')).not.toHaveAccessibleDescription(/hello/);
await expect(page.locator('div')).toHaveAccessibleDescription(/hello/, { ignoreCase: true });
await page.setContent(`
<div role="button" aria-describedby="desc"></div>
<span id="desc">foo&nbsp;bar\nbaz</span>
`);
await expect(page.locator('div')).toHaveAccessibleDescription('foo bar baz');
});
test('toHaveRole', async ({ page }) => {

View file

@ -479,6 +479,14 @@ it('should escape yaml text in text nodes', async ({ page }) => {
`);
});
it('should normalize accessible name', async ({ page }) => {
await page.setContent(`<button>foo&nbsp;bar\nbaz</button>`);
await checkAndMatchSnapshot(page.locator('body'), `
- button "foo bar baz"
`);
});
it('should handle long strings', async ({ page }) => {
const s = 'a'.repeat(10000);
await page.setContent(`