From 0d9daf9536d83d64831eec811cf38709dc5e3c3e Mon Sep 17 00:00:00 2001 From: Marcy Sutton Todd Date: Wed, 13 Nov 2024 15:05:00 -0800 Subject: [PATCH 01/44] Initial commit of Announcer --- .../announcer.stories.tsx | 116 ++++++++++++++++++ packages/wonder-blocks-announcer/CHANGELOG.md | 1 + packages/wonder-blocks-announcer/package.json | 28 +++++ .../src/__tests__/clear-messages.test.ts | 5 + .../src/__tests__/send-message.test.ts | 35 ++++++ packages/wonder-blocks-announcer/src/index.ts | 108 ++++++++++++++++ .../tsconfig-build.json | 11 ++ 7 files changed, 304 insertions(+) create mode 100644 __docs__/wonder-blocks-announcer/announcer.stories.tsx create mode 100644 packages/wonder-blocks-announcer/CHANGELOG.md create mode 100644 packages/wonder-blocks-announcer/package.json create mode 100644 packages/wonder-blocks-announcer/src/__tests__/clear-messages.test.ts create mode 100644 packages/wonder-blocks-announcer/src/__tests__/send-message.test.ts create mode 100644 packages/wonder-blocks-announcer/src/index.ts create mode 100644 packages/wonder-blocks-announcer/tsconfig-build.json diff --git a/__docs__/wonder-blocks-announcer/announcer.stories.tsx b/__docs__/wonder-blocks-announcer/announcer.stories.tsx new file mode 100644 index 000000000..8ce24ddb3 --- /dev/null +++ b/__docs__/wonder-blocks-announcer/announcer.stories.tsx @@ -0,0 +1,116 @@ +import * as React from "react"; +import {StyleSheet} from "aphrodite"; +import type {Meta, StoryObj} from "@storybook/react"; + +import { + sendMessage, + type SendMessageProps, +} from "@khanacademy/wonder-blocks-announcer"; +import Button from "@khanacademy/wonder-blocks-button"; +import {View} from "@khanacademy/wonder-blocks-core"; + +import ComponentInfo from "../../.storybook/components/component-info"; +import packageConfig from "../../packages/wonder-blocks-announcer/package.json"; + +const AnnouncerExample = ({ + message = "Clicked!", + level, + timeoutDelay, +}: SendMessageProps) => { + return ( + + ); +}; +type StoryComponentType = StoryObj; + +/** + * Announcer exposes an API for screen reader messages using ARIA Live Regions. + * It can be used to notify Assistive Technology users without moving focus. Use + * cases include combobox filtering, toast notifications, client-side routing, + * and more. + * + * Calling the sendMessage method automatically prepends the appropriate live regions + * to the document body. It sends messages at a default `polite` level, with the + * ability to override to `assertive` by passing a `level` argument. You can also + * pass a `timeoutDelay` to wait a specific duration before sending a message. + * + * To test this API, turn on VoiceOver or NVDA on Windows and click the example button. + * + * ### Usage + * ```jsx + * import { sendMessage } from "@khanacademy/wonder-blocks-announcer"; + * + *
+ * + *
+ * ``` + */ +export default { + title: "Packages / Announcer", + component: AnnouncerExample, + decorators: [ + (Story): React.ReactElement> => ( + + + + ), + ], + parameters: { + componentSubtitle: ( + + ), + docs: { + source: { + // See https://github.com/storybookjs/storybook/issues/12596 + excludeDecorators: true, + }, + }, + }, + argTypes: { + level: { + control: "radio", + options: ["polite", "assertive"], + defaultValue: "polite", + }, + timeoutDelay: { + control: "number", + type: "number", + description: "(milliseconds)", + }, + }, +} as Meta; + +/** + * This is an example of a live region with all the options set to their default + * values and the `message` argument set to some example text. + */ +export const SendMessage: StoryComponentType = { + args: { + message: "Here is some example text.", + level: "polite", + }, +}; + +const styles = StyleSheet.create({ + example: { + alignItems: "center", + justifyContent: "center", + }, + container: { + width: "100%", + }, + narrowBanner: { + maxWidth: 400, + }, + rightToLeft: { + width: "100%", + direction: "rtl", + }, +}); diff --git a/packages/wonder-blocks-announcer/CHANGELOG.md b/packages/wonder-blocks-announcer/CHANGELOG.md new file mode 100644 index 000000000..60bd72daa --- /dev/null +++ b/packages/wonder-blocks-announcer/CHANGELOG.md @@ -0,0 +1 @@ +# @khanacademy/wonder-blocks-announcer diff --git a/packages/wonder-blocks-announcer/package.json b/packages/wonder-blocks-announcer/package.json new file mode 100644 index 000000000..7696edfec --- /dev/null +++ b/packages/wonder-blocks-announcer/package.json @@ -0,0 +1,28 @@ +{ + "name": "@khanacademy/wonder-blocks-announcer", + "version": "0.0.1", + "design": "v1", + "description": "Live Region Announcer for Wonder Blocks.", + "main": "dist/index.js", + "module": "dist/es/index.js", + "source": "src/index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "types": "dist/index.d.ts", + "author": "", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@khanacademy/wonder-blocks-core": "^7.0.1" + }, + "peerDependencies": { + "aphrodite": "^1.2.5", + "react": "16.14.0" + }, + "devDependencies": { + "@khanacademy/wb-dev-build-settings": "^1.0.1" + } +} \ No newline at end of file diff --git a/packages/wonder-blocks-announcer/src/__tests__/clear-messages.test.ts b/packages/wonder-blocks-announcer/src/__tests__/clear-messages.test.ts new file mode 100644 index 000000000..815651a01 --- /dev/null +++ b/packages/wonder-blocks-announcer/src/__tests__/clear-messages.test.ts @@ -0,0 +1,5 @@ +xdescribe("Announcer.clearMessages", () => { + it("empties a targeted live region element by IDREF", () => {}); + + it("empties all live region elements by default", () => {}); +}); diff --git a/packages/wonder-blocks-announcer/src/__tests__/send-message.test.ts b/packages/wonder-blocks-announcer/src/__tests__/send-message.test.ts new file mode 100644 index 000000000..3fb11725d --- /dev/null +++ b/packages/wonder-blocks-announcer/src/__tests__/send-message.test.ts @@ -0,0 +1,35 @@ +import {sendMessage} from "../index"; + +describe("Announcer.sendMessage", () => { + it("creates the live region elements when called", () => { + // ARRANGE + const message = "Ta-da!"; + + // ACT: call function + sendMessage({message}); + + // ASSERT: expect live regions to exist + const wrapperElement = document.getElementById("wbAnnouncer"); + expect(wrapperElement).toBeInTheDocument(); + }); + + xit("appends to polite live regions by default", () => { + // ARRANGE + const message = "Ta-da!"; + + // ACT: call function + sendMessage({message}); + + // ASSERT: expect live regions to exist + const wrapperElement = document.getElementById("wbAnnounceWrapper"); + expect(wrapperElement).toBeInTheDocument(); + }); + + xit("appends messages in alternating polite live region elements", () => {}); + + xit("appends messages in alternating assertive live region elements", () => {}); + + xit("appends messages after an optional delay", () => {}); + + xit("returns a targeted element IDREF", () => {}); +}); diff --git a/packages/wonder-blocks-announcer/src/index.ts b/packages/wonder-blocks-announcer/src/index.ts new file mode 100644 index 000000000..87d2ccea2 --- /dev/null +++ b/packages/wonder-blocks-announcer/src/index.ts @@ -0,0 +1,108 @@ +// TODO: publish wonder-blocks-style package WB-1776 +// import {srOnly} from "../../wonder-blocks-style/src/styles/a11y"; + +export type PolitenessLevel = "polite" | "assertive"; + +const TIMEOUT_DELAY = 5000; +let announcer: Announcer | null = null; + +export type SendMessageProps = { + message: string; + level?: PolitenessLevel; + timeoutDelay?: number; +}; + +export function sendMessage({ + message, + level = "polite", // TODO: find way to factor in `timer` + timeoutDelay, +}: SendMessageProps): string { + if (!announcer) { + announcer = new Announcer(); + + return announcer.announce(message, level, timeoutDelay); + } else { + return announcer.announce(message, level, timeoutDelay); + } +} + +export function clearMessages(id?: string) { + if (id && document?.getElementById(id)) { + // clear target element + } else if (document) { + // clear all elements + } +} + +class Announcer { + node: HTMLElement | null = null; + assertiveRegions: HTMLElement | null = null; + politeRegions: HTMLElement | null = null; + + constructor() { + if (typeof document !== "undefined") { + this.node = document.createElement("div"); + this.node.id = `wbAnnouncer`; + + Object.assign(this.node.style, srOnly); + + this.assertiveRegions = this.createElements("assertive"); + this.node.appendChild(this.assertiveRegions); + + this.politeRegions = this.createElements("polite"); + this.node.appendChild(this.politeRegions); + + document.body.prepend(this.node); + } + } + + createElements(level: PolitenessLevel) { + const wrapper = document.createElement("div"); + wrapper.id = `wbAnnounceWrapper-${level}`; + + const region1 = this.createRegion(level, 1); + const region2 = this.createRegion(level, 2); + + wrapper.appendChild(region1); + wrapper.appendChild(region2); + + return wrapper; + } + + createRegion(level: PolitenessLevel, id: number, role = "log") { + const region = document.createElement("div"); + // TODO: test combinations of attrs + region.setAttribute("role", role); + region.setAttribute("aria-live", level); + region.id = `wbAnnounce-${level}${id}`; + return region; + } + + announce( + message: string, + level: PolitenessLevel, + timeoutDelay = TIMEOUT_DELAY, + ): string { + if (level === "polite") { + // do polite things + } else if (level === "assertive") { + // do assertive things + } + + // TODO: return ID of element targeted for announcement + return ""; + } +} + +export default Announcer; + +export const srOnly = { + border: 0, + clip: "rect(0,0,0,0)", + height: 1, + margin: -1, + overflow: "hidden", + padding: 0, + position: "absolute", + width: 1, +}; diff --git a/packages/wonder-blocks-announcer/tsconfig-build.json b/packages/wonder-blocks-announcer/tsconfig-build.json new file mode 100644 index 000000000..1abf980ab --- /dev/null +++ b/packages/wonder-blocks-announcer/tsconfig-build.json @@ -0,0 +1,11 @@ +{ + "exclude": ["dist"], + "extends": "../tsconfig-shared.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "src", + }, + "references": [ + {"path": "../wonder-blocks-core/tsconfig-build.json"}, + ] +} \ No newline at end of file From 737fdc5c5ea50c21e28eea4a8ac13bfe9a9fa936 Mon Sep 17 00:00:00 2001 From: Marcy Sutton Todd Date: Thu, 14 Nov 2024 16:21:53 -0800 Subject: [PATCH 02/44] WIP: append messages --- .../announcer.stories.tsx | 10 +- .../announcer.styles.css | 13 ++ .../src/__tests__/send-message.test.ts | 53 ++++- packages/wonder-blocks-announcer/src/index.ts | 218 +++++++++++++++--- 4 files changed, 250 insertions(+), 44 deletions(-) create mode 100644 __docs__/wonder-blocks-announcer/announcer.styles.css diff --git a/__docs__/wonder-blocks-announcer/announcer.stories.tsx b/__docs__/wonder-blocks-announcer/announcer.stories.tsx index 8ce24ddb3..f16d5b3c5 100644 --- a/__docs__/wonder-blocks-announcer/announcer.stories.tsx +++ b/__docs__/wonder-blocks-announcer/announcer.stories.tsx @@ -12,13 +12,21 @@ import {View} from "@khanacademy/wonder-blocks-core"; import ComponentInfo from "../../.storybook/components/component-info"; import packageConfig from "../../packages/wonder-blocks-announcer/package.json"; +import "./announcer.styles.css"; + const AnnouncerExample = ({ message = "Clicked!", level, timeoutDelay, }: SendMessageProps) => { return ( - ); diff --git a/__docs__/wonder-blocks-announcer/announcer.styles.css b/__docs__/wonder-blocks-announcer/announcer.styles.css new file mode 100644 index 000000000..14d012a1a --- /dev/null +++ b/__docs__/wonder-blocks-announcer/announcer.styles.css @@ -0,0 +1,13 @@ +.wbARegion { + border: 1px solid red; + margin-bottom: 0.5em; + position: relative; +} +.wbARegion::before { + background-color: white; + border: 1px solid red; + content: attr(id); + display: block; + font-size: 0.75rem; + padding: 0.25em; +} \ No newline at end of file diff --git a/packages/wonder-blocks-announcer/src/__tests__/send-message.test.ts b/packages/wonder-blocks-announcer/src/__tests__/send-message.test.ts index 3fb11725d..8fb8421ae 100644 --- a/packages/wonder-blocks-announcer/src/__tests__/send-message.test.ts +++ b/packages/wonder-blocks-announcer/src/__tests__/send-message.test.ts @@ -9,27 +9,66 @@ describe("Announcer.sendMessage", () => { sendMessage({message}); // ASSERT: expect live regions to exist - const wrapperElement = document.getElementById("wbAnnouncer"); + const wrapperElement = document.getElementById("wbAnnounce"); + const regionElements = document.querySelectorAll("[id^='wbARegion']"); expect(wrapperElement).toBeInTheDocument(); + expect(regionElements).toHaveLength(4); }); - xit("appends to polite live regions by default", () => { + it("appends to polite live regions by default", () => { // ARRANGE - const message = "Ta-da!"; + const message = "Ta-da, nicely!"; // ACT: call function sendMessage({message}); // ASSERT: expect live regions to exist - const wrapperElement = document.getElementById("wbAnnounceWrapper"); + const politeRegions = document.querySelectorAll( + "[id^='wbARegion-polite']", + ); + const wrapperElement = document.getElementById("wbAnnounce"); expect(wrapperElement).toBeInTheDocument(); + expect(politeRegions).toHaveLength(2); + expect(politeRegions[0]).toHaveAttribute("aria-live", "polite"); + expect(politeRegions[0]).toHaveAttribute("id", "wbARegion-polite0"); + expect(politeRegions[1]).toHaveAttribute("aria-live", "polite"); + expect(politeRegions[1]).toHaveAttribute("id", "wbARegion-polite1"); }); - xit("appends messages in alternating polite live region elements", () => {}); + it("appends messages in alternating polite live region elements", async () => { + // ARRANGE + const message1 = "Rainier McCheddarton"; + const message2 = "Bagley Fluffpants"; + + // ACT: post two messages + sendMessage({message: message1, timeoutDelay: 10}); + // sendMessage({message: message2}); + + // ASSERT: check messages were appended to elements + const politeRegions = document.querySelectorAll( + "[id^='wbARegion-polite']", + ); + console.log("test:", politeRegions[0].textContent); + // The second region will be targeted first + expect(politeRegions[0].textContent).toBe(message2); + expect(politeRegions[1].textContent).toBe(message1); + }); + + it("returns a targeted element IDREF", () => { + // ARRANGE + const message1 = "One Fish Two Fish"; + const message2 = "Red Fish Blue Fish"; + + // ACT + const announcement1 = sendMessage({message: message1}); + const announcement2 = sendMessage({message: message2}); + + // ASSERT + expect(announcement1).toBe("wbARegion-polite0"); + expect(announcement2).toBe("wbARegion-polite1"); + }); xit("appends messages in alternating assertive live region elements", () => {}); xit("appends messages after an optional delay", () => {}); - - xit("returns a targeted element IDREF", () => {}); }); diff --git a/packages/wonder-blocks-announcer/src/index.ts b/packages/wonder-blocks-announcer/src/index.ts index 87d2ccea2..1ff64f10c 100644 --- a/packages/wonder-blocks-announcer/src/index.ts +++ b/packages/wonder-blocks-announcer/src/index.ts @@ -3,6 +3,12 @@ export type PolitenessLevel = "polite" | "assertive"; +type RegionFactory = { + count: number; + aIndex: number; + pIndex: number; +}; + const TIMEOUT_DELAY = 5000; let announcer: Announcer | null = null; @@ -14,67 +20,141 @@ export type SendMessageProps = { export function sendMessage({ message, - level = "polite", // TODO: find way to factor in `timer` + level = "polite", // TODO: decide whether to allow role=`timer` timeoutDelay, -}: SendMessageProps): string { - if (!announcer) { - announcer = new Announcer(); +}: SendMessageProps): string | void { + announcer = Announcer.getInstance(); - return announcer.announce(message, level, timeoutDelay); + if (typeof jest === "undefined") { + setTimeout(() => { + return announcer?.announce(message, level, timeoutDelay); + }, 100); } else { + // If we are in a test environment, announce without waiting return announcer.announce(message, level, timeoutDelay); } } export function clearMessages(id?: string) { if (id && document?.getElementById(id)) { - // clear target element + // announcer?.clear(id); } else if (document) { - // clear all elements + // announcer?.clear(); } } class Announcer { + private static _instance: Announcer; node: HTMLElement | null = null; - assertiveRegions: HTMLElement | null = null; - politeRegions: HTMLElement | null = null; - - constructor() { + assertiveRegions: HTMLElement[] | null = null; + politeRegions: HTMLElement[] | null = null; + regionFactory: RegionFactory = { + count: 2, + aIndex: 0, + pIndex: 0, + }; + delayNum: number = TIMEOUT_DELAY; + + private constructor() { if (typeof document !== "undefined") { - this.node = document.createElement("div"); - this.node.id = `wbAnnouncer`; - - Object.assign(this.node.style, srOnly); - - this.assertiveRegions = this.createElements("assertive"); - this.node.appendChild(this.assertiveRegions); + const topLevelId = `wbAnnounce`; + + // Prevent duplicates in HMR + const announcerCheck = document.getElementById(topLevelId); + console.log(announcerCheck); + if (announcerCheck === null) { + this.init(topLevelId); + } + } + } - this.politeRegions = this.createElements("polite"); - this.node.appendChild(this.politeRegions); + rebootForHMR() { + // Recover in the event regions get lost + // This happens in Storybook when saving a file: + // Announcer exists, but it loses the connection to element Refs + const announcerCheck = document.getElementById(`wbAnnounce`); + if (announcerCheck !== null) { + this.node = announcerCheck; + const pRegions = Array.from( + announcerCheck.querySelectorAll( + "[id^='wbARegion-polite'", + ), + ); + if (pRegions.length) { + this.politeRegions = pRegions; + } + const aRegions = Array.from( + announcerCheck.querySelectorAll( + "[id^='wbARegion-assertive'", + ), + ); + if (aRegions.length) { + this.assertiveRegions = aRegions; + } + } + } - document.body.prepend(this.node); + static getInstance() { + if (!Announcer._instance) { + Announcer._instance = new Announcer(); + console.log(Announcer._instance); } + + Announcer._instance.rebootForHMR(); + return Announcer._instance; } - createElements(level: PolitenessLevel) { - const wrapper = document.createElement("div"); - wrapper.id = `wbAnnounceWrapper-${level}`; + init(id: string) { + this.node = document.createElement("div"); + this.node.id = id; + + // Object.assign(this.node.style, srOnly); - const region1 = this.createRegion(level, 1); - const region2 = this.createRegion(level, 2); + const aWrapper = this.createRegionWrapper("assertive"); + this.assertiveRegions = this.createDuplicateRegions( + aWrapper, + "assertive", + ); + this.node?.appendChild(aWrapper); - wrapper.appendChild(region1); - wrapper.appendChild(region2); + const pWrapper = this.createRegionWrapper("polite"); + this.politeRegions = this.createDuplicateRegions(pWrapper, "polite"); + this.node.appendChild(pWrapper); + + document.body.prepend(this.node); + } + isAttached() { + return this.node?.isConnected; + } + + createRegionWrapper(level: PolitenessLevel) { + const wrapper = document.createElement("div"); + wrapper.id = `wbAWrap-${level}`; return wrapper; } + createDuplicateRegions( + wrapper: HTMLElement, + level: PolitenessLevel, + ): HTMLElement[] { + const result = new Array(this.regionFactory.count) + .fill(0) + .map((el, i) => { + const region = this.createRegion(level, i); + wrapper.appendChild(region); + return region; + }); + return result; + } + createRegion(level: PolitenessLevel, id: number, role = "log") { const region = document.createElement("div"); // TODO: test combinations of attrs region.setAttribute("role", role); region.setAttribute("aria-live", level); - region.id = `wbAnnounce-${level}${id}`; + region.classList.add("wbARegion"); + region.id = `wbARegion-${level}${id}`; return region; } @@ -82,15 +162,81 @@ class Announcer { message: string, level: PolitenessLevel, timeoutDelay = TIMEOUT_DELAY, - ): string { - if (level === "polite") { - // do polite things - } else if (level === "assertive") { - // do assertive things + ): string | void { + if (!this.node) { + return; + } + + if (timeoutDelay) { + this.delayNum = timeoutDelay; + } + let targetedId = ""; + + if (level === "polite" && this.politeRegions) { + const index = this.appendMessage( + message, + this.politeRegions, + this.regionFactory.pIndex, + ); + this.regionFactory.pIndex = index; + // console.log( + // "code:", + // this.politeRegions[this.regionFactory.pIndex].textContent, + // ); + targetedId = this.politeRegions[this.regionFactory.pIndex].id || ""; + } else if (level === "assertive" && this.assertiveRegions) { + const index = this.appendMessage( + message, + this.assertiveRegions, + this.regionFactory.aIndex, + ); + this.regionFactory.aIndex = index; + targetedId = + this.assertiveRegions[this.regionFactory.aIndex].id || ""; + } + return targetedId; + } + + appendMessage( + message: string, + targetRegions: HTMLElement[], + index: number, + ): number { + // empty region at the previous index + targetRegions[index].replaceChildren(); + + // overwrite index passed in to update locally + index = this.alternateIndex(index); + + // create element for new message + const messageEl = document.createElement("p"); + messageEl.textContent = message; + + // append message to new index + targetRegions[index].appendChild(messageEl); + + setTimeout(() => { + messageEl.remove(); + }, this.delayNum); + + return index; + } + + alternateIndex(index: number): number { + index += 1; + index = index % this.regionFactory.count; + return index; + } + + clear(targetRegions?: HTMLElement[] | null, index?: number) { + if (!this.node) { + return; } - // TODO: return ID of element targeted for announcement - return ""; + // if (index && targetRegions) { + // targetRegions[index].replaceChildren(); + // } else { + // } } } From 78f120840a28663c62c7b48967d4a436daf99263 Mon Sep 17 00:00:00 2001 From: Marcy Sutton Todd Date: Fri, 15 Nov 2024 10:40:47 -0800 Subject: [PATCH 03/44] Leverage React for tests --- .../src/__tests__/send-message.test.ts | 74 ------------- .../src/__tests__/send-message.test.tsx | 103 ++++++++++++++++++ .../src/components/send-message-button.tsx | 13 +++ packages/wonder-blocks-announcer/src/index.ts | 68 ++++++------ 4 files changed, 152 insertions(+), 106 deletions(-) delete mode 100644 packages/wonder-blocks-announcer/src/__tests__/send-message.test.ts create mode 100644 packages/wonder-blocks-announcer/src/__tests__/send-message.test.tsx create mode 100644 packages/wonder-blocks-announcer/src/components/send-message-button.tsx diff --git a/packages/wonder-blocks-announcer/src/__tests__/send-message.test.ts b/packages/wonder-blocks-announcer/src/__tests__/send-message.test.ts deleted file mode 100644 index 8fb8421ae..000000000 --- a/packages/wonder-blocks-announcer/src/__tests__/send-message.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import {sendMessage} from "../index"; - -describe("Announcer.sendMessage", () => { - it("creates the live region elements when called", () => { - // ARRANGE - const message = "Ta-da!"; - - // ACT: call function - sendMessage({message}); - - // ASSERT: expect live regions to exist - const wrapperElement = document.getElementById("wbAnnounce"); - const regionElements = document.querySelectorAll("[id^='wbARegion']"); - expect(wrapperElement).toBeInTheDocument(); - expect(regionElements).toHaveLength(4); - }); - - it("appends to polite live regions by default", () => { - // ARRANGE - const message = "Ta-da, nicely!"; - - // ACT: call function - sendMessage({message}); - - // ASSERT: expect live regions to exist - const politeRegions = document.querySelectorAll( - "[id^='wbARegion-polite']", - ); - const wrapperElement = document.getElementById("wbAnnounce"); - expect(wrapperElement).toBeInTheDocument(); - expect(politeRegions).toHaveLength(2); - expect(politeRegions[0]).toHaveAttribute("aria-live", "polite"); - expect(politeRegions[0]).toHaveAttribute("id", "wbARegion-polite0"); - expect(politeRegions[1]).toHaveAttribute("aria-live", "polite"); - expect(politeRegions[1]).toHaveAttribute("id", "wbARegion-polite1"); - }); - - it("appends messages in alternating polite live region elements", async () => { - // ARRANGE - const message1 = "Rainier McCheddarton"; - const message2 = "Bagley Fluffpants"; - - // ACT: post two messages - sendMessage({message: message1, timeoutDelay: 10}); - // sendMessage({message: message2}); - - // ASSERT: check messages were appended to elements - const politeRegions = document.querySelectorAll( - "[id^='wbARegion-polite']", - ); - console.log("test:", politeRegions[0].textContent); - // The second region will be targeted first - expect(politeRegions[0].textContent).toBe(message2); - expect(politeRegions[1].textContent).toBe(message1); - }); - - it("returns a targeted element IDREF", () => { - // ARRANGE - const message1 = "One Fish Two Fish"; - const message2 = "Red Fish Blue Fish"; - - // ACT - const announcement1 = sendMessage({message: message1}); - const announcement2 = sendMessage({message: message2}); - - // ASSERT - expect(announcement1).toBe("wbARegion-polite0"); - expect(announcement2).toBe("wbARegion-polite1"); - }); - - xit("appends messages in alternating assertive live region elements", () => {}); - - xit("appends messages after an optional delay", () => {}); -}); diff --git a/packages/wonder-blocks-announcer/src/__tests__/send-message.test.tsx b/packages/wonder-blocks-announcer/src/__tests__/send-message.test.tsx new file mode 100644 index 000000000..5f7e46015 --- /dev/null +++ b/packages/wonder-blocks-announcer/src/__tests__/send-message.test.tsx @@ -0,0 +1,103 @@ +import * as React from "react"; +import {render, screen} from "@testing-library/react"; +import {SendMessageButton} from "../components/send-message-button"; +import {sendMessage} from "../index"; + +describe("Announcer.sendMessage", () => { + test("creates the live region elements when called", () => { + // ARRANGE + const message = "Ta-da!"; + render(); + + // ACT: call function + const button = screen.getByRole("button"); + button.click(); + + // ASSERT: expect live regions to exist + const wrapperElement = screen.getByTestId("wbAnnounce"); + const regionElements = screen.queryAllByRole("log"); + expect(wrapperElement).toBeInTheDocument(); + expect(regionElements).toHaveLength(4); + }); + + test("appends to polite live regions by default", () => { + // ARRANGE + const message = "Ta-da, nicely!"; + render(); + + // ACT: call function + const button = screen.getByRole("button"); + button.click(); + + // ASSERT: expect live regions to exist + const politeRegion1 = screen.queryByTestId("wbARegion-polite0"); + const politeRegion2 = screen.queryByTestId("wbARegion-polite1"); + expect(politeRegion1).toHaveAttribute("aria-live", "polite"); + expect(politeRegion1).toHaveAttribute("id", "wbARegion-polite0"); + expect(politeRegion2).toHaveAttribute("aria-live", "polite"); + expect(politeRegion2).toHaveAttribute("id", "wbARegion-polite1"); + }); + + test("appends messages in alternating polite live region elements", async () => { + // ARRANGE + const rainierMsg = "Rainier McCheddarton"; + const bagleyMsg = "Bagley Fluffpants"; + render(); + render(); + + // ACT: post two messages + const button = screen.getAllByRole("button"); + button[0].click(); + + // ASSERT: check messages were appended to elements + // The second region will be targeted first + const message1Region = screen.queryByTestId("wbARegion-polite1"); + expect(message1Region).toHaveTextContent(rainierMsg); + + button[1].click(); + const message2Region = screen.queryByTestId("wbARegion-polite0"); + expect(message2Region).toHaveTextContent(bagleyMsg); + }); + + test("returns a targeted element IDREF", () => { + // ARRANGE + const message1 = "One Fish Two Fish"; + const message2 = "Red Fish Blue Fish"; + + // ACT + const announcement1 = sendMessage({message: message1}); + const announcement2 = sendMessage({message: message2}); + + // ASSERT + expect(announcement1).toBe("wbARegion-polite1"); + expect(announcement2).toBe("wbARegion-polite0"); + }); + + test("appends messages in alternating assertive live region elements", () => { + const rainierMsg = "Rainier McCheddarton"; + const bagleyMsg = "Bagley Fluffpants"; + render(); + render(); + + // ACT: post two messages + const button = screen.getAllByRole("button"); + button[0].click(); + + // ASSERT: check messages were appended to elements + // The second region will be targeted first + const message1Region = screen.queryByTestId("wbARegion-assertive1"); + expect(message1Region).toHaveTextContent(rainierMsg); + + button[1].click(); + const message2Region = screen.queryByTestId("wbARegion-assertive0"); + expect(message2Region).toHaveTextContent(bagleyMsg); + }); + + // test("removes messages after an optional delay", () => { + // const rainierMsg = "Rainier McCheddarton"; + // const bagleyMsg = "Bagley Fluffpants"; + // // default timeout is 5000ms + // render(); + // render(); + // }); +}); diff --git a/packages/wonder-blocks-announcer/src/components/send-message-button.tsx b/packages/wonder-blocks-announcer/src/components/send-message-button.tsx new file mode 100644 index 000000000..bd56ec0bc --- /dev/null +++ b/packages/wonder-blocks-announcer/src/components/send-message-button.tsx @@ -0,0 +1,13 @@ +import * as React from "react"; +// TODO: fix dupMap.get is not a function issue +// import Button from "@khanacademy/wonder-blocks-button"; +import {sendMessage, type SendMessageProps} from "../index"; + +type SendMessageButtonProps = { + buttonText?: string; +} & SendMessageProps; + +export const SendMessageButton = (props: SendMessageButtonProps) => { + const {buttonText = "Click"} = props; + return ; +}; diff --git a/packages/wonder-blocks-announcer/src/index.ts b/packages/wonder-blocks-announcer/src/index.ts index 1ff64f10c..eb5f4ff68 100644 --- a/packages/wonder-blocks-announcer/src/index.ts +++ b/packages/wonder-blocks-announcer/src/index.ts @@ -68,32 +68,6 @@ class Announcer { } } - rebootForHMR() { - // Recover in the event regions get lost - // This happens in Storybook when saving a file: - // Announcer exists, but it loses the connection to element Refs - const announcerCheck = document.getElementById(`wbAnnounce`); - if (announcerCheck !== null) { - this.node = announcerCheck; - const pRegions = Array.from( - announcerCheck.querySelectorAll( - "[id^='wbARegion-polite'", - ), - ); - if (pRegions.length) { - this.politeRegions = pRegions; - } - const aRegions = Array.from( - announcerCheck.querySelectorAll( - "[id^='wbARegion-assertive'", - ), - ); - if (aRegions.length) { - this.assertiveRegions = aRegions; - } - } - } - static getInstance() { if (!Announcer._instance) { Announcer._instance = new Announcer(); @@ -107,6 +81,7 @@ class Announcer { init(id: string) { this.node = document.createElement("div"); this.node.id = id; + this.node.setAttribute("data-testid", `wbAnnounce`); // Object.assign(this.node.style, srOnly); @@ -124,6 +99,32 @@ class Announcer { document.body.prepend(this.node); } + rebootForHMR() { + // Recover in the event regions get lost + // This happens in Storybook when saving a file: + // Announcer exists, but it loses the connection to element Refs + const announcerCheck = document.getElementById(`wbAnnounce`); + if (announcerCheck !== null) { + this.node = announcerCheck; + const pRegions = Array.from( + announcerCheck.querySelectorAll( + "[id^='wbARegion-polite'", + ), + ); + if (pRegions.length) { + this.politeRegions = pRegions; + } + const aRegions = Array.from( + announcerCheck.querySelectorAll( + "[id^='wbARegion-assertive'", + ), + ); + if (aRegions.length) { + this.assertiveRegions = aRegions; + } + } + } + isAttached() { return this.node?.isConnected; } @@ -148,13 +149,15 @@ class Announcer { return result; } - createRegion(level: PolitenessLevel, id: number, role = "log") { + createRegion(level: PolitenessLevel, index: number, role = "log") { const region = document.createElement("div"); // TODO: test combinations of attrs region.setAttribute("role", role); region.setAttribute("aria-live", level); region.classList.add("wbARegion"); - region.id = `wbARegion-${level}${id}`; + const id = `wbARegion-${level}${index}`; + region.id = id; + region.setAttribute("data-testid", id); return region; } @@ -179,10 +182,11 @@ class Announcer { this.regionFactory.pIndex, ); this.regionFactory.pIndex = index; - // console.log( - // "code:", - // this.politeRegions[this.regionFactory.pIndex].textContent, - // ); + console.log( + "code:", + this.politeRegions[this.regionFactory.pIndex].id, + this.politeRegions[this.regionFactory.pIndex].textContent, + ); targetedId = this.politeRegions[this.regionFactory.pIndex].id || ""; } else if (level === "assertive" && this.assertiveRegions) { const index = this.appendMessage( From b3a31d42cf411ce940730065ecee24808343aae5 Mon Sep 17 00:00:00 2001 From: Marcy Sutton Todd Date: Fri, 15 Nov 2024 14:01:58 -0800 Subject: [PATCH 04/44] Refactor to use dictionary --- .../announcer.styles.css | 6 +- .../src/__tests__/clear-messages.test.ts | 5 - .../src/__tests__/clear-messages.test.tsx | 54 ++++++ .../src/__tests__/send-message.test.tsx | 8 +- packages/wonder-blocks-announcer/src/index.ts | 172 ++++++++++-------- 5 files changed, 161 insertions(+), 84 deletions(-) delete mode 100644 packages/wonder-blocks-announcer/src/__tests__/clear-messages.test.ts create mode 100644 packages/wonder-blocks-announcer/src/__tests__/clear-messages.test.tsx diff --git a/__docs__/wonder-blocks-announcer/announcer.styles.css b/__docs__/wonder-blocks-announcer/announcer.styles.css index 14d012a1a..59ba76d89 100644 --- a/__docs__/wonder-blocks-announcer/announcer.styles.css +++ b/__docs__/wonder-blocks-announcer/announcer.styles.css @@ -1,7 +1,11 @@ +#wbAnnounce { + display: block !important; + clip: revert !important; + position: relative !important; +} .wbARegion { border: 1px solid red; margin-bottom: 0.5em; - position: relative; } .wbARegion::before { background-color: white; diff --git a/packages/wonder-blocks-announcer/src/__tests__/clear-messages.test.ts b/packages/wonder-blocks-announcer/src/__tests__/clear-messages.test.ts deleted file mode 100644 index 815651a01..000000000 --- a/packages/wonder-blocks-announcer/src/__tests__/clear-messages.test.ts +++ /dev/null @@ -1,5 +0,0 @@ -xdescribe("Announcer.clearMessages", () => { - it("empties a targeted live region element by IDREF", () => {}); - - it("empties all live region elements by default", () => {}); -}); diff --git a/packages/wonder-blocks-announcer/src/__tests__/clear-messages.test.tsx b/packages/wonder-blocks-announcer/src/__tests__/clear-messages.test.tsx new file mode 100644 index 000000000..89563848a --- /dev/null +++ b/packages/wonder-blocks-announcer/src/__tests__/clear-messages.test.tsx @@ -0,0 +1,54 @@ +import * as React from "react"; +import {render, screen} from "@testing-library/react"; +import {SendMessageButton} from "../components/send-message-button"; +import {sendMessage, clearMessages} from "../index"; + +describe("Announcer.clearMessages", () => { + test("empties a targeted live region element by IDREF", () => { + // ARRANGE + const message1 = "Shine a million stars"; + const message2 = "Dull no stars"; + + // ACT + const announcement1Id = sendMessage({message: message1}); + + const region1 = screen.getByTestId("wbARegion-polite1"); + expect(region1).toHaveTextContent(message1); + + sendMessage({message: message2}); + const region2 = screen.getByTestId("wbARegion-polite0"); + + clearMessages(announcement1Id); + + // ASSERT + expect(region1).toBeEmptyDOMElement(); + expect(region2).toHaveTextContent(message2); + }); + + test("empties all live region elements by default", () => { + // ARRANGE + const message1 = "One fish two fish"; + const message2 = "Red fish blue fish"; + + // ACT + sendMessage({message: message1}); + + const region1 = screen.getByTestId("wbARegion-polite1"); + expect(region1).toHaveTextContent(message1); + + sendMessage({message: message2}); + const region2 = screen.getByTestId("wbARegion-polite0"); + expect(region2).toHaveTextContent(message2); + + sendMessage({message: message1, level: "assertive"}); + const region3 = screen.getByTestId("wbARegion-assertive1"); + expect(region3).toHaveTextContent(message1); + + clearMessages(); + + // ASSERT + expect(region1).toBeEmptyDOMElement(); + expect(region2).toBeEmptyDOMElement(); + expect(region3).toBeEmptyDOMElement(); + }); +}); diff --git a/packages/wonder-blocks-announcer/src/__tests__/send-message.test.tsx b/packages/wonder-blocks-announcer/src/__tests__/send-message.test.tsx index 5f7e46015..1dcaa1107 100644 --- a/packages/wonder-blocks-announcer/src/__tests__/send-message.test.tsx +++ b/packages/wonder-blocks-announcer/src/__tests__/send-message.test.tsx @@ -65,12 +65,12 @@ describe("Announcer.sendMessage", () => { const message2 = "Red Fish Blue Fish"; // ACT - const announcement1 = sendMessage({message: message1}); - const announcement2 = sendMessage({message: message2}); + const announcement1Id = sendMessage({message: message1}); + const announcement2Id = sendMessage({message: message2}); // ASSERT - expect(announcement1).toBe("wbARegion-polite1"); - expect(announcement2).toBe("wbARegion-polite0"); + expect(announcement1Id).toBe("wbARegion-polite1"); + expect(announcement2Id).toBe("wbARegion-polite0"); }); test("appends messages in alternating assertive live region elements", () => { diff --git a/packages/wonder-blocks-announcer/src/index.ts b/packages/wonder-blocks-announcer/src/index.ts index eb5f4ff68..7b2b9f2da 100644 --- a/packages/wonder-blocks-announcer/src/index.ts +++ b/packages/wonder-blocks-announcer/src/index.ts @@ -3,12 +3,6 @@ export type PolitenessLevel = "polite" | "assertive"; -type RegionFactory = { - count: number; - aIndex: number; - pIndex: number; -}; - const TIMEOUT_DELAY = 5000; let announcer: Announcer | null = null; @@ -22,7 +16,7 @@ export function sendMessage({ message, level = "polite", // TODO: decide whether to allow role=`timer` timeoutDelay, -}: SendMessageProps): string | void { +}: SendMessageProps): string { announcer = Announcer.getInstance(); if (typeof jest === "undefined") { @@ -33,27 +27,57 @@ export function sendMessage({ // If we are in a test environment, announce without waiting return announcer.announce(message, level, timeoutDelay); } + return ""; } export function clearMessages(id?: string) { if (id && document?.getElementById(id)) { - // announcer?.clear(id); + announcer?.clear(id); } else if (document) { - // announcer?.clear(); + announcer?.clear(); } } +type RegionFactory = { + count: number; + aIndex: number; + pIndex: number; +}; + +interface RegionSet { + polite: HTMLElement[]; + assertive: HTMLElement[]; +} //deprecate + +type RegionList = {[K in keyof RegionSet]: HTMLElement[]}; //deprecate +type RegionDef = { + id: string; + levelIndex: number; + level: PolitenessLevel; + element: HTMLElement; +}; +type RegionDictionary = Map; + +/* { + wbARegion-polite0: {id: 0, level: polite, element: HTMLElement} + wbARegion-polite1: {id: 1, level: polite, element: HTMLElement} + wbARegion-assertive0: {id: 0, level: assertive, element: HTMLElement} + wbARegion-assertive1: {id: 1, level: assertive, element: HTMLElement} +} +{ + assertive: [element0, element1] + polite: [element0, element1] +} */ class Announcer { private static _instance: Announcer; node: HTMLElement | null = null; - assertiveRegions: HTMLElement[] | null = null; - politeRegions: HTMLElement[] | null = null; regionFactory: RegionFactory = { count: 2, aIndex: 0, pIndex: 0, }; - delayNum: number = TIMEOUT_DELAY; + regionList: RegionList = {polite: [], assertive: []}; + dictionary: RegionDictionary = new Map(); private constructor() { if (typeof document !== "undefined") { @@ -61,7 +85,6 @@ class Announcer { // Prevent duplicates in HMR const announcerCheck = document.getElementById(topLevelId); - console.log(announcerCheck); if (announcerCheck === null) { this.init(topLevelId); } @@ -71,7 +94,6 @@ class Announcer { static getInstance() { if (!Announcer._instance) { Announcer._instance = new Announcer(); - console.log(Announcer._instance); } Announcer._instance.rebootForHMR(); @@ -83,17 +105,14 @@ class Announcer { this.node.id = id; this.node.setAttribute("data-testid", `wbAnnounce`); - // Object.assign(this.node.style, srOnly); + Object.assign(this.node.style, srOnly); const aWrapper = this.createRegionWrapper("assertive"); - this.assertiveRegions = this.createDuplicateRegions( - aWrapper, - "assertive", - ); + this.createDuplicateRegions(aWrapper, "assertive"); this.node?.appendChild(aWrapper); const pWrapper = this.createRegionWrapper("polite"); - this.politeRegions = this.createDuplicateRegions(pWrapper, "polite"); + this.createDuplicateRegions(pWrapper, "polite"); this.node.appendChild(pWrapper); document.body.prepend(this.node); @@ -106,22 +125,21 @@ class Announcer { const announcerCheck = document.getElementById(`wbAnnounce`); if (announcerCheck !== null) { this.node = announcerCheck; - const pRegions = Array.from( + const regions = Array.from( announcerCheck.querySelectorAll( - "[id^='wbARegion-polite'", + "[id^='wbARegion'", ), ); - if (pRegions.length) { - this.politeRegions = pRegions; - } - const aRegions = Array.from( - announcerCheck.querySelectorAll( - "[id^='wbARegion-assertive'", - ), - ); - if (aRegions.length) { - this.assertiveRegions = aRegions; - } + regions.forEach((region) => { + this.dictionary.set(region.id, { + id: region.id, + levelIndex: parseInt( + region.id.charAt(region.id.length - 1), + ), + level: region.getAttribute("aria-live") as PolitenessLevel, + element: region, + }); + }); } } @@ -158,56 +176,60 @@ class Announcer { const id = `wbARegion-${level}${index}`; region.id = id; region.setAttribute("data-testid", id); + this.dictionary.set(id, { + id, + levelIndex: index, + level, + element: region, + }); return region; } announce( message: string, level: PolitenessLevel, - timeoutDelay = TIMEOUT_DELAY, - ): string | void { + timeoutDelay?: number, + ): string { if (!this.node) { - return; + return ""; } - if (timeoutDelay) { - this.delayNum = timeoutDelay; - } - let targetedId = ""; + // Filter region elements to the selected level + const regions: RegionDef[] = [...this.dictionary.values()].filter( + (entry: RegionDef) => entry.level === level, + ); - if (level === "polite" && this.politeRegions) { - const index = this.appendMessage( - message, - this.politeRegions, - this.regionFactory.pIndex, - ); - this.regionFactory.pIndex = index; - console.log( - "code:", - this.politeRegions[this.regionFactory.pIndex].id, - this.politeRegions[this.regionFactory.pIndex].textContent, - ); - targetedId = this.politeRegions[this.regionFactory.pIndex].id || ""; - } else if (level === "assertive" && this.assertiveRegions) { - const index = this.appendMessage( - message, - this.assertiveRegions, - this.regionFactory.aIndex, - ); - this.regionFactory.aIndex = index; - targetedId = - this.assertiveRegions[this.regionFactory.aIndex].id || ""; + const newIndex = this.appendMessage( + message, + level, + regions, + timeoutDelay, + ); + + // overwrite central index for the given level + if (level === "assertive") { + this.regionFactory.aIndex = newIndex; + } else { + this.regionFactory.pIndex = newIndex; } - return targetedId; + + return regions[newIndex].id || ""; } appendMessage( message: string, - targetRegions: HTMLElement[], - index: number, + level: PolitenessLevel, // level + regionList: RegionDef[], // list of relevant elements + timeoutDelay: number = TIMEOUT_DELAY, ): number { + // Starting index for a given level + let index = + level === "assertive" + ? this.regionFactory.aIndex + : this.regionFactory.pIndex; + // empty region at the previous index - targetRegions[index].replaceChildren(); + regionList[index].element.replaceChildren(); // overwrite index passed in to update locally index = this.alternateIndex(index); @@ -217,11 +239,11 @@ class Announcer { messageEl.textContent = message; // append message to new index - targetRegions[index].appendChild(messageEl); + regionList[index].element.appendChild(messageEl); setTimeout(() => { messageEl.remove(); - }, this.delayNum); + }, timeoutDelay); return index; } @@ -232,15 +254,17 @@ class Announcer { return index; } - clear(targetRegions?: HTMLElement[] | null, index?: number) { + clear(id?: string) { if (!this.node) { return; } - - // if (index && targetRegions) { - // targetRegions[index].replaceChildren(); - // } else { - // } + if (id) { + this.dictionary.get(id)?.element.replaceChildren(); + } else { + this.dictionary.forEach((region) => { + region.element.replaceChildren(); + }); + } } } From 74ad711120efa3ac5be72c42ea23feca09a2b48b Mon Sep 17 00:00:00 2001 From: Marcy Sutton Todd Date: Fri, 15 Nov 2024 14:34:59 -0800 Subject: [PATCH 05/44] Cleanup, move types, add comments --- .../src/__tests__/clear-messages.test.tsx | 4 +- .../src/announcer.types.ts | 16 ++ packages/wonder-blocks-announcer/src/index.ts | 185 +++++++++++------- 3 files changed, 131 insertions(+), 74 deletions(-) create mode 100644 packages/wonder-blocks-announcer/src/announcer.types.ts diff --git a/packages/wonder-blocks-announcer/src/__tests__/clear-messages.test.tsx b/packages/wonder-blocks-announcer/src/__tests__/clear-messages.test.tsx index 89563848a..6634cdf0f 100644 --- a/packages/wonder-blocks-announcer/src/__tests__/clear-messages.test.tsx +++ b/packages/wonder-blocks-announcer/src/__tests__/clear-messages.test.tsx @@ -1,6 +1,4 @@ -import * as React from "react"; -import {render, screen} from "@testing-library/react"; -import {SendMessageButton} from "../components/send-message-button"; +import {screen} from "@testing-library/react"; import {sendMessage, clearMessages} from "../index"; describe("Announcer.clearMessages", () => { diff --git a/packages/wonder-blocks-announcer/src/announcer.types.ts b/packages/wonder-blocks-announcer/src/announcer.types.ts new file mode 100644 index 000000000..e330985e6 --- /dev/null +++ b/packages/wonder-blocks-announcer/src/announcer.types.ts @@ -0,0 +1,16 @@ +export type PolitenessLevel = "polite" | "assertive"; + +export type RegionFactory = { + count: number; + aIndex: number; + pIndex: number; +}; + +export type RegionDef = { + id: string; + levelIndex: number; + level: PolitenessLevel; + element: HTMLElement; +}; + +export type RegionDictionary = Map; diff --git a/packages/wonder-blocks-announcer/src/index.ts b/packages/wonder-blocks-announcer/src/index.ts index 7b2b9f2da..bdd54877c 100644 --- a/packages/wonder-blocks-announcer/src/index.ts +++ b/packages/wonder-blocks-announcer/src/index.ts @@ -1,7 +1,11 @@ // TODO: publish wonder-blocks-style package WB-1776 // import {srOnly} from "../../wonder-blocks-style/src/styles/a11y"; - -export type PolitenessLevel = "polite" | "assertive"; +import type { + PolitenessLevel, + RegionDef, + RegionDictionary, + RegionFactory, +} from "./announcer.types"; const TIMEOUT_DELAY = 5000; let announcer: Announcer | null = null; @@ -9,27 +13,38 @@ let announcer: Announcer | null = null; export type SendMessageProps = { message: string; level?: PolitenessLevel; - timeoutDelay?: number; + removalDelay?: number; }; +/** + * Public API method to send screen reader messages. + * @param {string} message The message to send. + * @param {PolitenessLevel} level Polite or assertive announcements + * @param {number} removalDelay Optional duration to remove a message after sending. Defaults to 5000ms. + * @returns {string} IDREF for targeted live region element + */ export function sendMessage({ message, - level = "polite", // TODO: decide whether to allow role=`timer` - timeoutDelay, + level = "polite", // TODO: decide whether to allow other roles, i.e. role=`timer` + removalDelay, }: SendMessageProps): string { announcer = Announcer.getInstance(); if (typeof jest === "undefined") { setTimeout(() => { - return announcer?.announce(message, level, timeoutDelay); + return announcer?.announce(message, level, removalDelay); }, 100); } else { // If we are in a test environment, announce without waiting - return announcer.announce(message, level, timeoutDelay); + return announcer.announce(message, level, removalDelay); } return ""; } - +/** + * Public API method to clear screen reader messages after sending. + * Clears all regions by default. + * @param {string} id Optional id of live region element to clear. + */ export function clearMessages(id?: string) { if (id && document?.getElementById(id)) { announcer?.clear(id); @@ -38,36 +53,9 @@ export function clearMessages(id?: string) { } } -type RegionFactory = { - count: number; - aIndex: number; - pIndex: number; -}; - -interface RegionSet { - polite: HTMLElement[]; - assertive: HTMLElement[]; -} //deprecate - -type RegionList = {[K in keyof RegionSet]: HTMLElement[]}; //deprecate -type RegionDef = { - id: string; - levelIndex: number; - level: PolitenessLevel; - element: HTMLElement; -}; -type RegionDictionary = Map; - -/* { - wbARegion-polite0: {id: 0, level: polite, element: HTMLElement} - wbARegion-polite1: {id: 1, level: polite, element: HTMLElement} - wbARegion-assertive0: {id: 0, level: assertive, element: HTMLElement} - wbARegion-assertive1: {id: 1, level: assertive, element: HTMLElement} -} -{ - assertive: [element0, element1] - polite: [element0, element1] -} */ +/** + * Internal class to manage screen reader announcements. + */ class Announcer { private static _instance: Announcer; node: HTMLElement | null = null; @@ -76,34 +64,43 @@ class Announcer { aIndex: 0, pIndex: 0, }; - regionList: RegionList = {polite: [], assertive: []}; dictionary: RegionDictionary = new Map(); private constructor() { if (typeof document !== "undefined") { const topLevelId = `wbAnnounce`; - - // Prevent duplicates in HMR + // Check if our top level element already exists const announcerCheck = document.getElementById(topLevelId); + + // Init new structure if the coast is clear if (announcerCheck === null) { this.init(topLevelId); } + // The structure exists but references are lost, so help HMR recover + else { + this.rebootForHMR(); + } } } - + /** + * Singleton handler to ensure we only have one Announcer instance + * @returns {Announcer} + */ static getInstance() { if (!Announcer._instance) { Announcer._instance = new Announcer(); } - - Announcer._instance.rebootForHMR(); return Announcer._instance; } - + /** + * Internal initializer method to create live region elements + * Prepends regions to document body + * @param {string} id ID of the top level node (wbAnnounce) + */ init(id: string) { this.node = document.createElement("div"); this.node.id = id; - this.node.setAttribute("data-testid", `wbAnnounce`); + this.node.setAttribute("data-testid", id); Object.assign(this.node.style, srOnly); @@ -117,11 +114,12 @@ class Announcer { document.body.prepend(this.node); } - + /** + * Recover in the event regions get lost + * This happens in Storybook when saving a file: + * Announcer exists, but it loses the connection to element Refs + */ rebootForHMR() { - // Recover in the event regions get lost - // This happens in Storybook when saving a file: - // Announcer exists, but it loses the connection to element Refs const announcerCheck = document.getElementById(`wbAnnounce`); if (announcerCheck !== null) { this.node = announcerCheck; @@ -143,16 +141,23 @@ class Announcer { } } - isAttached() { - return this.node?.isConnected; - } - + /** + * Create a wrapper element to group regions for a given level + * @param {string} level Politeness level for grouping + * @returns {HTMLElement} Wrapper DOM element reference + */ createRegionWrapper(level: PolitenessLevel) { const wrapper = document.createElement("div"); wrapper.id = `wbAWrap-${level}`; return wrapper; } + /** + * Create multiple live regions for a given level + * @param {HTMLElement} wrapper Parent DOM element reference to append into + * @param {string} level Politeness level for grouping + * @returns {HTMLElement[]} Array of region elements + */ createDuplicateRegions( wrapper: HTMLElement, level: PolitenessLevel, @@ -167,6 +172,13 @@ class Announcer { return result; } + /** + * Create live region element for a given level + * @param {string} level Politeness level for grouping + * @param {number} index Incrementor for duplicate regions + * @param {string} role Role attribute for live regions, defaults to log + * @returns {HTMLElement} DOM element reference for live region + */ createRegion(level: PolitenessLevel, index: number, role = "log") { const region = document.createElement("div"); // TODO: test combinations of attrs @@ -185,10 +197,17 @@ class Announcer { return region; } + /** + * Announce a live region message for a given level + * @param {string} message The message to be announced + * @param {string} level Politeness level: should it interrupt? + * @param {number} removalDelay How long to wait before removing message + * @returns {string} IDREF for targeted element or empty string if it failed + */ announce( message: string, level: PolitenessLevel, - timeoutDelay?: number, + removalDelay?: number, ): string { if (!this.node) { return ""; @@ -203,7 +222,7 @@ class Announcer { message, level, regions, - timeoutDelay, + removalDelay, ); // overwrite central index for the given level @@ -216,11 +235,38 @@ class Announcer { return regions[newIndex].id || ""; } + /** + * Clear messages on demand. + * This could be useful for clearing immediately, rather than waiting for the removalDelay. + * Defaults to clearing all live region elements + * @param {string} id Optional IDREF of specific element to empty + */ + clear(id?: string) { + if (!this.node) { + return; + } + if (id) { + this.dictionary.get(id)?.element.replaceChildren(); + } else { + this.dictionary.forEach((region) => { + region.element.replaceChildren(); + }); + } + } + + /** + * Append message to alternating element for a given level + * @param {string} message The message to be appended + * @param {string} level Which level to alternate + * @param {RegionDef[]} regionList Filtered dictionary of regions for level + * @param {number} removalDelay How long to wait before removing message + * @returns {number} Index of targeted region for updating central register + */ appendMessage( message: string, level: PolitenessLevel, // level regionList: RegionDef[], // list of relevant elements - timeoutDelay: number = TIMEOUT_DELAY, + removalDelay: number = TIMEOUT_DELAY, ): number { // Starting index for a given level let index = @@ -243,33 +289,30 @@ class Announcer { setTimeout(() => { messageEl.remove(); - }, timeoutDelay); + }, removalDelay); return index; } + /** + * Alternate index for cycling through elements + * @param {number} index Previous element index (0 or 1) + * @returns {number} New index + */ alternateIndex(index: number): number { index += 1; index = index % this.regionFactory.count; return index; } - - clear(id?: string) { - if (!this.node) { - return; - } - if (id) { - this.dictionary.get(id)?.element.replaceChildren(); - } else { - this.dictionary.forEach((region) => { - region.element.replaceChildren(); - }); - } - } } export default Announcer; +/** + * Styling for live region. + * TODO: move to wonder-blocks-style package. + * Note: This style is overridden in Storybook for testing. + */ export const srOnly = { border: 0, clip: "rect(0,0,0,0)", From 0eb7de3ad2c5e5250324cc3f84e83a2e6a26cea6 Mon Sep 17 00:00:00 2001 From: Marcy Sutton Todd Date: Fri, 15 Nov 2024 14:52:22 -0800 Subject: [PATCH 06/44] Fix outdated param in story --- __docs__/wonder-blocks-announcer/announcer.stories.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/__docs__/wonder-blocks-announcer/announcer.stories.tsx b/__docs__/wonder-blocks-announcer/announcer.stories.tsx index f16d5b3c5..f1b6f6373 100644 --- a/__docs__/wonder-blocks-announcer/announcer.stories.tsx +++ b/__docs__/wonder-blocks-announcer/announcer.stories.tsx @@ -17,13 +17,13 @@ import "./announcer.styles.css"; const AnnouncerExample = ({ message = "Clicked!", level, - timeoutDelay, + removalDelay, }: SendMessageProps) => { return ( ; +}; diff --git a/packages/wonder-blocks-announcer/src/__tests__/util/send-message-button.tsx b/packages/wonder-blocks-announcer/src/__tests__/util/send-message-button.tsx deleted file mode 100644 index 1e9aa006c..000000000 --- a/packages/wonder-blocks-announcer/src/__tests__/util/send-message-button.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import * as React from "react"; -// TODO: fix dupMap.get is not a function issue -// import Button from "@khanacademy/wonder-blocks-button"; -import {sendMessage} from "../../send-message"; -import {type SendMessageProps} from "../../send-message"; - -type SendMessageButtonProps = { - buttonText?: string; -} & SendMessageProps; - -export const SendMessageButton = (props: SendMessageButtonProps) => { - const {buttonText = "Click"} = props; - return ; -}; diff --git a/packages/wonder-blocks-announcer/src/send-message.ts b/packages/wonder-blocks-announcer/src/announce-message.ts similarity index 52% rename from packages/wonder-blocks-announcer/src/send-message.ts rename to packages/wonder-blocks-announcer/src/announce-message.ts index e57a54410..efe9e9d75 100644 --- a/packages/wonder-blocks-announcer/src/send-message.ts +++ b/packages/wonder-blocks-announcer/src/announce-message.ts @@ -1,33 +1,33 @@ import type {PolitenessLevel} from "../types/Announcer.types"; import Announcer from "./Announcer"; -export type SendMessageProps = { +export type AnnounceMessageProps = { message: string; level?: PolitenessLevel; removalDelay?: number; }; /** - * Public API method to send screen reader messages. - * @param {string} message The message to send. + * Public API method to announce screen reader messages in ARIA Live Regions. + * @param {string} message The message to announce. * @param {PolitenessLevel} level Polite or assertive announcements * @param {number} removalDelay Optional duration to remove a message after sending. Defaults to 5000ms. * @returns {string} IDREF for targeted live region element */ -export function sendMessage({ +export function announceMessage({ message, level = "polite", // TODO: decide whether to allow other roles, i.e. role=`timer` removalDelay, -}: SendMessageProps): string { +}: AnnounceMessageProps): string { const announcer = Announcer.getInstance(); - if (typeof jest === "undefined") { - setTimeout(() => { - return announcer?.announce(message, level, removalDelay); - }, 100); - } else { - // If we are in a test environment, announce without waiting - return announcer.announce(message, level, removalDelay); - } + // if (typeof jest === "undefined") { + // setTimeout(() => { + // return announcer?.announce(message, level, removalDelay); + // }, 100); + // } else { + // If we are in a test environment, announce without waiting + return announcer.announce(message, level, removalDelay); + // } return ""; } diff --git a/packages/wonder-blocks-announcer/src/index.ts b/packages/wonder-blocks-announcer/src/index.ts index c013ee65e..bb3335d7c 100644 --- a/packages/wonder-blocks-announcer/src/index.ts +++ b/packages/wonder-blocks-announcer/src/index.ts @@ -1,4 +1,4 @@ -import {sendMessage, type SendMessageProps} from "./send-message"; +import {announceMessage, type AnnounceMessageProps} from "./announce-message"; import {clearMessages} from "./clear-message"; -export {sendMessage, type SendMessageProps, clearMessages}; +export {announceMessage, type AnnounceMessageProps, clearMessages}; From 3cdbc6586ef1254daa244cc9a1129201eb310317 Mon Sep 17 00:00:00 2001 From: Marcy Sutton Todd Date: Fri, 22 Nov 2024 12:14:01 -0800 Subject: [PATCH 14/44] Rename clear-messages file to match function --- .../src/__tests__/announce-message.test.tsx | 2 +- .../src/__tests__/clear-messages.test.tsx | 2 +- .../src/{clear-message.ts => clear-messages.ts} | 0 packages/wonder-blocks-announcer/src/index.ts | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename packages/wonder-blocks-announcer/src/{clear-message.ts => clear-messages.ts} (100%) diff --git a/packages/wonder-blocks-announcer/src/__tests__/announce-message.test.tsx b/packages/wonder-blocks-announcer/src/__tests__/announce-message.test.tsx index e2817a6be..ded8af848 100644 --- a/packages/wonder-blocks-announcer/src/__tests__/announce-message.test.tsx +++ b/packages/wonder-blocks-announcer/src/__tests__/announce-message.test.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import {render, screen, waitFor} from "@testing-library/react"; import {AnnounceMessageButton} from "./util/announce-message-button"; import {announceMessage} from "../announce-message"; -import {clearMessages} from "../clear-message"; +import {clearMessages} from "../clear-messages"; jest.useFakeTimers(); jest.spyOn(global, "setTimeout"); diff --git a/packages/wonder-blocks-announcer/src/__tests__/clear-messages.test.tsx b/packages/wonder-blocks-announcer/src/__tests__/clear-messages.test.tsx index a92713327..6e94a7f1a 100644 --- a/packages/wonder-blocks-announcer/src/__tests__/clear-messages.test.tsx +++ b/packages/wonder-blocks-announcer/src/__tests__/clear-messages.test.tsx @@ -1,6 +1,6 @@ import {screen} from "@testing-library/react"; import {announceMessage} from "../announce-message"; -import {clearMessages} from "../clear-message"; +import {clearMessages} from "../clear-messages"; describe("Announcer.clearMessages", () => { test("empties a targeted live region element by IDREF", () => { diff --git a/packages/wonder-blocks-announcer/src/clear-message.ts b/packages/wonder-blocks-announcer/src/clear-messages.ts similarity index 100% rename from packages/wonder-blocks-announcer/src/clear-message.ts rename to packages/wonder-blocks-announcer/src/clear-messages.ts diff --git a/packages/wonder-blocks-announcer/src/index.ts b/packages/wonder-blocks-announcer/src/index.ts index bb3335d7c..c87dd6045 100644 --- a/packages/wonder-blocks-announcer/src/index.ts +++ b/packages/wonder-blocks-announcer/src/index.ts @@ -1,4 +1,4 @@ import {announceMessage, type AnnounceMessageProps} from "./announce-message"; -import {clearMessages} from "./clear-message"; +import {clearMessages} from "./clear-messages"; export {announceMessage, type AnnounceMessageProps, clearMessages}; From 5791768e45c176862e89e5ce44870fa121e9be59 Mon Sep 17 00:00:00 2001 From: Marcy Sutton Todd Date: Fri, 22 Nov 2024 12:15:10 -0800 Subject: [PATCH 15/44] Move utility functions into separate files --- .../wonder-blocks-announcer/src/Announcer.ts | 92 ++----------------- .../wonder-blocks-announcer/src/util/dom.ts | 82 +++++++++++++++++ .../wonder-blocks-announcer/src/util/util.ts | 10 ++ 3 files changed, 101 insertions(+), 83 deletions(-) create mode 100644 packages/wonder-blocks-announcer/src/util/dom.ts create mode 100644 packages/wonder-blocks-announcer/src/util/util.ts diff --git a/packages/wonder-blocks-announcer/src/Announcer.ts b/packages/wonder-blocks-announcer/src/Announcer.ts index 2e9ab5de6..7b5abf1f2 100644 --- a/packages/wonder-blocks-announcer/src/Announcer.ts +++ b/packages/wonder-blocks-announcer/src/Announcer.ts @@ -8,6 +8,9 @@ import { RegionDef, } from "../types/Announcer.types"; +import { createRegionWrapper, createDuplicateRegions, removeMessage } from "./util/dom"; +import { alternateIndex } from "./util/util" + const TIMEOUT_DELAY = 5000; /** @@ -61,12 +64,12 @@ class Announcer { Object.assign(this.node.style, srOnly); - const aWrapper = this.createRegionWrapper("assertive"); - this.createDuplicateRegions(aWrapper, "assertive"); + const aWrapper = createRegionWrapper("assertive"); + createDuplicateRegions(aWrapper, "assertive", this.regionFactory.count, this.dictionary); this.node?.appendChild(aWrapper); - const pWrapper = this.createRegionWrapper("polite"); - this.createDuplicateRegions(pWrapper, "polite"); + const pWrapper = createRegionWrapper("polite"); + createDuplicateRegions(pWrapper, "polite", this.regionFactory.count, this.dictionary); this.node.appendChild(pWrapper); document.body.prepend(this.node); @@ -98,61 +101,6 @@ class Announcer { } } - /** - * Create a wrapper element to group regions for a given level - * @param {string} level Politeness level for grouping - * @returns {HTMLElement} Wrapper DOM element reference - */ - createRegionWrapper(level: PolitenessLevel) { - const wrapper = document.createElement("div"); - wrapper.id = `wbAWrap-${level}`; - return wrapper; - } - - /** - * Create multiple live regions for a given level - * @param {HTMLElement} wrapper Parent DOM element reference to append into - * @param {string} level Politeness level for grouping - * @returns {HTMLElement[]} Array of region elements - */ - createDuplicateRegions( - wrapper: HTMLElement, - level: PolitenessLevel, - ): HTMLElement[] { - const result = new Array(this.regionFactory.count) - .fill(0) - .map((el, i) => { - const region = this.createRegion(level, i); - wrapper.appendChild(region); - return region; - }); - return result; - } - - /** - * Create live region element for a given level - * @param {string} level Politeness level for grouping - * @param {number} index Incrementor for duplicate regions - * @param {string} role Role attribute for live regions, defaults to log - * @returns {HTMLElement} DOM element reference for live region - */ - createRegion(level: PolitenessLevel, index: number, role = "log") { - const region = document.createElement("div"); - // TODO: test combinations of attrs - region.setAttribute("role", role); - region.setAttribute("aria-live", level); - region.classList.add("wbARegion"); - const id = `wbARegion-${level}${index}`; - region.id = id; - region.setAttribute("data-testid", id); - this.dictionary.set(id, { - id, - levelIndex: index, - level, - element: region, - }); - return region; - } /** * Announce a live region message for a given level @@ -235,7 +183,7 @@ class Announcer { regionList[index].element.replaceChildren(); // overwrite index passed in to update locally - index = this.alternateIndex(index); + index = alternateIndex(index, this.regionFactory.count); // create element for new message const messageEl = document.createElement("p"); @@ -244,30 +192,8 @@ class Announcer { // append message to new index regionList[index].element.appendChild(messageEl); - this.removeMessage(messageEl, removalDelay); - - return index; - } - - /** - * Alternate index for cycling through elements - * @param {number} index Previous element index (0 or 1) - * @returns {number} New index - */ - removeMessage(messageElement: HTMLElement, removalDelay: number) { - setTimeout(() => { - messageElement.remove(); - }, removalDelay); - } + removeMessage(messageEl, removalDelay); - /** - * Alternate index for cycling through elements - * @param {number} index Previous element index (0 or 1) - * @returns {number} New index - */ - alternateIndex(index: number): number { - index += 1; - index = index % this.regionFactory.count; return index; } } diff --git a/packages/wonder-blocks-announcer/src/util/dom.ts b/packages/wonder-blocks-announcer/src/util/dom.ts new file mode 100644 index 000000000..287c4cc71 --- /dev/null +++ b/packages/wonder-blocks-announcer/src/util/dom.ts @@ -0,0 +1,82 @@ +import { + type PolitenessLevel, + RegionDictionary, +} from "../../types/Announcer.types"; + +/** + * Create a wrapper element to group regions for a given level + * @param {string} level Politeness level for grouping + * @returns {HTMLElement} Wrapper DOM element reference + */ +export function createRegionWrapper(level: PolitenessLevel) { + const wrapper = document.createElement("div"); + wrapper.id = `wbAWrap-${level}`; + return wrapper; +} + +/** + * Create multiple live regions for a given level + * @param {HTMLElement} wrapper Parent DOM element reference to append into + * @param {string} level Politeness level for grouping + * @param {number} regionCount Number of regions to create + * @param {RegionDictionary} dictionary Reference to Announcer dictionary + * @returns {HTMLElement[]} Array of region elements + */ +export function createDuplicateRegions( + wrapper: HTMLElement, + level: PolitenessLevel, + regionCount: number, + dictionary: RegionDictionary, +): HTMLElement[] { + const result = new Array(regionCount).fill(0).map((el, i) => { + const region = createRegion(level, i, dictionary); + wrapper.appendChild(region); + return region; + }); + return result; +} + +/** + * Create live region element for a given level + * @param {string} level Politeness level for grouping + * @param {number} index Incrementor for duplicate regions + * @param {RegionDef} dictionary Reference to Announcer dictionary to update + * @param {string} role Role attribute for live regions, defaults to log + * @returns {HTMLElement} DOM element reference for live region + */ +function createRegion( + level: PolitenessLevel, + index: number, + dictionary: RegionDictionary, + role = "log", +) { + const region = document.createElement("div"); + // TODO: test combinations of attrs + region.setAttribute("role", role); + region.setAttribute("aria-live", level); + region.classList.add("wbARegion"); + const id = `wbARegion-${level}${index}`; + region.id = id; + region.setAttribute("data-testid", id); + dictionary.set(id, { + id, + levelIndex: index, + level, + element: region, + }); + return region; +} + +/** + * Remove message element from the DOM + * @param {HTMLElement} messageElement Dynamically created message element + * @param {number} removalDelay Configurable timeout to wait before removing + */ +export function removeMessage( + messageElement: HTMLElement, + removalDelay: number, +) { + setTimeout(() => { + messageElement.remove(); + }, removalDelay); +} diff --git a/packages/wonder-blocks-announcer/src/util/util.ts b/packages/wonder-blocks-announcer/src/util/util.ts new file mode 100644 index 000000000..f87aada51 --- /dev/null +++ b/packages/wonder-blocks-announcer/src/util/util.ts @@ -0,0 +1,10 @@ +/** + * Alternate index for cycling through elements + * @param {number} index Previous element index (0 or 1) + * @returns {number} New index + */ +export function alternateIndex(index: number, count: number): number { + index += 1; + index = index % count; + return index; +} From 47f83009aa965969dcd932f3a01d6e3659602c56 Mon Sep 17 00:00:00 2001 From: Marcy Sutton Todd Date: Fri, 22 Nov 2024 12:15:27 -0800 Subject: [PATCH 16/44] Append regions to end of document.body --- packages/wonder-blocks-announcer/src/Announcer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wonder-blocks-announcer/src/Announcer.ts b/packages/wonder-blocks-announcer/src/Announcer.ts index 7b5abf1f2..894c20f28 100644 --- a/packages/wonder-blocks-announcer/src/Announcer.ts +++ b/packages/wonder-blocks-announcer/src/Announcer.ts @@ -72,7 +72,7 @@ class Announcer { createDuplicateRegions(pWrapper, "polite", this.regionFactory.count, this.dictionary); this.node.appendChild(pWrapper); - document.body.prepend(this.node); + document.body.append(this.node); } /** * Recover in the event regions get lost From b19c852e31eec4a35273f99d692660179d6cd642 Mon Sep 17 00:00:00 2001 From: Marcy Sutton Todd Date: Fri, 22 Nov 2024 13:56:05 -0800 Subject: [PATCH 17/44] Renaming timeouts, adding comments for clarity --- .../wonder-blocks-announcer/src/Announcer.ts | 16 +++++++------ .../src/__tests__/announce-message.test.tsx | 2 ++ .../src/__tests__/clear-messages.test.tsx | 17 ++++++++++---- .../util/announce-message-button.tsx | 3 ++- .../src/announce-message.ts | 22 +++++++++++------- .../types/Announcer.types.ts | 23 ++++++++++++++++++- 6 files changed, 61 insertions(+), 22 deletions(-) diff --git a/packages/wonder-blocks-announcer/src/Announcer.ts b/packages/wonder-blocks-announcer/src/Announcer.ts index 894c20f28..76471e38b 100644 --- a/packages/wonder-blocks-announcer/src/Announcer.ts +++ b/packages/wonder-blocks-announcer/src/Announcer.ts @@ -11,7 +11,7 @@ import { import { createRegionWrapper, createDuplicateRegions, removeMessage } from "./util/dom"; import { alternateIndex } from "./util/util" -const TIMEOUT_DELAY = 5000; +const REMOVAL_TIMEOUT_DELAY = 5000; /** * Internal class to manage screen reader announcements. @@ -38,7 +38,7 @@ class Announcer { } // The structure exists but references are lost, so help HMR recover else { - this.rebootForHMR(); + this.reattachNodes(); } } } @@ -64,6 +64,8 @@ class Announcer { Object.assign(this.node.style, srOnly); + // For each level, we create at least two live region elements. + // This is to work around AT occasionally dropping messages. const aWrapper = createRegionWrapper("assertive"); createDuplicateRegions(aWrapper, "assertive", this.regionFactory.count, this.dictionary); this.node?.appendChild(aWrapper); @@ -76,10 +78,10 @@ class Announcer { } /** * Recover in the event regions get lost - * This happens in Storybook when saving a file: - * Announcer exists, but it loses the connection to element Refs + * This happens in Storybook or other HMR environments when saving a file: + * Announcer exists, but it loses the connection to DOM element Refs */ - rebootForHMR() { + reattachNodes() { const announcerCheck = document.getElementById(`wbAnnounce`); if (announcerCheck !== null) { this.node = announcerCheck; @@ -115,7 +117,7 @@ class Announcer { removalDelay?: number, ): string { if (!this.node) { - return ""; + this.reattachNodes(); } // Filter region elements to the selected level @@ -171,7 +173,7 @@ class Announcer { message: string, level: PolitenessLevel, // level regionList: RegionDef[], // list of relevant elements - removalDelay: number = TIMEOUT_DELAY, + removalDelay: number = REMOVAL_TIMEOUT_DELAY, ): number { // Starting index for a given level let index = diff --git a/packages/wonder-blocks-announcer/src/__tests__/announce-message.test.tsx b/packages/wonder-blocks-announcer/src/__tests__/announce-message.test.tsx index ded8af848..e39342b5a 100644 --- a/packages/wonder-blocks-announcer/src/__tests__/announce-message.test.tsx +++ b/packages/wonder-blocks-announcer/src/__tests__/announce-message.test.tsx @@ -75,9 +75,11 @@ describe("Announcer.announceMessage", () => { // ACT const announcement1Id = announceMessage({ message: message1, + timeoutDelay: 0, }); const announcement2Id = announceMessage({ message: message2, + timeoutDelay: 0, }); // ASSERT diff --git a/packages/wonder-blocks-announcer/src/__tests__/clear-messages.test.tsx b/packages/wonder-blocks-announcer/src/__tests__/clear-messages.test.tsx index 6e94a7f1a..e463bc9a3 100644 --- a/packages/wonder-blocks-announcer/src/__tests__/clear-messages.test.tsx +++ b/packages/wonder-blocks-announcer/src/__tests__/clear-messages.test.tsx @@ -9,12 +9,15 @@ describe("Announcer.clearMessages", () => { const message2 = "Dull no stars"; // ACT - const announcement1Id = announceMessage({message: message1}); + const announcement1Id = announceMessage({ + message: message1, + timeoutDelay: 0, + }); const region1 = screen.getByTestId("wbARegion-polite1"); expect(region1).toHaveTextContent(message1); - announceMessage({message: message2}); + announceMessage({message: message2, timeoutDelay: 0}); const region2 = screen.getByTestId("wbARegion-polite0"); clearMessages(announcement1Id); @@ -30,16 +33,20 @@ describe("Announcer.clearMessages", () => { const message2 = "Red fish blue fish"; // ACT - announceMessage({message: message1}); + announceMessage({message: message1, timeoutDelay: 0}); const region1 = screen.getByTestId("wbARegion-polite1"); expect(region1).toHaveTextContent(message1); - announceMessage({message: message2}); + announceMessage({message: message2, timeoutDelay: 0}); const region2 = screen.getByTestId("wbARegion-polite0"); expect(region2).toHaveTextContent(message2); - announceMessage({message: message1, level: "assertive"}); + announceMessage({ + message: message1, + level: "assertive", + timeoutDelay: 0, + }); const region3 = screen.getByTestId("wbARegion-assertive1"); expect(region3).toHaveTextContent(message1); diff --git a/packages/wonder-blocks-announcer/src/__tests__/util/announce-message-button.tsx b/packages/wonder-blocks-announcer/src/__tests__/util/announce-message-button.tsx index 0430c6f61..9d3f76e18 100644 --- a/packages/wonder-blocks-announcer/src/__tests__/util/announce-message-button.tsx +++ b/packages/wonder-blocks-announcer/src/__tests__/util/announce-message-button.tsx @@ -10,5 +10,6 @@ type AnnounceMessageButtonProps = { export const AnnounceMessageButton = (props: AnnounceMessageButtonProps) => { const {buttonText = "Click"} = props; - return ; + // add timeoutDelay: 0 to skip browser setTimeout in Jest tests + return ; }; diff --git a/packages/wonder-blocks-announcer/src/announce-message.ts b/packages/wonder-blocks-announcer/src/announce-message.ts index efe9e9d75..36a9c39de 100644 --- a/packages/wonder-blocks-announcer/src/announce-message.ts +++ b/packages/wonder-blocks-announcer/src/announce-message.ts @@ -5,29 +5,35 @@ export type AnnounceMessageProps = { message: string; level?: PolitenessLevel; removalDelay?: number; + timeoutDelay?: number; }; +export const defaultTimeout = 100; + /** * Public API method to announce screen reader messages in ARIA Live Regions. * @param {string} message The message to announce. * @param {PolitenessLevel} level Polite or assertive announcements * @param {number} removalDelay Optional duration to remove a message after sending. Defaults to 5000ms. + * @param {number} timeoutDelay Optional duration to alter an announcement timeout. Useful in tests for rendering immediately. * @returns {string} IDREF for targeted live region element */ export function announceMessage({ message, level = "polite", // TODO: decide whether to allow other roles, i.e. role=`timer` removalDelay, + timeoutDelay = defaultTimeout, }: AnnounceMessageProps): string { const announcer = Announcer.getInstance(); - // if (typeof jest === "undefined") { - // setTimeout(() => { - // return announcer?.announce(message, level, removalDelay); - // }, 100); - // } else { - // If we are in a test environment, announce without waiting - return announcer.announce(message, level, removalDelay); - // } + // This timeout is for Safari/Voiceover to improve reliability of the first appended message. + if (timeoutDelay > 0) { + setTimeout(() => { + return announcer.announce(message, level, removalDelay); + }, timeoutDelay); + } else { + return announcer.announce(message, level, removalDelay); + } + return ""; } diff --git a/packages/wonder-blocks-announcer/types/Announcer.types.ts b/packages/wonder-blocks-announcer/types/Announcer.types.ts index e330985e6..9223c43a5 100644 --- a/packages/wonder-blocks-announcer/types/Announcer.types.ts +++ b/packages/wonder-blocks-announcer/types/Announcer.types.ts @@ -1,16 +1,37 @@ +/* +PolitenessLevel: The two options for ARIA Live Regions: +- polite, which will wait for other announcements to finish +- assertive, which will interrupt other messages +*/ export type PolitenessLevel = "polite" | "assertive"; +/* +RegionFactory: A config for creating duplicate region elements. +- Count is the total number for each level. +- aIndex references the index of the last-used assertive log element. +- pIndex references the index of the last-used polite log element. +*/ export type RegionFactory = { count: number; aIndex: number; pIndex: number; }; +/* +RegionDef: A type for Announcer dictionary entries for fast lookup. +- id: the IDREF for a live region element. +- level: the politeness level (polite or assertive) +- levelIndex: the index of the region at a particular level +- element: an element reference for a live region. +*/ export type RegionDef = { id: string; - levelIndex: number; level: PolitenessLevel; + levelIndex: number; element: HTMLElement; }; +/* +RegionDictionary: a Map data structure of live regions for fast lookup. +*/ export type RegionDictionary = Map; From 389452ad0a45f0f888d109f10616d251c480c85d Mon Sep 17 00:00:00 2001 From: Marcy Sutton Todd Date: Fri, 22 Nov 2024 14:27:16 -0800 Subject: [PATCH 18/44] Reformat files for linter Why didn't Prettier do this automatically? I do not know... --- .../wonder-blocks-announcer/src/Announcer.ts | 23 +++++++++++++++---- .../util/announce-message-button.tsx | 4 +++- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/wonder-blocks-announcer/src/Announcer.ts b/packages/wonder-blocks-announcer/src/Announcer.ts index 76471e38b..b4e32b08d 100644 --- a/packages/wonder-blocks-announcer/src/Announcer.ts +++ b/packages/wonder-blocks-announcer/src/Announcer.ts @@ -8,8 +8,12 @@ import { RegionDef, } from "../types/Announcer.types"; -import { createRegionWrapper, createDuplicateRegions, removeMessage } from "./util/dom"; -import { alternateIndex } from "./util/util" +import { + createRegionWrapper, + createDuplicateRegions, + removeMessage +} from "./util/dom"; +import {alternateIndex} from "./util/util" const REMOVAL_TIMEOUT_DELAY = 5000; @@ -67,11 +71,21 @@ class Announcer { // For each level, we create at least two live region elements. // This is to work around AT occasionally dropping messages. const aWrapper = createRegionWrapper("assertive"); - createDuplicateRegions(aWrapper, "assertive", this.regionFactory.count, this.dictionary); + createDuplicateRegions( + aWrapper, + "assertive", + this.regionFactory.count, + this.dictionary + ); this.node?.appendChild(aWrapper); const pWrapper = createRegionWrapper("polite"); - createDuplicateRegions(pWrapper, "polite", this.regionFactory.count, this.dictionary); + createDuplicateRegions( + pWrapper, + "polite", + this.regionFactory.count, + this.dictionary + ); this.node.appendChild(pWrapper); document.body.append(this.node); @@ -103,7 +117,6 @@ class Announcer { } } - /** * Announce a live region message for a given level * @param {string} message The message to be announced diff --git a/packages/wonder-blocks-announcer/src/__tests__/util/announce-message-button.tsx b/packages/wonder-blocks-announcer/src/__tests__/util/announce-message-button.tsx index 9d3f76e18..aa1500c06 100644 --- a/packages/wonder-blocks-announcer/src/__tests__/util/announce-message-button.tsx +++ b/packages/wonder-blocks-announcer/src/__tests__/util/announce-message-button.tsx @@ -11,5 +11,7 @@ type AnnounceMessageButtonProps = { export const AnnounceMessageButton = (props: AnnounceMessageButtonProps) => { const {buttonText = "Click"} = props; // add timeoutDelay: 0 to skip browser setTimeout in Jest tests - return ; + return ; }; From a283cc712f06c2e0504e498a9987b823c6a147d9 Mon Sep 17 00:00:00 2001 From: Marcy Sutton Todd Date: Fri, 22 Nov 2024 14:33:47 -0800 Subject: [PATCH 19/44] Try kicking the linter one more time --- packages/wonder-blocks-announcer/src/Announcer.ts | 8 ++++---- .../src/__tests__/util/announce-message-button.tsx | 8 +++++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/wonder-blocks-announcer/src/Announcer.ts b/packages/wonder-blocks-announcer/src/Announcer.ts index b4e32b08d..6ac9d33ff 100644 --- a/packages/wonder-blocks-announcer/src/Announcer.ts +++ b/packages/wonder-blocks-announcer/src/Announcer.ts @@ -11,9 +11,9 @@ import { import { createRegionWrapper, createDuplicateRegions, - removeMessage + removeMessage, } from "./util/dom"; -import {alternateIndex} from "./util/util" +import {alternateIndex} from "./util/util"; const REMOVAL_TIMEOUT_DELAY = 5000; @@ -75,7 +75,7 @@ class Announcer { aWrapper, "assertive", this.regionFactory.count, - this.dictionary + this.dictionary, ); this.node?.appendChild(aWrapper); @@ -84,7 +84,7 @@ class Announcer { pWrapper, "polite", this.regionFactory.count, - this.dictionary + this.dictionary, ); this.node.appendChild(pWrapper); diff --git a/packages/wonder-blocks-announcer/src/__tests__/util/announce-message-button.tsx b/packages/wonder-blocks-announcer/src/__tests__/util/announce-message-button.tsx index aa1500c06..69918f131 100644 --- a/packages/wonder-blocks-announcer/src/__tests__/util/announce-message-button.tsx +++ b/packages/wonder-blocks-announcer/src/__tests__/util/announce-message-button.tsx @@ -11,7 +11,9 @@ type AnnounceMessageButtonProps = { export const AnnounceMessageButton = (props: AnnounceMessageButtonProps) => { const {buttonText = "Click"} = props; // add timeoutDelay: 0 to skip browser setTimeout in Jest tests - return ; + return ( + + ); }; From 05018defcc6d0c512eaeac7deafc82b3e286c4a6 Mon Sep 17 00:00:00 2001 From: Marcy Sutton Todd Date: Fri, 22 Nov 2024 16:19:54 -0800 Subject: [PATCH 20/44] Expand tests --- .../wonder-blocks-announcer/src/Announcer.ts | 7 + .../src/__tests__/Announcer.test.ts | 97 +++++++++++++ .../src/__tests__/announce-message.test.tsx | 2 +- .../announce-message-button.tsx | 0 .../src/__tests__/util/dom.test.ts | 133 ++++++++++++++++++ .../wonder-blocks-announcer/src/util/dom.ts | 2 +- 6 files changed, 239 insertions(+), 2 deletions(-) create mode 100644 packages/wonder-blocks-announcer/src/__tests__/Announcer.test.ts rename packages/wonder-blocks-announcer/src/__tests__/{util => components}/announce-message-button.tsx (100%) create mode 100644 packages/wonder-blocks-announcer/src/__tests__/util/dom.test.ts diff --git a/packages/wonder-blocks-announcer/src/Announcer.ts b/packages/wonder-blocks-announcer/src/Announcer.ts index 6ac9d33ff..d75ff5e26 100644 --- a/packages/wonder-blocks-announcer/src/Announcer.ts +++ b/packages/wonder-blocks-announcer/src/Announcer.ts @@ -211,6 +211,13 @@ class Announcer { return index; } + + reset() { + this.regionFactory.aIndex = 0; + this.regionFactory.pIndex = 0; + + this.clear(); + } } export default Announcer; diff --git a/packages/wonder-blocks-announcer/src/__tests__/Announcer.test.ts b/packages/wonder-blocks-announcer/src/__tests__/Announcer.test.ts new file mode 100644 index 000000000..af4a58d14 --- /dev/null +++ b/packages/wonder-blocks-announcer/src/__tests__/Announcer.test.ts @@ -0,0 +1,97 @@ +import {screen} from "@testing-library/react"; +import Announcer from "../Announcer"; + +describe("Announcer class", () => { + describe("instantiation", () => { + test("creating one singleton instance", () => { + // Arrange/Act + const announcer = Announcer.getInstance(); + const announcer2 = Announcer.getInstance(); + + // Assert: is this testing anything useful? + expect(announcer).toEqual(announcer2); + }); + + test("initializing the element structure", () => { + // Arrange/Act + const announcer = Announcer.getInstance(); + const wrapperElement = announcer.node; + const regions = announcer.dictionary; + + // Assert + expect(wrapperElement).toBeInTheDocument(); + expect(wrapperElement?.childElementCount).toBe(2); + expect(regions.size).toBe(4); + }); + }); + + describe("announcing messages", () => { + afterEach(() => { + const announcer = Announcer.getInstance(); + announcer.reset(); + }); + + test("appending a message", () => { + // Arrange + const announcer = Announcer.getInstance(); + expect(announcer.regionFactory.pIndex).toBe(0); + + // Act + announcer.announce("a thing", "polite"); + + // Assert + expect(announcer.regionFactory.pIndex).toBe(1); + expect( + announcer.dictionary.get("wbARegion-polite1")?.element + .textContent, + ).toBe("a thing"); + }); + + test("returning an IDREF", () => { + // Arrange + const announcer = Announcer.getInstance(); + expect(announcer.regionFactory.pIndex).toBe(0); + + // Act + const idRef = announcer.announce("another thing", "polite"); + + // Assert + expect(idRef).toBe("wbARegion-polite1"); + }); + }); + + describe("clearing messages", () => { + test("clearing by IDREF", () => { + // Arrange + const announcer = Announcer.getInstance(); + expect(announcer.regionFactory.pIndex).toBe(0); + + // Act + const idRef = announcer.announce("something", "polite"); + const firstRegion = announcer.dictionary.get(idRef)?.element; + expect(firstRegion?.textContent).toBe("something"); + + announcer.clear(idRef); + + // Assert + expect(firstRegion?.textContent).not.toBe("something"); + }); + + test("clearing all elements", () => { + // Arrange + const announcer = Announcer.getInstance(); + + // Act + announcer.announce("One Fish", "polite"); + announcer.announce("Loud Fish", "assertive"); + expect(screen.getByText("One Fish")).toBeInTheDocument(); + expect(screen.getByText("Loud Fish")).toBeInTheDocument(); + + announcer.clear(); + + // Assert + expect(screen.queryByText("One Fish")).not.toBeInTheDocument(); + expect(screen.queryByText("Loud Fish")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/wonder-blocks-announcer/src/__tests__/announce-message.test.tsx b/packages/wonder-blocks-announcer/src/__tests__/announce-message.test.tsx index e39342b5a..32c4ee06d 100644 --- a/packages/wonder-blocks-announcer/src/__tests__/announce-message.test.tsx +++ b/packages/wonder-blocks-announcer/src/__tests__/announce-message.test.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import {render, screen, waitFor} from "@testing-library/react"; -import {AnnounceMessageButton} from "./util/announce-message-button"; +import {AnnounceMessageButton} from "./components/announce-message-button"; import {announceMessage} from "../announce-message"; import {clearMessages} from "../clear-messages"; diff --git a/packages/wonder-blocks-announcer/src/__tests__/util/announce-message-button.tsx b/packages/wonder-blocks-announcer/src/__tests__/components/announce-message-button.tsx similarity index 100% rename from packages/wonder-blocks-announcer/src/__tests__/util/announce-message-button.tsx rename to packages/wonder-blocks-announcer/src/__tests__/components/announce-message-button.tsx diff --git a/packages/wonder-blocks-announcer/src/__tests__/util/dom.test.ts b/packages/wonder-blocks-announcer/src/__tests__/util/dom.test.ts new file mode 100644 index 000000000..2aa64cfbe --- /dev/null +++ b/packages/wonder-blocks-announcer/src/__tests__/util/dom.test.ts @@ -0,0 +1,133 @@ +import {screen, waitFor} from "@testing-library/react"; +import { + createRegionWrapper, + createDuplicateRegions, + createRegion, + removeMessage, +} from "../../util/dom"; + +jest.useFakeTimers(); +jest.spyOn(global, "setTimeout"); + +describe("Announcer utility functions", () => { + describe("createRegionWrapper", () => { + test("it creates a polite region wrapper element", () => { + const element = createRegionWrapper("polite"); + + expect(element.tagName).toBe("DIV"); + expect(element.id).toEqual("wbAWrap-polite"); + }); + + test("it creates an assertive region wrapper element", () => { + const element = createRegionWrapper("assertive"); + + expect(element.tagName).toBe("DIV"); + expect(element.id).toEqual("wbAWrap-assertive"); + }); + }); + + describe("createDuplicateRegions", () => { + test("it creates multiple polite region elements", () => { + const wrapper = document.createElement("div"); + const dictionary = new Map(); + + const regionList = createDuplicateRegions( + wrapper, + "polite", + 2, + dictionary, + ); + + expect(regionList.length).toBe(2); + expect(regionList[0].id).toBe("wbARegion-polite0"); + expect(regionList[1].id).toBe("wbARegion-polite1"); + expect(dictionary.size).toBe(2); + }); + + test("it creates multiple assertive region elements", () => { + const wrapper = document.createElement("div"); + const dictionary = new Map(); + + const regionList = createDuplicateRegions( + wrapper, + "assertive", + 2, + dictionary, + ); + + expect(regionList.length).toBe(2); + expect(regionList[0].id).toBe("wbARegion-assertive0"); + expect(regionList[1].id).toBe("wbARegion-assertive1"); + expect(dictionary.size).toBe(2); + }); + }); + + describe("createRegion", () => { + test("it creates a polite Live Region element", () => { + const dictionary = new Map(); + const region = createRegion("polite", 0, dictionary); + + expect(region.id).toBe("wbARegion-polite0"); + expect(region.getAttribute("aria-live")).toBe("polite"); + expect(region.getAttribute("role")).toBe("log"); + expect(dictionary.size).toBe(1); + }); + + test("it creates an assertive Live Region element", () => { + const dictionary = new Map(); + const region = createRegion("assertive", 0, dictionary); + + expect(region.id).toBe("wbARegion-assertive0"); + expect(region.getAttribute("aria-live")).toBe("assertive"); + expect(region.getAttribute("role")).toBe("log"); + expect(dictionary.size).toBe(1); + }); + + test("it allows the role to be overridden", () => { + const dictionary = new Map(); + const region = createRegion("polite", 0, dictionary, "timer"); + + expect(region.getAttribute("aria-live")).toBe("polite"); + expect(region.getAttribute("role")).toBe("timer"); + }); + }); + + describe("removeMessage", () => { + test("it removes an element from the DOM", async () => { + // Arrange + const message = document.createElement("p"); + document.body.appendChild(message); + expect(message).toBeInTheDocument(); + + // Act + removeMessage(message, 0); + + // Assert + await waitFor(() => { + expect(message).not.toBeInTheDocument(); + }); + }); + + test("it removes an element after a configurable delay", async () => { + // Arrange + const messageText = "Thar she blows"; + const message = document.createElement("p"); + message.textContent = messageText; + document.body.appendChild(message); + + const delay = 300; + + // Act + removeMessage(message, delay); + + // Assert + expect(setTimeout).toHaveBeenLastCalledWith( + expect.any(Function), + delay, + ); + await waitFor(() => { + expect(screen.queryByText(messageText)).not.toBeInTheDocument(); + }); + }); + }); +}); diff --git a/packages/wonder-blocks-announcer/src/util/dom.ts b/packages/wonder-blocks-announcer/src/util/dom.ts index 287c4cc71..eea244b43 100644 --- a/packages/wonder-blocks-announcer/src/util/dom.ts +++ b/packages/wonder-blocks-announcer/src/util/dom.ts @@ -44,7 +44,7 @@ export function createDuplicateRegions( * @param {string} role Role attribute for live regions, defaults to log * @returns {HTMLElement} DOM element reference for live region */ -function createRegion( +export function createRegion( level: PolitenessLevel, index: number, dictionary: RegionDictionary, From 925e1b708261ac55e3ef8cfef91c06fed352e6c5 Mon Sep 17 00:00:00 2001 From: Marcy Sutton Todd Date: Fri, 22 Nov 2024 16:22:07 -0800 Subject: [PATCH 21/44] Make document check more consistent --- packages/wonder-blocks-announcer/src/clear-messages.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wonder-blocks-announcer/src/clear-messages.ts b/packages/wonder-blocks-announcer/src/clear-messages.ts index 401f7d772..4a0c3eb20 100644 --- a/packages/wonder-blocks-announcer/src/clear-messages.ts +++ b/packages/wonder-blocks-announcer/src/clear-messages.ts @@ -9,7 +9,7 @@ export function clearMessages(id?: string) { const announcer = Announcer.getInstance(); if (id && document?.getElementById(id)) { announcer?.clear(id); - } else if (document) { + } else if (typeof document !== "undefined") { announcer?.clear(); } } From 4729428a7d92d737100c52b97e3e8af03f2c6b73 Mon Sep 17 00:00:00 2001 From: Marcy Sutton Todd Date: Fri, 22 Nov 2024 16:53:56 -0800 Subject: [PATCH 22/44] Add comments, types, and a few more tests --- .../wonder-blocks-announcer/src/Announcer.ts | 6 ++- .../src/__tests__/Announcer.test.ts | 37 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/wonder-blocks-announcer/src/Announcer.ts b/packages/wonder-blocks-announcer/src/Announcer.ts index d75ff5e26..23d4a08c5 100644 --- a/packages/wonder-blocks-announcer/src/Announcer.ts +++ b/packages/wonder-blocks-announcer/src/Announcer.ts @@ -21,7 +21,7 @@ const REMOVAL_TIMEOUT_DELAY = 5000; * Internal class to manage screen reader announcements. */ class Announcer { - private static _instance: Announcer; + private static _instance: Announcer | null; node: HTMLElement | null = null; regionFactory: RegionFactory = { count: 2, @@ -212,6 +212,10 @@ class Announcer { return index; } + /** + * Reset state to defaults. + * Useful for testing. + **/ reset() { this.regionFactory.aIndex = 0; this.regionFactory.pIndex = 0; diff --git a/packages/wonder-blocks-announcer/src/__tests__/Announcer.test.ts b/packages/wonder-blocks-announcer/src/__tests__/Announcer.test.ts index af4a58d14..a81d41324 100644 --- a/packages/wonder-blocks-announcer/src/__tests__/Announcer.test.ts +++ b/packages/wonder-blocks-announcer/src/__tests__/Announcer.test.ts @@ -47,6 +47,31 @@ describe("Announcer class", () => { ).toBe("a thing"); }); + test("appending two messages", () => { + // Arrange + const announcer = Announcer.getInstance(); + expect(announcer.regionFactory.pIndex).toBe(0); + + // Act + announcer.announce("a nice thing", "polite"); + + // Assert + expect(announcer.regionFactory.pIndex).toBe(1); + expect( + announcer.dictionary.get("wbARegion-polite1")?.element + .textContent, + ).toBe("a nice thing"); + + announcer.announce("another nice thing", "polite"); + + // Assert + expect(announcer.regionFactory.pIndex).toBe(0); + expect( + announcer.dictionary.get("wbARegion-polite0")?.element + .textContent, + ).toBe("another nice thing"); + }); + test("returning an IDREF", () => { // Arrange const announcer = Announcer.getInstance(); @@ -93,5 +118,17 @@ describe("Announcer class", () => { expect(screen.queryByText("One Fish")).not.toBeInTheDocument(); expect(screen.queryByText("Loud Fish")).not.toBeInTheDocument(); }); + + test("handling calls when nothing has been announced", () => { + const announcer = Announcer.getInstance(); + + expect(() => announcer.clear()).not.toThrow(); + }); + + test("handling calls with an invalid IDREF", () => { + const announcer = Announcer.getInstance(); + + expect(() => announcer.clear("random-id")).not.toThrow(); + }); }); }); From 37ac932d0631e4a138223c46ae9b387d74a3c259 Mon Sep 17 00:00:00 2001 From: Marcy Sutton Todd Date: Mon, 25 Nov 2024 16:29:10 -0800 Subject: [PATCH 23/44] Implement debounce / async logic 1. Keeps announce from being called too frequently. We can play with the timeout duration. 2. Makes the returned IDREF more reliable in a browser. --- .../announcer.stories.tsx | 11 ++- .../wonder-blocks-announcer/src/Announcer.ts | 78 +++++++++++++------ .../src/__tests__/Announcer.test.ts | 4 +- .../src/__tests__/announce-message.test.tsx | 2 - .../src/__tests__/clear-messages.test.tsx | 12 ++- .../components/announce-message-button.tsx | 7 +- .../src/announce-message.ts | 19 +---- 7 files changed, 75 insertions(+), 58 deletions(-) diff --git a/__docs__/wonder-blocks-announcer/announcer.stories.tsx b/__docs__/wonder-blocks-announcer/announcer.stories.tsx index 290f5800b..7d93b3248 100644 --- a/__docs__/wonder-blocks-announcer/announcer.stories.tsx +++ b/__docs__/wonder-blocks-announcer/announcer.stories.tsx @@ -19,9 +19,14 @@ const AnnouncerExample = ({ }: AnnounceMessageProps) => { return ( - ); + return ; }; diff --git a/packages/wonder-blocks-announcer/src/announce-message.ts b/packages/wonder-blocks-announcer/src/announce-message.ts index 36a9c39de..311a3265f 100644 --- a/packages/wonder-blocks-announcer/src/announce-message.ts +++ b/packages/wonder-blocks-announcer/src/announce-message.ts @@ -5,7 +5,6 @@ export type AnnounceMessageProps = { message: string; level?: PolitenessLevel; removalDelay?: number; - timeoutDelay?: number; }; export const defaultTimeout = 100; @@ -15,25 +14,13 @@ export const defaultTimeout = 100; * @param {string} message The message to announce. * @param {PolitenessLevel} level Polite or assertive announcements * @param {number} removalDelay Optional duration to remove a message after sending. Defaults to 5000ms. - * @param {number} timeoutDelay Optional duration to alter an announcement timeout. Useful in tests for rendering immediately. * @returns {string} IDREF for targeted live region element */ -export function announceMessage({ +export async function announceMessage({ message, level = "polite", // TODO: decide whether to allow other roles, i.e. role=`timer` removalDelay, - timeoutDelay = defaultTimeout, -}: AnnounceMessageProps): string { +}: AnnounceMessageProps): Promise { const announcer = Announcer.getInstance(); - - // This timeout is for Safari/Voiceover to improve reliability of the first appended message. - if (timeoutDelay > 0) { - setTimeout(() => { - return announcer.announce(message, level, removalDelay); - }, timeoutDelay); - } else { - return announcer.announce(message, level, removalDelay); - } - - return ""; + return announcer.announce(message, level, removalDelay); } From 992746410071c17c19bab01345f5fcae949a4f87 Mon Sep 17 00:00:00 2001 From: Marcy Sutton Todd Date: Mon, 25 Nov 2024 17:01:56 -0800 Subject: [PATCH 24/44] Implement debounce / async logic 1. Keeps announce from being called too frequently. We can play with the timeout duration. 2. Makes the returned IDREF more reliable in a browser. --- .../wonder-blocks-announcer/src/Announcer.ts | 6 +-- .../src/__tests__/Announcer.test.ts | 20 +++---- .../src/__tests__/announce-message.test.tsx | 52 +++++++++++++------ .../src/__tests__/clear-messages.test.tsx | 26 ++++++---- 4 files changed, 62 insertions(+), 42 deletions(-) diff --git a/packages/wonder-blocks-announcer/src/Announcer.ts b/packages/wonder-blocks-announcer/src/Announcer.ts index 08c6b746f..263d04449 100644 --- a/packages/wonder-blocks-announcer/src/Announcer.ts +++ b/packages/wonder-blocks-announcer/src/Announcer.ts @@ -130,11 +130,7 @@ class Announcer { level: PolitenessLevel, removalDelay?: number, ): Promise { - const announceCB = ( - message: string, - level: PolitenessLevel, - removalDelay?: number, - ) => { + const announceCB = () => { if (!this.node) { this.reattachNodes(); } diff --git a/packages/wonder-blocks-announcer/src/__tests__/Announcer.test.ts b/packages/wonder-blocks-announcer/src/__tests__/Announcer.test.ts index 1f6b1c895..780340461 100644 --- a/packages/wonder-blocks-announcer/src/__tests__/Announcer.test.ts +++ b/packages/wonder-blocks-announcer/src/__tests__/Announcer.test.ts @@ -31,13 +31,13 @@ describe("Announcer class", () => { announcer.reset(); }); - test("appending a message", () => { + test("appending a message", async () => { // Arrange const announcer = Announcer.getInstance(); expect(announcer.regionFactory.pIndex).toBe(0); // Act - announcer.announce("a thing", "polite"); + await announcer.announce("a thing", "polite"); // Assert expect(announcer.regionFactory.pIndex).toBe(1); @@ -47,13 +47,13 @@ describe("Announcer class", () => { ).toBe("a thing"); }); - test("appending two messages", () => { + test("appending two messages", async () => { // Arrange const announcer = Announcer.getInstance(); expect(announcer.regionFactory.pIndex).toBe(0); // Act - announcer.announce("a nice thing", "polite"); + await announcer.announce("a nice thing", "polite"); // Assert expect(announcer.regionFactory.pIndex).toBe(1); @@ -62,7 +62,7 @@ describe("Announcer class", () => { .textContent, ).toBe("a nice thing"); - announcer.announce("another nice thing", "polite"); + await announcer.announce("another nice thing", "polite"); // Assert expect(announcer.regionFactory.pIndex).toBe(0); @@ -72,13 +72,13 @@ describe("Announcer class", () => { ).toBe("another nice thing"); }); - test("returning an IDREF", () => { + test("returning an IDREF", async () => { // Arrange const announcer = Announcer.getInstance(); expect(announcer.regionFactory.pIndex).toBe(0); // Act - const idRef = announcer.announce("another thing", "polite"); + const idRef = await announcer.announce("another thing", "polite"); // Assert expect(idRef).toBe("wbARegion-polite1"); @@ -102,13 +102,13 @@ describe("Announcer class", () => { expect(firstRegion?.textContent).not.toBe("something"); }); - test("clearing all elements", () => { + test("clearing all elements", async () => { // Arrange const announcer = Announcer.getInstance(); // Act - announcer.announce("One Fish", "polite"); - announcer.announce("Loud Fish", "assertive"); + await announcer.announce("One Fish", "polite"); + await announcer.announce("Loud Fish", "assertive"); expect(screen.getByText("One Fish")).toBeInTheDocument(); expect(screen.getByText("Loud Fish")).toBeInTheDocument(); diff --git a/packages/wonder-blocks-announcer/src/__tests__/announce-message.test.tsx b/packages/wonder-blocks-announcer/src/__tests__/announce-message.test.tsx index 1e478cf6b..10cfd467f 100644 --- a/packages/wonder-blocks-announcer/src/__tests__/announce-message.test.tsx +++ b/packages/wonder-blocks-announcer/src/__tests__/announce-message.test.tsx @@ -59,33 +59,41 @@ describe("Announcer.announceMessage", () => { // ASSERT: check messages were appended to elements // The second region will be targeted first - const message1Region = screen.queryByTestId("wbARegion-polite1"); - expect(message1Region).toHaveTextContent(rainierMsg); + await waitFor(() => { + const message1Region = screen.queryByTestId("wbARegion-polite1"); + expect(message1Region).toHaveTextContent(rainierMsg); + }); button[1].click(); - const message2Region = screen.queryByTestId("wbARegion-polite0"); - expect(message2Region).toHaveTextContent(bagleyMsg); + await waitFor(() => { + const message2Region = screen.queryByTestId("wbARegion-polite0"); + expect(message2Region).toHaveTextContent(bagleyMsg); + }); }); - test("returns a targeted element IDREF", () => { + test("returns a targeted element IDREF", async () => { // ARRANGE const message1 = "One Fish Two Fish"; const message2 = "Red Fish Blue Fish"; // ACT - const announcement1Id = announceMessage({ + const announcement1Id = await announceMessage({ message: message1, }); - const announcement2Id = announceMessage({ + const announcement2Id = await announceMessage({ message: message2, }); // ASSERT - expect(announcement1Id).toBe("wbARegion-polite1"); - expect(announcement2Id).toBe("wbARegion-polite0"); + // await waitFor(() => { + await expect(announcement1Id).toBe("wbARegion-polite1"); + // }); + // await waitFor(() => { + await expect(announcement2Id).toBe("wbARegion-polite0"); + // }); }); - test("appends messages in alternating assertive live region elements", () => { + test("appends messages in alternating assertive live region elements", async () => { const rainierMsg = "Rainier McCheddarton"; const bagleyMsg = "Bagley Fluffpants"; render( @@ -99,12 +107,15 @@ describe("Announcer.announceMessage", () => { // ASSERT: check messages were appended to elements // The second region will be targeted first - const message1Region = screen.queryByTestId("wbARegion-assertive1"); - expect(message1Region).toHaveTextContent(rainierMsg); - + await waitFor(() => { + const message1Region = screen.queryByTestId("wbARegion-assertive1"); + expect(message1Region).toHaveTextContent(rainierMsg); + }); button[1].click(); - const message2Region = screen.queryByTestId("wbARegion-assertive0"); - expect(message2Region).toHaveTextContent(bagleyMsg); + await waitFor(() => { + const message2Region = screen.queryByTestId("wbARegion-assertive0"); + expect(message2Region).toHaveTextContent(bagleyMsg); + }); }); test("removes messages after an optional duration", async () => { @@ -120,8 +131,15 @@ describe("Announcer.announceMessage", () => { const message1Region = screen.queryByTestId("wbARegion-polite1"); // Assert - expect(message1Region).toHaveTextContent(message1); - expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 500); + await waitFor(() => { + expect(message1Region).toHaveTextContent(message1); + }); + await waitFor(() => { + expect(setTimeout).toHaveBeenLastCalledWith( + expect.any(Function), + 500, + ); + }); await waitFor(() => expect(screen.queryByText(message1)).not.toBeInTheDocument(), ); diff --git a/packages/wonder-blocks-announcer/src/__tests__/clear-messages.test.tsx b/packages/wonder-blocks-announcer/src/__tests__/clear-messages.test.tsx index 4727a8042..84ccef285 100644 --- a/packages/wonder-blocks-announcer/src/__tests__/clear-messages.test.tsx +++ b/packages/wonder-blocks-announcer/src/__tests__/clear-messages.test.tsx @@ -1,4 +1,4 @@ -import {screen} from "@testing-library/react"; +import {screen, waitFor} from "@testing-library/react"; import {announceMessage} from "../announce-message"; import {clearMessages} from "../clear-messages"; @@ -13,35 +13,41 @@ describe("Announcer.clearMessages", () => { message: message1, }); - const region1 = screen.getByTestId("wbARegion-polite1"); - expect(region1).toHaveTextContent(message1); + let region1: HTMLElement | null = null; + await waitFor(() => { + region1 = screen.getByTestId("wbARegion-polite1"); + expect(region1).toHaveTextContent(message1); + }); + + await announceMessage({message: message2}); - announceMessage({message: message2}); const region2 = screen.getByTestId("wbARegion-polite0"); clearMessages(announcement1Id); // ASSERT - expect(region1).toBeEmptyDOMElement(); + await waitFor(() => { + expect(region1).toBeEmptyDOMElement(); + }); expect(region2).toHaveTextContent(message2); }); - test("empties all live region elements by default", () => { + test("empties all live region elements by default", async () => { // ARRANGE const message1 = "One fish two fish"; const message2 = "Red fish blue fish"; // ACT - announceMessage({message: message1}); + await announceMessage({message: message1}); - const region1 = screen.getByTestId("wbARegion-polite1"); + const region1 = screen.queryByTestId("wbARegion-polite1"); expect(region1).toHaveTextContent(message1); - announceMessage({message: message2}); + await announceMessage({message: message2}); const region2 = screen.getByTestId("wbARegion-polite0"); expect(region2).toHaveTextContent(message2); - announceMessage({ + await announceMessage({ message: message1, level: "assertive", }); From cba83f9a8bd032d811fd5cc585075d62e0231de0 Mon Sep 17 00:00:00 2001 From: Marcy Sutton Todd Date: Tue, 26 Nov 2024 14:40:26 -0800 Subject: [PATCH 25/44] Get async tests working --- .../wonder-blocks-announcer/src/Announcer.ts | 2 +- .../src/__tests__/Announcer.test.ts | 144 ++++++++++++++++-- .../src/__tests__/announce-message.test.tsx | 55 +++---- .../src/announce-message.ts | 4 +- 4 files changed, 161 insertions(+), 44 deletions(-) diff --git a/packages/wonder-blocks-announcer/src/Announcer.ts b/packages/wonder-blocks-announcer/src/Announcer.ts index 263d04449..9b9656866 100644 --- a/packages/wonder-blocks-announcer/src/Announcer.ts +++ b/packages/wonder-blocks-announcer/src/Announcer.ts @@ -123,7 +123,7 @@ class Announcer { * @param {string} message The message to be announced * @param {string} level Politeness level: should it interrupt? * @param {number} removalDelay How long to wait before removing message - * @returns {string} IDREF for targeted element or empty string if it failed + * @returns {Promise} Promise that resolves with an IDREF for targeted element or empty string if it failed */ async announce( message: string, diff --git a/packages/wonder-blocks-announcer/src/__tests__/Announcer.test.ts b/packages/wonder-blocks-announcer/src/__tests__/Announcer.test.ts index 780340461..aa011eb33 100644 --- a/packages/wonder-blocks-announcer/src/__tests__/Announcer.test.ts +++ b/packages/wonder-blocks-announcer/src/__tests__/Announcer.test.ts @@ -1,5 +1,10 @@ import {screen} from "@testing-library/react"; import Announcer from "../Announcer"; +import { + createTestRegionList, + createTestElements, + resetTestElements, +} from "./util/util"; describe("Announcer class", () => { describe("instantiation", () => { @@ -25,21 +30,120 @@ describe("Announcer class", () => { }); }); - describe("announcing messages", () => { + describe("Debouncing messages", () => { + jest.useFakeTimers(); + + test("a single message", async () => { + // ARRANGE + const announcer = Announcer.getInstance(); + const callback = jest.fn((message: string) => message); + const debounced = announcer.debounce(callback, 300); + + // ACT + const resultPromise = debounced("Hello, World!"); + + // ASSERT + expect(resultPromise).toBeInstanceOf(Promise); + + jest.advanceTimersByTime(300); + + await expect(resultPromise).resolves.toBe("Hello, World!"); + }); + + test("resolving with the last argument passed if debounced multiple times", async () => { + // ARRANGE + const announcer = Announcer.getInstance(); + const callback = jest.fn((message: string) => message); + const debounced = announcer.debounce(callback, 500); + + // ACT + debounced("First message"); + debounced("Second message"); + const thirdCall = debounced("Third message"); + + jest.advanceTimersByTime(500); + + await expect(thirdCall).resolves.toBe("Third message"); + expect(callback).toHaveBeenCalledTimes(1); + + // ASSERT + expect(callback).toHaveBeenCalledWith("Third message"); + }); + }); + + describe("Appending messages", () => { + let element1: HTMLElement | null = null; + let element2: HTMLElement | null = null; + + beforeEach(() => { + ({testElement1: element1, testElement2: element2} = + createTestElements()); + }); afterEach(() => { const announcer = Announcer.getInstance(); + resetTestElements(element1, element2); announcer.reset(); }); - test("appending a message", async () => { + test("adding a polite message to a specific element index", () => { + // ARRANGE + const announcer = Announcer.getInstance(); + + const regionList = createTestRegionList( + "polite", + element1 as HTMLElement, + element2 as HTMLElement, + ); + + // ACT + const index = announcer.appendMessage( + "Saved by the bell!", + "polite", + regionList, + ); + + // ASSERT + expect(index).toBe(1); + }); + + test("adding an assertive message to the DOM", () => { + // ARRANGE + const announcer = Announcer.getInstance(); + + const regionList = createTestRegionList( + "assertive", + element1 as HTMLElement, + element2 as HTMLElement, + ); + + // ACT + const index = announcer.appendMessage( + "Saved by the bell!", + "assertive", + regionList, + ); + + // ASSERT + expect(index).toBe(1); + }); + }); + + describe("Announcing messages", () => { + afterEach(() => { + const announcer = Announcer.getInstance(); + announcer.reset(); + }); + + test("a single message", async () => { // Arrange const announcer = Announcer.getInstance(); expect(announcer.regionFactory.pIndex).toBe(0); // Act - await announcer.announce("a thing", "polite"); + announcer.announce("a thing", "polite"); - // Assert + // // Assert + jest.advanceTimersByTime(500); expect(announcer.regionFactory.pIndex).toBe(1); expect( announcer.dictionary.get("wbARegion-polite1")?.element @@ -47,24 +151,27 @@ describe("Announcer class", () => { ).toBe("a thing"); }); - test("appending two messages", async () => { + test("two messages", async () => { // Arrange const announcer = Announcer.getInstance(); expect(announcer.regionFactory.pIndex).toBe(0); // Act - await announcer.announce("a nice thing", "polite"); + announcer.announce("a nice thing", "polite"); // Assert + jest.advanceTimersByTime(500); expect(announcer.regionFactory.pIndex).toBe(1); + expect( announcer.dictionary.get("wbARegion-polite1")?.element .textContent, ).toBe("a nice thing"); - await announcer.announce("another nice thing", "polite"); + announcer.announce("another nice thing", "polite"); // Assert + jest.advanceTimersByTime(500); expect(announcer.regionFactory.pIndex).toBe(0); expect( announcer.dictionary.get("wbARegion-polite0")?.element @@ -78,10 +185,11 @@ describe("Announcer class", () => { expect(announcer.regionFactory.pIndex).toBe(0); // Act - const idRef = await announcer.announce("another thing", "polite"); + const idRef = announcer.announce("another thing", "polite"); // Assert - expect(idRef).toBe("wbARegion-polite1"); + jest.advanceTimersByTime(500); + await expect(idRef).resolves.toBe("wbARegion-polite1"); }); }); @@ -92,14 +200,18 @@ describe("Announcer class", () => { expect(announcer.regionFactory.pIndex).toBe(0); // Act - const idRef = await announcer.announce("something", "polite"); - const firstRegion = announcer.dictionary.get(idRef)?.element; - expect(firstRegion?.textContent).toBe("something"); + const idRef = "wbARegion-polite0"; + const message = "This is a test"; + const firstRegion = announcer.dictionary.get(idRef)?.element; + if (firstRegion) { + firstRegion.textContent = message; + } + expect(firstRegion?.textContent).toBe(message); announcer.clear(idRef); // Assert - expect(firstRegion?.textContent).not.toBe("something"); + expect(firstRegion?.textContent).not.toBe(message); }); test("clearing all elements", async () => { @@ -107,8 +219,10 @@ describe("Announcer class", () => { const announcer = Announcer.getInstance(); // Act - await announcer.announce("One Fish", "polite"); - await announcer.announce("Loud Fish", "assertive"); + announcer.announce("One Fish", "polite"); + announcer.announce("Loud Fish", "assertive"); + + jest.advanceTimersByTime(500); expect(screen.getByText("One Fish")).toBeInTheDocument(); expect(screen.getByText("Loud Fish")).toBeInTheDocument(); diff --git a/packages/wonder-blocks-announcer/src/__tests__/announce-message.test.tsx b/packages/wonder-blocks-announcer/src/__tests__/announce-message.test.tsx index 10cfd467f..d5a297779 100644 --- a/packages/wonder-blocks-announcer/src/__tests__/announce-message.test.tsx +++ b/packages/wonder-blocks-announcer/src/__tests__/announce-message.test.tsx @@ -77,20 +77,20 @@ describe("Announcer.announceMessage", () => { const message2 = "Red Fish Blue Fish"; // ACT - const announcement1Id = await announceMessage({ + const announcement1Id = announceMessage({ message: message1, }); - const announcement2Id = await announceMessage({ + jest.advanceTimersByTime(500); + + // ASSERT + await expect(announcement1Id).resolves.toBe("wbARegion-polite1"); + + const announcement2Id = announceMessage({ message: message2, }); - // ASSERT - // await waitFor(() => { - await expect(announcement1Id).toBe("wbARegion-polite1"); - // }); - // await waitFor(() => { - await expect(announcement2Id).toBe("wbARegion-polite0"); - // }); + jest.advanceTimersByTime(500); + await expect(announcement2Id).resolves.toBe("wbARegion-polite0"); }); test("appends messages in alternating assertive live region elements", async () => { @@ -123,33 +123,36 @@ describe("Announcer.announceMessage", () => { const message2 = "A Different Thing"; // default timeout is 5000ms - render(); - render(); + render( + , + ); + render( + , + ); const button = screen.getAllByRole("button"); button[0].click(); + const message1Region = screen.queryByTestId("wbARegion-polite1"); // Assert + jest.advanceTimersByTime(500); + expect(message1Region).toHaveTextContent(message1); + + expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 2000); + + jest.advanceTimersByTime(2000); await waitFor(() => { - expect(message1Region).toHaveTextContent(message1); - }); - await waitFor(() => { - expect(setTimeout).toHaveBeenLastCalledWith( - expect.any(Function), - 500, - ); + expect(screen.queryByText(message1)).not.toBeInTheDocument(); }); - await waitFor(() => - expect(screen.queryByText(message1)).not.toBeInTheDocument(), - ); button[1].click(); const message2Region = screen.queryByTestId("wbARegion-polite0"); - expect(message2Region).toHaveTextContent(message2); - expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 700); - await waitFor(() => - expect(screen.queryByText(message2)).not.toBeInTheDocument(), - ); + await waitFor(() => { + expect(message2Region).toHaveTextContent(message2); + }); + expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 7000); + jest.advanceTimersByTime(7000); + expect(screen.queryByText(message2)).not.toBeInTheDocument(); }); }); diff --git a/packages/wonder-blocks-announcer/src/announce-message.ts b/packages/wonder-blocks-announcer/src/announce-message.ts index 311a3265f..311aacaf1 100644 --- a/packages/wonder-blocks-announcer/src/announce-message.ts +++ b/packages/wonder-blocks-announcer/src/announce-message.ts @@ -14,9 +14,9 @@ export const defaultTimeout = 100; * @param {string} message The message to announce. * @param {PolitenessLevel} level Polite or assertive announcements * @param {number} removalDelay Optional duration to remove a message after sending. Defaults to 5000ms. - * @returns {string} IDREF for targeted live region element + * @returns {Promise} Promise that resolves with an IDREF for targeted live region element */ -export async function announceMessage({ +export function announceMessage({ message, level = "polite", // TODO: decide whether to allow other roles, i.e. role=`timer` removalDelay, From 8526915c9607b5cd885018d6e771fd49b7d982d3 Mon Sep 17 00:00:00 2001 From: Marcy Sutton Todd Date: Tue, 26 Nov 2024 14:44:29 -0800 Subject: [PATCH 26/44] Add missing test utility file D'oh! --- .../src/__tests__/util/util.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 packages/wonder-blocks-announcer/src/__tests__/util/util.ts diff --git a/packages/wonder-blocks-announcer/src/__tests__/util/util.ts b/packages/wonder-blocks-announcer/src/__tests__/util/util.ts new file mode 100644 index 000000000..070a7cbe6 --- /dev/null +++ b/packages/wonder-blocks-announcer/src/__tests__/util/util.ts @@ -0,0 +1,45 @@ +import type {RegionDef, PolitenessLevel} from "../../../types/Announcer.types"; + +export function createTestRegionList( + level: PolitenessLevel, + element1: HTMLElement, + element2: HTMLElement, +): RegionDef[] { + return [ + { + id: `wbARegion-${level}0`, + level: level, + levelIndex: 0, + element: element1, + }, + { + id: `wbARegion-${level}1`, + level: level, + levelIndex: 1, + element: element2, + }, + ]; +} + +export function createTestElements() { + const testElement1 = document.createElement("div"); + testElement1.setAttribute("data-testid", "test-element1"); + const testElement2 = document.createElement("div"); + testElement2.setAttribute("data-testid", "test-element2"); + document.body.appendChild(testElement1); + document.body.appendChild(testElement2); + + return {testElement1, testElement2}; +} + +export function resetTestElements( + testElement1: HTMLElement | null, + testElement2: HTMLElement | null, +) { + if (testElement1 !== null) { + document.body.removeChild(testElement1); + } + if (testElement2 !== null) { + document.body.removeChild(testElement2); + } +} From ea2d1364df9502ae8d5d650bd14f0f685af093d0 Mon Sep 17 00:00:00 2001 From: Marcy Sutton Todd Date: Tue, 26 Nov 2024 14:53:43 -0800 Subject: [PATCH 27/44] Update docs in Storybook for latest API changes --- .../wonder-blocks-announcer/announcer.stories.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/__docs__/wonder-blocks-announcer/announcer.stories.tsx b/__docs__/wonder-blocks-announcer/announcer.stories.tsx index 7d93b3248..7c57513ee 100644 --- a/__docs__/wonder-blocks-announcer/announcer.stories.tsx +++ b/__docs__/wonder-blocks-announcer/announcer.stories.tsx @@ -41,19 +41,20 @@ type StoryComponentType = StoryObj; * cases include combobox filtering, toast notifications, client-side routing, * and more. * - * Calling the sendMessage method automatically prepends the appropriate live regions + * Calling the `announceMessage` function automatically appends the appropriate live regions * to the document body. It sends messages at a default `polite` level, with the * ability to override to `assertive` by passing a `level` argument. You can also - * pass a `timeoutDelay` to wait a specific duration before sending a message. + * pass a `timeoutDelay` to wait a specific duration before the message is automatically + * removed from the DOM. * - * To test this API, turn on VoiceOver or NVDA on Windows and click the example button. + * To test this API, turn on VoiceOver for Mac/iOS or NVDA on Windows and click the example button. * * ### Usage * ```jsx - * import { sendMessage } from "@khanacademy/wonder-blocks-announcer"; + * import { appendMessage } from "@khanacademy/wonder-blocks-announcer"; * *
- * *
From 4b244e57ce33e56861e23570f62b4b11675b7c86 Mon Sep 17 00:00:00 2001 From: Marcy Sutton Todd Date: Wed, 27 Nov 2024 11:42:25 -0800 Subject: [PATCH 28/44] Firm up debounce logic 1. Return first result and debounce subsequent calls within the debounceThreshold 2. Remove removalDelay parameter to simplify API 3. Put debounce utility into a separate file and add tests --- .../announcer.stories.tsx | 17 +++-- .../wonder-blocks-announcer/src/Announcer.ts | 64 +++++++----------- .../src/__tests__/Announcer.test.ts | 67 +++++++------------ .../src/__tests__/announce-message.test.tsx | 49 ++++++++------ .../util/{util.ts => test-utilities.ts} | 0 .../src/__tests__/util/util.test.ts | 40 +++++++++++ .../src/announce-message.ts | 10 ++- .../wonder-blocks-announcer/src/util/dom.ts | 2 +- .../wonder-blocks-announcer/src/util/util.ts | 30 +++++++++ 9 files changed, 164 insertions(+), 115 deletions(-) rename packages/wonder-blocks-announcer/src/__tests__/util/{util.ts => test-utilities.ts} (100%) create mode 100644 packages/wonder-blocks-announcer/src/__tests__/util/util.test.ts diff --git a/__docs__/wonder-blocks-announcer/announcer.stories.tsx b/__docs__/wonder-blocks-announcer/announcer.stories.tsx index 7c57513ee..94623cfc1 100644 --- a/__docs__/wonder-blocks-announcer/announcer.stories.tsx +++ b/__docs__/wonder-blocks-announcer/announcer.stories.tsx @@ -15,7 +15,7 @@ import packageConfig from "../../packages/wonder-blocks-announcer/package.json"; const AnnouncerExample = ({ message = "Clicked!", level, - removalDelay, + debounceThreshold, }: AnnounceMessageProps) => { return ( ; + const announceProps = { + initialTimeout: 0, + ...props, + }; + return ( + + ); }; diff --git a/packages/wonder-blocks-announcer/src/__tests__/util/util.test.ts b/packages/wonder-blocks-announcer/src/__tests__/util/util.test.ts index c0d1afb23..f4079ebc2 100644 --- a/packages/wonder-blocks-announcer/src/__tests__/util/util.test.ts +++ b/packages/wonder-blocks-announcer/src/__tests__/util/util.test.ts @@ -1,25 +1,27 @@ -import {debounce} from "../../util/util"; +import Announcer from "../../announcer"; +import {createDebounceFunction} from "../../util/util"; describe("Debouncing messages", () => { jest.useFakeTimers(); test("a single message", async () => { // ARRANGE + const announcer = Announcer.getInstance(); const callback = jest.fn((message: string) => message); - const debounced = debounce(callback, 300); + const debounced = createDebounceFunction(announcer, callback, 100); // ACT - const resultPromise = debounced("Hello, World!"); - jest.advanceTimersByTime(300); + const result = await debounced("Hello, World!"); + jest.advanceTimersByTime(100); // ASSERT - await expect(resultPromise).resolves.toBe("Hello, World!"); + expect(result).toBe("Hello, World!"); }); test("resolving with the first argument passed if debounced multiple times", async () => { // ARRANGE const callback = jest.fn((message: string) => message); - const debounced = debounce(callback, 500); + const debounced = createDebounceFunction(window, callback, 500); // ACT debounced("First message"); diff --git a/packages/wonder-blocks-announcer/src/announce-message.ts b/packages/wonder-blocks-announcer/src/announce-message.ts index 4fa273c98..8bec5624e 100644 --- a/packages/wonder-blocks-announcer/src/announce-message.ts +++ b/packages/wonder-blocks-announcer/src/announce-message.ts @@ -9,7 +9,7 @@ export type AnnounceMessageProps = { }; /** - * Public API method to announce screen reader messages in ARIA Live Regions. + * Method to announce screen reader messages in ARIA Live Regions. * @param {string} message The message to announce. * @param {PolitenessLevel} level Polite or assertive announcements * @param {number} debounceThreshold Optional duration to wait before announcing another message. Defaults to 250ms. @@ -23,13 +23,21 @@ export function announceMessage({ initialTimeout = 150, }: AnnounceMessageProps): Promise { const announcer = Announcer.getInstance(); - if (initialTimeout > 0) { - setTimeout(() => { - return announcer.announce(message, level, debounceThreshold); - }, initialTimeout); + return new Promise((resolve) => { + setTimeout(async () => { + const result = await announcer.announce( + message, + level, + debounceThreshold, + ); + resolve(result); + }, initialTimeout); + }); } else { - return announcer.announce(message, level, debounceThreshold); + const result = announcer.announce(message, level, debounceThreshold); + return new Promise((resolve) => { + resolve(result); + }); } - return Promise.resolve(""); } diff --git a/packages/wonder-blocks-announcer/src/announcer.ts b/packages/wonder-blocks-announcer/src/announcer.ts index 108170546..2739c03b8 100644 --- a/packages/wonder-blocks-announcer/src/announcer.ts +++ b/packages/wonder-blocks-announcer/src/announcer.ts @@ -10,10 +10,10 @@ import { createDuplicateRegions, removeMessage, } from "./util/dom"; -import {alternateIndex, debounce} from "./util/util"; +import {alternateIndex, createDebounceFunction} from "./util/util"; -const REMOVAL_TIMEOUT_DELAY = 5000; -const DEFAULT_WAIT_THRESHOLD = 250; +export const REMOVAL_TIMEOUT_DELAY = 5000; +export const DEFAULT_WAIT_THRESHOLD = 250; /** * Internal class to manage screen reader announcements. @@ -27,7 +27,12 @@ class Announcer { pIndex: 0, }; dictionary: RegionDictionary = new Map(); - stateLock = false; + waitThreshold: number = DEFAULT_WAIT_THRESHOLD; + lastExecutionTime = 0; + private debounced!: { + (...args: any[]): Promise; + updateWaitTime: (newWaitTime: number) => void; + }; private constructor() { if (typeof document !== "undefined") { @@ -43,6 +48,15 @@ class Announcer { else { this.reattachNodes(); } + + // Create the debounced message attachment function + // This API makes leading edge debouncing work while preserving the + // ability to change the wait parameter through Announcer.announce + this.debounced = createDebounceFunction( + this, + this.processAnnouncement, + this.waitThreshold, + ); } } /** @@ -115,7 +129,6 @@ class Announcer { }); } } - /** * Announce a live region message for a given level * @param {string} message The message to be announced @@ -123,46 +136,57 @@ class Announcer { * @param {number} debounceThreshold Optional duration to wait before appending another message (defaults to 250ms) * @returns {Promise} Promise that resolves with an IDREF for targeted element or empty string if it failed */ - async announce( + announce( message: string, level: PolitenessLevel, - debounceThreshold: number = DEFAULT_WAIT_THRESHOLD, + debounceThreshold?: number, ): Promise { - // Locking variable to prevent simultaneous updates - if (this.stateLock) { - return Promise.resolve(""); // Return early if state is locked + // if callers specify a different wait threshold, update our debounce fn + if (debounceThreshold !== undefined) { + this.updateWaitThreshold(debounceThreshold); + } + return this.debounced(this, message, level); + } + /** + * Override the default debounce wait threshold + * @param {number} debounceThreshold Duration to wait before appending messages + */ + updateWaitThreshold(debounceThreshold: number) { + this.waitThreshold = debounceThreshold; + if (this.debounced) { + this.debounced.updateWaitTime(debounceThreshold); + } + } + /** + * Callback for appending live region messages through debounce + * @param {Announcer} context Pass the correct `this` arg to the callback + * @param {sting} message The live region message to append + * @param {string} level The politeness level for whether to interrupt + */ + processAnnouncement( + context: Announcer, + message: string, + level: PolitenessLevel, + ) { + if (!context.node) { + context.reattachNodes(); } - const announceCB = () => { - if (!this.node) { - this.reattachNodes(); - } - // Filter region elements to the selected level - const regions: RegionDef[] = [...this.dictionary.values()].filter( - (entry: RegionDef) => entry.level === level, - ); - - const newIndex = this.appendMessage(message, level, regions); - - // overwrite central index for the given level - if (level === "assertive") { - this.regionFactory.aIndex = newIndex; - } else { - this.regionFactory.pIndex = newIndex; - } + // Filter region elements to the selected level + const regions: RegionDef[] = [...context.dictionary.values()].filter( + (entry: RegionDef) => entry.level === level, + ); - this.stateLock = false; // Release the lock + const newIndex = context.appendMessage(message, level, regions); - return regions[newIndex].id || ""; - }; + // overwrite central index for the given level + if (level === "assertive") { + context.regionFactory.aIndex = newIndex; + } else { + context.regionFactory.pIndex = newIndex; + } - const safeAnnounce = debounce(announceCB, debounceThreshold); - const result = await safeAnnounce({ - message, - level, - debounceThreshold, - }); - return result; + return regions[newIndex].id || ""; } /** diff --git a/packages/wonder-blocks-announcer/src/clear-messages.ts b/packages/wonder-blocks-announcer/src/clear-messages.ts index 2739bd264..a91484b7c 100644 --- a/packages/wonder-blocks-announcer/src/clear-messages.ts +++ b/packages/wonder-blocks-announcer/src/clear-messages.ts @@ -8,8 +8,8 @@ import Announcer from "./announcer"; export function clearMessages(id?: string) { const announcer = Announcer.getInstance(); if (id && document?.getElementById(id)) { - announcer?.clear(id); + announcer.clear(id); } else if (typeof document !== "undefined") { - announcer?.clear(); + announcer.clear(); } } diff --git a/packages/wonder-blocks-announcer/src/util/util.ts b/packages/wonder-blocks-announcer/src/util/util.ts index 081730fac..f328d3a57 100644 --- a/packages/wonder-blocks-announcer/src/util/util.ts +++ b/packages/wonder-blocks-announcer/src/util/util.ts @@ -1,3 +1,5 @@ +import type Announcer from "../announcer"; + /** * Alternate index for cycling through elements * @param {number} index Previous element index (0 or 1) @@ -16,24 +18,48 @@ export function alternateIndex(index: number, count: number): number { * @param {number} wait Length of time to wait before calling callback again * @returns {string} idRef of targeted live region element */ -export function debounce(callback: (...args: any[]) => string, wait: number) { +export function createDebounceFunction( + context: Announcer | typeof window, + callback: (...args: any[]) => string, + debounceThreshold: number, +): { + (...args: any[]): Promise; + updateWaitTime: (time: number) => void; +} { let timeoutId: ReturnType | null = null; let executed = false; + let lastExecutionTime = 0; - return (...args: any[]): Promise => { - return new Promise((resolve) => { - if (!executed) { - executed = true; - const result = callback(...args); - resolve(result); + const debouncedFn = (...args: []) => { + return new Promise((resolve) => { + const now = Date.now(); + const timeSinceLastExecution = now - lastExecutionTime; + if (timeSinceLastExecution >= debounceThreshold) { + lastExecutionTime = now; + // Leading edge: Execute the callback immediately + if (!executed) { + executed = true; + const result = callback.apply(context, args); + resolve(result); + } } + + // If the timeout exists, clear it if (timeoutId !== null) { clearTimeout(timeoutId); } + // Trailing edge: Set the timeout for the next allowed execution timeoutId = setTimeout(() => { executed = false; - }, wait); + }, debounceThreshold); }); }; + + // Allow callers to adjust the debounce wait time + debouncedFn.updateWaitTime = (newWaitTime: number) => { + debounceThreshold = newWaitTime; + }; + + return debouncedFn; } From c44e59a5d87f961bed7be5879805addc94328050 Mon Sep 17 00:00:00 2001 From: Marcy Sutton Todd Date: Thu, 12 Dec 2024 12:25:52 -0800 Subject: [PATCH 40/44] Clean up storybook styling with custom body class --- .storybook/preview.tsx | 13 +++++++++++++ .../wonder-blocks-announcer/announcer.stories.tsx | 10 +--------- static/sb-styles/preview.css | 9 +++++---- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 2349861e7..f282c76a5 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -101,6 +101,19 @@ const decorators = [ const enableRenderStateRootDecorator = context.parameters.enableRenderStateRootDecorator; + // Allow stories to specify a CSS body class + if (context.parameters.addBodyClass) { + document.body.classList.add(context.parameters.addBodyClass); + } + // Remove body class when changing stories + React.useEffect(() => { + return () => { + if (context.parameters.addBodyClass) { + document.body.classList.remove(context.parameters.addBodyClass); + } + }; + }, [context.parameters.addBodyClass]); + if (enableRenderStateRootDecorator) { return ( diff --git a/__docs__/wonder-blocks-announcer/announcer.stories.tsx b/__docs__/wonder-blocks-announcer/announcer.stories.tsx index 94623cfc1..0cdadb73e 100644 --- a/__docs__/wonder-blocks-announcer/announcer.stories.tsx +++ b/__docs__/wonder-blocks-announcer/announcer.stories.tsx @@ -70,6 +70,7 @@ export default { ), ], parameters: { + addBodyClass: "showAnnouncer", componentSubtitle: ( Date: Thu, 12 Dec 2024 12:32:43 -0800 Subject: [PATCH 41/44] Update jsdoc comments for debounce utility --- packages/wonder-blocks-announcer/src/util/util.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/wonder-blocks-announcer/src/util/util.ts b/packages/wonder-blocks-announcer/src/util/util.ts index f328d3a57..2270044f9 100644 --- a/packages/wonder-blocks-announcer/src/util/util.ts +++ b/packages/wonder-blocks-announcer/src/util/util.ts @@ -14,12 +14,13 @@ export function alternateIndex(index: number, count: number): number { /** * Keep announcements from happening too often by limiting callback execution by time. * Anytime the announcer is called repeatedly, this can slow down the results. - * @param {Function} callback Announce method to call with argments - * @param {number} wait Length of time to wait before calling callback again - * @returns {string} idRef of targeted live region element + * @param {Announcer} context Reference to the Announcer instance for maintaining correct scope + * @param {Function} callback Callback announcer method to call with argments + * @param {number} debounceThreshold Length of time to wait before calling callback again + * @returns {Function & { updateWaitTime: (time: number) => void }} Promise resolving with idRef of targeted live region element, and a method to update wait duration */ export function createDebounceFunction( - context: Announcer | typeof window, + context: Announcer, callback: (...args: any[]) => string, debounceThreshold: number, ): { From 7eacfef8c551b62d4d84697436d0694d40727190 Mon Sep 17 00:00:00 2001 From: Marcy Sutton Todd Date: Thu, 12 Dec 2024 12:36:46 -0800 Subject: [PATCH 42/44] Fix incorrect object in debounce test I was trying to avoid having to import the Announcer in this test to keep things isolated, but it's so specific to the Announcer that I decided it didn't matter that much. Specifying the Announcer instance for the scope instead of generic thisArg logic simplified things quite a bit as well. --- .../wonder-blocks-announcer/src/__tests__/util/util.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/wonder-blocks-announcer/src/__tests__/util/util.test.ts b/packages/wonder-blocks-announcer/src/__tests__/util/util.test.ts index f4079ebc2..09d3a0ed5 100644 --- a/packages/wonder-blocks-announcer/src/__tests__/util/util.test.ts +++ b/packages/wonder-blocks-announcer/src/__tests__/util/util.test.ts @@ -20,8 +20,9 @@ describe("Debouncing messages", () => { test("resolving with the first argument passed if debounced multiple times", async () => { // ARRANGE + const announcer = Announcer.getInstance(); const callback = jest.fn((message: string) => message); - const debounced = createDebounceFunction(window, callback, 500); + const debounced = createDebounceFunction(announcer, callback, 500); // ACT debounced("First message"); From b961200a451c341dad62106bbb78f45b290d2a34 Mon Sep 17 00:00:00 2001 From: Marcy Sutton Todd Date: Thu, 12 Dec 2024 14:19:08 -0800 Subject: [PATCH 43/44] Allow node access for vanilla JS testing --- packages/wonder-blocks-announcer/src/__tests__/announcer.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/wonder-blocks-announcer/src/__tests__/announcer.test.ts b/packages/wonder-blocks-announcer/src/__tests__/announcer.test.ts index fc168cdc8..4be252e19 100644 --- a/packages/wonder-blocks-announcer/src/__tests__/announcer.test.ts +++ b/packages/wonder-blocks-announcer/src/__tests__/announcer.test.ts @@ -27,6 +27,7 @@ describe("Announcer class", () => { // Assert expect(wrapperElement).toBeInTheDocument(); + // eslint-disable-next-line testing-library/no-node-access expect(wrapperElement?.childElementCount).toBe(2); expect(regions.size).toBe(4); }); From 50f263bc42f4736eabe219342b2bc6a3e2e2618d Mon Sep 17 00:00:00 2001 From: Marcy Sutton Todd Date: Thu, 12 Dec 2024 14:24:17 -0800 Subject: [PATCH 44/44] Update dependencies for React 18 --- packages/wonder-blocks-announcer/package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/wonder-blocks-announcer/package.json b/packages/wonder-blocks-announcer/package.json index 7696edfec..ce602af68 100644 --- a/packages/wonder-blocks-announcer/package.json +++ b/packages/wonder-blocks-announcer/package.json @@ -16,13 +16,13 @@ "access": "public" }, "dependencies": { - "@khanacademy/wonder-blocks-core": "^7.0.1" + "@khanacademy/wonder-blocks-core": "^9.0.0" }, "peerDependencies": { "aphrodite": "^1.2.5", - "react": "16.14.0" + "react": "18.2.0" }, "devDependencies": { - "@khanacademy/wb-dev-build-settings": "^1.0.1" + "@khanacademy/wb-dev-build-settings": "^2.0.0" } } \ No newline at end of file