diff --git a/packages/injected/src/ariaSnapshot.ts b/packages/injected/src/ariaSnapshot.ts index 43a79f824d..6782814970 100644 --- a/packages/injected/src/ariaSnapshot.ts +++ b/packages/injected/src/ariaSnapshot.ts @@ -102,7 +102,7 @@ export function generateAriaTree(rootElement: Element, generation: number, optio if (treatAsBlock) ariaNode.children.push(treatAsBlock); - ariaNode.children.push(roleUtils.getPseudoContent(element, '::before')); + ariaNode.children.push(roleUtils.getCSSContent(element, '::before') || ''); const assignedNodes = element.nodeName === 'SLOT' ? (element as HTMLSlotElement).assignedNodes() : []; if (assignedNodes.length) { for (const child of assignedNodes) @@ -121,7 +121,7 @@ export function generateAriaTree(rootElement: Element, generation: number, optio for (const child of ariaChildren) visit(ariaNode, child); - ariaNode.children.push(roleUtils.getPseudoContent(element, '::after')); + ariaNode.children.push(roleUtils.getCSSContent(element, '::after') || ''); if (treatAsBlock) ariaNode.children.push(treatAsBlock); diff --git a/packages/injected/src/roleUtils.ts b/packages/injected/src/roleUtils.ts index 9e5d6e4416..d1e2c91f49 100644 --- a/packages/injected/src/roleUtils.ts +++ b/packages/injected/src/roleUtils.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import * as css from '@isomorphic/cssTokenizer'; + import { getGlobalOptions, closestCrossShadow, elementSafeTagName, enclosingShadowRootOrDocument, getElementComputedStyle, isElementStyleVisibilityVisible, isVisibleTextNode, parentElementOrShadowHost } from './domUtils'; import type { AriaRole } from '@isomorphic/ariaSnapshot'; @@ -355,41 +357,80 @@ function queryInAriaOwned(element: Element, selector: string): Element[] { return result; } -export function getPseudoContent(element: Element, pseudo: '::before' | '::after') { - const cache = pseudo === '::before' ? cachePseudoContentBefore : cachePseudoContentAfter; +export function getCSSContent(element: Element, pseudo?: '::before' | '::after') { + // Relevant spec: 2.6.2 from https://w3c.github.io/accname/#computation-steps. + // Additional considerations: https://github.com/w3c/accname/issues/204. + const cache = pseudo === '::before' ? cachePseudoContentBefore : (pseudo === '::after' ? cachePseudoContentAfter : cachePseudoContent); if (cache?.has(element)) - return cache?.get(element) || ''; - const pseudoStyle = getElementComputedStyle(element, pseudo); - const content = getPseudoContentImpl(element, pseudoStyle); + return cache?.get(element); + + const style = getElementComputedStyle(element, pseudo); + let content: string | undefined; + if (style && style.display !== 'none' && style.visibility !== 'hidden') { + // Note: all browsers ignore display:none and visibility:hidden pseudos. + content = parseCSSContentPropertyAsString(element, style.content, !!pseudo); + } + + if (pseudo && content !== undefined) { + // SPEC DIFFERENCE. + // Spec says "CSS textual content, without a space", but we account for display + // to pass "name_file-label-inline-block-styles-manual.html" + const display = style?.display || 'inline'; + if (display !== 'inline') + content = ' ' + content + ' '; + } + if (cache) cache.set(element, content); return content; } -function getPseudoContentImpl(element: Element, pseudoStyle: CSSStyleDeclaration | undefined) { - // Note: all browsers ignore display:none and visibility:hidden pseudos. - if (!pseudoStyle || pseudoStyle.display === 'none' || pseudoStyle.visibility === 'hidden') - return ''; - const content = pseudoStyle.content; - let resolvedContent: string | undefined; - if ((content[0] === '\'' && content[content.length - 1] === '\'') || - (content[0] === '"' && content[content.length - 1] === '"')) { - resolvedContent = content.substring(1, content.length - 1); - } else if (content.startsWith('attr(') && content.endsWith(')')) { - // Firefox does not resolve attribute accessors in content. - const attrName = content.substring('attr('.length, content.length - 1).trim(); - resolvedContent = element.getAttribute(attrName) || ''; +function parseCSSContentPropertyAsString(element: Element, content: string, isPseudo: boolean): string | undefined { + // Welcome to the mini CSS parser! + // It aims to support the following syntax and any subset of it: + // content: "one" attr(...) "two" "three" / "alt" attr(...) "more alt" + // See https://developer.mozilla.org/en-US/docs/Web/CSS/content for more details. + + if (!content || content === 'none' || content === 'normal') { + // Common fast path. + return; } - if (resolvedContent !== undefined) { - // SPEC DIFFERENCE. - // Spec says "CSS textual content, without a space", but we account for display - // to pass "name_file-label-inline-block-styles-manual.html" - const display = pseudoStyle.display || 'inline'; - if (display !== 'inline') - return ' ' + resolvedContent + ' '; - return resolvedContent; + + try { + let tokens = css.tokenize(content).filter(token => !(token instanceof css.WhitespaceToken)); + const delimIndex = tokens.findIndex(token => token instanceof css.DelimToken && token.value === '/'); + if (delimIndex !== -1) { + // Use the alternative text part when exists. + // content: ... / "alternative text" + tokens = tokens.slice(delimIndex + 1); + } else if (!isPseudo) { + // For non-pseudo elements, the only valid content is a url() or various gradients. + // Therefore, we follow Chrome and only consider the alternative text. + // Firefox, on the other hand, calculates accessible name to be empty. + return; + } + + const accumulated: string[] = []; + let index = 0; + while (index < tokens.length) { + if (tokens[index] instanceof css.StringToken) { + // content: "some text" + accumulated.push(tokens[index].value as string); + index++; + } else if (index + 2 < tokens.length && tokens[index] instanceof css.FunctionToken && tokens[index].value === 'attr' && tokens[index + 1] instanceof css.IdentToken && tokens[index + 2] instanceof css.CloseParenToken) { + // content: attr(...) + // Firefox does not resolve attribute accessors in content, so we do it manually. + const attrName = tokens[index + 1].value as string; + accumulated.push(element.getAttribute(attrName) || ''); + index += 3; + } else { + // Failed to parse the content, so ignore it. + return; + } + } + return accumulated.join(''); + } catch { } - return ''; } export function getAriaLabelledByElements(element: Element): Element[] | null { @@ -879,22 +920,31 @@ function innerAccumulatedElementText(element: Element, options: AccessibleNameOp tokens.push(node.textContent || ''); } }; - tokens.push(getPseudoContent(element, '::before')); - const assignedNodes = element.nodeName === 'SLOT' ? (element as HTMLSlotElement).assignedNodes() : []; - if (assignedNodes.length) { - for (const child of assignedNodes) - visit(child, false); + tokens.push(getCSSContent(element, '::before') || ''); + const content = getCSSContent(element); + if (content !== undefined) { + // `content` CSS property replaces everything inside the element. + // I was not able to find any spec or description on how this interacts with accname, + // so this is a guess based on what browsers do. + tokens.push(content); } else { - for (let child = element.firstChild; child; child = child.nextSibling) - visit(child, true); - if (element.shadowRoot) { - for (let child = element.shadowRoot.firstChild; child; child = child.nextSibling) + // step 2h. + const assignedNodes = element.nodeName === 'SLOT' ? (element as HTMLSlotElement).assignedNodes() : []; + if (assignedNodes.length) { + for (const child of assignedNodes) + visit(child, false); + } else { + for (let child = element.firstChild; child; child = child.nextSibling) visit(child, true); + if (element.shadowRoot) { + for (let child = element.shadowRoot.firstChild; child; child = child.nextSibling) + visit(child, true); + } + for (const owned of getIdRefs(element, element.getAttribute('aria-owns'))) + visit(owned, true); } - for (const owned of getIdRefs(element, element.getAttribute('aria-owns'))) - visit(owned, true); } - tokens.push(getPseudoContent(element, '::after')); + tokens.push(getCSSContent(element, '::after') || ''); return tokens.join(''); } @@ -1091,8 +1141,9 @@ let cacheAccessibleDescription: Map | undefined; let cacheAccessibleDescriptionHidden: Map | undefined; let cacheAccessibleErrorMessage: Map | undefined; let cacheIsHidden: Map | undefined; -let cachePseudoContentBefore: Map | undefined; -let cachePseudoContentAfter: Map | undefined; +let cachePseudoContent: Map | undefined; +let cachePseudoContentBefore: Map | undefined; +let cachePseudoContentAfter: Map | undefined; let cachePointerEvents: Map | undefined; let cachesCounter = 0; @@ -1104,6 +1155,7 @@ export function beginAriaCaches() { cacheAccessibleDescriptionHidden ??= new Map(); cacheAccessibleErrorMessage ??= new Map(); cacheIsHidden ??= new Map(); + cachePseudoContent ??= new Map(); cachePseudoContentBefore ??= new Map(); cachePseudoContentAfter ??= new Map(); cachePointerEvents ??= new Map(); @@ -1117,6 +1169,7 @@ export function endAriaCaches() { cacheAccessibleDescriptionHidden = undefined; cacheAccessibleErrorMessage = undefined; cacheIsHidden = undefined; + cachePseudoContent = undefined; cachePseudoContentBefore = undefined; cachePseudoContentAfter = undefined; cachePointerEvents = undefined; diff --git a/tests/library/role-utils.spec.ts b/tests/library/role-utils.spec.ts index 1a59094419..bdfb9c78c7 100644 --- a/tests/library/role-utils.spec.ts +++ b/tests/library/role-utils.spec.ts @@ -47,13 +47,6 @@ for (let range = 0; range <= ranges.length; range++) { // Spec says role=combobox should use selected options, not a title attribute. 'description_1.0_combobox-focusable-manual.html', ]; - if (browserName === 'firefox') { - // This test contains the following style: - // [data-after]:after { content: attr(data-after); } - // In firefox, content is returned as "attr(data-after)" - // instead of being resolved to the actual value. - skipped.push('name_test_case_553-manual.html'); - } await page.addInitScript(() => { const self = window as any; @@ -104,7 +97,7 @@ for (let range = 0; range <= ranges.length; range++) { }); } -test('wpt accname non-manual', async ({ page, asset, server }) => { +test('wpt accname non-manual', async ({ page, asset, server, browserName }) => { await page.addInitScript(() => { const self = window as any; self.AriaUtils = {}; @@ -123,14 +116,6 @@ test('wpt accname non-manual', async ({ page, asset, server }) => { 'label valid on dd element', 'label valid on dt element', - // TODO: support Alternative Text syntax in ::before and ::after. - 'button name from fallback content with ::before and ::after', - 'heading name from fallback content with ::before and ::after', - 'link name from fallback content with ::before and ::after', - 'button name from fallback content mixing attr() and strings with ::before and ::after', - 'heading name from fallback content mixing attr() and strings with ::before and ::after', - 'link name from fallback content mixing attr() and strings with ::before and ::after', - // TODO: recursive bugs 'heading with link referencing image using aria-labelledby, that in turn references text element via aria-labelledby', 'heading with link referencing image using aria-labelledby, that in turn references itself and another element via aria-labelledby', @@ -525,6 +510,35 @@ test('should resolve pseudo content from attr', async ({ page }) => { expect(await getNameAndRole(page, 'a')).toEqual({ role: 'link', name: 'hello world' }); }); +test('should resolve pseudo content alternative text', async ({ page }) => { + await page.setContent(` + +
inner text
+ `); + expect(await getNameAndRole(page, 'div')).toEqual({ role: 'button', name: 'alternative text inner text' }); +}); + +test('should resolve css content property for an element', async ({ page }) => { + await page.setContent(` + +
inner text
+
inner text
+ `); + expect(await getNameAndRole(page, '#button1')).toEqual({ role: 'button', name: 'alternative text' }); + expect(await getNameAndRole(page, '#button2')).toEqual({ role: 'button', name: 'inner text' }); +}); + test('should ignore invalid aria-labelledby', async ({ page }) => { await page.setContent(`