Skip to content

Commit 33f811b

Browse files
authored
fix(role): support alternative text in CSS content property (#35878)
1 parent f89d0ae commit 33f811b

File tree

3 files changed

+126
-59
lines changed

3 files changed

+126
-59
lines changed

packages/injected/src/ariaSnapshot.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ export function generateAriaTree(rootElement: Element, generation: number, optio
102102
if (treatAsBlock)
103103
ariaNode.children.push(treatAsBlock);
104104

105-
ariaNode.children.push(roleUtils.getPseudoContent(element, '::before'));
105+
ariaNode.children.push(roleUtils.getCSSContent(element, '::before') || '');
106106
const assignedNodes = element.nodeName === 'SLOT' ? (element as HTMLSlotElement).assignedNodes() : [];
107107
if (assignedNodes.length) {
108108
for (const child of assignedNodes)
@@ -121,7 +121,7 @@ export function generateAriaTree(rootElement: Element, generation: number, optio
121121
for (const child of ariaChildren)
122122
visit(ariaNode, child);
123123

124-
ariaNode.children.push(roleUtils.getPseudoContent(element, '::after'));
124+
ariaNode.children.push(roleUtils.getCSSContent(element, '::after') || '');
125125

126126
if (treatAsBlock)
127127
ariaNode.children.push(treatAsBlock);

packages/injected/src/roleUtils.ts

Lines changed: 94 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
* limitations under the License.
1515
*/
1616

17+
import * as css from '@isomorphic/cssTokenizer';
18+
1719
import { getGlobalOptions, closestCrossShadow, elementSafeTagName, enclosingShadowRootOrDocument, getElementComputedStyle, isElementStyleVisibilityVisible, isVisibleTextNode, parentElementOrShadowHost } from './domUtils';
1820

1921
import type { AriaRole } from '@isomorphic/ariaSnapshot';
@@ -355,41 +357,80 @@ function queryInAriaOwned(element: Element, selector: string): Element[] {
355357
return result;
356358
}
357359

358-
export function getPseudoContent(element: Element, pseudo: '::before' | '::after') {
359-
const cache = pseudo === '::before' ? cachePseudoContentBefore : cachePseudoContentAfter;
360+
export function getCSSContent(element: Element, pseudo?: '::before' | '::after') {
361+
// Relevant spec: 2.6.2 from https://w3c.github.io/accname/#computation-steps.
362+
// Additional considerations: https://github.com/w3c/accname/issues/204.
363+
const cache = pseudo === '::before' ? cachePseudoContentBefore : (pseudo === '::after' ? cachePseudoContentAfter : cachePseudoContent);
360364
if (cache?.has(element))
361-
return cache?.get(element) || '';
362-
const pseudoStyle = getElementComputedStyle(element, pseudo);
363-
const content = getPseudoContentImpl(element, pseudoStyle);
364-
if (cache)
365-
cache.set(element, content);
366-
return content;
367-
}
365+
return cache?.get(element);
368366

369-
function getPseudoContentImpl(element: Element, pseudoStyle: CSSStyleDeclaration | undefined) {
370-
// Note: all browsers ignore display:none and visibility:hidden pseudos.
371-
if (!pseudoStyle || pseudoStyle.display === 'none' || pseudoStyle.visibility === 'hidden')
372-
return '';
373-
const content = pseudoStyle.content;
374-
let resolvedContent: string | undefined;
375-
if ((content[0] === '\'' && content[content.length - 1] === '\'') ||
376-
(content[0] === '"' && content[content.length - 1] === '"')) {
377-
resolvedContent = content.substring(1, content.length - 1);
378-
} else if (content.startsWith('attr(') && content.endsWith(')')) {
379-
// Firefox does not resolve attribute accessors in content.
380-
const attrName = content.substring('attr('.length, content.length - 1).trim();
381-
resolvedContent = element.getAttribute(attrName) || '';
367+
const style = getElementComputedStyle(element, pseudo);
368+
let content: string | undefined;
369+
if (style && style.display !== 'none' && style.visibility !== 'hidden') {
370+
// Note: all browsers ignore display:none and visibility:hidden pseudos.
371+
content = parseCSSContentPropertyAsString(element, style.content, !!pseudo);
382372
}
383-
if (resolvedContent !== undefined) {
373+
374+
if (pseudo && content !== undefined) {
384375
// SPEC DIFFERENCE.
385376
// Spec says "CSS textual content, without a space", but we account for display
386377
// to pass "name_file-label-inline-block-styles-manual.html"
387-
const display = pseudoStyle.display || 'inline';
378+
const display = style?.display || 'inline';
388379
if (display !== 'inline')
389-
return ' ' + resolvedContent + ' ';
390-
return resolvedContent;
380+
content = ' ' + content + ' ';
381+
}
382+
383+
if (cache)
384+
cache.set(element, content);
385+
return content;
386+
}
387+
388+
function parseCSSContentPropertyAsString(element: Element, content: string, isPseudo: boolean): string | undefined {
389+
// Welcome to the mini CSS parser!
390+
// It aims to support the following syntax and any subset of it:
391+
// content: "one" attr(...) "two" "three" / "alt" attr(...) "more alt"
392+
// See https://developer.mozilla.org/en-US/docs/Web/CSS/content for more details.
393+
394+
if (!content || content === 'none' || content === 'normal') {
395+
// Common fast path.
396+
return;
397+
}
398+
399+
try {
400+
let tokens = css.tokenize(content).filter(token => !(token instanceof css.WhitespaceToken));
401+
const delimIndex = tokens.findIndex(token => token instanceof css.DelimToken && token.value === '/');
402+
if (delimIndex !== -1) {
403+
// Use the alternative text part when exists.
404+
// content: ... / "alternative text"
405+
tokens = tokens.slice(delimIndex + 1);
406+
} else if (!isPseudo) {
407+
// For non-pseudo elements, the only valid content is a url() or various gradients.
408+
// Therefore, we follow Chrome and only consider the alternative text.
409+
// Firefox, on the other hand, calculates accessible name to be empty.
410+
return;
411+
}
412+
413+
const accumulated: string[] = [];
414+
let index = 0;
415+
while (index < tokens.length) {
416+
if (tokens[index] instanceof css.StringToken) {
417+
// content: "some text"
418+
accumulated.push(tokens[index].value as string);
419+
index++;
420+
} 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) {
421+
// content: attr(...)
422+
// Firefox does not resolve attribute accessors in content, so we do it manually.
423+
const attrName = tokens[index + 1].value as string;
424+
accumulated.push(element.getAttribute(attrName) || '');
425+
index += 3;
426+
} else {
427+
// Failed to parse the content, so ignore it.
428+
return;
429+
}
430+
}
431+
return accumulated.join('');
432+
} catch {
391433
}
392-
return '';
393434
}
394435

395436
export function getAriaLabelledByElements(element: Element): Element[] | null {
@@ -879,22 +920,31 @@ function innerAccumulatedElementText(element: Element, options: AccessibleNameOp
879920
tokens.push(node.textContent || '');
880921
}
881922
};
882-
tokens.push(getPseudoContent(element, '::before'));
883-
const assignedNodes = element.nodeName === 'SLOT' ? (element as HTMLSlotElement).assignedNodes() : [];
884-
if (assignedNodes.length) {
885-
for (const child of assignedNodes)
886-
visit(child, false);
923+
tokens.push(getCSSContent(element, '::before') || '');
924+
const content = getCSSContent(element);
925+
if (content !== undefined) {
926+
// `content` CSS property replaces everything inside the element.
927+
// I was not able to find any spec or description on how this interacts with accname,
928+
// so this is a guess based on what browsers do.
929+
tokens.push(content);
887930
} else {
888-
for (let child = element.firstChild; child; child = child.nextSibling)
889-
visit(child, true);
890-
if (element.shadowRoot) {
891-
for (let child = element.shadowRoot.firstChild; child; child = child.nextSibling)
931+
// step 2h.
932+
const assignedNodes = element.nodeName === 'SLOT' ? (element as HTMLSlotElement).assignedNodes() : [];
933+
if (assignedNodes.length) {
934+
for (const child of assignedNodes)
935+
visit(child, false);
936+
} else {
937+
for (let child = element.firstChild; child; child = child.nextSibling)
892938
visit(child, true);
939+
if (element.shadowRoot) {
940+
for (let child = element.shadowRoot.firstChild; child; child = child.nextSibling)
941+
visit(child, true);
942+
}
943+
for (const owned of getIdRefs(element, element.getAttribute('aria-owns')))
944+
visit(owned, true);
893945
}
894-
for (const owned of getIdRefs(element, element.getAttribute('aria-owns')))
895-
visit(owned, true);
896946
}
897-
tokens.push(getPseudoContent(element, '::after'));
947+
tokens.push(getCSSContent(element, '::after') || '');
898948
return tokens.join('');
899949
}
900950

@@ -1091,8 +1141,9 @@ let cacheAccessibleDescription: Map<Element, string> | undefined;
10911141
let cacheAccessibleDescriptionHidden: Map<Element, string> | undefined;
10921142
let cacheAccessibleErrorMessage: Map<Element, string> | undefined;
10931143
let cacheIsHidden: Map<Element, boolean> | undefined;
1094-
let cachePseudoContentBefore: Map<Element, string> | undefined;
1095-
let cachePseudoContentAfter: Map<Element, string> | undefined;
1144+
let cachePseudoContent: Map<Element, string | undefined> | undefined;
1145+
let cachePseudoContentBefore: Map<Element, string | undefined> | undefined;
1146+
let cachePseudoContentAfter: Map<Element, string | undefined> | undefined;
10961147
let cachePointerEvents: Map<Element, boolean> | undefined;
10971148
let cachesCounter = 0;
10981149

@@ -1104,6 +1155,7 @@ export function beginAriaCaches() {
11041155
cacheAccessibleDescriptionHidden ??= new Map();
11051156
cacheAccessibleErrorMessage ??= new Map();
11061157
cacheIsHidden ??= new Map();
1158+
cachePseudoContent ??= new Map();
11071159
cachePseudoContentBefore ??= new Map();
11081160
cachePseudoContentAfter ??= new Map();
11091161
cachePointerEvents ??= new Map();
@@ -1117,6 +1169,7 @@ export function endAriaCaches() {
11171169
cacheAccessibleDescriptionHidden = undefined;
11181170
cacheAccessibleErrorMessage = undefined;
11191171
cacheIsHidden = undefined;
1172+
cachePseudoContent = undefined;
11201173
cachePseudoContentBefore = undefined;
11211174
cachePseudoContentAfter = undefined;
11221175
cachePointerEvents = undefined;

tests/library/role-utils.spec.ts

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,6 @@ for (let range = 0; range <= ranges.length; range++) {
4747
// Spec says role=combobox should use selected options, not a title attribute.
4848
'description_1.0_combobox-focusable-manual.html',
4949
];
50-
if (browserName === 'firefox') {
51-
// This test contains the following style:
52-
// [data-after]:after { content: attr(data-after); }
53-
// In firefox, content is returned as "attr(data-after)"
54-
// instead of being resolved to the actual value.
55-
skipped.push('name_test_case_553-manual.html');
56-
}
5750

5851
await page.addInitScript(() => {
5952
const self = window as any;
@@ -104,7 +97,7 @@ for (let range = 0; range <= ranges.length; range++) {
10497
});
10598
}
10699

107-
test('wpt accname non-manual', async ({ page, asset, server }) => {
100+
test('wpt accname non-manual', async ({ page, asset, server, browserName }) => {
108101
await page.addInitScript(() => {
109102
const self = window as any;
110103
self.AriaUtils = {};
@@ -123,14 +116,6 @@ test('wpt accname non-manual', async ({ page, asset, server }) => {
123116
'label valid on dd element',
124117
'label valid on dt element',
125118

126-
// TODO: support Alternative Text syntax in ::before and ::after.
127-
'button name from fallback content with ::before and ::after',
128-
'heading name from fallback content with ::before and ::after',
129-
'link name from fallback content with ::before and ::after',
130-
'button name from fallback content mixing attr() and strings with ::before and ::after',
131-
'heading name from fallback content mixing attr() and strings with ::before and ::after',
132-
'link name from fallback content mixing attr() and strings with ::before and ::after',
133-
134119
// TODO: recursive bugs
135120
'heading with link referencing image using aria-labelledby, that in turn references text element via aria-labelledby',
136121
'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 }) => {
525510
expect(await getNameAndRole(page, 'a')).toEqual({ role: 'link', name: 'hello world' });
526511
});
527512

513+
test('should resolve pseudo content alternative text', async ({ page }) => {
514+
await page.setContent(`
515+
<style>
516+
.with-content:before {
517+
content: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'></svg>") / "alternative text";
518+
}
519+
</style>
520+
<div role="button" class="with-content"> inner text</div>
521+
`);
522+
expect(await getNameAndRole(page, 'div')).toEqual({ role: 'button', name: 'alternative text inner text' });
523+
});
524+
525+
test('should resolve css content property for an element', async ({ page }) => {
526+
await page.setContent(`
527+
<style>
528+
.with-content-1 {
529+
content: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'></svg>") / "alternative text";
530+
}
531+
.with-content-2 {
532+
content: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'></svg>");
533+
}
534+
</style>
535+
<div id="button1" role="button" class="with-content-1">inner text</div>
536+
<div id="button2" role="button" class="with-content-2">inner text</div>
537+
`);
538+
expect(await getNameAndRole(page, '#button1')).toEqual({ role: 'button', name: 'alternative text' });
539+
expect(await getNameAndRole(page, '#button2')).toEqual({ role: 'button', name: 'inner text' });
540+
});
541+
528542
test('should ignore invalid aria-labelledby', async ({ page }) => {
529543
await page.setContent(`
530544
<label>

0 commit comments

Comments
 (0)