Skip to content
Open
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
23 changes: 22 additions & 1 deletion src/getElementFromNode.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
import { isElement } from "./isElement";

const DOCUMENT_FRAGMENT_NODE = 11;

function isShadowRoot(node: Node): node is ShadowRoot {
return node.nodeType === DOCUMENT_FRAGMENT_NODE;
}

/**
* The element a node belongs to.
*
* A mutation target can be a `ShadowRoot` - rendering a component replaces the
* children of its shadow root - which has no `parentElement`, so for those the
* element to speak about is the host.
*/
export const getElementFromNode = (node: Node): HTMLElement => {
return isElement(node) ? node : node.parentElement!;
if (isElement(node)) {
return node;
}

if (isShadowRoot(node)) {
return node.host as HTMLElement;
}

return node.parentElement!;
};
19 changes: 18 additions & 1 deletion src/getLiveSpokenPhrase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,23 @@ function getSpokenPhraseForNode(node: Node) {
);
}

/**
* The element above `target`, crossing the shadow boundary when `target` is the
* top element inside a shadow root.
*
* A live region is often the host element itself (a component that renders its
* message in its own shadow root), and `parentElement` never reaches that host
* from inside the shadow root.
*/
function getTargetAncestor(target: Element): Element | null {
return (
target.assignedSlot ??
target.parentElement ??
(target.getRootNode() as ShadowRoot).host ??
null
);
}

function getAllSpokenPhrase({
addedNodes,
removedNodes,
Expand Down Expand Up @@ -261,7 +278,7 @@ function getLiveRegionAttributes(
};
}

const targetAncestor = target.parentElement;
const targetAncestor = getTargetAncestor(target);

if (target === container || targetAncestor === null) {
return {
Expand Down
88 changes: 76 additions & 12 deletions src/observeDOM.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { isElement } from "./isElement";
import type { Root } from "./Virtual";

const MUTATION_OBSERVER_OPTIONS: MutationObserverInit = {
attributes: true,
characterData: true,
childList: true,
subtree: true,
};

export function observeDOM(
root: Root | undefined,
node: Node,
Expand All @@ -13,23 +20,80 @@ export function observeDOM(
const MutationObserver =
typeof root !== "undefined" ? root?.MutationObserver : null;

if (MutationObserver) {
const mutationObserver = new MutationObserver(onChange);
if (!MutationObserver) {
return () => {
// gracefully fallback to not supporting Accessibility Tree refreshes if
// the DOM changes.
};
}

const observed = new Set<Node>();

mutationObserver.observe(node, {
attributes: true,
characterData: true,
childList: true,
subtree: true,
});
const mutationObserver = new MutationObserver((mutations, observer) => {
scan();

return () => {
mutationObserver.disconnect();
onChange(mutations, observer);
});

const watch = (target: Node) => {
if (observed.has(target)) {
return;
}

observed.add(target);

mutationObserver.observe(target, MUTATION_OBSERVER_OPTIONS);
};

/**
* A `MutationObserver` does not cross shadow boundaries, so observing `node`
* with `subtree: true` misses everything inside a shadow root - which is where
* web components render their content, live regions included.
*/
const scan = () => {
const queue: Node[] = [node];

while (queue.length > 0) {
const current = queue.pop()!;

watch(current);

if (isElement(current)) {
current.querySelectorAll("*").forEach((element) => {
if (element.shadowRoot) {
queue.push(element.shadowRoot);
}
});
}
}
};

const elementConstructor =
root?.document?.defaultView?.Element ?? globalThis.Element;

const attachShadow = elementConstructor?.prototype?.attachShadow;

scan();

if (attachShadow) {
// A shadow root that is already populated when we start observing it never
// replays those changes, so shadow roots attached later have to be covered
// as they are created.
elementConstructor.prototype.attachShadow = function (init) {
const shadowRoot = attachShadow.call(this, init);

watch(shadowRoot);

return shadowRoot;
};
}

return () => {
// gracefully fallback to not supporting Accessibility Tree refreshes if
// the DOM changes.
if (attachShadow) {
elementConstructor.prototype.attachShadow = attachShadow;
}

mutationObserver.disconnect();
observed.clear();
};
}
95 changes: 95 additions & 0 deletions test/int/ariaLiveShadowRoot.int.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { virtual } from "../../src/index.js";
import { waitFor } from "@testing-library/dom";

const MESSAGE = "Please enter a valid IBAN.";

/**
* A live region on the host element, with the content it announces rendered
* inside that host's shadow root - the shape a web component uses when it
* renders its own feedback (Lion's `lion-validation-feedback`, for example).
*/
function setupHostLiveRegion() {
document.body.innerHTML = `
<button id="trigger-1">Trigger host live region</button>
<div id="host-1" aria-live="assertive" aria-atomic="false"></div>

<button id="trigger-2">Trigger shadow live region</button>
<div id="host-2"></div>
`;

const host1 = document.getElementById("host-1")!;
const host2 = document.getElementById("host-2")!;

host1.attachShadow({ mode: "open" });
host2.attachShadow({ mode: "open" }).innerHTML = "<div id=\"target-2\" aria-live=\"polite\"></div>";

document.getElementById("trigger-1")!.addEventListener("click", () => {
// replaces the children of the shadow root, so the mutation target is the
// ShadowRoot itself
host1.shadowRoot!.innerHTML = `<span>${MESSAGE}</span>`;
});

document.getElementById("trigger-2")!.addEventListener("click", () => {
// a text change inside the shadow root, so the walk up to the host of the
// live region has to cross the shadow boundary
host2.shadowRoot!.getElementById("target-2")!.textContent = MESSAGE;
});

return () => {
document.body.innerHTML = "";
};
}

describe("Aria Live inside a shadow root", () => {
let teardown: () => void;

beforeEach(() => {
teardown = setupHostLiveRegion();
});

afterEach(async () => {
await virtual.stop();
teardown();
});

it("should announce changes to a live region that renders its content in its own shadow root", async () => {
await virtual.start({ container: document.body });

document.querySelector<HTMLButtonElement>("#trigger-1")!.focus();

await virtual.act();

await waitFor(
() => expect(document.getElementById("host-1")!.shadowRoot!.textContent).toBe(MESSAGE),
{ timeout: 7000 }
);

expect(await virtual.spokenPhraseLog()).toEqual([
"document",
"button, Trigger host live region",
`assertive: ${MESSAGE}`,
]);
});

it("should announce changes to a live region inside the shadow root of an ancestor", async () => {
await virtual.start({ container: document.body });

document.querySelector<HTMLButtonElement>("#trigger-2")!.focus();

await virtual.act();

await waitFor(
() =>
expect(
document.getElementById("host-2")!.shadowRoot!.querySelector("#target-2")!.textContent
).toBe(MESSAGE),
{ timeout: 7000 }
);

expect(await virtual.spokenPhraseLog()).toEqual([
"document",
"button, Trigger shadow live region",
`polite: ${MESSAGE}`,
]);
});
});