diff --git a/src/Virtual.ts b/src/Virtual.ts index 051de4a..607a6cb 100644 --- a/src/Virtual.ts +++ b/src/Virtual.ts @@ -340,6 +340,7 @@ export class Virtual { getLiveSpokenPhrase({ container, mutation, + mutations, }), ) .filter(Boolean) diff --git a/src/createAccessibilityTree.ts b/src/createAccessibilityTree.ts index 4868197..b8afa3a 100644 --- a/src/createAccessibilityTree.ts +++ b/src/createAccessibilityTree.ts @@ -14,6 +14,7 @@ export interface AccessibilityNode { accessibleValue: string; allowedAccessibilityChildRoles: string[]; alternateReadingOrderParents: Node[]; + busy: boolean; childrenPresentational: boolean; isInert: boolean; node: Node; @@ -181,6 +182,7 @@ function growTree( accessibleName, accessibleValue, allowedAccessibilityChildRoles, + busy, childrenPresentational, isExplicitPresentational, isInert, @@ -201,6 +203,7 @@ function growTree( accessibleValue, allowedAccessibilityChildRoles, alternateReadingOrderParents, + busy, children: [], childrenPresentational, isInert, @@ -250,6 +253,7 @@ function growTree( accessibleName, accessibleValue, allowedAccessibilityChildRoles, + busy, childrenPresentational, isInert, isExplicitPresentational, @@ -270,6 +274,7 @@ function growTree( accessibleValue, allowedAccessibilityChildRoles, alternateReadingOrderParents, + busy, children: [], childrenPresentational, isInert, @@ -309,6 +314,7 @@ export function createAccessibilityTree( accessibleName, accessibleValue, allowedAccessibilityChildRoles, + busy, childrenPresentational, isInert, role, @@ -328,6 +334,7 @@ export function createAccessibilityTree( accessibleValue, allowedAccessibilityChildRoles, alternateReadingOrderParents: [], + busy, children: [], childrenPresentational, isInert, diff --git a/src/getLiveSpokenPhrase.ts b/src/getLiveSpokenPhrase.ts index bc3e564..00ff7b7 100644 --- a/src/getLiveSpokenPhrase.ts +++ b/src/getLiveSpokenPhrase.ts @@ -20,7 +20,7 @@ type ValueOf = T[keyof T]; * Live region attributes: * * - aria-atomic - * - TODO: aria-busy + * - aria-busy * - aria-live * - aria-relevant * @@ -53,6 +53,7 @@ const RELEVANT = { const RELEVANT_VALUES = new Set(Object.values(RELEVANT)); const DEFAULT_ATOMIC = false; +const DEFAULT_BUSY = false; const DEFAULT_LIVE = LIVE.OFF; const DEFAULT_RELEVANT = [RELEVANT.ADDITIONS, RELEVANT.TEXT]; @@ -185,17 +186,20 @@ function getLiveRegionAttributes( }, { atomic, + busy, live, liveTarget, relevant, }: { atomic?: boolean; + busy?: boolean; live?: ValueOf; liveTarget?: Element; relevant?: ValueOf[]; } = {} ): { atomic: boolean; + busy: boolean; live: ValueOf; liveTarget?: Element; relevant: ValueOf[]; @@ -218,6 +222,10 @@ function getLiveRegionAttributes( atomic = target.getAttribute("aria-atomic") === "true"; } + if (typeof busy === "undefined" && target.hasAttribute("aria-busy")) { + busy = target.getAttribute("aria-busy") === "true"; + } + if (typeof live === "undefined" && target.hasAttribute("aria-live")) { live = target.getAttribute("aria-live") as ValueOf; liveTarget = target; @@ -250,11 +258,13 @@ function getLiveRegionAttributes( if ( typeof atomic !== "undefined" && + typeof busy !== "undefined" && typeof live !== "undefined" && typeof relevant !== "undefined" ) { return { atomic, + busy, live, liveTarget, relevant, @@ -266,6 +276,7 @@ function getLiveRegionAttributes( if (target === container || targetAncestor === null) { return { atomic: atomic ?? DEFAULT_ATOMIC, + busy: busy ?? DEFAULT_BUSY, live: live ?? DEFAULT_LIVE, liveTarget, relevant: relevant ?? DEFAULT_RELEVANT, @@ -276,6 +287,7 @@ function getLiveRegionAttributes( { container, target: targetAncestor }, { atomic, + busy, live, liveTarget, relevant, @@ -285,20 +297,65 @@ function getLiveRegionAttributes( export function getLiveSpokenPhrase({ container, - mutation: { addedNodes, removedNodes, target, type }, + mutation, + mutations, }: { container: Node | null; mutation: MutationRecord; + mutations: MutationRecord[]; }): string { - const { atomic, live, liveTarget, relevant } = getLiveRegionAttributes({ + const { addedNodes, attributeName, removedNodes, target, type } = mutation; + const targetElement = getElementFromNode(target); + const { atomic, busy, live, liveTarget, relevant } = getLiveRegionAttributes({ container, - target: getElementFromNode(target), + target: targetElement, }); if (live === LIVE.OFF || !liveTarget) { return ""; } + if (type === "attributes" && attributeName === "aria-busy") { + const isLastAriaBusyMutationForTarget = + mutations + .filter( + (m) => + m.type === "attributes" && + m.attributeName === "aria-busy" && + isElement(m.target) && + liveTarget.contains(m.target) + ) + .at(-1) === mutation; + + if (!isLastAriaBusyMutationForTarget) { + return ""; + } + + if (liveTarget.getAttribute("aria-busy") === "true") { + return ""; + } + + const liveContent = getSpokenPhraseForNode(liveTarget); + + if (!liveContent) { + return ""; + } + + return `${live}: ${liveContent}`; + } + + const hasAriaBusyChangeInBatch = mutations.some( + (m) => + m.type === "attributes" && + m.attributeName === "aria-busy" && + isElement(m.target) && + liveTarget.contains(m.target) + ); + + if (hasAriaBusyChangeInBatch || busy) { + return ""; + } + /** * TODO: Indicates whether assistive technologies will present all, or only * parts of, the changed region based on the change notifications defined by diff --git a/src/getNodeAccessibilityData/index.ts b/src/getNodeAccessibilityData/index.ts index ba5ecb9..a6ce2eb 100644 --- a/src/getNodeAccessibilityData/index.ts +++ b/src/getNodeAccessibilityData/index.ts @@ -102,6 +102,7 @@ export function getNodeAccessibilityData({ const accessibleDescription = getAccessibleDescription(node); const accessibleName = getAccessibleName(node); const accessibleValue = getAccessibleValue(node); + const busy = isElement(node) && node.getAttribute("aria-busy") === "true"; const { explicitRole, implicitRole, role } = getRole({ accessibleName, @@ -185,6 +186,7 @@ export function getNodeAccessibilityData({ accessibleName, accessibleValue, allowedAccessibilityChildRoles, + busy, childrenPresentational, isExplicitPresentational, isInert, diff --git a/test/int/ariaBusy.int.test.ts b/test/int/ariaBusy.int.test.ts new file mode 100644 index 0000000..8b4833d --- /dev/null +++ b/test/int/ariaBusy.int.test.ts @@ -0,0 +1,370 @@ +import { createAccessibilityTree } from "../../src/createAccessibilityTree.js"; +import { flattenTree } from "../../src/flattenTree.js"; +import { virtual } from "../../src/index.js"; +import { waitFor } from "@testing-library/dom"; + +describe("Aria Busy State", () => { + afterEach(async () => { + await virtual.stop(); + document.body.innerHTML = ""; + }); + + describe("navigation announcement", () => { + it("should announce that a button with an aria-busy attribute set to true is 'busy'", async () => { + document.body.innerHTML = ` + + `; + + await virtual.start({ container: document.body }); + await virtual.next(); + + expect(await virtual.lastSpokenPhrase()).toBe("button, Submit, busy"); + + await virtual.stop(); + }); + + it("should announce that a button with an aria-busy attribute set to false is 'not busy'", async () => { + document.body.innerHTML = ` + + `; + + await virtual.start({ container: document.body }); + await virtual.next(); + + expect(await virtual.lastSpokenPhrase()).toBe("button, Submit, not busy"); + + await virtual.stop(); + }); + + it("should announce a button without an aria-busy attribute without any busy state labelling", async () => { + document.body.innerHTML = ` + + `; + + await virtual.start({ container: document.body }); + await virtual.next(); + + expect(await virtual.lastSpokenPhrase()).toBe("button, Submit"); + + await virtual.stop(); + }); + + it("should announce busy state on a container element", async () => { + document.body.innerHTML = ` +
+

You have new messages

+
+ `; + + await virtual.start({ container: document.body }); + + while ((await virtual.lastSpokenPhrase()) !== "end of document") { + await virtual.next(); + } + + expect(await virtual.spokenPhraseLog()).toEqual([ + "document", + "region, Notifications, busy", + "paragraph", + "You have new messages", + "end of paragraph", + "end of region, Notifications, busy", + "end of document", + ]); + + await virtual.stop(); + }); + + it("should announce busy state on a generic element", async () => { + document.body.innerHTML = ` +
Loading content
+ `; + + await virtual.start({ container: document.body }); + + while ((await virtual.lastSpokenPhrase()) !== "end of document") { + await virtual.next(); + } + + expect(await virtual.spokenPhraseLog()).toEqual([ + "document", + "busy", + "Loading content", + "end, busy", + "end of document", + ]); + + await virtual.stop(); + }); + }); + + describe("accessibility tree exposure", () => { + it("should expose busy state as true in accessibility tree when aria-busy is true", () => { + document.body.innerHTML = ` + + `; + + const tree = createAccessibilityTree(document.body)!; + const flattened = flattenTree(document.body, tree, null); + const treeNode = tree.children.find( + ({ node }) => (node as HTMLElement).id === "btn" + ); + const buttonNode = flattened.find( + ({ node }) => (node as HTMLElement).id === "btn" + ); + + expect(treeNode?.busy).toBe(true); + expect(buttonNode?.busy).toBe(true); + expect(buttonNode?.accessibleAttributeToLabelMap["aria-busy"]).toEqual({ + label: "busy", + value: "true", + }); + expect(buttonNode?.accessibleAttributeLabels).toContain("busy"); + }); + + it("should expose busy state as false in accessibility tree when aria-busy is false", () => { + document.body.innerHTML = ` + + `; + + const tree = createAccessibilityTree(document.body)!; + const flattened = flattenTree(document.body, tree, null); + const treeNode = tree.children.find( + ({ node }) => (node as HTMLElement).id === "btn" + ); + const buttonNode = flattened.find( + ({ node }) => (node as HTMLElement).id === "btn" + ); + + expect(treeNode?.busy).toBe(false); + expect(buttonNode?.busy).toBe(false); + expect(buttonNode?.accessibleAttributeToLabelMap["aria-busy"]).toEqual({ + label: "not busy", + value: "false", + }); + expect(buttonNode?.accessibleAttributeLabels).toContain("not busy"); + }); + + it("should expose busy state as false in accessibility tree when aria-busy is absent", () => { + document.body.innerHTML = ` + + `; + + const tree = createAccessibilityTree(document.body)!; + const flattened = flattenTree(document.body, tree, null); + const treeNode = tree.children.find( + ({ node }) => (node as HTMLElement).id === "btn" + ); + const buttonNode = flattened.find( + ({ node }) => (node as HTMLElement).id === "btn" + ); + + expect(treeNode?.busy).toBe(false); + expect(buttonNode?.busy).toBe(false); + expect(buttonNode?.accessibleAttributeToLabelMap["aria-busy"]).toBeUndefined(); + expect(buttonNode?.accessibleAttributeLabels).not.toContain("busy"); + }); + }); + + describe("live regions with aria-busy", () => { + it("should suppress intermediate updates when aria-busy is set to true on a polite live region and announce atomic update when busy ends", async () => { + document.body.innerHTML = ` +
Initial content
+ `; + + await virtual.start({ container: document.body }); + + const live = document.getElementById("live")!; + live.setAttribute("aria-busy", "true"); + + // Update content while busy - should be suppressed + live.textContent = "Intermediate update 1"; + live.textContent = "Intermediate update 2"; + + // Give observer time to fire + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(await virtual.spokenPhraseLog()).toEqual(["document"]); + + // Complete updates by clearing aria-busy + live.setAttribute("aria-busy", "false"); + + await waitFor(async () => { + expect(await virtual.lastSpokenPhrase()).toBe( + "polite: Intermediate update 2" + ); + }); + + expect(await virtual.spokenPhraseLog()).toEqual([ + "document", + "polite: Intermediate update 2", + ]); + + await virtual.stop(); + }); + + it("should suppress intermediate updates when aria-busy is set to true on an assertive live region and announce atomic update when busy ends", async () => { + document.body.innerHTML = ` +
Initial alert
+ `; + + await virtual.start({ container: document.body }); + + const live = document.getElementById("live")!; + live.setAttribute("aria-busy", "true"); + live.textContent = "Severe error occurred"; + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(await virtual.spokenPhraseLog()).toEqual(["document"]); + + live.setAttribute("aria-busy", "false"); + + await waitFor(async () => { + expect(await virtual.lastSpokenPhrase()).toBe( + "assertive: Severe error occurred" + ); + }); + + expect(await virtual.spokenPhraseLog()).toEqual([ + "document", + "assertive: Severe error occurred", + ]); + + await virtual.stop(); + }); + + it("should handle aria-busy attribute removal on a live region as completion of the busy period", async () => { + document.body.innerHTML = ` +
Loading
+ `; + + await virtual.start({ container: document.body }); + + const live = document.getElementById("live")!; + live.textContent = "Data loaded successfully"; + live.removeAttribute("aria-busy"); + + await waitFor(async () => { + expect(await virtual.lastSpokenPhrase()).toBe( + "polite: Data loaded successfully" + ); + }); + + expect(await virtual.spokenPhraseLog()).toEqual([ + "document", + "polite: Data loaded successfully", + ]); + + await virtual.stop(); + }); + + it("should suppress mutations in descendants when an ancestor is marked aria-busy", async () => { + document.body.innerHTML = ` +
+
    +
  • Item 1
  • +
+
+ `; + + await virtual.start({ container: document.body }); + + const list = document.getElementById("list")!; + const newItem = document.createElement("li"); + newItem.textContent = "Item 2"; + list.appendChild(newItem); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(await virtual.spokenPhraseLog()).toEqual(["document"]); + + const live = document.getElementById("live")!; + live.setAttribute("aria-busy", "false"); + + await waitFor(async () => { + expect(await virtual.lastSpokenPhrase()).toContain("Item 2"); + }); + + await virtual.stop(); + }); + + it("should only announce the last aria-busy change when multiple mutations occur in the same batch", async () => { + document.body.innerHTML = ` +
Initial content
+ `; + + await virtual.start({ container: document.body }); + + const live = document.getElementById("live")!; + live.setAttribute("aria-busy", "true"); + live.setAttribute("aria-busy", "false"); + + await waitFor(async () => { + expect(await virtual.lastSpokenPhrase()).toBe("polite: Initial content"); + }); + + expect(await virtual.spokenPhraseLog()).toEqual([ + "document", + "polite: Initial content", + ]); + + await virtual.stop(); + }); + + it("should return empty string when child aria-busy clears but parent live region is still busy", async () => { + document.body.innerHTML = ` +
+ +
+ `; + + await virtual.start({ container: document.body }); + + const child = document.getElementById("child")!; + child.setAttribute("aria-busy", "false"); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(await virtual.spokenPhraseLog()).toEqual(["document"]); + + await virtual.stop(); + }); + + it("should not announce spoken phrase when live region is empty after aria-busy clears", async () => { + document.body.innerHTML = ` +
+ `; + + await virtual.start({ container: document.body }); + + const live = document.getElementById("live")!; + live.removeAttribute("aria-busy"); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(await virtual.spokenPhraseLog()).toEqual(["document"]); + + await virtual.stop(); + }); + + it("should handle live region element with all four attributes: aria-live, aria-atomic, aria-busy, aria-relevant", async () => { + document.body.innerHTML = ` +
+ Initial +
+ `; + + await virtual.start({ container: document.body }); + const live = document.getElementById("live")!; + live.textContent = "Updated"; + live.setAttribute("aria-busy", "false"); + + await waitFor(async () => { + expect(await virtual.lastSpokenPhrase()).toBe("polite: Updated"); + }); + + await virtual.stop(); + }); + }); +}); diff --git a/test/int/focus.int.test.ts b/test/int/focus.int.test.ts index ae9c96e..ea2e9a4 100644 --- a/test/int/focus.int.test.ts +++ b/test/int/focus.int.test.ts @@ -71,4 +71,15 @@ describe("click", () => { await virtual.stop(); }); + + it("should return early when focus changes in a container whose accessibility tree becomes empty", async () => { + const container = document.querySelector("#container")!; + await virtual.start({ container }); + + container.innerHTML = ""; + const hiddenInput = document.querySelector("#hiddenInput")! as HTMLElement; + hiddenInput.focus(); + + await virtual.stop(); + }); });