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
1 change: 1 addition & 0 deletions src/Virtual.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,7 @@ export class Virtual {
getLiveSpokenPhrase({
container,
mutation,
mutations,
}),
)
.filter(Boolean)
Expand Down
7 changes: 7 additions & 0 deletions src/createAccessibilityTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface AccessibilityNode {
accessibleValue: string;
allowedAccessibilityChildRoles: string[];
alternateReadingOrderParents: Node[];
busy: boolean;
childrenPresentational: boolean;
isInert: boolean;
node: Node;
Expand Down Expand Up @@ -181,6 +182,7 @@ function growTree(
accessibleName,
accessibleValue,
allowedAccessibilityChildRoles,
busy,
childrenPresentational,
isExplicitPresentational,
isInert,
Expand All @@ -201,6 +203,7 @@ function growTree(
accessibleValue,
allowedAccessibilityChildRoles,
alternateReadingOrderParents,
busy,
children: [],
childrenPresentational,
isInert,
Expand Down Expand Up @@ -250,6 +253,7 @@ function growTree(
accessibleName,
accessibleValue,
allowedAccessibilityChildRoles,
busy,
childrenPresentational,
isInert,
isExplicitPresentational,
Expand All @@ -270,6 +274,7 @@ function growTree(
accessibleValue,
allowedAccessibilityChildRoles,
alternateReadingOrderParents,
busy,
children: [],
childrenPresentational,
isInert,
Expand Down Expand Up @@ -309,6 +314,7 @@ export function createAccessibilityTree(
accessibleName,
accessibleValue,
allowedAccessibilityChildRoles,
busy,
childrenPresentational,
isInert,
role,
Expand All @@ -328,6 +334,7 @@ export function createAccessibilityTree(
accessibleValue,
allowedAccessibilityChildRoles,
alternateReadingOrderParents: [],
busy,
children: [],
childrenPresentational,
isInert,
Expand Down
65 changes: 61 additions & 4 deletions src/getLiveSpokenPhrase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ type ValueOf<T> = T[keyof T];
* Live region attributes:
*
* - aria-atomic
* - TODO: aria-busy
* - aria-busy
* - aria-live
* - aria-relevant
*
Expand Down Expand Up @@ -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];

Expand Down Expand Up @@ -185,17 +186,20 @@ function getLiveRegionAttributes(
},
{
atomic,
busy,
live,
liveTarget,
relevant,
}: {
atomic?: boolean;
busy?: boolean;
live?: ValueOf<typeof LIVE>;
liveTarget?: Element;
relevant?: ValueOf<typeof RELEVANT>[];
} = {}
): {
atomic: boolean;
busy: boolean;
live: ValueOf<typeof LIVE>;
liveTarget?: Element;
relevant: ValueOf<typeof RELEVANT>[];
Expand All @@ -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<typeof LIVE>;
liveTarget = target;
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -276,6 +287,7 @@ function getLiveRegionAttributes(
{ container, target: targetAncestor },
{
atomic,
busy,
live,
liveTarget,
relevant,
Expand All @@ -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") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it work to simply check busy here? That would cover if the live target is busy, but also if it is nested in an ancestor that is busy (which feels more complete?).

Suggested change
if (liveTarget.getAttribute("aria-busy") === "true") {
if (busy) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider even hoisting this out of the if to live at the LIVE.OFF level as there is no need to consider mutations etc. if we know the element / ancestor is busy.

return "";
}

const liveContent = getSpokenPhraseForNode(liveTarget);

if (!liveContent) {
return "";
}

return `${live}: ${liveContent}`;
Comment on lines +338 to +344

@cmorten cmorten Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer to fall-through to the existing logic in this instance - this neglects to utilise the existing aria-atomic and aria-relevant logic.

I.e. this if block exists soley to early return for the "busy" cases, otherwise handle with existing behaviour.

}

const hasAriaBusyChangeInBatch = mutations.some(
(m) =>
m.type === "attributes" &&
m.attributeName === "aria-busy" &&
isElement(m.target) &&
liveTarget.contains(m.target)
);
Comment on lines +347 to +353

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I understand what this is for?

Any mutation in the batch that changes aria-busy, even to false now prevents the announcement? That doesn't seem right?


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
Expand Down
2 changes: 2 additions & 0 deletions src/getNodeAccessibilityData/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -185,6 +186,7 @@ export function getNodeAccessibilityData({
accessibleName,
accessibleValue,
allowedAccessibilityChildRoles,
busy,
childrenPresentational,
isExplicitPresentational,
isInert,
Expand Down
Loading
Loading