fix(role): use <title> for elements inside svg (#22043)

Follows svg-aam mapping:
https://www.w3.org/TR/svg-aam-1.0/#mapping_additional_nd

Fixes #21486.
This commit is contained in:
Dmitry Gozman 2023-03-28 15:52:16 -07:00 committed by GitHub
parent 1ba07bcd81
commit 6a2b4ed142
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 49 additions and 13 deletions

View file

@ -129,6 +129,11 @@ const kImplicitRoleByTagName: { [tagName: string]: (e: Element) => string | null
'STRONG': () => 'strong',
'SUB': () => 'subscript',
'SUP': () => 'superscript',
// For <svg> we default to Chrome behavior:
// - Chrome reports 'img'.
// - Firefox reports 'diagram' that is not in official ARIA spec yet.
// - Safari reports 'no role', but still computes accessible name.
'SVG': () => 'img',
'TABLE': () => 'table',
'TBODY': () => 'rowgroup',
'TD': (e: Element) => {
@ -167,7 +172,8 @@ const kPresentationInheritanceParents: { [tagName: string]: string[] } = {
};
function getImplicitAriaRole(element: Element): string | null {
const implicitRole = kImplicitRoleByTagName[element.tagName]?.(element) || '';
// Elements from the svg namespace do not have uppercase tagName.
const implicitRole = kImplicitRoleByTagName[element.tagName.toUpperCase()]?.(element) || '';
if (!implicitRole)
return null;
// Inherit presentation role when required.
@ -578,18 +584,25 @@ function getElementAccessibleNameInternal(element: Element, options: AccessibleN
return title;
}
// https://www.w3.org/TR/svg-aam-1.0/
if (element.tagName === 'SVG' && (element as SVGElement).ownerSVGElement) {
// https://www.w3.org/TR/svg-aam-1.0/#mapping_additional_nd
if (element.tagName.toUpperCase() === 'SVG' || (element as SVGElement).ownerSVGElement) {
options.visitedElements.add(element);
for (let child = element.firstElementChild; child; child = child.nextElementSibling) {
if (child.tagName === 'TITLE' && (element as SVGElement).ownerSVGElement) {
if (child.tagName.toUpperCase() === 'TITLE' && (child as SVGElement).ownerSVGElement) {
return getElementAccessibleNameInternal(child, {
...childOptions,
embeddedInTextAlternativeElement: true,
embeddedInLabelledBy: 'self',
});
}
}
}
if ((element as SVGElement).ownerSVGElement && element.tagName.toUpperCase() === 'A') {
const title = element.getAttribute('xlink:title') || '';
if (title.trim()) {
options.visitedElements.add(element);
return title;
}
}
}
// step 2f + step 2h.

View file

@ -15,10 +15,19 @@
*/
import { contextTest as test, expect } from '../config/browserTest';
import type { Page } from 'playwright-core';
import fs from 'fs';
test.skip(({ mode }) => mode !== 'default');
async function getNameAndRole(page: Page, selector: string) {
return await page.$eval(selector, e => {
const name = (window as any).__injectedScript.getElementAccessibleName(e);
const role = (window as any).__injectedScript.getAriaRole(e);
return { name, role };
});
}
const ranges = [
'name_test_case_539-manual.html',
'name_test_case_721-manual.html',
@ -159,8 +168,7 @@ test('accessible name with slots', async ({ page }) => {
})();
</script>
`);
const name1 = await page.$eval('button', e => (window as any).__injectedScript.getElementAccessibleName(e));
expect.soft(name1).toBe('foo');
expect.soft(await getNameAndRole(page, 'button')).toEqual({ role: 'button', name: 'foo' });
// Text "foo" is assigned to the slot, should be used instead of slot content.
await page.setContent(`
@ -179,8 +187,7 @@ test('accessible name with slots', async ({ page }) => {
})();
</script>
`);
const name2 = await page.$eval('button', e => (window as any).__injectedScript.getElementAccessibleName(e));
expect.soft(name2).toBe('foo');
expect.soft(await getNameAndRole(page, 'button')).toEqual({ role: 'button', name: 'foo' });
// Nothing is assigned to the slot, should use slot content.
await page.setContent(`
@ -199,8 +206,7 @@ test('accessible name with slots', async ({ page }) => {
})();
</script>
`);
const name3 = await page.$eval('button', e => (window as any).__injectedScript.getElementAccessibleName(e));
expect.soft(name3).toBe('pre');
expect.soft(await getNameAndRole(page, 'button')).toEqual({ role: 'button', name: 'pre' });
});
test('accessible name nested treeitem', async ({ page }) => {
@ -213,8 +219,25 @@ test('accessible name nested treeitem', async ({ page }) => {
</div>
</div>
`);
const name = await page.$eval('#target', e => (window as any).__injectedScript.getElementAccessibleName(e));
expect.soft(name).toBe('Top-level');
expect.soft(await getNameAndRole(page, '#target')).toEqual({ role: 'treeitem', name: 'Top-level' });
});
test('svg title', async ({ page }) => {
await page.setContent(`
<div>
<svg width="162" height="30" viewBox="0 0 162 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<title>Submit</title>
<g>
<title>Hello</title>
</g>
<a href="example.com" xlink:title="a link"><circle cx="50" cy="40" r="35" /></a>
</svg>
</div>
`);
expect.soft(await getNameAndRole(page, 'svg')).toEqual({ role: 'img', name: 'Submit' });
expect.soft(await getNameAndRole(page, 'g')).toEqual({ role: null, name: 'Hello' });
expect.soft(await getNameAndRole(page, 'a')).toEqual({ role: 'link', name: 'a link' });
});
function toArray(x: any): any[] {