diff --git a/.changeset/thirty-ducks-type.md b/.changeset/thirty-ducks-type.md new file mode 100644 index 0000000000..112cb9f500 --- /dev/null +++ b/.changeset/thirty-ducks-type.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/wonder-blocks-announcer": minor +--- + +New package for WB Announcer diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 2349861e78..f282c76a58 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 new file mode 100644 index 0000000000..6763cfa194 --- /dev/null +++ b/__docs__/wonder-blocks-announcer/announcer.stories.tsx @@ -0,0 +1,127 @@ +import * as React from "react"; +import {StyleSheet} from "aphrodite"; +import type {Meta, StoryObj} from "@storybook/react"; + +import { + announceMessage, + type AnnounceMessageProps, +} from "@khanacademy/wonder-blocks-announcer"; +import Button from "@khanacademy/wonder-blocks-button"; +import {View} from "@khanacademy/wonder-blocks-core"; + +import ComponentInfo from "../components/component-info"; +import packageConfig from "../../packages/wonder-blocks-announcer/package.json"; + +const AnnouncerExample = ({ + message = "Clicked!", + level, + debounceThreshold, +}: AnnounceMessageProps) => { + 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 `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 `debounceThreshold` to wait a specific duration before making another announcement. + * + * To test this API, turn on VoiceOver for Mac/iOS or NVDA on Windows and click the example button. + * + * ### Usage + * ```jsx + * import { appendMessage } from "@khanacademy/wonder-blocks-announcer"; + * + *
+ * + *
+ * ``` + */ +export default { + title: "Packages / Announcer", + component: AnnouncerExample, + decorators: [ + (Story): React.ReactElement> => ( + + + + ), + ], + parameters: { + addBodyClass: "showAnnouncer", + componentSubtitle: ( + + ), + docs: { + source: { + // See https://github.com/storybookjs/storybook/issues/12596 + excludeDecorators: true, + }, + }, + chromatic: {disableSnapshot: true}, + }, + argTypes: { + level: { + control: "radio", + options: ["polite", "assertive"], + }, + debounceThreshold: { + 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/package.json b/packages/wonder-blocks-announcer/package.json new file mode 100644 index 0000000000..ce602af686 --- /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": "^9.0.0" + }, + "peerDependencies": { + "aphrodite": "^1.2.5", + "react": "18.2.0" + }, + "devDependencies": { + "@khanacademy/wb-dev-build-settings": "^2.0.0" + } +} \ No newline at end of file diff --git a/packages/wonder-blocks-announcer/src/__tests__/announce-message.test.tsx b/packages/wonder-blocks-announcer/src/__tests__/announce-message.test.tsx new file mode 100644 index 0000000000..f17ed1ea06 --- /dev/null +++ b/packages/wonder-blocks-announcer/src/__tests__/announce-message.test.tsx @@ -0,0 +1,172 @@ +import * as React from "react"; +import {render, screen, waitFor} from "@testing-library/react"; +import Announcer, {REMOVAL_TIMEOUT_DELAY} from "../announcer"; +import {AnnounceMessageButton} from "./components/announce-message-button"; +import {announceMessage} from "../announce-message"; + +jest.useFakeTimers(); +jest.spyOn(global, "setTimeout"); + +describe("Announcer.announceMessage", () => { + afterEach(() => { + const announcer = Announcer.getInstance(); + jest.advanceTimersByTime(REMOVAL_TIMEOUT_DELAY); + announcer.reset(); + }); + + test("returns a targeted element IDREF", async () => { + // ARRANGE + const message1 = "One Fish Two Fish"; + + // ACT + const announcement1Id = await announceMessage({ + message: message1, + initialTimeout: 0, + debounceThreshold: 0, + }); + jest.advanceTimersByTime(500); + + // ASSERT + expect(announcement1Id).toBe("wbARegion-polite1"); + }); + + 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(); + + jest.advanceTimersByTime(250); + + // ASSERT: check messages were appended to elements + // The second region will be targeted first + const message1Region = screen.queryByTestId("wbARegion-polite1"); + await waitFor(() => { + expect(message1Region).toHaveTextContent(rainierMsg); + }); + + button[1].click(); + const message2Region = screen.queryByTestId("wbARegion-polite0"); + await waitFor(() => { + expect(message2Region).toHaveTextContent(bagleyMsg); + }); + }); + + test("appends messages in alternating assertive live region elements", async () => { + const rainierMsg = "Rainier McCheese"; + const bagleyMsg = "Bagley The Cat"; + render( + , + ); + render( + , + ); + + // ACT: post two messages + const button = screen.getAllByRole("button"); + button[0].click(); + + jest.advanceTimersByTime(250); + + // ASSERT: check messages were appended to elements + // The second region will be targeted first + const message1Region = screen.queryByTestId("wbARegion-assertive1"); + await waitFor(() => { + expect(message1Region).toHaveTextContent(rainierMsg); + }); + button[1].click(); + jest.advanceTimersByTime(250); + + const message2Region = screen.queryByTestId("wbARegion-assertive0"); + await waitFor(() => { + expect(message2Region).toHaveTextContent(bagleyMsg); + }); + }); + + test("removes messages after a length of time", async () => { + const message1 = "A Thing"; + + // default timeout is 5000ms + 250ms (removalDelay + debounceThreshold) + 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).toHaveBeenNthCalledWith( + 1, + expect.any(Function), + 5250, + ); + + jest.advanceTimersByTime(5250); + await waitFor(() => { + expect(screen.queryByText(message1)).not.toBeInTheDocument(); + }); + }); +}); 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 0000000000..4be252e19c --- /dev/null +++ b/packages/wonder-blocks-announcer/src/__tests__/announcer.test.ts @@ -0,0 +1,233 @@ +import {screen} from "@testing-library/react"; +import Announcer, {REMOVAL_TIMEOUT_DELAY} from "../announcer"; +import { + createTestRegionList, + createTestElements, + resetTestElements, +} from "./util/test-utilities"; + +jest.useFakeTimers(); + +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(); + // eslint-disable-next-line testing-library/no-node-access + expect(wrapperElement?.childElementCount).toBe(2); + expect(regions.size).toBe(4); + }); + }); + + 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("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(); + jest.advanceTimersByTime(REMOVAL_TIMEOUT_DELAY); + announcer.reset(); + }); + + test("a single message", async () => { + // Arrange + const announcer = Announcer.getInstance(); + expect(announcer.regionFactory.pIndex).toBe(0); + + // Act + announcer.announce("a thing", "polite"); + + // // Assert + jest.advanceTimersByTime(500); + expect(announcer.regionFactory.pIndex).toBe(1); + expect( + announcer.dictionary.get("wbARegion-polite1")?.element + .textContent, + ).toBe("a thing"); + }); + + test("two messages", async () => { + // Arrange + const announcer = Announcer.getInstance(); + expect(announcer.regionFactory.pIndex).toBe(0); + + // Act + 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"); + + announcer.announce("another nice thing", "polite"); + + // Assert + jest.advanceTimersByTime(500); + expect(announcer.regionFactory.pIndex).toBe(0); + expect( + announcer.dictionary.get("wbARegion-polite0")?.element + .textContent, + ).toBe("another nice thing"); + }); + + test("returning an IDREF", async () => { + // Arrange + const announcer = Announcer.getInstance(); + expect(announcer.regionFactory.pIndex).toBe(0); + + // Act + const idRef = announcer.announce("another thing", "polite"); + + // Assert + jest.advanceTimersByTime(500); + await expect(idRef).resolves.toBe("wbARegion-polite1"); + }); + + test("debouncing with a specific wait threshold", async () => { + // ARRANGE + const announcer = Announcer.getInstance(); + const waitThreshold = 1000; + + // Act + announcer.announce("a thing", "polite", waitThreshold); + announcer.announce("two things", "polite", waitThreshold); + + // Assert + jest.advanceTimersByTime(1010); + + const targetElement = + announcer.dictionary.get(`wbARegion-polite1`)?.element; + const targetElement2 = + announcer.dictionary.get(`wbARegion-polite0`)?.element; + + // ASSERT + await expect(targetElement?.textContent).toBe("a thing"); + await expect(targetElement2?.textContent).toBe(""); + }); + }); + + describe("clearing messages", () => { + test("clearing by IDREF", async () => { + // Arrange + const announcer = Announcer.getInstance(); + expect(announcer.regionFactory.pIndex).toBe(0); + + // Act + 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(message); + }); + + test("clearing all elements", async () => { + // Arrange + const announcer = Announcer.getInstance(); + + // Act + announcer.announce("One Fish", "polite", 0); + jest.advanceTimersByTime(5); + announcer.announce("Loud Fish", "assertive", 0); + + 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(); + }); + + 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(); + }); + }); +}); 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 0000000000..3387b47081 --- /dev/null +++ b/packages/wonder-blocks-announcer/src/__tests__/clear-messages.test.tsx @@ -0,0 +1,89 @@ +import {screen, waitFor} from "@testing-library/react"; +import {announceMessage} from "../announce-message"; +import {clearMessages} from "../clear-messages"; + +jest.useFakeTimers(); + +describe("Announcer.clearMessages", () => { + test("empties a targeted live region element by IDREF", async () => { + // ARRANGE + const message1 = "Shine a million stars"; + const message2 = "Dull no stars"; + + // ACT + const announcement1Id = await announceMessage({ + message: message1, + initialTimeout: 0, + debounceThreshold: 0, + }); + + const region1 = screen.getByTestId("wbARegion-polite1"); + + jest.advanceTimersByTime(250); + + await waitFor(() => { + expect(region1).toHaveTextContent(message1); + }); + + await announceMessage({ + message: message2, + initialTimeout: 0, + debounceThreshold: 0, + }); + + const region2 = screen.getByTestId("wbARegion-polite0"); + + jest.advanceTimersByTime(250); + clearMessages(announcement1Id); + + // ASSERT + await waitFor(() => { + expect(region1).toBeEmptyDOMElement(); + }); + expect(region2).toHaveTextContent(message2); + }); + + test("empties all live region elements by default", async () => { + // ARRANGE + const message1 = "One fish two fish"; + const message2 = "Red fish blue fish"; + + // ACT + await announceMessage({ + message: message1, + initialTimeout: 0, + debounceThreshold: 0, + }); + + jest.advanceTimersByTime(250); + + const region1 = screen.queryByTestId("wbARegion-polite1"); + expect(region1).toHaveTextContent(message1); + + await announceMessage({ + message: message2, + initialTimeout: 0, + debounceThreshold: 0, + }); + jest.advanceTimersByTime(250); + const region2 = screen.getByTestId("wbARegion-polite0"); + expect(region2).toHaveTextContent(message2); + + await announceMessage({ + message: message1, + level: "assertive", + initialTimeout: 0, + debounceThreshold: 0, + }); + jest.advanceTimersByTime(250); + 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__/components/announce-message-button.tsx b/packages/wonder-blocks-announcer/src/__tests__/components/announce-message-button.tsx new file mode 100644 index 0000000000..b567453c8b --- /dev/null +++ b/packages/wonder-blocks-announcer/src/__tests__/components/announce-message-button.tsx @@ -0,0 +1,20 @@ +import * as React from "react"; +import {announceMessage} from "../../announce-message"; +import {type AnnounceMessageProps} from "../../announce-message"; + +type AnnounceMessageButtonProps = { + buttonText?: string; +} & AnnounceMessageProps; + +export const AnnounceMessageButton = (props: AnnounceMessageButtonProps) => { + const {buttonText = "Click"} = props; + const announceProps = { + initialTimeout: 0, + ...props, + }; + return ( + + ); +}; 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 0000000000..cfed9fe466 --- /dev/null +++ b/packages/wonder-blocks-announcer/src/__tests__/util/dom.test.ts @@ -0,0 +1,120 @@ +import {screen, waitFor} from "@testing-library/react"; +import { + createRegionWrapper, + createDuplicateRegions, + createRegion, + removeMessage, +} from "../../util/dom"; +import {PolitenessLevel} from "../../../types/announcer.types"; + +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.each(["polite", "assertive"])( + "it creates a group of multiple %s Live Region elements", + (politenessLevel) => { + const wrapper = document.createElement("div"); + const dictionary = new Map(); + + const regionList = createDuplicateRegions( + wrapper, + politenessLevel as PolitenessLevel, + 2, + dictionary, + ); + + expect(regionList.length).toBe(2); + expect(regionList[0].id).toBe(`wbARegion-${politenessLevel}0`); + expect(regionList[1].id).toBe(`wbARegion-${politenessLevel}1`); + expect(dictionary.size).toBe(2); + }, + ); + }); + + describe("createRegion", () => { + test.each(["polite", "assertive"])( + "it creates a %s Live Region element", + (politenessLevel) => { + // Arrange + const dictionary = new Map(); + + // Act + const region = createRegion( + politenessLevel as PolitenessLevel, + 0, + dictionary, + ); + + // Assert + expect(region.getAttribute("aria-live")).toBe(politenessLevel); + 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/__tests__/util/test-utilities.ts b/packages/wonder-blocks-announcer/src/__tests__/util/test-utilities.ts new file mode 100644 index 0000000000..1f361ac2bb --- /dev/null +++ b/packages/wonder-blocks-announcer/src/__tests__/util/test-utilities.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); + } +} diff --git a/packages/wonder-blocks-announcer/src/__tests__/util/util.test.ts b/packages/wonder-blocks-announcer/src/__tests__/util/util.test.ts new file mode 100644 index 0000000000..09d3a0ed54 --- /dev/null +++ b/packages/wonder-blocks-announcer/src/__tests__/util/util.test.ts @@ -0,0 +1,39 @@ +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 = createDebounceFunction(announcer, callback, 100); + + // ACT + const result = await debounced("Hello, World!"); + jest.advanceTimersByTime(100); + + // ASSERT + expect(result).toBe("Hello, World!"); + }); + + 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(announcer, callback, 500); + + // ACT + debounced("First message"); + debounced("Second message"); + debounced("Third message"); + + jest.advanceTimersByTime(500); + + expect(callback).toHaveBeenCalledTimes(1); + + // ASSERT + expect(callback).toHaveBeenCalledWith("First message"); + }); +}); diff --git a/packages/wonder-blocks-announcer/src/announce-message.ts b/packages/wonder-blocks-announcer/src/announce-message.ts new file mode 100644 index 0000000000..8bec5624e8 --- /dev/null +++ b/packages/wonder-blocks-announcer/src/announce-message.ts @@ -0,0 +1,43 @@ +import type {PolitenessLevel} from "../types/announcer.types"; +import Announcer from "./announcer"; + +export type AnnounceMessageProps = { + message: string; + level?: PolitenessLevel; + debounceThreshold?: number; + initialTimeout?: number; +}; + +/** + * 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. + * @param {number} initialTimeout Optional duration to wait before the first announcement. Useful for Safari and automated testing. + * @returns {Promise} Promise that resolves with an IDREF for targeted live region element or an empty string + */ +export function announceMessage({ + message, + level = "polite", // TODO: decide whether to allow other roles, i.e. role=`timer` + debounceThreshold, + initialTimeout = 150, +}: AnnounceMessageProps): Promise { + const announcer = Announcer.getInstance(); + if (initialTimeout > 0) { + return new Promise((resolve) => { + setTimeout(async () => { + const result = await announcer.announce( + message, + level, + debounceThreshold, + ); + resolve(result); + }, initialTimeout); + }); + } else { + const result = announcer.announce(message, level, debounceThreshold); + return new Promise((resolve) => { + resolve(result); + }); + } +} diff --git a/packages/wonder-blocks-announcer/src/announcer.ts b/packages/wonder-blocks-announcer/src/announcer.ts new file mode 100644 index 0000000000..2739c03b83 --- /dev/null +++ b/packages/wonder-blocks-announcer/src/announcer.ts @@ -0,0 +1,278 @@ +import { + PolitenessLevel, + RegionFactory, + RegionDictionary, + RegionDef, +} from "../types/announcer.types"; + +import { + createRegionWrapper, + createDuplicateRegions, + removeMessage, +} from "./util/dom"; +import {alternateIndex, createDebounceFunction} from "./util/util"; + +export const REMOVAL_TIMEOUT_DELAY = 5000; +export const DEFAULT_WAIT_THRESHOLD = 250; + +/** + * Internal class to manage screen reader announcements. + */ +class Announcer { + private static _instance: Announcer | null; + node: HTMLElement | null = null; + regionFactory: RegionFactory = { + count: 2, + aIndex: 0, + pIndex: 0, + }; + dictionary: RegionDictionary = new Map(); + waitThreshold: number = DEFAULT_WAIT_THRESHOLD; + lastExecutionTime = 0; + private debounced!: { + (...args: any[]): Promise; + updateWaitTime: (newWaitTime: number) => void; + }; + + private constructor() { + if (typeof document !== "undefined") { + const topLevelId = `wbAnnounce`; + // 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.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, + ); + } + } + /** + * Singleton handler to ensure we only have one Announcer instance + * @returns {Announcer} + */ + static getInstance() { + if (!Announcer._instance) { + Announcer._instance = new Announcer(); + } + 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", id); + + 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); + + const pWrapper = createRegionWrapper("polite"); + createDuplicateRegions( + pWrapper, + "polite", + this.regionFactory.count, + this.dictionary, + ); + this.node.appendChild(pWrapper); + + document.body.append(this.node); + } + /** + * Recover in the event regions get lost + * This happens in Storybook or other HMR environments when saving a file: + * Announcer exists, but it loses the connection to DOM element Refs + */ + reattachNodes() { + const announcerCheck = document.getElementById(`wbAnnounce`); + if (announcerCheck !== null) { + this.node = announcerCheck; + const regions = Array.from( + announcerCheck.querySelectorAll( + "[id^='wbARegion']", + ), + ); + 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, + }); + }); + } + } + /** + * 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} 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 + */ + announce( + message: string, + level: PolitenessLevel, + debounceThreshold?: number, + ): Promise { + // 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(); + } + + // Filter region elements to the selected level + const regions: RegionDef[] = [...context.dictionary.values()].filter( + (entry: RegionDef) => entry.level === level, + ); + + const newIndex = context.appendMessage(message, level, regions); + + // overwrite central index for the given level + if (level === "assertive") { + context.regionFactory.aIndex = newIndex; + } else { + context.regionFactory.pIndex = newIndex; + } + + return regions[newIndex].id || ""; + } + + /** + * Clear messages on demand. + * This could be useful for clearing immediately, rather than waiting for the default 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 + * @returns {number} Index of targeted region for updating central register + */ + appendMessage( + message: string, + level: PolitenessLevel, // level + regionList: RegionDef[], // list of relevant elements + debounceThreshold: number = DEFAULT_WAIT_THRESHOLD, + ): number { + // Starting index for a given level + let index = + level === "assertive" + ? this.regionFactory.aIndex + : this.regionFactory.pIndex; + + // empty region at the previous index + regionList[index].element.replaceChildren(); + + // overwrite index passed in to update locally + index = alternateIndex(index, this.regionFactory.count); + + // create element for new message + const messageEl = document.createElement("p"); + messageEl.textContent = message; + + // append message to new index + regionList[index].element.appendChild(messageEl); + + // add debounce wait duration to the default removalDelay + // so we aren't removing messages before a debounce cycle has concluded + removeMessage(messageEl, debounceThreshold + REMOVAL_TIMEOUT_DELAY); + + return index; + } + + /** + * Reset state to defaults. + * Useful for testing. + **/ + reset() { + this.regionFactory.aIndex = 0; + this.regionFactory.pIndex = 0; + + this.clear(); + } +} + +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)", + height: 1, + margin: -1, + overflow: "hidden", + padding: 0, + position: "absolute", + width: 1, +}; diff --git a/packages/wonder-blocks-announcer/src/clear-messages.ts b/packages/wonder-blocks-announcer/src/clear-messages.ts new file mode 100644 index 0000000000..a91484b7c8 --- /dev/null +++ b/packages/wonder-blocks-announcer/src/clear-messages.ts @@ -0,0 +1,15 @@ +import Announcer from "./announcer"; + +/** + * 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) { + const announcer = Announcer.getInstance(); + if (id && document?.getElementById(id)) { + announcer.clear(id); + } else if (typeof document !== "undefined") { + announcer.clear(); + } +} diff --git a/packages/wonder-blocks-announcer/src/index.ts b/packages/wonder-blocks-announcer/src/index.ts new file mode 100644 index 0000000000..c87dd6045d --- /dev/null +++ b/packages/wonder-blocks-announcer/src/index.ts @@ -0,0 +1,4 @@ +import {announceMessage, type AnnounceMessageProps} from "./announce-message"; +import {clearMessages} from "./clear-messages"; + +export {announceMessage, type AnnounceMessageProps, clearMessages}; 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 0000000000..c89e5c8cce --- /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 + */ +export 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 How long to wait before removing the message + */ +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 0000000000..2270044f9f --- /dev/null +++ b/packages/wonder-blocks-announcer/src/util/util.ts @@ -0,0 +1,66 @@ +import type Announcer from "../announcer"; + +/** + * 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; +} + +/** + * 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 {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, + callback: (...args: any[]) => string, + debounceThreshold: number, +): { + (...args: any[]): Promise; + updateWaitTime: (time: number) => void; +} { + let timeoutId: ReturnType | null = null; + let executed = false; + let lastExecutionTime = 0; + + 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; + }, debounceThreshold); + }); + }; + + // Allow callers to adjust the debounce wait time + debouncedFn.updateWaitTime = (newWaitTime: number) => { + debounceThreshold = newWaitTime; + }; + + return debouncedFn; +} diff --git a/packages/wonder-blocks-announcer/tsconfig-build.json b/packages/wonder-blocks-announcer/tsconfig-build.json new file mode 100644 index 0000000000..1abf980abf --- /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 diff --git a/packages/wonder-blocks-announcer/types/announcer.types.ts b/packages/wonder-blocks-announcer/types/announcer.types.ts new file mode 100644 index 0000000000..9223c43a5f --- /dev/null +++ b/packages/wonder-blocks-announcer/types/announcer.types.ts @@ -0,0 +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; + level: PolitenessLevel; + levelIndex: number; + element: HTMLElement; +}; + +/* +RegionDictionary: a Map data structure of live regions for fast lookup. +*/ +export type RegionDictionary = Map; diff --git a/static/sb-styles/preview.css b/static/sb-styles/preview.css index 5b859e65d5..b626b81aa4 100644 --- a/static/sb-styles/preview.css +++ b/static/sb-styles/preview.css @@ -79,3 +79,24 @@ font-size: var(--typography-heading-size-xs); line-height: var(--typography-heading-line-height-xs); } + +.showAnnouncer.sb-show-main #wbAnnounce { + bottom: 0; + display: block !important; + clip: revert !important; + position: fixed !important; +} + +.showAnnouncer.sb-show-main .wbARegion { + border: 1px solid red; + margin-bottom: 0.5em; +} + +.showAnnouncer.sb-show-main .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