|
| 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 | +}); |
0 commit comments