diff --git a/.changeset/clean-peas-prove.md b/.changeset/clean-peas-prove.md new file mode 100644 index 0000000000..447312521e --- /dev/null +++ b/.changeset/clean-peas-prove.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/wonder-blocks-announcer": major +--- + +Introducing WB Announcer API for ARIA Live Regions diff --git a/.changeset/plenty-crews-search.md b/.changeset/plenty-crews-search.md new file mode 100644 index 0000000000..bd920cf947 --- /dev/null +++ b/.changeset/plenty-crews-search.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/wonder-blocks-dropdown": minor +--- + +Integrates Announcer for value announcements in SingleSelect and MultiSelect 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 c7b5c9b70b..e3554e1ddb 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -97,8 +97,21 @@ const parameters = { const withThemeSwitcher: Decorator = ( Story, - {globals: {theme}, parameters: {enableRenderStateRootDecorator}}, + {globals: {theme}, parameters: {enableRenderStateRootDecorator, addBodyClass}}, ) => { + // Allow stories to specify a CSS body class + if (addBodyClass) { + document.body.classList.add(addBodyClass); + } + // Remove body class when changing stories + React.useEffect(() => { + return () => { + if (addBodyClass) { + document.body.classList.remove(addBodyClass); + } + }; + }, [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..10d9d18088 --- /dev/null +++ b/__docs__/wonder-blocks-announcer/announcer.stories.tsx @@ -0,0 +1,117 @@ +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", + }, +}); diff --git a/__docs__/wonder-blocks-dropdown/multi-select.stories.tsx b/__docs__/wonder-blocks-dropdown/multi-select.stories.tsx index e4a5dfafae..f537dc0de0 100644 --- a/__docs__/wonder-blocks-dropdown/multi-select.stories.tsx +++ b/__docs__/wonder-blocks-dropdown/multi-select.stories.tsx @@ -74,6 +74,7 @@ export default { "aria-label": "Planets", }, parameters: { + addBodyClass: "showAnnouncer", componentSubtitle: ( >( [], ); - const [opened, setOpened] = React.useState(props.opened || false); + const [opened, setOpened] = React.useState(false); return ( diff --git a/packages/wonder-blocks-announcer/package.json b/packages/wonder-blocks-announcer/package.json new file mode 100644 index 0000000000..209cec1459 --- /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": "workspace:*" + } +} \ 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..87286c3868 --- /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 = announceMessage({ + message: message1, + initialTimeout: 0, + debounceThreshold: 0, + }); + jest.advanceTimersByTime(500); + + // ASSERT + await expect(announcement1Id).resolves.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( + 2, + 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..62b2d5956f --- /dev/null +++ b/packages/wonder-blocks-announcer/src/__tests__/announcer.test.ts @@ -0,0 +1,230 @@ +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 + // The second call will win out in the trailing edge implementation + 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("two things"); + 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); + expect(screen.getByText("One Fish")).toBeInTheDocument(); + + announcer.clear(); + + // Assert + expect(screen.queryByText("One 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..334160a40d --- /dev/null +++ b/packages/wonder-blocks-announcer/src/__tests__/clear-messages.test.tsx @@ -0,0 +1,96 @@ +import {screen, waitFor} from "@testing-library/react"; +import {announceMessage} from "../announce-message"; +import {clearMessages} from "../clear-messages"; + +describe("Announcer.clearMessages", () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + afterAll(() => { + jest.useRealTimers(); + }); + test("empties a targeted live region element by IDREF", async () => { + // ARRANGE + const message1 = "Shine a million stars"; + const message2 = "Dull no stars"; + + // ACT + const announcement1IdPromise = announceMessage({ + message: message1, + initialTimeout: 0, + debounceThreshold: 0, + }); + jest.advanceTimersByTime(0); + await Promise.resolve(); + + const region1 = screen.getByTestId("wbARegion-polite1"); + + const announcement1Id = await announcement1IdPromise; + + jest.advanceTimersByTime(0); + await Promise.resolve(); + + expect(region1).toHaveTextContent(message1); + clearMessages(announcement1Id); + + announceMessage({ + message: message2, + initialTimeout: 0, + debounceThreshold: 0, + }); + + const region2 = screen.getByTestId("wbARegion-polite0"); + + jest.advanceTimersByTime(0); + + // 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 + announceMessage({ + message: message1, + initialTimeout: 0, + debounceThreshold: 0, + }); + + jest.advanceTimersByTime(250); + + const region1 = screen.queryByTestId("wbARegion-polite1"); + expect(region1).toHaveTextContent(message1); + + announceMessage({ + message: message2, + initialTimeout: 0, + debounceThreshold: 0, + }); + jest.advanceTimersByTime(250); + const region2 = screen.getByTestId("wbARegion-polite0"); + expect(region2).toHaveTextContent(message2); + + 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__/init-announcer.test.tsx b/packages/wonder-blocks-announcer/src/__tests__/init-announcer.test.tsx new file mode 100644 index 0000000000..d64e0911c4 --- /dev/null +++ b/packages/wonder-blocks-announcer/src/__tests__/init-announcer.test.tsx @@ -0,0 +1,30 @@ +import {screen} from "@testing-library/react"; +import Announcer from "../announcer"; +import {initAnnouncer} from "../init-announcer"; +import {announceMessage} from "../announce-message"; + +describe("Announcer.initAnnouncer", () => { + let announcer: Announcer; + afterEach(() => { + announcer.reset(); + }); + + it("injects the Announcer when called", () => { + // Arrange + announcer = initAnnouncer(); + // Act + const regionWrapper = screen.getByTestId("wbAnnounce"); + // Assert + expect(regionWrapper).toBeInTheDocument(); + }); + + it("only injects one Announcer", () => { + // Arrange + announcer = initAnnouncer(); + announceMessage({message: "A thing"}); + // Act + const regionWrapper = screen.getAllByTestId("wbAnnounce"); + // Assert + expect(regionWrapper.length).toEqual(1); + }); +}); 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..42cd069cef --- /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 {type PolitenessLevel} from "../../util/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..20ed2fb986 --- /dev/null +++ b/packages/wonder-blocks-announcer/src/__tests__/util/test-utilities.ts @@ -0,0 +1,45 @@ +import type {RegionDef, PolitenessLevel} from "../../util/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..8824a153be --- /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, 10); + + // ACT + const result = debounced("Hello, World!"); + jest.advanceTimersByTime(100); + + // ASSERT + await expect(result).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 = 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("Third 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..2a7618be3e --- /dev/null +++ b/packages/wonder-blocks-announcer/src/announce-message.ts @@ -0,0 +1,40 @@ +import type {PolitenessLevel} from "./util/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) => { + return setTimeout(async () => { + const result = announcer.announce( + message, + level, + debounceThreshold, + ); + resolve(result); + }, initialTimeout); + }); + } else { + return announcer.announce(message, level, debounceThreshold); + } +} diff --git a/packages/wonder-blocks-announcer/src/announcer.ts b/packages/wonder-blocks-announcer/src/announcer.ts new file mode 100644 index 0000000000..80cbc6f25c --- /dev/null +++ b/packages/wonder-blocks-announcer/src/announcer.ts @@ -0,0 +1,278 @@ +import type { + PolitenessLevel, + RegionFactory, + RegionDictionary, + RegionDef, +} from "./util/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; + topLevelId: string = `wbAnnounce`; + 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") { + // Check if our top level element already exists + const announcerCheck = document.getElementById(this.topLevelId); + + // Init new structure if the coast is clear + if (announcerCheck === null) { + this.init(this.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(this.topLevelId); + 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..be5cfe5d9e --- /dev/null +++ b/packages/wonder-blocks-announcer/src/index.ts @@ -0,0 +1,7 @@ +import {initAnnouncer} from "./init-announcer"; +import {announceMessage} from "./announce-message"; +import {clearMessages} from "./clear-messages"; +import type {AnnounceMessageProps} from "./announce-message"; + +export {initAnnouncer, announceMessage, clearMessages}; +export {type AnnounceMessageProps}; diff --git a/packages/wonder-blocks-announcer/src/init-announcer.ts b/packages/wonder-blocks-announcer/src/init-announcer.ts new file mode 100644 index 0000000000..e65e68f8b6 --- /dev/null +++ b/packages/wonder-blocks-announcer/src/init-announcer.ts @@ -0,0 +1,18 @@ +import Announcer from "./announcer"; + +type InitAnnouncerProps = { + debounceThreshold?: number; +}; + +/** + * Utility to inject Announcer on page load. + * It can be called from useEffect or elsewhere to improve ARIA Live Region performance on the first announcement. + * @returns {Announcer} The Announcer instance created. + */ +export function initAnnouncer(props?: InitAnnouncerProps): Announcer { + const announcer = Announcer.getInstance(); + if (props?.debounceThreshold !== undefined) { + announcer.updateWaitThreshold(props?.debounceThreshold); + } + return announcer; +} diff --git a/packages/wonder-blocks-announcer/src/util/announcer.types.ts b/packages/wonder-blocks-announcer/src/util/announcer.types.ts new file mode 100644 index 0000000000..9223c43a5f --- /dev/null +++ b/packages/wonder-blocks-announcer/src/util/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/packages/wonder-blocks-announcer/src/util/dom.ts b/packages/wonder-blocks-announcer/src/util/dom.ts new file mode 100644 index 0000000000..4abb7dfb91 --- /dev/null +++ b/packages/wonder-blocks-announcer/src/util/dom.ts @@ -0,0 +1,79 @@ +import type {PolitenessLevel, RegionDictionary} from "./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..99e1516e03 --- /dev/null +++ b/packages/wonder-blocks-announcer/src/util/util.ts @@ -0,0 +1,60 @@ +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; + + const debouncedFn = (...args: []) => { + return new Promise((resolve) => { + const later = () => { + const result = callback.apply(context, args); + if (timeoutId) { + clearTimeout(timeoutId); + } + return 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(() => { + later(); + }, 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 b/packages/wonder-blocks-announcer/types new file mode 120000 index 0000000000..8788aa2845 --- /dev/null +++ b/packages/wonder-blocks-announcer/types @@ -0,0 +1 @@ +../../types \ No newline at end of file diff --git a/packages/wonder-blocks-birthday-picker/src/components/__tests__/birthday-picker.test.tsx b/packages/wonder-blocks-birthday-picker/src/components/__tests__/birthday-picker.test.tsx index 458182ac8a..7fa0b77794 100644 --- a/packages/wonder-blocks-birthday-picker/src/components/__tests__/birthday-picker.test.tsx +++ b/packages/wonder-blocks-birthday-picker/src/components/__tests__/birthday-picker.test.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import moment from "moment"; -import {render, screen, waitFor} from "@testing-library/react"; +import {render, act, screen, waitFor} from "@testing-library/react"; import * as DateMock from "jest-date-mock"; import {userEvent, PointerEventsCheckLevel} from "@testing-library/user-event"; @@ -8,6 +8,19 @@ import BirthdayPicker, {defaultLabels} from "../birthday-picker"; import type {Labels} from "../birthday-picker"; +jest.mock("react-popper", () => ({ + ...jest.requireActual("react-popper"), + Popper: jest.fn().mockImplementation(({children}) => { + // Mock `isReferenceHidden` to always return false (or true for testing visibility) + return children({ + ref: jest.fn(), + style: {}, + placement: "bottom", + isReferenceHidden: false, // Mocking isReferenceHidden + }); + }), +})); + describe("BirthdayPicker", () => { const today = new Date("2021-07-19T09:30:00Z"); @@ -251,11 +264,15 @@ describe("BirthdayPicker", () => { render(); - // Act - await userEvent.click( - await screen.findByTestId("birthday-picker-month"), + const monthDropdown = await screen.findByTestId( + "birthday-picker-month", ); - const monthOption = await screen.findByText("Jul"); + // Act + await userEvent.click(monthDropdown); + + const monthOption = await screen.findByRole("option", { + name: "Jul", + }); await userEvent.click(monthOption, { pointerEventsCheck: PointerEventsCheckLevel.Never, }); @@ -263,7 +280,9 @@ describe("BirthdayPicker", () => { await userEvent.click( await screen.findByTestId("birthday-picker-day"), ); - const dayOption = await screen.findByText("5"); + const dayOption = await screen.findByRole("option", { + name: "5", + }); await userEvent.click(dayOption, { pointerEventsCheck: PointerEventsCheckLevel.Never, }); @@ -271,7 +290,9 @@ describe("BirthdayPicker", () => { await userEvent.click( await screen.findByTestId("birthday-picker-year"), ); - const yearOption = await screen.findByText("2021"); + const yearOption = await screen.findByRole("option", { + name: "2021", + }); await userEvent.click(yearOption, { pointerEventsCheck: PointerEventsCheckLevel.Never, }); @@ -288,7 +309,9 @@ describe("BirthdayPicker", () => { await userEvent.click( await screen.findByTestId("birthday-picker-month"), ); - const monthOption = await screen.findByText("Jul"); + const monthOption = await screen.findByRole("option", { + name: "Jul", + }); await userEvent.click(monthOption, { pointerEventsCheck: PointerEventsCheckLevel.Never, }); @@ -296,7 +319,9 @@ describe("BirthdayPicker", () => { await userEvent.click( await screen.findByTestId("birthday-picker-day"), ); - const dayOption = await screen.findByText("5"); + const dayOption = await screen.findByRole("option", { + name: "5", + }); await userEvent.click(dayOption, { pointerEventsCheck: PointerEventsCheckLevel.Never, @@ -305,7 +330,9 @@ describe("BirthdayPicker", () => { await userEvent.click( await screen.findByTestId("birthday-picker-year"), ); - const yearOption = await screen.findByText("2021"); + const yearOption = await screen.findByRole("option", { + name: "2021", + }); await userEvent.click(yearOption, { pointerEventsCheck: PointerEventsCheckLevel.Never, }); @@ -315,7 +342,9 @@ describe("BirthdayPicker", () => { await userEvent.click( await screen.findByTestId("birthday-picker-month"), ); - const monthOptionNew = await screen.findByText("Aug"); + const monthOptionNew = await screen.findByRole("option", { + name: "Aug", + }); await userEvent.click(monthOptionNew, { pointerEventsCheck: PointerEventsCheckLevel.Never, }); @@ -323,7 +352,7 @@ describe("BirthdayPicker", () => { await userEvent.click( await screen.findByTestId("birthday-picker-day"), ); - const dayOptionNew = await screen.findByText("9"); + const dayOptionNew = await screen.findByRole("option", {name: "9"}); await userEvent.click(dayOptionNew, { pointerEventsCheck: PointerEventsCheckLevel.Never, }); @@ -331,7 +360,9 @@ describe("BirthdayPicker", () => { await userEvent.click( await screen.findByTestId("birthday-picker-year"), ); - const yearOptionNew = await screen.findByText("2020"); + const yearOptionNew = await screen.findByRole("option", { + name: "2020", + }); await userEvent.click(yearOptionNew, { pointerEventsCheck: PointerEventsCheckLevel.Never, }); @@ -403,9 +434,9 @@ describe("BirthdayPicker", () => { // This test was written by calling methods on the instance because // react-window (used by SingleSelect) doesn't show all of the items // in the dropdown. - instance.handleMonthChange("1"); - instance.handleDayChange("31"); - instance.handleYearChange("2021"); + await act(() => instance.handleMonthChange("1")); + await act(() => instance.handleDayChange("31")); + await act(() => instance.handleYearChange("2021")); // Assert await waitFor(() => expect(onChange).toHaveBeenCalledWith(null)); diff --git a/packages/wonder-blocks-dropdown/package.json b/packages/wonder-blocks-dropdown/package.json index 6e7e003a0e..97f4ba7ee6 100644 --- a/packages/wonder-blocks-dropdown/package.json +++ b/packages/wonder-blocks-dropdown/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@babel/runtime": "^7.24.5", + "@khanacademy/wonder-blocks-announcer": "workspace:*", "@khanacademy/wonder-blocks-cell": "workspace:*", "@khanacademy/wonder-blocks-clickable": "workspace:*", "@khanacademy/wonder-blocks-core": "workspace:*", diff --git a/packages/wonder-blocks-dropdown/src/components/__tests__/dropdown-core.test.tsx b/packages/wonder-blocks-dropdown/src/components/__tests__/dropdown-core.test.tsx index e54972b673..ff2976a6f2 100644 --- a/packages/wonder-blocks-dropdown/src/components/__tests__/dropdown-core.test.tsx +++ b/packages/wonder-blocks-dropdown/src/components/__tests__/dropdown-core.test.tsx @@ -733,30 +733,6 @@ describe("DropdownCore", () => { }); }); - describe("a11y > Live region", () => { - it("should render a live region announcing the number of options", async () => { - // Arrange - - // Act - const {container} = render( - } - onOpenChanged={jest.fn()} - />, - ); - - // Assert - expect(container).toHaveTextContent("3 items"); - }); - }); - describe("onOpenChanged", () => { it("Should be triggered when the down key is pressed and the menu is closed", async () => { // Arrange diff --git a/packages/wonder-blocks-dropdown/src/components/__tests__/multi-select.test.tsx b/packages/wonder-blocks-dropdown/src/components/__tests__/multi-select.test.tsx index 35d1bc3987..26ae8002ff 100644 --- a/packages/wonder-blocks-dropdown/src/components/__tests__/multi-select.test.tsx +++ b/packages/wonder-blocks-dropdown/src/components/__tests__/multi-select.test.tsx @@ -15,6 +15,7 @@ import { } from "@testing-library/user-event"; import {PropsFor} from "@khanacademy/wonder-blocks-core"; +import {initAnnouncer} from "@khanacademy/wonder-blocks-announcer"; import OptionItem from "../option-item"; import MultiSelect from "../multi-select"; import {defaultLabels as builtinLabels} from "../../util/constants"; @@ -40,6 +41,8 @@ const defaultLabels: LabelsValues = { allSelected: "All students", }; +jest.useFakeTimers(); + describe("MultiSelect", () => { beforeEach(() => { window.scrollTo = jest.fn(); @@ -965,9 +968,7 @@ describe("MultiSelect", () => { expect(filteredOption).toBeInTheDocument(); }); - // NOTE(john) FEI-5533: After upgrading to user-event v14, this test is failing. - // The Venus option is still in the document. - it.skip("should filter out an option if it's not part of the results", async () => { + it("should filter out an option if it's not part of the results", async () => { // Arrange const labels: LabelsValues = { ...builtinLabels, @@ -1667,7 +1668,14 @@ describe("MultiSelect", () => { }); describe("a11y > Live region", () => { + beforeEach(() => { + initAnnouncer({debounceThreshold: 0}); + }); + it("should announce the number of options when the listbox is open", async () => { + // const mockDateNow = jest.spyOn(Date, "now"); + // mockDateNow.mockImplementation(() => 0); + // Arrange const labels: LabelsValues = { ...builtinLabels, @@ -1677,8 +1685,7 @@ describe("MultiSelect", () => { : `${numOptions} schools`, }; - // Act - const {container} = doRender( + const {userEvent} = doRender( { , ); + const opener = await screen.findByRole("combobox"); + + jest.advanceTimersByTime(10); + // mockDateNow.mockImplementation(() => 150); + + // Act + await userEvent.click(opener); + + const announcer = screen.getByTestId("wbAnnounce"); + const announcementText = + await within(announcer).findByText("3 schools"); // Assert - expect(container).toHaveTextContent("3 schools"); + expect(announcementText).toBeInTheDocument(); + + // mockDateNow.mockRestore(); }); it("should change the number of options after using the search filter", async () => { // Arrange const labels: LabelsValues = { ...builtinLabels, + noneSelected: "0 planets", someSelected: (numOptions: number): string => numOptions <= 1 ? `${numOptions} planet` : `${numOptions} planets`, }; - const {container, userEvent} = doRender( + const {userEvent} = doRender( { await userEvent.click(textbox); await userEvent.paste("ear"); + // wait to avoid getting caught in the Announcer debounce + jest.advanceTimersByTime(250); + + const announcer = await screen.findByTestId("wbAnnounce"); + const announcementText = + await within(announcer).findByText("1 planet"); // Assert await waitFor(() => { - expect(container).toHaveTextContent("1 planet"); + expect(announcementText).toBeInTheDocument(); }); }); }); diff --git a/packages/wonder-blocks-dropdown/src/components/dropdown-core.tsx b/packages/wonder-blocks-dropdown/src/components/dropdown-core.tsx index c2f86d4486..8498c91e02 100644 --- a/packages/wonder-blocks-dropdown/src/components/dropdown-core.tsx +++ b/packages/wonder-blocks-dropdown/src/components/dropdown-core.tsx @@ -14,7 +14,7 @@ import { border, } from "@khanacademy/wonder-blocks-tokens"; -import {addStyle, PropsFor, View, keys} from "@khanacademy/wonder-blocks-core"; +import {PropsFor, View, keys} from "@khanacademy/wonder-blocks-core"; import SearchField from "@khanacademy/wonder-blocks-search-field"; import {LabelMedium} from "@khanacademy/wonder-blocks-typography"; import {withActionScheduler} from "@khanacademy/wonder-blocks-timing"; @@ -41,8 +41,6 @@ import OptionItem from "./option-item"; */ const VIRTUALIZE_THRESHOLD = 125; -const StyledSpan = addStyle("span"); - type LabelsValues = { /** * Label for describing the dismiss icon on the search filter. @@ -1056,24 +1054,6 @@ class DropdownCore extends React.Component { ); } - renderLiveRegion(): React.ReactNode { - const {items, open} = this.props; - const {labels} = this.state; - const totalItems = items.length; - - return ( - - {open && labels.someResults(totalItems)} - - ); - } - render(): React.ReactNode { const {open, opener, style, className, disabled} = this.props; @@ -1084,7 +1064,6 @@ class DropdownCore extends React.Component { style={[styles.menuWrapper, style]} className={className} > - {this.renderLiveRegion()} {opener} {open && this.renderDropdown()} diff --git a/packages/wonder-blocks-dropdown/src/components/multi-select.tsx b/packages/wonder-blocks-dropdown/src/components/multi-select.tsx index aa13d93272..69f6c938cf 100644 --- a/packages/wonder-blocks-dropdown/src/components/multi-select.tsx +++ b/packages/wonder-blocks-dropdown/src/components/multi-select.tsx @@ -7,6 +7,10 @@ import { type StyleType, } from "@khanacademy/wonder-blocks-core"; +import { + announceMessage, + initAnnouncer, +} from "@khanacademy/wonder-blocks-announcer"; import ActionItem from "./action-item"; import DropdownCore from "./dropdown-core"; import DropdownOpener from "./dropdown-opener"; @@ -25,7 +29,11 @@ import type { OptionItemComponent, OptionItemComponentArray, } from "../util/types"; -import {getLabel, getSelectOpenerLabel} from "../util/helpers"; +import { + getLabel, + getSelectOpenerLabel, + maybeExtractStringFromNode, +} from "../util/helpers"; import {useSelectValidation} from "../hooks/use-select-validation"; export type LabelsValues = { @@ -303,6 +311,10 @@ const MultiSelect = (props: Props) => { const hasError = error || !!errorMessage; + React.useEffect(() => { + initAnnouncer(); + }, []); + React.useEffect(() => { // Used to sync the `opened` state when this component acts as a controlled component if (disabled) { @@ -369,7 +381,7 @@ const MultiSelect = (props: Props) => { const getMenuTextOrNode = ( children: OptionItemComponentArray, - ): string | JSX.Element => { + ): string | {[key: string]: string | JSX.Element} => { const {noneSelected, someSelected, allSelected} = labels; const numSelectedAll = children.filter( (option) => !option.props.disabled, @@ -404,7 +416,6 @@ const MultiSelect = (props: Props) => { return someSelected(1); } } - return noSelectionText; case numSelectedAll: return allSelected; @@ -536,6 +547,12 @@ const MultiSelect = (props: Props) => { handleOpenChanged(!open); }; + const handleAnnouncement = (message: string) => { + announceMessage({ + message, + }); + }; + const renderOpener = ( allChildren: React.ReactElement< React.ComponentProps @@ -547,7 +564,14 @@ const MultiSelect = (props: Props) => { | React.ReactElement> => { const {noneSelected} = labels; - const menuContent = getMenuTextOrNode(allChildren); + const menuTextOrNode = getMenuTextOrNode(allChildren); + const [openerStringValue, openerContent] = + maybeExtractStringFromNode(menuTextOrNode); + + if (openerStringValue) { + // opener value changed, so let's announce it + handleAnnouncement(openerStringValue); + } const dropdownOpener = ( @@ -564,7 +588,7 @@ const MultiSelect = (props: Props) => { disabled={isDisabled} ref={handleOpenerRef} role="combobox" - text={menuContent} + text={openerContent} opened={open} > {opener} @@ -577,14 +601,14 @@ const MultiSelect = (props: Props) => { id={uniqueOpenerId} aria-label={ariaLabel} aria-controls={dropdownId} - isPlaceholder={menuContent === noneSelected} + isPlaceholder={openerContent === noneSelected} onOpenChanged={handleOpenChanged} onBlur={onOpenerBlurValidation} open={open} ref={handleOpenerRef} testId={testId} > - {menuContent} + {openerContent} ); }} @@ -607,6 +631,10 @@ const MultiSelect = (props: Props) => { const filteredItems = getMenuItems(allChildren); const isDisabled = numEnabledOptions === 0 || disabled; + if (open && isFilterable) { + handleAnnouncement(labels.someSelected(filteredItems.length)); + } + return ( {(uniqueDropdownId) => ( diff --git a/packages/wonder-blocks-dropdown/src/components/single-select.tsx b/packages/wonder-blocks-dropdown/src/components/single-select.tsx index e77e3ca9f2..4bd5288401 100644 --- a/packages/wonder-blocks-dropdown/src/components/single-select.tsx +++ b/packages/wonder-blocks-dropdown/src/components/single-select.tsx @@ -7,6 +7,10 @@ import { type StyleType, } from "@khanacademy/wonder-blocks-core"; +import { + initAnnouncer, + announceMessage, +} from "@khanacademy/wonder-blocks-announcer"; import DropdownCore from "./dropdown-core"; import DropdownOpener from "./dropdown-opener"; import SelectOpener from "./select-opener"; @@ -22,7 +26,11 @@ import type { OpenerProps, OptionItemComponentArray, } from "../util/types"; -import {getLabel, getSelectOpenerLabel} from "../util/helpers"; +import { + getLabel, + getSelectOpenerLabel, + maybeExtractStringFromNode, +} from "../util/helpers"; import {useSelectValidation} from "../hooks/use-select-validation"; export type SingleSelectLabelsValues = { @@ -316,6 +324,10 @@ const SingleSelect = (props: Props) => { }); const hasError = error || !!errorMessage; + React.useEffect(() => { + initAnnouncer(); + }, []); + React.useEffect(() => { // Used to sync the `opened` state when this component acts as a controlled if (disabled) { @@ -444,9 +456,23 @@ const SingleSelect = (props: Props) => { ); // If nothing is selected, or if the selectedValue doesn't match any // item in the menu, use the placeholder. - const menuText = selectedItem - ? getSelectOpenerLabel(showOpenerLabelAsText, selectedItem.props) - : placeholder; + + let menuContent; + + if (selectedItem) { + const menuOpenerLabel = getSelectOpenerLabel( + showOpenerLabelAsText, + selectedItem.props, + ); + // For Custom Option Items with Node Labels, we have to extract + // strings to announce + const [label, node] = maybeExtractStringFromNode(menuOpenerLabel); + menuContent = node; + + announceMessage({message: label}); + } else { + menuContent = placeholder; + } const dropdownOpener = ( @@ -461,7 +487,7 @@ const SingleSelect = (props: Props) => { disabled={isDisabled} ref={handleOpenerRef} role="combobox" - text={menuText} + text={menuContent} opened={open} error={hasError} onBlur={onOpenerBlurValidation} @@ -483,7 +509,7 @@ const SingleSelect = (props: Props) => { testId={testId} onBlur={onOpenerBlurValidation} > - {menuText} + {menuContent} ); }} diff --git a/packages/wonder-blocks-dropdown/src/util/__tests__/helpers.test.tsx b/packages/wonder-blocks-dropdown/src/util/__tests__/helpers.test.tsx index 3ac0ffd26a..001a0cce5a 100644 --- a/packages/wonder-blocks-dropdown/src/util/__tests__/helpers.test.tsx +++ b/packages/wonder-blocks-dropdown/src/util/__tests__/helpers.test.tsx @@ -6,6 +6,7 @@ import { getLabel, getSelectOpenerLabel, getStringForKey, + maybeExtractStringFromNode, } from "../helpers"; describe("getStringForKey", () => { @@ -126,7 +127,7 @@ describe("getLabel", () => { }); describe("getSelectOpenerLabel", () => { - it("should return the label if the label is a Node and showOpenerLabelAsText is true", () => { + it("should return an object if the label is a Node and showOpenerLabelAsText is true", () => { // Arrange const props: PropsFor = { label:
a custom node
, @@ -135,12 +136,29 @@ describe("getSelectOpenerLabel", () => { }; // Act - const label = getSelectOpenerLabel(false, props); + const labelObj = getSelectOpenerLabel(false, props); + const label = Object.values(labelObj)[0]; // Assert expect(label).toStrictEqual(
a custom node
); }); + it("should return a string as an object key if label is a Node and labelAsText is populated", () => { + // Arrange + const props: PropsFor = { + label:
a custom node
, + labelAsText: "plain text", + value: "foo", + }; + + // Act + const labelObj = getSelectOpenerLabel(false, props); + const label = Object.keys(labelObj)[0]; + + // Assert + expect(label).toStrictEqual("plain text"); + }); + it("should return a string if the label is a Node and showOpenerLabelAsText is false", () => { // Arrange const props: PropsFor = { @@ -156,3 +174,32 @@ describe("getSelectOpenerLabel", () => { expect(label).toBe("plain text"); }); }); + +describe("maybeExtractStringFromNode", () => { + it("should return an array with two strings if opener content is a string", () => { + // Arrange + const input = "a string"; + + // Act + const [definitelyALabel, theSameLabel] = + maybeExtractStringFromNode(input); + + // Assert + expect(definitelyALabel).toStrictEqual("a string"); + expect(theSameLabel).toStrictEqual("a string"); + }); + + it("should return an array with a string and node if opener content is a node", () => { + // Arrange + const input = { + "a string":
a custom node
, + }; + + // Act + const [label, node] = maybeExtractStringFromNode(input); + + // Assert + expect(label).toStrictEqual("a string"); + expect(node).toStrictEqual(
a custom node
); + }); +}); diff --git a/packages/wonder-blocks-dropdown/src/util/helpers.ts b/packages/wonder-blocks-dropdown/src/util/helpers.ts index e6f0f7232b..90fdd7eabe 100644 --- a/packages/wonder-blocks-dropdown/src/util/helpers.ts +++ b/packages/wonder-blocks-dropdown/src/util/helpers.ts @@ -71,6 +71,8 @@ export function getLabel(props: OptionItemProps): string { return ""; } +type OpenerStringOrNode = string | {[key: string]: string | JSX.Element}; + /** * Returns the label for the SelectOpener in the SingleSelect and MultiSelect. * If the label is a Node, and `labelAsText` is undefined, returns the label. @@ -78,9 +80,30 @@ export function getLabel(props: OptionItemProps): string { export function getSelectOpenerLabel( showOpenerLabelAsText: boolean, props: OptionItemProps, -): string | JSX.Element { +): OpenerStringOrNode { + const stringLabel = getLabel(props); if (showOpenerLabelAsText) { - return getLabel(props); + return stringLabel; } - return props.label; + return { + [stringLabel]: props.label, + }; } + +/** + * Returns a normalized structure for Opener content when Options can be either + * strings OR nodes with various label props + */ +export const maybeExtractStringFromNode = ( + openerContent: OpenerStringOrNode, +): [string, string | JSX.Element] => { + // For a selected Custom Option Item with Node Label, + // we have to extract a string to announce + if (typeof openerContent === "object") { + const [label, node] = Object.entries(openerContent)[0]; + return [label, node]; + } else { + // For other cases, we can use the string content passed through + return [openerContent, openerContent]; + } +}; diff --git a/packages/wonder-blocks-dropdown/tsconfig-build.json b/packages/wonder-blocks-dropdown/tsconfig-build.json index 883d3dc0c3..6489ae14a8 100644 --- a/packages/wonder-blocks-dropdown/tsconfig-build.json +++ b/packages/wonder-blocks-dropdown/tsconfig-build.json @@ -6,6 +6,7 @@ "rootDir": "src", }, "references": [ + {"path": "../wonder-blocks-announcer/tsconfig-build.json"}, {"path": "../wonder-blocks-cell/tsconfig-build.json"}, {"path": "../wonder-blocks-clickable/tsconfig-build.json"}, {"path": "../wonder-blocks-core/tsconfig-build.json"}, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 518fa1dc64..d145be1011 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -358,6 +358,22 @@ importers: specifier: workspace:* version: link:../../build-settings + packages/wonder-blocks-announcer: + dependencies: + '@khanacademy/wonder-blocks-core': + specifier: ^9.0.0 + version: 9.0.0(aphrodite@1.2.5)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@5.3.4(react@18.2.0))(react@18.2.0) + aphrodite: + specifier: ^1.2.5 + version: 1.2.5 + react: + specifier: 18.2.0 + version: 18.2.0 + devDependencies: + '@khanacademy/wb-dev-build-settings': + specifier: workspace:* + version: link:../../build-settings + packages/wonder-blocks-banner: dependencies: '@babel/runtime': @@ -623,6 +639,9 @@ importers: '@babel/runtime': specifier: ^7.24.5 version: 7.26.7 + '@khanacademy/wonder-blocks-announcer': + specifier: workspace:* + version: link:../wonder-blocks-announcer '@khanacademy/wonder-blocks-cell': specifier: workspace:* version: link:../wonder-blocks-cell @@ -2407,6 +2426,15 @@ packages: '@khanacademy/eslint-plugin@3.1.1': resolution: {integrity: sha512-1tOJRN1MWc7tOiSwxBT9KfOmo46d0/eB3P3On4PCZKkrrdC+wDNTxQ3FWX+xZBCjl6bSdh/ab5HZkOatDD7efA==} + '@khanacademy/wonder-blocks-core@9.0.0': + resolution: {integrity: sha512-IjNJi4upvwkeNR8rxFlDhj6k4rJuj0tmsAml6GwYC+cX9huuyHgQHrSNYHgJP3qsPb9v4hLzTE4VEwn0LEkf1Q==} + peerDependencies: + aphrodite: ^1.2.5 + react: 18.2.0 + react-dom: 18.2.0 + react-router: 5.3.4 + react-router-dom: 5.3.4 + '@khanacademy/wonder-stuff-core@1.5.4': resolution: {integrity: sha512-Z1DOHdSNrXyIKCgYsq6XKT6ZTRq5bb7Daof8mN31O30twStrwBynP5fS0lARS9qgxycaueThEcmeCF8AXyU21g==} engines: {node: '>=16'} @@ -9185,6 +9213,15 @@ snapshots: - supports-color - typescript + '@khanacademy/wonder-blocks-core@9.0.0(aphrodite@1.2.5)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@5.3.4(react@18.2.0))(react@18.2.0)': + dependencies: + '@babel/runtime': 7.26.7 + aphrodite: 1.2.5 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-router: 5.3.4(react@18.2.0) + react-router-dom: 5.3.4(react@18.2.0) + '@khanacademy/wonder-stuff-core@1.5.4': {} '@khanacademy/wonder-stuff-testing@3.0.5(jest@29.7.0(@types/node@22.13.0))': diff --git a/static/sb-styles/preview.css b/static/sb-styles/preview.css index f1d1ebbef0..2beb49c778 100644 --- a/static/sb-styles/preview.css +++ b/static/sb-styles/preview.css @@ -93,4 +93,24 @@ html { width: 20rem; padding-top: 8rem; padding-bottom: 4rem; +} + +.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; + padding: 0.25em; } \ No newline at end of file