Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/isHiddenFromAccessibilityTree.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,47 @@
import { getLocalName } from "./getLocalName";
import { isElement } from "./isElement";

const TEXT_NODE = 3;

/**
* The content of a closed `<details>` 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;
}

// `node.textContent` is only `null` for `document` and `doctype`.

if (isInClosedDetailsContent(node)) {
return true;
}

if (node.nodeType === TEXT_NODE && node.textContent!.trim()) {
return false;
}
Expand Down
94 changes: 94 additions & 0 deletions test/int/detailsSummary.int.test.ts
Original file line number Diff line number Diff line change
@@ -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 = `
<main>
<details>
<summary>Plan details</summary>
<p>Hidden part</p>
Hidden text
</details>
</main>
`;

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 = `
<main>
<details>
<summary>First summary</summary>
<summary>Second summary</summary>
</details>
</main>
`;

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 = `
<main>
<details open>
<summary>Plan details</summary>
<p>Visible part</p>
</details>
</main>
`;

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",
]);
});
});
Loading