fix(role): explicitly hidden aria-labelledby should be recursively traversed (#30402)
The accessible name computation spec has changed to explicitly mention this case: Step 2A. Hidden Not Referenced. If the current node is hidden and is: - Not part of an aria-labelledby or aria-describedby traversal, where the node directly referenced by that relation was hidden. - Nor part of a native host language text alternative element (e.g. label in HTML) or attribute traversal, where the root of that traversal was hidden. See https://w3c.github.io/accname/#computation-steps. Chromium, Firefox and Safari all agree with the spec. Fixes #29796.
This commit is contained in:
parent
82aefd24db
commit
b72e3a3eba
|
|
@ -389,9 +389,9 @@ export function getElementAccessibleName(element: Element, includeHidden: boolea
|
|||
accessibleName = normalizeAccessbileName(getElementAccessibleNameInternal(element, {
|
||||
includeHidden,
|
||||
visitedElements: new Set(),
|
||||
embeddedInLabelledBy: 'none',
|
||||
embeddedInLabel: 'none',
|
||||
embeddedInTextAlternativeElement: false,
|
||||
embeddedInLabelledBy: undefined,
|
||||
embeddedInLabel: undefined,
|
||||
embeddedInNativeTextAlternative: undefined,
|
||||
embeddedInTargetElement: 'self',
|
||||
}));
|
||||
}
|
||||
|
|
@ -404,9 +404,9 @@ export function getElementAccessibleName(element: Element, includeHidden: boolea
|
|||
type AccessibleNameOptions = {
|
||||
includeHidden: boolean,
|
||||
visitedElements: Set<Element>,
|
||||
embeddedInLabelledBy: 'none' | 'self' | 'descendant',
|
||||
embeddedInLabel: 'none' | 'self' | 'descendant',
|
||||
embeddedInTextAlternativeElement: boolean,
|
||||
embeddedInLabelledBy: { element: Element, hidden: boolean } | undefined,
|
||||
embeddedInLabel: { element: Element, hidden: boolean } | undefined,
|
||||
embeddedInNativeTextAlternative: { element: Element, hidden: boolean } | undefined,
|
||||
embeddedInTargetElement: 'none' | 'self' | 'descendant',
|
||||
};
|
||||
|
||||
|
|
@ -416,13 +416,17 @@ function getElementAccessibleNameInternal(element: Element, options: AccessibleN
|
|||
|
||||
const childOptions: AccessibleNameOptions = {
|
||||
...options,
|
||||
embeddedInLabel: options.embeddedInLabel === 'self' ? 'descendant' : options.embeddedInLabel,
|
||||
embeddedInLabelledBy: options.embeddedInLabelledBy === 'self' ? 'descendant' : options.embeddedInLabelledBy,
|
||||
embeddedInTargetElement: options.embeddedInTargetElement === 'self' ? 'descendant' : options.embeddedInTargetElement,
|
||||
};
|
||||
|
||||
// step 2a.
|
||||
if (!options.includeHidden && options.embeddedInLabelledBy !== 'self' && isElementHiddenForAria(element)) {
|
||||
// step 2a. Hidden Not Referenced: If the current node is hidden and is:
|
||||
// Not part of an aria-labelledby or aria-describedby traversal, where the node directly referenced by that relation was hidden.
|
||||
// Nor part of a native host language text alternative element (e.g. label in HTML) or attribute traversal, where the root of that traversal was hidden.
|
||||
if (!options.includeHidden &&
|
||||
!options.embeddedInLabelledBy?.hidden &&
|
||||
!options?.embeddedInNativeTextAlternative?.hidden &&
|
||||
!options?.embeddedInLabel?.hidden &&
|
||||
isElementHiddenForAria(element)) {
|
||||
options.visitedElements.add(element);
|
||||
return '';
|
||||
}
|
||||
|
|
@ -430,13 +434,13 @@ function getElementAccessibleNameInternal(element: Element, options: AccessibleN
|
|||
const labelledBy = getAriaLabelledByElements(element);
|
||||
|
||||
// step 2b.
|
||||
if (options.embeddedInLabelledBy === 'none') {
|
||||
if (!options.embeddedInLabelledBy) {
|
||||
const accessibleName = (labelledBy || []).map(ref => getElementAccessibleNameInternal(ref, {
|
||||
...options,
|
||||
embeddedInLabelledBy: 'self',
|
||||
embeddedInLabelledBy: { element: ref, hidden: isElementHiddenForAria(ref) },
|
||||
embeddedInTargetElement: 'none',
|
||||
embeddedInLabel: 'none',
|
||||
embeddedInTextAlternativeElement: false,
|
||||
embeddedInLabel: undefined,
|
||||
embeddedInNativeTextAlternative: undefined,
|
||||
})).join(' ');
|
||||
if (accessibleName)
|
||||
return accessibleName;
|
||||
|
|
@ -445,7 +449,8 @@ function getElementAccessibleNameInternal(element: Element, options: AccessibleN
|
|||
const role = getAriaRole(element) || '';
|
||||
|
||||
// step 2c.
|
||||
if (options.embeddedInLabel !== 'none' || options.embeddedInLabelledBy !== 'none') {
|
||||
// TODO: should we check embeddedInLabel here?
|
||||
if (!!options.embeddedInLabel || !!options.embeddedInLabelledBy) {
|
||||
const isOwnLabel = [...(element as (HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement)).labels || []].includes(element as any);
|
||||
const isOwnLabelledBy = (labelledBy || []).includes(element);
|
||||
if (!isOwnLabel && !isOwnLabelledBy) {
|
||||
|
|
@ -519,7 +524,7 @@ function getElementAccessibleNameInternal(element: Element, options: AccessibleN
|
|||
if (element.tagName === 'INPUT' && (element as HTMLInputElement).type === 'image') {
|
||||
options.visitedElements.add(element);
|
||||
const labels = (element as HTMLInputElement).labels || [];
|
||||
if (labels.length && options.embeddedInLabelledBy === 'none')
|
||||
if (labels.length && !options.embeddedInLabelledBy)
|
||||
return getAccessibleNameFromAssociatedLabels(labels, options);
|
||||
const alt = element.getAttribute('alt') || '';
|
||||
if (alt.trim())
|
||||
|
|
@ -576,7 +581,7 @@ function getElementAccessibleNameInternal(element: Element, options: AccessibleN
|
|||
if (child.tagName === 'LEGEND') {
|
||||
return getElementAccessibleNameInternal(child, {
|
||||
...childOptions,
|
||||
embeddedInTextAlternativeElement: true,
|
||||
embeddedInNativeTextAlternative: { element: child, hidden: isElementHiddenForAria(child) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -591,7 +596,7 @@ function getElementAccessibleNameInternal(element: Element, options: AccessibleN
|
|||
if (child.tagName === 'FIGCAPTION') {
|
||||
return getElementAccessibleNameInternal(child, {
|
||||
...childOptions,
|
||||
embeddedInTextAlternativeElement: true,
|
||||
embeddedInNativeTextAlternative: { element: child, hidden: isElementHiddenForAria(child) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -619,7 +624,7 @@ function getElementAccessibleNameInternal(element: Element, options: AccessibleN
|
|||
if (child.tagName === 'CAPTION') {
|
||||
return getElementAccessibleNameInternal(child, {
|
||||
...childOptions,
|
||||
embeddedInTextAlternativeElement: true,
|
||||
embeddedInNativeTextAlternative: { element: child, hidden: isElementHiddenForAria(child) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -650,7 +655,7 @@ function getElementAccessibleNameInternal(element: Element, options: AccessibleN
|
|||
if (child.tagName.toUpperCase() === 'TITLE' && (child as SVGElement).ownerSVGElement) {
|
||||
return getElementAccessibleNameInternal(child, {
|
||||
...childOptions,
|
||||
embeddedInLabelledBy: 'self',
|
||||
embeddedInLabelledBy: { element: child, hidden: isElementHiddenForAria(child) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -666,8 +671,8 @@ function getElementAccessibleNameInternal(element: Element, options: AccessibleN
|
|||
|
||||
// step 2f + step 2h.
|
||||
if (allowsNameFromContent(role, options.embeddedInTargetElement === 'descendant') ||
|
||||
options.embeddedInLabelledBy !== 'none' || options.embeddedInLabel !== 'none' ||
|
||||
options.embeddedInTextAlternativeElement) {
|
||||
!!options.embeddedInLabelledBy || !!options.embeddedInLabel ||
|
||||
!!options.embeddedInNativeTextAlternative) {
|
||||
options.visitedElements.add(element);
|
||||
const tokens: string[] = [];
|
||||
const visit = (node: Node, skipSlotted: boolean) => {
|
||||
|
|
@ -838,9 +843,9 @@ function hasExplicitAriaDisabled(element: Element | undefined): boolean {
|
|||
function getAccessibleNameFromAssociatedLabels(labels: Iterable<HTMLLabelElement>, options: AccessibleNameOptions) {
|
||||
return [...labels].map(label => getElementAccessibleNameInternal(label, {
|
||||
...options,
|
||||
embeddedInLabel: 'self',
|
||||
embeddedInTextAlternativeElement: false,
|
||||
embeddedInLabelledBy: 'none',
|
||||
embeddedInLabel: { element: label, hidden: isElementHiddenForAria(label) },
|
||||
embeddedInNativeTextAlternative: undefined,
|
||||
embeddedInLabelledBy: undefined,
|
||||
embeddedInTargetElement: 'none',
|
||||
})).filter(accessibleName => !!accessibleName).join(' ');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,10 +73,7 @@ module.exports = [
|
|||
'<label for="t1">HTML Label</label>' +
|
||||
'<input type="text" id="t1" aria-labelledby="t1label">',
|
||||
target: '#t1',
|
||||
// accessibleText: 'This is a hidden secret',
|
||||
// Note: axe-core insists on child nodes being used as visible, although
|
||||
// spec 2A says "directly referenced by aria-labelledby".
|
||||
accessibleText: 'This is a',
|
||||
accessibleText: 'This is a hidden secret',
|
||||
},
|
||||
|
||||
{
|
||||
|
|
|
|||
|
|
@ -306,6 +306,26 @@ test('display:contents should be visible when contents are visible', async ({ pa
|
|||
await expect(page.getByRole('button')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('label/labelled-by aria-hidden with descendants', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<body>
|
||||
<div id="case1">
|
||||
<button aria-labelledby="label1" type="button"></button>
|
||||
<tool-tip id="label1" for="button-preview" popover="manual" aria-hidden="true" role="tooltip">Label1</tool-tip>
|
||||
</div>
|
||||
<div id="case2">
|
||||
<label for="button2" aria-hidden="true"><div id="label2">Label2</div></label>
|
||||
<button id="button2" type="button"></button>
|
||||
</div>
|
||||
</body>
|
||||
`);
|
||||
await page.$$eval('#label1, #label2', els => {
|
||||
els.forEach(el => el.attachShadow({ mode: 'open' }).appendChild(document.createElement('slot')));
|
||||
});
|
||||
expect.soft(await getNameAndRole(page, '#case1 button')).toEqual({ role: 'button', name: 'Label1' });
|
||||
expect.soft(await getNameAndRole(page, '#case2 button')).toEqual({ role: 'button', name: 'Label2' });
|
||||
});
|
||||
|
||||
function toArray(x: any): any[] {
|
||||
return Array.isArray(x) ? x : [x];
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue