fix(role): support alternative text in CSS content property (#35878)
This commit is contained in:
parent
f89d0ae870
commit
33f811b2ae
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue