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', 'STRONG': () => 'strong',
'SUB': () => 'subscript', 'SUB': () => 'subscript',
'SUP': () => 'superscript', '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', 'TABLE': () => 'table',
'TBODY': () => 'rowgroup', 'TBODY': () => 'rowgroup',
'TD': (e: Element) => { 'TD': (e: Element) => {
@ -167,7 +172,8 @@ const kPresentationInheritanceParents: { [tagName: string]: string[] } = {
}; };
function getImplicitAriaRole(element: Element): string | null { 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) if (!implicitRole)
return null; return null;
// Inherit presentation role when required. // Inherit presentation role when required.
@ -578,18 +584,25 @@ function getElementAccessibleNameInternal(element: Element, options: AccessibleN
return title; return title;
} }
// https://www.w3.org/TR/svg-aam-1.0/ // https://www.w3.org/TR/svg-aam-1.0/#mapping_additional_nd
if (element.tagName === 'SVG' && (element as SVGElement).ownerSVGElement) { if (element.tagName.toUpperCase() === 'SVG' || (element as SVGElement).ownerSVGElement) {
options.visitedElements.add(element); options.visitedElements.add(element);
for (let child = element.firstElementChild; child; child = child.nextElementSibling) { 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, { return getElementAccessibleNameInternal(child, {
...childOptions, ...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. // step 2f + step 2h.

View file

@ -15,10 +15,19 @@
*/ */
import { contextTest as test, expect } from '../config/browserTest'; import { contextTest as test, expect } from '../config/browserTest';
import type { Page } from 'playwright-core';
import fs from 'fs'; import fs from 'fs';
test.skip(({ mode }) => mode !== 'default'); 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 = [ const ranges = [
'name_test_case_539-manual.html', 'name_test_case_539-manual.html',
'name_test_case_721-manual.html', 'name_test_case_721-manual.html',
@ -159,8 +168,7 @@ test('accessible name with slots', async ({ page }) => {
})(); })();
</script> </script>
`); `);
const name1 = await page.$eval('button', e => (window as any).__injectedScript.getElementAccessibleName(e)); expect.soft(await getNameAndRole(page, 'button')).toEqual({ role: 'button', name: 'foo' });
expect.soft(name1).toBe('foo');
// Text "foo" is assigned to the slot, should be used instead of slot content. // Text "foo" is assigned to the slot, should be used instead of slot content.
await page.setContent(` await page.setContent(`
@ -179,8 +187,7 @@ test('accessible name with slots', async ({ page }) => {
})(); })();
</script> </script>
`); `);
const name2 = await page.$eval('button', e => (window as any).__injectedScript.getElementAccessibleName(e)); expect.soft(await getNameAndRole(page, 'button')).toEqual({ role: 'button', name: 'foo' });
expect.soft(name2).toBe('foo');
// Nothing is assigned to the slot, should use slot content. // Nothing is assigned to the slot, should use slot content.
await page.setContent(` await page.setContent(`
@ -199,8 +206,7 @@ test('accessible name with slots', async ({ page }) => {
})(); })();
</script> </script>
`); `);
const name3 = await page.$eval('button', e => (window as any).__injectedScript.getElementAccessibleName(e)); expect.soft(await getNameAndRole(page, 'button')).toEqual({ role: 'button', name: 'pre' });
expect.soft(name3).toBe('pre');
}); });
test('accessible name nested treeitem', async ({ page }) => { test('accessible name nested treeitem', async ({ page }) => {
@ -213,8 +219,25 @@ test('accessible name nested treeitem', async ({ page }) => {
</div> </div>
</div> </div>
`); `);
const name = await page.$eval('#target', e => (window as any).__injectedScript.getElementAccessibleName(e)); expect.soft(await getNameAndRole(page, '#target')).toEqual({ role: 'treeitem', name: 'Top-level' });
expect.soft(name).toBe('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[] { function toArray(x: any): any[] {