Skip to content

Commit 137ce2d

Browse files
Add screen-reader announcements, group semantics, and focus return
- Polite two-node live region announces bubbles added/dismissed and the flock expanding (with item count) or collapsing. - A hidden role=group owns every bubble via aria-owns, so assistive tech reads the flock as one named set; accessibility tree only, no position/z-order/drag impact. - Focus returns out of the flock (to the prior external focus, or a registered trigger) when the last open-row bubble is deleted, instead of stranding on <body>. - The expanded panel is now role=region, not dialog, matching its non-modal free focus-in/out behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1b91a9c commit 137ce2d

11 files changed

Lines changed: 468 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,27 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Screen-reader announcements through a polite live region: bubbles entering and
13+
leaving, and the flock expanding (with its item count) or collapsing.
14+
- Group semantics — a hidden `role="group"` owns every bubble via `aria-owns`,
15+
so assistive tech reads the flock as one named set, named by its count. It
16+
touches the accessibility tree only; bubble position, z-order, and drag are
17+
unaffected.
18+
19+
### Changed
20+
21+
- The expanded panel is now `role="region"` (a disclosure) rather than
22+
`role="dialog"`. It is non-modal, with focus moving freely in and out — which
23+
the region role matches and the dialog role (implying a focus trap) did not.
24+
25+
### Fixed
26+
27+
- Deleting the last bubble in the open row no longer strands keyboard focus on
28+
`<body>`; focus returns to the element focused before the flock was engaged,
29+
or to a registered trigger.
30+
1031
## [0.7.0] - 2026-06-15
1132

1233
### Added

src/accessibility.test.ts

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
// @vitest-environment happy-dom
2+
3+
import { createBubbles } from "$src/index";
4+
import type { BubbleManager } from "$src/types";
5+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
6+
7+
/**
8+
* Manager-level accessibility behavior in happy-dom: live announcements,
9+
* the aria-owns group, focus return on empty, and the disclosure panel role.
10+
* Reduced motion is forced so the choreography settles deterministically.
11+
*/
12+
13+
const mediaQueryList = (matches: boolean) =>
14+
({ matches, addEventListener: () => {}, removeEventListener: () => {} }) as unknown as MediaQueryList;
15+
16+
const stubAnimate = () => {
17+
Element.prototype.animate = function () {
18+
let cancelled = false;
19+
const animation = {
20+
onfinish: null as (() => void) | null,
21+
cancel: () => {
22+
cancelled = true;
23+
}
24+
};
25+
setTimeout(() => {
26+
if (!cancelled) animation.onfinish?.();
27+
}, 0);
28+
return animation as unknown as Animation;
29+
};
30+
};
31+
32+
const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
33+
34+
const bubbleEl = (label: string): HTMLElement => {
35+
const el = [...document.querySelectorAll<HTMLElement>("[role='button']")].find(
36+
(candidate) => candidate.getAttribute("aria-label") === label
37+
);
38+
if (!el) throw new Error(`no bubble labelled "${label}"`);
39+
return el;
40+
};
41+
42+
// The live region is a two-node pair (one always empty); their combined text
43+
// is the latest announcement.
44+
const liveText = () =>
45+
[...document.querySelectorAll("[aria-live]")].map((node) => node.textContent).join("");
46+
const groupEl = () => document.querySelector("[role='group']");
47+
48+
let manager: BubbleManager | undefined;
49+
50+
beforeEach(() => {
51+
window.matchMedia = (query: string) => mediaQueryList(query.includes("prefers-reduced-motion"));
52+
stubAnimate();
53+
});
54+
55+
afterEach(async () => {
56+
manager?.destroy();
57+
manager = undefined;
58+
await tick();
59+
document.body.innerHTML = "";
60+
});
61+
62+
describe("live announcements", () => {
63+
it("announces an added bubble by label", async () => {
64+
manager = createBubbles();
65+
manager.add({ id: "a", label: "Chat" });
66+
await tick();
67+
expect(liveText()).toBe("Chat added");
68+
});
69+
70+
it("announces expand with the item count, then collapse", async () => {
71+
manager = createBubbles();
72+
manager.add({ id: "a", label: "a" });
73+
manager.add({ id: "b", label: "b" });
74+
await tick();
75+
76+
manager.toggle();
77+
await tick();
78+
expect(liveText()).toBe("Bubbles expanded, 2 items");
79+
80+
manager.toggle();
81+
await tick();
82+
expect(liveText()).toBe("Bubbles collapsed");
83+
});
84+
85+
it("announces a user dismissal by label", async () => {
86+
manager = createBubbles();
87+
manager.add({ id: "a", label: "Alpha" });
88+
manager.add({ id: "b", label: "Beta" });
89+
manager.toggle();
90+
await tick();
91+
92+
bubbleEl("Alpha").dispatchEvent(new KeyboardEvent("keydown", { key: "Delete" }));
93+
await tick();
94+
expect(liveText()).toBe("Alpha dismissed");
95+
});
96+
});
97+
98+
describe("group semantics (aria-owns)", () => {
99+
it("owns every bubble and names the group by count", async () => {
100+
manager = createBubbles();
101+
manager.add({ id: "a", label: "a" });
102+
manager.add({ id: "b", label: "b" });
103+
await tick();
104+
105+
expect(groupEl()?.getAttribute("aria-owns")).toBe("bubble-a bubble-b");
106+
expect(groupEl()?.getAttribute("aria-label")).toBe("2 bubbles");
107+
});
108+
109+
it("gives each bubble the element id the group owns", async () => {
110+
manager = createBubbles();
111+
manager.add({ id: "a", label: "a" });
112+
await tick();
113+
expect(bubbleEl("a").id).toBe("bubble-a");
114+
});
115+
116+
it("updates the ownership list as bubbles leave", async () => {
117+
manager = createBubbles();
118+
manager.add({ id: "a", label: "a" });
119+
manager.add({ id: "b", label: "b" });
120+
await tick();
121+
122+
manager.remove("a");
123+
await tick();
124+
await tick();
125+
126+
expect(groupEl()?.getAttribute("aria-owns")).toBe("bubble-b");
127+
expect(groupEl()?.getAttribute("aria-label")).toBe("1 bubble");
128+
});
129+
});
130+
131+
describe("focus return on empty", () => {
132+
it("returns focus to where it was before the flock when the last bubble is deleted", async () => {
133+
manager = createBubbles();
134+
manager.add({ id: "a", label: "a" });
135+
manager.toggle();
136+
await tick();
137+
138+
// Focus was last outside the flock before the deletion.
139+
const outside = document.body.appendChild(document.createElement("button"));
140+
outside.dispatchEvent(new Event("focusin", { bubbles: true }));
141+
142+
bubbleEl("a").dispatchEvent(new KeyboardEvent("keydown", { key: "Delete" }));
143+
await tick();
144+
await tick();
145+
146+
expect(document.activeElement).toBe(outside);
147+
});
148+
149+
it("returns focus to an element focused before the flock existed", async () => {
150+
// Focus predates the manager — the focusin listener isn't attached yet,
151+
// so this is only recoverable via the seed at group creation.
152+
const outside = document.body.appendChild(document.createElement("button"));
153+
outside.focus();
154+
155+
manager = createBubbles();
156+
manager.add({ id: "a", label: "a" });
157+
manager.toggle();
158+
await tick();
159+
160+
bubbleEl("a").dispatchEvent(new KeyboardEvent("keydown", { key: "Delete" }));
161+
await tick();
162+
await tick();
163+
164+
expect(document.activeElement).toBe(outside);
165+
});
166+
167+
it("falls back to a registered trigger when nothing else is known", async () => {
168+
manager = createBubbles();
169+
const launcher = document.body.appendChild(document.createElement("button"));
170+
manager.registerTrigger(launcher);
171+
manager.add({ id: "a", label: "a" });
172+
manager.toggle();
173+
await tick();
174+
175+
bubbleEl("a").dispatchEvent(new KeyboardEvent("keydown", { key: "Delete" }));
176+
await tick();
177+
await tick();
178+
179+
expect(document.activeElement).toBe(launcher);
180+
});
181+
});
182+
183+
describe("disclosure panel", () => {
184+
it("uses role=region, not dialog", async () => {
185+
manager = createBubbles();
186+
manager.add({ id: "a", label: "a", content: document.createElement("div") });
187+
await tick();
188+
expect(document.getElementById("bubble-panel-a")?.getAttribute("role")).toBe("region");
189+
});
190+
});

src/behaviors/group/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -587,7 +587,10 @@ export const createBubbleGroup = (
587587
const neighbor = row[i + 1] ?? row[i - 1];
588588
callbacks.dismissed(id);
589589
callbacks.remove(id);
590-
neighbor?.el.focus();
590+
// The dismissed bubble was the last one — hand focus back out of the
591+
// flock rather than stranding it on <body>.
592+
if (neighbor) neighbor.el.focus();
593+
else callbacks.restoreFocus();
591594
},
592595

593596
toggle() {

src/elements/group-owner.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// @vitest-environment happy-dom
2+
3+
import { createGroupOwner } from "$src/elements/group-owner";
4+
import { afterEach, describe, expect, it } from "vitest";
5+
6+
const groupEl = () => document.querySelector("[role='group']");
7+
8+
afterEach(() => {
9+
document.body.innerHTML = "";
10+
});
11+
12+
describe("createGroupOwner", () => {
13+
it("mounts a hidden role=group element", () => {
14+
createGroupOwner();
15+
expect(groupEl()).not.toBeNull();
16+
});
17+
18+
it("owns the given ids and names itself by count", () => {
19+
createGroupOwner().sync(["bubble-a", "bubble-b"]);
20+
expect(groupEl()?.getAttribute("aria-owns")).toBe("bubble-a bubble-b");
21+
expect(groupEl()?.getAttribute("aria-label")).toBe("2 bubbles");
22+
});
23+
24+
it("uses the singular for a lone bubble", () => {
25+
createGroupOwner().sync(["bubble-a"]);
26+
expect(groupEl()?.getAttribute("aria-label")).toBe("1 bubble");
27+
});
28+
29+
it("removes the element on destroy", () => {
30+
createGroupOwner().destroy();
31+
expect(groupEl()).toBeNull();
32+
});
33+
});

src/elements/group-owner.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { visuallyHidden } from "$src/elements/visually-hidden";
2+
3+
/** Hidden element that groups the bubbles in the accessibility tree. */
4+
export interface GroupOwner {
5+
/**
6+
* Reparents the given bubble element ids under the group via aria-owns and
7+
* names the group by its size — no DOM move, so the bubbles' position,
8+
* z-order, and drag logic are untouched; only the a11y tree reparents them.
9+
*/
10+
sync(elementIds: string[]): void;
11+
destroy(): void;
12+
}
13+
14+
export const createGroupOwner = (): GroupOwner => {
15+
const el = document.createElement("div");
16+
el.setAttribute("role", "group");
17+
Object.assign(el.style, visuallyHidden);
18+
document.body.appendChild(el);
19+
20+
return {
21+
sync(elementIds) {
22+
el.setAttribute("aria-owns", elementIds.join(" "));
23+
const n = elementIds.length;
24+
el.setAttribute("aria-label", `${n} bubble${n === 1 ? "" : "s"}`);
25+
},
26+
destroy() {
27+
el.remove();
28+
}
29+
};
30+
};

src/elements/live-region.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// @vitest-environment happy-dom
2+
3+
import { createLiveRegion } from "$src/elements/live-region";
4+
import { afterEach, describe, expect, it } from "vitest";
5+
6+
const regions = () => [...document.querySelectorAll<HTMLElement>("[aria-live]")];
7+
const announced = () => regions().map((region) => region.textContent).join("");
8+
9+
afterEach(() => {
10+
document.body.innerHTML = "";
11+
});
12+
13+
describe("createLiveRegion", () => {
14+
it("touches no DOM until the first announcement", () => {
15+
createLiveRegion();
16+
expect(regions()).toHaveLength(0);
17+
});
18+
19+
it("announces into polite, atomic regions", () => {
20+
createLiveRegion().announce("Chat added");
21+
expect(regions().every((region) => region.getAttribute("aria-live") === "polite")).toBe(true);
22+
expect(regions().every((region) => region.getAttribute("aria-atomic") === "true")).toBe(true);
23+
expect(announced()).toBe("Chat added");
24+
});
25+
26+
it("alternates nodes so a repeated message still changes a region", () => {
27+
const live = createLiveRegion();
28+
live.announce("Bubble added");
29+
const first = regions().find((region) => region.textContent === "Bubble added");
30+
live.announce("Bubble added");
31+
const second = regions().find((region) => region.textContent === "Bubble added");
32+
33+
// Same text, but it moved to the other node — an unambiguous change.
34+
expect(second).not.toBe(first);
35+
expect(announced()).toBe("Bubble added");
36+
});
37+
38+
it("keeps only the latest message across the pair", () => {
39+
const live = createLiveRegion();
40+
live.announce("first");
41+
live.announce("second");
42+
expect(regions()).toHaveLength(2);
43+
expect(announced()).toBe("second");
44+
});
45+
46+
it("removes the regions on destroy", () => {
47+
const live = createLiveRegion();
48+
live.announce("x");
49+
live.destroy();
50+
expect(regions()).toHaveLength(0);
51+
});
52+
});

src/elements/live-region.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { visuallyHidden } from "$src/elements/visually-hidden";
2+
3+
/** A polite ARIA live region for status announcements. */
4+
export interface LiveRegion {
5+
/** Speaks a message to assistive tech politely — never interrupting. */
6+
announce(message: string): void;
7+
destroy(): void;
8+
}
9+
10+
/**
11+
* Two polite regions, announced into alternately. Re-setting a node to the
12+
* same text it already holds is dropped by some screen reader/browser combos
13+
* (two unlabelled adds both say "Bubble added"), so each message lands in a
14+
* freshly-cleared region — an unambiguous change even when it repeats. Exactly
15+
* one node holds text at a time, so the pair's combined text is always just the
16+
* latest message. Created lazily, kept until destroy() so a message fired as
17+
* the overlay tears down (the last bubble leaving) still has somewhere to read.
18+
*/
19+
export const createLiveRegion = (): LiveRegion => {
20+
let nodes: [HTMLElement, HTMLElement] | undefined;
21+
let active: 0 | 1 = 0;
22+
23+
const ensure = (): [HTMLElement, HTMLElement] => {
24+
if (nodes) return nodes;
25+
const make = () => {
26+
const el = document.createElement("div");
27+
el.setAttribute("aria-live", "polite");
28+
el.setAttribute("aria-atomic", "true");
29+
Object.assign(el.style, visuallyHidden);
30+
return document.body.appendChild(el);
31+
};
32+
nodes = [make(), make()];
33+
return nodes;
34+
};
35+
36+
return {
37+
announce(message) {
38+
const pair = ensure();
39+
pair[active].textContent = "";
40+
active = active === 0 ? 1 : 0;
41+
pair[active].textContent = message;
42+
},
43+
destroy() {
44+
nodes?.forEach((node) => node.remove());
45+
nodes = undefined;
46+
}
47+
};
48+
};

0 commit comments

Comments
 (0)