diff --git a/src/getElementFromNode.ts b/src/getElementFromNode.ts index ef36f6e..472dce7 100644 --- a/src/getElementFromNode.ts +++ b/src/getElementFromNode.ts @@ -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!; }; diff --git a/src/getLiveSpokenPhrase.ts b/src/getLiveSpokenPhrase.ts index bc3e564..621093b 100644 --- a/src/getLiveSpokenPhrase.ts +++ b/src/getLiveSpokenPhrase.ts @@ -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, @@ -261,7 +278,7 @@ function getLiveRegionAttributes( }; } - const targetAncestor = target.parentElement; + const targetAncestor = getTargetAncestor(target); if (target === container || targetAncestor === null) { return { diff --git a/src/observeDOM.ts b/src/observeDOM.ts index 42b81a3..2177cfc 100644 --- a/src/observeDOM.ts +++ b/src/observeDOM.ts @@ -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, @@ -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(); - 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(); }; } diff --git a/test/int/ariaLiveShadowRoot.int.test.ts b/test/int/ariaLiveShadowRoot.int.test.ts new file mode 100644 index 0000000..9ca76ab --- /dev/null +++ b/test/int/ariaLiveShadowRoot.int.test.ts @@ -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 = ` + +
+ + +
+ `; + + const host1 = document.getElementById("host-1")!; + const host2 = document.getElementById("host-2")!; + + host1.attachShadow({ mode: "open" }); + host2.attachShadow({ mode: "open" }).innerHTML = "
"; + + document.getElementById("trigger-1")!.addEventListener("click", () => { + // replaces the children of the shadow root, so the mutation target is the + // ShadowRoot itself + host1.shadowRoot!.innerHTML = `${MESSAGE}`; + }); + + 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("#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("#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}`, + ]); + }); +});