diff --git a/src/isHiddenFromAccessibilityTree.ts b/src/isHiddenFromAccessibilityTree.ts index 2c25551..c6c29e9 100644 --- a/src/isHiddenFromAccessibilityTree.ts +++ b/src/isHiddenFromAccessibilityTree.ts @@ -1,7 +1,36 @@ +import { getLocalName } from "./getLocalName"; import { isElement } from "./isElement"; const TEXT_NODE = 3; +/** + * The content of a closed `
` element, other than its summary, is + * rendered with `content-visibility: hidden`, so its contents are skipped and + * must not be accessible to user-agent features (similar to `display: none`). + * + * REF: + * - https://html.spec.whatwg.org/multipage/rendering.html#the-details-and-summary-elements + * - https://www.w3.org/TR/css-contain-2/#content-visibility + */ +function isInClosedDetailsContent(node: Node) { + const parent = node.parentElement; + + if ( + !parent || + getLocalName(parent) !== "details" || + parent.hasAttribute("open") + ) { + return false; + } + + // REF: https://html.spec.whatwg.org/multipage/interactive-elements.html#summary-for-its-parent-details + const summaryForParentDetails = Array.from(parent.children).find( + (child) => getLocalName(child) === "summary" + ); + + return node !== summaryForParentDetails; +} + export function isHiddenFromAccessibilityTree(node: Node | null): node is null { if (!node) { return true; @@ -9,6 +38,10 @@ export function isHiddenFromAccessibilityTree(node: Node | null): node is null { // `node.textContent` is only `null` for `document` and `doctype`. + if (isInClosedDetailsContent(node)) { + return true; + } + if (node.nodeType === TEXT_NODE && node.textContent!.trim()) { return false; } diff --git a/test/int/detailsSummary.int.test.ts b/test/int/detailsSummary.int.test.ts new file mode 100644 index 0000000..2c15748 --- /dev/null +++ b/test/int/detailsSummary.int.test.ts @@ -0,0 +1,94 @@ +import { virtual } from "../../src/index.js"; + +describe("Details and Summary", () => { + afterEach(async () => { + await virtual.stop(); + + document.body.innerHTML = ""; + }); + + it("should not announce the content of a closed details element", async () => { + document.body.innerHTML = ` +
+
+ Plan details +

Hidden part

+ Hidden text +
+
+ `; + + await virtual.start({ container: document.body }); + + while ((await virtual.lastSpokenPhrase()) !== "end of document") { + await virtual.next(); + } + + expect(await virtual.spokenPhraseLog()).toEqual([ + "document", + "main", + "group", + "Plan details", + "end of group", + "end of main", + "end of document", + ]); + }); + + it("should only announce the first summary of a closed details element", async () => { + document.body.innerHTML = ` +
+
+ First summary + Second summary +
+
+ `; + + await virtual.start({ container: document.body }); + + while ((await virtual.lastSpokenPhrase()) !== "end of document") { + await virtual.next(); + } + + expect(await virtual.spokenPhraseLog()).toEqual([ + "document", + "main", + "group", + "First summary", + "end of group", + "end of main", + "end of document", + ]); + }); + + it("should announce the content of an open details element", async () => { + document.body.innerHTML = ` +
+
+ Plan details +

Visible part

+
+
+ `; + + await virtual.start({ container: document.body }); + + while ((await virtual.lastSpokenPhrase()) !== "end of document") { + await virtual.next(); + } + + expect(await virtual.spokenPhraseLog()).toEqual([ + "document", + "main", + "group", + "Plan details", + "paragraph", + "Visible part", + "end of paragraph", + "end of group", + "end of main", + "end of document", + ]); + }); +});