fix(role): support alternative text in CSS content property (#35878)

This commit is contained in:
Dmitry Gozman 2025-05-08 17:42:13 +00:00 committed by GitHub
parent f89d0ae870
commit 33f811b2ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 126 additions and 59 deletions

View File

@ -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);

View File

@ -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<Element, string> | undefined;
let cacheAccessibleDescriptionHidden: Map<Element, string> | undefined;
let cacheAccessibleErrorMessage: Map<Element, string> | undefined;
let cacheIsHidden: Map<Element, boolean> | undefined;
let cachePseudoContentBefore: Map<Element, string> | undefined;
let cachePseudoContentAfter: Map<Element, string> | undefined;
let cachePseudoContent: Map<Element, string | undefined> | undefined;
let cachePseudoContentBefore: Map<Element, string | undefined> | undefined;
let cachePseudoContentAfter: Map<Element, string | undefined> | undefined;
let cachePointerEvents: Map<Element, boolean> | 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;

View File

@ -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(`
<style>
.with-content:before {
content: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'></svg>") / "alternative text";
}
</style>
<div role="button" class="with-content"> inner text</div>
`);
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(`
<style>
.with-content-1 {
content: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'></svg>") / "alternative text";
}
.with-content-2 {
content: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'></svg>");
}
</style>
<div id="button1" role="button" class="with-content-1">inner text</div>
<div id="button2" role="button" class="with-content-2">inner text</div>
`);
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(`
<label>