From 6a2b4ed142ad400f03c51cd1eedb62c5a46324f5 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 28 Mar 2023 15:52:16 -0700 Subject: [PATCH] fix(role): use for elements inside svg (#22043) Follows svg-aam mapping: https://www.w3.org/TR/svg-aam-1.0/#mapping_additional_nd Fixes #21486. --- .../src/server/injected/roleUtils.ts | 23 ++++++++--- tests/library/role-utils.spec.ts | 39 +++++++++++++++---- 2 files changed, 49 insertions(+), 13 deletions(-) diff --git a/packages/playwright-core/src/server/injected/roleUtils.ts b/packages/playwright-core/src/server/injected/roleUtils.ts index 53455be8c4..b86bbfcc78 100644 --- a/packages/playwright-core/src/server/injected/roleUtils.ts +++ b/packages/playwright-core/src/server/injected/roleUtils.ts @@ -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. diff --git a/tests/library/role-utils.spec.ts b/tests/library/role-utils.spec.ts index 0f4061245d..869d701cba 100644 --- a/tests/library/role-utils.spec.ts +++ b/tests/library/role-utils.spec.ts @@ -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 + + Hello + + + + + `); + + 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[] {