fix(role): account for unslotted elements being hidden for aria (#22070)

When element is not assigned to any slot in the shadow root, it is not
rendered and is considered hidden for ARIA in all browsers.

In Chromium/Firefox we use `Element.checkVisibility` that already
handles this, but in WebKit we have to check it manually.

Fixes #21487.
This commit is contained in:
Dmitry Gozman 2023-03-29 17:08:05 -07:00 committed by GitHub
parent 0c4eedbabe
commit 548e4a0c0f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 53 additions and 5 deletions

View File

@ -232,6 +232,8 @@ function getAriaBoolean(attr: string | null) {
}
// https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion, but including "none" and "presentation" roles
// Not implemented:
// `Any descendants of elements that have the characteristic "Children Presentational: True"`
// https://www.w3.org/TR/wai-aria-1.2/#aria-hidden
export function isElementHiddenForAria(element: Element, cache: Map<Element, boolean>): boolean {
if (['STYLE', 'SCRIPT', 'NOSCRIPT', 'TEMPLATE'].includes(element.tagName))
@ -242,17 +244,30 @@ export function isElementHiddenForAria(element: Element, cache: Map<Element, boo
const isSlot = element.nodeName === 'SLOT';
if (!isOptionInsideSelect && !isSlot && !isElementStyleVisibilityVisible(element))
return true;
return belongsToDisplayNoneOrAriaHidden(element, cache);
return belongsToDisplayNoneOrAriaHiddenOrNonSlotted(element, cache);
}
function belongsToDisplayNoneOrAriaHidden(element: Element, cache: Map<Element, boolean>): boolean {
function belongsToDisplayNoneOrAriaHiddenOrNonSlotted(element: Element, cache: Map<Element, boolean>): boolean {
if (!cache.has(element)) {
const style = getElementComputedStyle(element);
let hidden = !style || style.display === 'none' || getAriaBoolean(element.getAttribute('aria-hidden')) === true;
let hidden = false;
// When parent has a shadow root, all light dom children must be assigned to a slot,
// otherwise they are not rendered and considered hidden for aria.
// Note: we can remove this logic once WebKit supports `Element.checkVisibility`.
if (element.parentElement && element.parentElement.shadowRoot && !element.assignedSlot)
hidden = true;
// display:none and aria-hidden=true are considered hidden for aria.
if (!hidden) {
const style = getElementComputedStyle(element);
hidden = !style || style.display === 'none' || getAriaBoolean(element.getAttribute('aria-hidden')) === true;
}
// Check recursively.
if (!hidden) {
const parent = parentElementOrShadowHost(element);
if (parent)
hidden = hidden || belongsToDisplayNoneOrAriaHidden(parent, cache);
hidden = belongsToDisplayNoneOrAriaHiddenOrNonSlotted(parent, cache);
}
cache.set(element, hidden);
}

View File

@ -446,3 +446,36 @@ test('errors', async ({ page }) => {
const e8 = await page.$('role=treeitem[expanded="none"]').catch(e => e);
expect(e8.message).toContain(`"expanded" must be one of true, false`);
});
test('hidden with shadow dom slots', async ({ page }) => {
await page.setContent(`
<div make-hidden>
<button>hidden1</button>
</div>
<div make-hidden>
<span><button>hidden2</button></v>
</div>
<div>
<button>visible1</button>
</div>
<div>
<span><button>visible2</button></span>
</div>
<script>
for (const div of document.querySelectorAll('div')) {
const hidden = div.hasAttribute('make-hidden');
div.attachShadow({ mode: 'open' }).innerHTML = hidden ? 'nothing to see here' : '<slot></slot>';
}
</script>
`);
expect(await page.locator(`role=button`).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([
`<button>visible1</button>`,
`<button>visible2</button>`,
]);
expect(await page.locator(`role=button[include-hidden]`).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([
`<button>hidden1</button>`,
`<button>hidden2</button>`,
`<button>visible1</button>`,
`<button>visible2</button>`,
]);
});