From efde5a5a823c70865ceb308dfb787c6d226d79b3 Mon Sep 17 00:00:00 2001 From: AdamMagued Date: Wed, 30 Sep 2026 00:15:12 +0000 Subject: [PATCH 1/2] feat: support aria-busy state announcement - Expose busy state and attribute mapping on accessibility nodes - Announce busy and not busy state transitions for focused elements - Support aria-busy on live regions, suppressing interim updates until busy period completes - Closes #36 --- src/Virtual.ts | 43 ++- src/createAccessibilityTree.ts | 7 + src/getLiveSpokenPhrase.ts | 72 +++- src/getNodeAccessibilityData/index.ts | 2 + test/int/ariaBusy.int.test.ts | 524 ++++++++++++++++++++++++++ test/int/focus.int.test.ts | 11 + 6 files changed, 653 insertions(+), 6 deletions(-) create mode 100644 test/int/ariaBusy.int.test.ts diff --git a/src/Virtual.ts b/src/Virtual.ts index 051de4a..93aef8a 100644 --- a/src/Virtual.ts +++ b/src/Virtual.ts @@ -7,7 +7,11 @@ import { ERR_VIRTUAL_MISSING_CONTAINER, ERR_VIRTUAL_NOT_STARTED, } from "./errors"; -import { getLiveSpokenPhrase, LIVE } from "./getLiveSpokenPhrase"; +import { + getLiveRegionAttributes, + getLiveSpokenPhrase, + LIVE, +} from "./getLiveSpokenPhrase"; import { type UserEvent, userEvent } from "@testing-library/user-event"; import { flattenTree } from "./flattenTree"; import { getElementNode } from "./commands/getElementNode"; @@ -330,6 +334,40 @@ export class Virtual { target?.focus(); } + #announceFocusedBusy(mutations: MutationRecord[]) { + const currentActiveElement = + (this.#activeNode ? getElementNode(this.#activeNode) : null) ?? + (this.#container?.ownerDocument ?? globalThis.document)?.activeElement; + + for (const mutation of mutations) { + if ( + mutation.type === "attributes" && + mutation.attributeName === "aria-busy" && + mutation.target === currentActiveElement + ) { + const targetElement = mutation.target as HTMLElement; + const { live } = getLiveRegionAttributes({ + container: this.#container, + target: targetElement, + }); + + if (live !== LIVE.OFF) { + continue; + } + + const isBusy = targetElement.getAttribute("aria-busy") === "true"; + const isExplicitNotBusy = + targetElement.getAttribute("aria-busy") === "false"; + + if (isBusy) { + this.#spokenPhraseLog.push("busy"); + } else if (isExplicitNotBusy) { + this.#spokenPhraseLog.push("not busy"); + } + } + } + } + async #announceLiveRegions(mutations: MutationRecord[]) { await tick(); @@ -340,12 +378,15 @@ export class Virtual { getLiveSpokenPhrase({ container, mutation, + mutations, }), ) .filter(Boolean) .forEach((spokenPhrase) => { this.#spokenPhraseLog.push(spokenPhrase); }); + + this.#announceFocusedBusy(mutations); } #spokenPhraseLogWithoutLiveRegions() { 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..786e50e 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 * @@ -175,7 +175,7 @@ const roleToImplicitLiveRegionStatesAndPropertiesMap: Record< }, }; -function getLiveRegionAttributes( +export function getLiveRegionAttributes( { container, target, @@ -185,17 +185,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 +221,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 +257,13 @@ function getLiveRegionAttributes( if ( typeof atomic !== "undefined" && + typeof busy !== "undefined" && typeof live !== "undefined" && typeof relevant !== "undefined" ) { return { atomic, + busy, live, liveTarget, relevant, @@ -266,6 +275,7 @@ function getLiveRegionAttributes( if (target === container || targetAncestor === null) { return { atomic: atomic ?? DEFAULT_ATOMIC, + busy: busy ?? false, live: live ?? DEFAULT_LIVE, liveTarget, relevant: relevant ?? DEFAULT_RELEVANT, @@ -276,6 +286,7 @@ function getLiveRegionAttributes( { container, target: targetAncestor }, { atomic, + busy, live, liveTarget, relevant, @@ -285,20 +296,71 @@ 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 ""; + } + + const isBusy = targetElement.getAttribute("aria-busy") === "true"; + + if (isBusy) { + return `${live}: busy`; + } + + 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..21a750b --- /dev/null +++ b/test/int/ariaBusy.int.test.ts @@ -0,0 +1,524 @@ +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("dynamic aria-busy changes on focused element", () => { + it("should announce 'busy' when aria-busy is dynamically set to true on the active element", async () => { + document.body.innerHTML = ` + + `; + + await virtual.start({ container: document.body }); + await virtual.next(); + + expect(await virtual.lastSpokenPhrase()).toBe("button, Submit"); + + const button = document.getElementById("btn")!; + button.setAttribute("aria-busy", "true"); + + await waitFor(async () => { + expect(await virtual.lastSpokenPhrase()).toBe("busy"); + }); + + expect(await virtual.spokenPhraseLog()).toEqual([ + "document", + "button, Submit", + "busy", + ]); + + await virtual.stop(); + }); + + it("should announce 'not busy' when aria-busy is dynamically set to false on the active element", async () => { + document.body.innerHTML = ` + + `; + + await virtual.start({ container: document.body }); + await virtual.next(); + + expect(await virtual.lastSpokenPhrase()).toBe("button, Submit, busy"); + + const button = document.getElementById("btn")!; + button.setAttribute("aria-busy", "false"); + + await waitFor(async () => { + expect(await virtual.lastSpokenPhrase()).toBe("not busy"); + }); + + expect(await virtual.spokenPhraseLog()).toEqual([ + "document", + "button, Submit, busy", + "not busy", + ]); + + await virtual.stop(); + }); + + it("should not announce aria-busy changes on an unfocused non-live element", async () => { + document.body.innerHTML = ` + + + `; + + await virtual.start({ container: document.body }); + await virtual.next(); + + expect(await virtual.lastSpokenPhrase()).toBe("button, First"); + + const button2 = document.getElementById("btn2")!; + button2.setAttribute("aria-busy", "true"); + + // Give observer time to fire + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(await virtual.spokenPhraseLog()).toEqual([ + "document", + "button, First", + ]); + + await virtual.stop(); + }); + + it("should announce busy when an element has DOM focus without moving virtual cursor", async () => { + document.body.innerHTML = ` + + `; + + await virtual.start({ container: document.body }); + + const button = document.getElementById("btn")!; + button.focus(); + + button.setAttribute("aria-busy", "true"); + + await waitFor(async () => { + expect(await virtual.lastSpokenPhrase()).toBe("busy"); + }); + + await virtual.stop(); + }); + + it("should not announce duplicate busy from focused handler when element is also a live region", async () => { + document.body.innerHTML = ` + + `; + + await virtual.start({ container: document.body }); + await virtual.next(); + + expect(await virtual.lastSpokenPhrase()).toBe("button, Submit"); + + const button = document.getElementById("btn")!; + button.setAttribute("aria-busy", "true"); + + await waitFor(async () => { + expect(await virtual.lastSpokenPhrase()).toBe("polite: busy"); + }); + + expect(await virtual.spokenPhraseLog()).toEqual([ + "document", + "button, Submit", + "polite: busy", + ]); + + await virtual.stop(); + }); + + it("should not announce when aria-busy is removed from focused element without setting to false", async () => { + document.body.innerHTML = ` + + `; + + await virtual.start({ container: document.body }); + await virtual.next(); + + const button = document.getElementById("btn")!; + button.removeAttribute("aria-busy"); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(await virtual.spokenPhraseLog()).toEqual([ + "document", + "button, Submit, busy", + ]); + + await virtual.stop(); + }); + }); + + describe("live regions with aria-busy", () => { + it("should announce 'busy' when aria-busy is set to true on a polite live region and suppress intermediate updates", async () => { + document.body.innerHTML = ` +
Initial content
+ `; + + await virtual.start({ container: document.body }); + + const live = document.getElementById("live")!; + live.setAttribute("aria-busy", "true"); + + await waitFor(async () => { + expect(await virtual.lastSpokenPhrase()).toBe("polite: busy"); + }); + + // 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", + "polite: busy", + ]); + + // 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: busy", + "polite: Intermediate update 2", + ]); + + await virtual.stop(); + }); + + it("should announce 'busy' 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"); + + await waitFor(async () => { + expect(await virtual.lastSpokenPhrase()).toBe("assertive: busy"); + }); + + live.textContent = "Severe error occurred"; + live.setAttribute("aria-busy", "false"); + + await waitFor(async () => { + expect(await virtual.lastSpokenPhrase()).toBe( + "assertive: Severe error occurred" + ); + }); + + expect(await virtual.spokenPhraseLog()).toEqual([ + "document", + "assertive: busy", + "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(); + }); }); From b7048b036a706a5cfa0cec4a949ec6903fc4c509 Mon Sep 17 00:00:00 2001 From: AdamMagued Date: Tue, 13 Oct 2026 01:07:02 +0300 Subject: [PATCH 2/2] refactor: limit aria-busy to tree state and live region deferral --- src/Virtual.ts | 42 +-------- src/getLiveSpokenPhrase.ts | 11 +-- test/int/ariaBusy.int.test.ts | 168 ++-------------------------------- 3 files changed, 11 insertions(+), 210 deletions(-) diff --git a/src/Virtual.ts b/src/Virtual.ts index 93aef8a..607a6cb 100644 --- a/src/Virtual.ts +++ b/src/Virtual.ts @@ -7,11 +7,7 @@ import { ERR_VIRTUAL_MISSING_CONTAINER, ERR_VIRTUAL_NOT_STARTED, } from "./errors"; -import { - getLiveRegionAttributes, - getLiveSpokenPhrase, - LIVE, -} from "./getLiveSpokenPhrase"; +import { getLiveSpokenPhrase, LIVE } from "./getLiveSpokenPhrase"; import { type UserEvent, userEvent } from "@testing-library/user-event"; import { flattenTree } from "./flattenTree"; import { getElementNode } from "./commands/getElementNode"; @@ -334,40 +330,6 @@ export class Virtual { target?.focus(); } - #announceFocusedBusy(mutations: MutationRecord[]) { - const currentActiveElement = - (this.#activeNode ? getElementNode(this.#activeNode) : null) ?? - (this.#container?.ownerDocument ?? globalThis.document)?.activeElement; - - for (const mutation of mutations) { - if ( - mutation.type === "attributes" && - mutation.attributeName === "aria-busy" && - mutation.target === currentActiveElement - ) { - const targetElement = mutation.target as HTMLElement; - const { live } = getLiveRegionAttributes({ - container: this.#container, - target: targetElement, - }); - - if (live !== LIVE.OFF) { - continue; - } - - const isBusy = targetElement.getAttribute("aria-busy") === "true"; - const isExplicitNotBusy = - targetElement.getAttribute("aria-busy") === "false"; - - if (isBusy) { - this.#spokenPhraseLog.push("busy"); - } else if (isExplicitNotBusy) { - this.#spokenPhraseLog.push("not busy"); - } - } - } - } - async #announceLiveRegions(mutations: MutationRecord[]) { await tick(); @@ -385,8 +347,6 @@ export class Virtual { .forEach((spokenPhrase) => { this.#spokenPhraseLog.push(spokenPhrase); }); - - this.#announceFocusedBusy(mutations); } #spokenPhraseLogWithoutLiveRegions() { diff --git a/src/getLiveSpokenPhrase.ts b/src/getLiveSpokenPhrase.ts index 786e50e..00ff7b7 100644 --- a/src/getLiveSpokenPhrase.ts +++ b/src/getLiveSpokenPhrase.ts @@ -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]; @@ -175,7 +176,7 @@ const roleToImplicitLiveRegionStatesAndPropertiesMap: Record< }, }; -export function getLiveRegionAttributes( +function getLiveRegionAttributes( { container, target, @@ -275,7 +276,7 @@ export function getLiveRegionAttributes( if (target === container || targetAncestor === null) { return { atomic: atomic ?? DEFAULT_ATOMIC, - busy: busy ?? false, + busy: busy ?? DEFAULT_BUSY, live: live ?? DEFAULT_LIVE, liveTarget, relevant: relevant ?? DEFAULT_RELEVANT, @@ -330,12 +331,6 @@ export function getLiveSpokenPhrase({ return ""; } - const isBusy = targetElement.getAttribute("aria-busy") === "true"; - - if (isBusy) { - return `${live}: busy`; - } - if (liveTarget.getAttribute("aria-busy") === "true") { return ""; } diff --git a/test/int/ariaBusy.int.test.ts b/test/int/ariaBusy.int.test.ts index 21a750b..8b4833d 100644 --- a/test/int/ariaBusy.int.test.ts +++ b/test/int/ariaBusy.int.test.ts @@ -166,153 +166,8 @@ describe("Aria Busy State", () => { }); }); - describe("dynamic aria-busy changes on focused element", () => { - it("should announce 'busy' when aria-busy is dynamically set to true on the active element", async () => { - document.body.innerHTML = ` - - `; - - await virtual.start({ container: document.body }); - await virtual.next(); - - expect(await virtual.lastSpokenPhrase()).toBe("button, Submit"); - - const button = document.getElementById("btn")!; - button.setAttribute("aria-busy", "true"); - - await waitFor(async () => { - expect(await virtual.lastSpokenPhrase()).toBe("busy"); - }); - - expect(await virtual.spokenPhraseLog()).toEqual([ - "document", - "button, Submit", - "busy", - ]); - - await virtual.stop(); - }); - - it("should announce 'not busy' when aria-busy is dynamically set to false on the active element", async () => { - document.body.innerHTML = ` - - `; - - await virtual.start({ container: document.body }); - await virtual.next(); - - expect(await virtual.lastSpokenPhrase()).toBe("button, Submit, busy"); - - const button = document.getElementById("btn")!; - button.setAttribute("aria-busy", "false"); - - await waitFor(async () => { - expect(await virtual.lastSpokenPhrase()).toBe("not busy"); - }); - - expect(await virtual.spokenPhraseLog()).toEqual([ - "document", - "button, Submit, busy", - "not busy", - ]); - - await virtual.stop(); - }); - - it("should not announce aria-busy changes on an unfocused non-live element", async () => { - document.body.innerHTML = ` - - - `; - - await virtual.start({ container: document.body }); - await virtual.next(); - - expect(await virtual.lastSpokenPhrase()).toBe("button, First"); - - const button2 = document.getElementById("btn2")!; - button2.setAttribute("aria-busy", "true"); - - // Give observer time to fire - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(await virtual.spokenPhraseLog()).toEqual([ - "document", - "button, First", - ]); - - await virtual.stop(); - }); - - it("should announce busy when an element has DOM focus without moving virtual cursor", async () => { - document.body.innerHTML = ` - - `; - - await virtual.start({ container: document.body }); - - const button = document.getElementById("btn")!; - button.focus(); - - button.setAttribute("aria-busy", "true"); - - await waitFor(async () => { - expect(await virtual.lastSpokenPhrase()).toBe("busy"); - }); - - await virtual.stop(); - }); - - it("should not announce duplicate busy from focused handler when element is also a live region", async () => { - document.body.innerHTML = ` - - `; - - await virtual.start({ container: document.body }); - await virtual.next(); - - expect(await virtual.lastSpokenPhrase()).toBe("button, Submit"); - - const button = document.getElementById("btn")!; - button.setAttribute("aria-busy", "true"); - - await waitFor(async () => { - expect(await virtual.lastSpokenPhrase()).toBe("polite: busy"); - }); - - expect(await virtual.spokenPhraseLog()).toEqual([ - "document", - "button, Submit", - "polite: busy", - ]); - - await virtual.stop(); - }); - - it("should not announce when aria-busy is removed from focused element without setting to false", async () => { - document.body.innerHTML = ` - - `; - - await virtual.start({ container: document.body }); - await virtual.next(); - - const button = document.getElementById("btn")!; - button.removeAttribute("aria-busy"); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(await virtual.spokenPhraseLog()).toEqual([ - "document", - "button, Submit, busy", - ]); - - await virtual.stop(); - }); - }); - describe("live regions with aria-busy", () => { - it("should announce 'busy' when aria-busy is set to true on a polite live region and suppress intermediate updates", async () => { + 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
`; @@ -322,10 +177,6 @@ describe("Aria Busy State", () => { const live = document.getElementById("live")!; live.setAttribute("aria-busy", "true"); - await waitFor(async () => { - expect(await virtual.lastSpokenPhrase()).toBe("polite: busy"); - }); - // Update content while busy - should be suppressed live.textContent = "Intermediate update 1"; live.textContent = "Intermediate update 2"; @@ -333,10 +184,7 @@ describe("Aria Busy State", () => { // Give observer time to fire await new Promise((resolve) => setTimeout(resolve, 50)); - expect(await virtual.spokenPhraseLog()).toEqual([ - "document", - "polite: busy", - ]); + expect(await virtual.spokenPhraseLog()).toEqual(["document"]); // Complete updates by clearing aria-busy live.setAttribute("aria-busy", "false"); @@ -349,14 +197,13 @@ describe("Aria Busy State", () => { expect(await virtual.spokenPhraseLog()).toEqual([ "document", - "polite: busy", "polite: Intermediate update 2", ]); await virtual.stop(); }); - it("should announce 'busy' when aria-busy is set to true on an assertive live region and announce atomic update when busy ends", async () => { + 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
`; @@ -365,12 +212,12 @@ describe("Aria Busy State", () => { const live = document.getElementById("live")!; live.setAttribute("aria-busy", "true"); + live.textContent = "Severe error occurred"; - await waitFor(async () => { - expect(await virtual.lastSpokenPhrase()).toBe("assertive: busy"); - }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(await virtual.spokenPhraseLog()).toEqual(["document"]); - live.textContent = "Severe error occurred"; live.setAttribute("aria-busy", "false"); await waitFor(async () => { @@ -381,7 +228,6 @@ describe("Aria Busy State", () => { expect(await virtual.spokenPhraseLog()).toEqual([ "document", - "assertive: busy", "assertive: Severe error occurred", ]);