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..0cdadb73e2 --- /dev/null +++ b/__docs__/wonder-blocks-announcer/announcer.stories.tsx @@ -0,0 +1,126 @@ +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 "../../.storybook/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, + }, + }, + }, + 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/packages/wonder-blocks-dropdown/package.json b/packages/wonder-blocks-dropdown/package.json index 97f927114d..645634ff66 100644 --- a/packages/wonder-blocks-dropdown/package.json +++ b/packages/wonder-blocks-dropdown/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@babel/runtime": "^7.18.6", + "@khanacademy/wonder-blocks-announcer": "^0.0.1", "@khanacademy/wonder-blocks-cell": "^4.0.3", "@khanacademy/wonder-blocks-clickable": "^5.0.3", "@khanacademy/wonder-blocks-core": "^11.0.0", diff --git a/packages/wonder-blocks-dropdown/src/components/combobox.tsx b/packages/wonder-blocks-dropdown/src/components/combobox.tsx index 88fe561723..9fb6715ef7 100644 --- a/packages/wonder-blocks-dropdown/src/components/combobox.tsx +++ b/packages/wonder-blocks-dropdown/src/components/combobox.tsx @@ -15,6 +15,7 @@ import { } from "@khanacademy/wonder-blocks-tokens"; import {DetailCell} from "@khanacademy/wonder-blocks-cell"; +import {announceMessage} from "@khanacademy/wonder-blocks-announcer"; import {PhosphorIcon} from "@khanacademy/wonder-blocks-icon"; import {useId} from "react"; import {useListbox} from "../hooks/use-listbox"; @@ -25,7 +26,6 @@ import { OptionItemComponent, } from "../util/types"; import {defaultComboboxLabels} from "../util/constants"; -import {ComboboxLiveRegion} from "./combobox-live-region"; import {MultipleSelection} from "./combobox-multiple-selection"; import DropdownPopper from "./dropdown-popper"; import Listbox from "./listbox"; @@ -206,6 +206,11 @@ export default function Combobox({ children: currentOptions, disabled, id: uniqueId, + labels: { + liveRegionCurrentItem: labels.liveRegionCurrentItem, + liveRegionListboxTotal: labels.liveRegionListboxTotal, + selected: labels.selected, + }, onChange: (value) => handleChange(value), value: valueState, // Allows pressing the space key in the input element without selecting @@ -227,13 +232,39 @@ export default function Combobox({ const initialValue = typeof value === "string" ? labelFromSelected : ""; const [inputValue, setInputValue] = React.useState(initialValue); + // The labels of the selected value(s). + const selectedLabels = React.useMemo(() => { + // For multiple selection, convert the selected value(s) to an array of + // labels. + if (Array.isArray(selected)) { + return selected.map((value) => { + // NOTE: Using the children prop to get the labels of the + // selected values, even when the list of options is filtered. + const item = children.find( + (item) => item.props.value === value, + ); + return item ? getLabel(item?.props) : ""; + }); + } + + // Single selection mode still wraps the selected value in an array + // to allow for a consistent API in ComboboxLiveRegion. + return [labelFromSelected]; + }, [children, labelFromSelected, selected]); + const { focusedMultiSelectIndex, handleKeyDown: handleMultipleSelectionKeyDown, } = useMultipleSelection({ inputValue, selected, + selectedLabels, setSelected, + labels: { + liveRegionCurrentItem: labels.liveRegionCurrentItem, + liveRegionMultipleSelectionTotal: + labels.liveRegionMultipleSelectionTotal, + }, }); /** @@ -257,6 +288,9 @@ export default function Combobox({ // Reset focused index when the listbox is closed. setFocusedIndex(-1); if (selectionType === "multiple") { + announceMessage({ + message: labels.closedState, + }); // Reset the input value when the listbox is closed. setInputValue(""); } else { @@ -276,6 +310,7 @@ export default function Combobox({ disabled, isControlled, labelFromSelected, + labels.closedState, onToggle, openState, selectionType, @@ -345,8 +380,24 @@ export default function Combobox({ // Otherwise, focus on the first item that matches the search // text. setFocusedIndex(itemIndex); + + if (itemIndex >= 0) { + announceMessage({ + message: + labels.liveRegionCurrentItem({ + current: getLabel(filtered[itemIndex].props), + focused: true, + index: itemIndex, + total: filtered.length, + }) + + " " + + labels.liveRegionListboxTotal(filtered.length), + }); + } else { + announceMessage({message: labels.noItems}); + } }, - [setFocusedIndex], + [labels, setFocusedIndex], ); /** @@ -406,6 +457,7 @@ export default function Combobox({ // escape press if (key === "Escape" && openState) { event.stopPropagation(); + announceMessage({message: labels.closedState}); updateOpenState(false); } @@ -458,36 +510,22 @@ export default function Combobox({ setSelected(""); onChange?.(""); comboboxRef.current?.focus(); - }, - [onChange, setSelected], - ); - React.useEffect(() => { - // Focus on the combobox input when the dropdown is opened. - if (openState) { - comboboxRef.current?.focus(); - } - }, [openState]); - - // The labels of the selected value(s). - const selectedLabels = React.useMemo(() => { - // For multiple selection, convert the selected value(s) to an array of - // labels. - if (Array.isArray(selected)) { - return selected.map((value) => { - // NOTE: Using the children prop to get the labels of the - // selected values, even when the list of options is filtered. - const item = children.find( - (item) => item.props.value === value, - ); - return item ? getLabel(item?.props) : ""; + console.log("should announce clear selection"); + announceMessage({ + message: labels.selectionCleared, + level: "assertive", }); - } + }, + [labels.selectionCleared, onChange, setSelected], + ); - // Single selection mode still wraps the selected value in an array - // to allow for a consistent API in ComboboxLiveRegion. - return [labelFromSelected]; - }, [children, labelFromSelected, selected]); + // React.useEffect(() => { + // // Focus on the combobox input when the dropdown is opened. + // if (openState) { + // comboboxRef.current?.focus(); + // } + // }, [openState]); /** * Renders the start icon if provided. @@ -545,18 +583,6 @@ export default function Combobox({ !disabled && error && styles.error, ]} > - - {/* Multi-select pills display before the input (if options are selected) */} {selectionType === "multiple" && Array.isArray(selected) && ( { + // Prevents the combobox from losing focus when clicking + // this element. + e.preventDefault(); + }} kind="tertiary" size="small" style={[styles.button, styles.clearButton]} diff --git a/packages/wonder-blocks-dropdown/src/components/listbox.tsx b/packages/wonder-blocks-dropdown/src/components/listbox.tsx index 23eafbf1af..9fdd72cee3 100644 --- a/packages/wonder-blocks-dropdown/src/components/listbox.tsx +++ b/packages/wonder-blocks-dropdown/src/components/listbox.tsx @@ -5,7 +5,12 @@ import {color} from "@khanacademy/wonder-blocks-tokens"; import {useId} from "react"; import {useListbox} from "../hooks/use-listbox"; -import {MaybeValueOrValues, OptionItemComponent} from "../util/types"; +import { + ComboboxLabels, + MaybeValueOrValues, + OptionItemComponent, +} from "../util/types"; +import {defaultComboboxLabels} from "../util/constants"; type Props = { /** @@ -13,6 +18,16 @@ type Props = { */ children: Array; + /** + * The object containing the custom labels used inside this component. + * + * This is useful for internationalization. Defaults to English. + */ + labels?: Pick< + ComboboxLabels, + "liveRegionCurrentItem" | "liveRegionListboxTotal" | "selected" + >; + /** * Whether the use can select more than one option item. Defaults to * `single`. @@ -90,6 +105,12 @@ function StandaloneListbox(props: Props) { children, disabled, id, + labels = { + liveRegionCurrentItem: defaultComboboxLabels.liveRegionCurrentItem, + liveRegionListboxTotal: + defaultComboboxLabels.liveRegionListboxTotal, + selected: defaultComboboxLabels.selected, + }, onChange, selectionType = "single", style, @@ -113,7 +134,14 @@ function StandaloneListbox(props: Props) { handleKeyUp, handleFocus, handleBlur, - } = useListbox({children, disabled, id: uniqueId, selectionType, value}); + } = useListbox({ + children, + disabled, + id: uniqueId, + labels, + selectionType, + value, + }); React.useEffect(() => { // If the value changes, update the parent component. diff --git a/packages/wonder-blocks-dropdown/src/hooks/use-listbox.tsx b/packages/wonder-blocks-dropdown/src/hooks/use-listbox.tsx index cf9fd1261c..8652e05779 100644 --- a/packages/wonder-blocks-dropdown/src/hooks/use-listbox.tsx +++ b/packages/wonder-blocks-dropdown/src/hooks/use-listbox.tsx @@ -1,6 +1,12 @@ import * as React from "react"; +import {announceMessage} from "@khanacademy/wonder-blocks-announcer"; +import {getLabel} from "../util/helpers"; import {updateMultipleSelection} from "../util/selection"; -import {MaybeValueOrValues, OptionItemComponent} from "../util/types"; +import { + ComboboxLabels, + MaybeValueOrValues, + OptionItemComponent, +} from "../util/types"; type Props = { /** @@ -22,6 +28,10 @@ type Props = { * The unique identifier of the listbox element. */ id: string; + labels: Pick< + ComboboxLabels, + "liveRegionCurrentItem" | "liveRegionListboxTotal" | "selected" + >; /** * The value of the currently selected items. */ @@ -51,6 +61,7 @@ export function useListbox({ disabled, disableSpaceSelection, id, + labels, onChange, selectionType = "single", value, @@ -71,9 +82,29 @@ export function useListbox({ const [selected, setSelected] = React.useState(value); - const focusItem = (index: number) => { - setFocusedIndex(index); - }; + const focusItem = React.useCallback( + (index: number) => { + const currentItemProps = options[index].props; + const label = getLabel(currentItemProps); + const totalResults = options.length; + + announceMessage({ + message: + labels.liveRegionCurrentItem({ + current: label, + disabled: currentItemProps.disabled, + focused: false, + index: index, + selected: currentItemProps.selected, + total: totalResults, + }) + + " " + + labels.liveRegionListboxTotal(totalResults), + }); + setFocusedIndex(index); + }, + [labels, options], + ); const focusPreviousItem = React.useCallback(() => { if (focusedIndex <= 0) { @@ -81,7 +112,7 @@ export function useListbox({ } else { focusItem(focusedIndex - 1); } - }, [options, focusedIndex]); + }, [focusedIndex, focusItem, options.length]); const focusNextItem = React.useCallback(() => { if (focusedIndex === options.length - 1) { @@ -89,7 +120,7 @@ export function useListbox({ } else { focusItem(focusedIndex + 1); } - }, [options, focusedIndex]); + }, [focusedIndex, options.length, focusItem]); const selectOption = React.useCallback( (index: number) => { @@ -99,6 +130,9 @@ export function useListbox({ return; } + const labelFromSelected = getLabel(optionItem.props); + announceMessage({message: labels.selected(labelFromSelected)}); + if (selectionType === "single") { setSelected(optionItem.props.value); // TODO(WB-1754): Add a callback for single selection to notify @@ -121,7 +155,7 @@ export function useListbox({ }); } }, - [onChange, options, selectionType], + [labels, onChange, options, selectionType], ); const handleKeyDown = React.useCallback( @@ -163,10 +197,11 @@ export function useListbox({ }, [ disableSpaceSelection, + focusItem, focusNextItem, focusPreviousItem, focusedIndex, - options, + options.length, selectOption, ], ); @@ -208,7 +243,7 @@ export function useListbox({ focusItem(index); selectOption(index); }, - [disabled, options, selectOption], + [disabled, focusItem, options, selectOption], ); const renderList = React.useMemo(() => { diff --git a/packages/wonder-blocks-dropdown/src/hooks/use-multiple-selection.tsx b/packages/wonder-blocks-dropdown/src/hooks/use-multiple-selection.tsx index 780aa9e8c8..52534924bd 100644 --- a/packages/wonder-blocks-dropdown/src/hooks/use-multiple-selection.tsx +++ b/packages/wonder-blocks-dropdown/src/hooks/use-multiple-selection.tsx @@ -1,5 +1,6 @@ import * as React from "react"; -import {MaybeValueOrValues} from "../util/types"; +import {announceMessage} from "@khanacademy/wonder-blocks-announcer"; +import {ComboboxLabels, MaybeValueOrValues} from "../util/types"; type Props = { /** @@ -7,6 +8,10 @@ type Props = { * selected option. */ selected: MaybeValueOrValues; + /** + * The label(s) of the selected item(s). + */ + selectedLabels: Array; /** * Function to set the selected items. */ @@ -15,6 +20,11 @@ type Props = { * The current value of the input. */ inputValue: string; + + labels: Pick< + ComboboxLabels, + "liveRegionCurrentItem" | "liveRegionMultipleSelectionTotal" + >; }; /** @@ -26,11 +36,33 @@ type Props = { export function useMultipleSelection({ inputValue, selected, + selectedLabels, setSelected, + labels, }: Props) { // Index of the currently focused pill in the multi-select combobox. const [focusedMultiSelectIndex, setFocusedMultiSelectIndex] = React.useState(-1); + const focusedItem = React.useCallback( + (index: number) => { + // Announces the pill group. + const label = selectedLabels[index]; + announceMessage({ + message: + labels.liveRegionCurrentItem({ + current: label, + focused: true, + index: index, + total: selectedLabels.length, + }) + + " " + + labels.liveRegionMultipleSelectionTotal( + selectedLabels.length, + ), + }); + }, + [labels, selectedLabels], + ); /** * Keyboard specific behaviors for the multi-select combobox. @@ -47,14 +79,19 @@ export function useMultipleSelection({ if (key === "ArrowLeft") { setFocusedMultiSelectIndex((prev) => { const newIndex = prev - 1; - return newIndex < 0 ? selected?.length - 1 : newIndex; + const index = + newIndex < 0 ? selected?.length - 1 : newIndex; + focusedItem(index); + return index; }); } if (key === "ArrowRight") { setFocusedMultiSelectIndex((prev) => { const newIndex = prev + 1; - return newIndex >= selected?.length ? 0 : newIndex; + const index = newIndex >= selected?.length ? 0 : newIndex; + focusedItem(index); + return index; }); } @@ -85,8 +122,8 @@ export function useMultipleSelection({ setFocusedMultiSelectIndex(-1); } - // Clear the focused pill index when navigating through the listbox, so - // the visual focus is back to the listbox. + // Clear the focused pill index when navigating through the listbox, + // so the visual focus is back to the listbox. if (key === "ArrowDown" || key === "ArrowUp") { setFocusedMultiSelectIndex(-1); } @@ -96,7 +133,13 @@ export function useMultipleSelection({ setFocusedMultiSelectIndex(-1); } }, - [focusedMultiSelectIndex, inputValue, selected, setSelected], + [ + focusedItem, + focusedMultiSelectIndex, + inputValue, + selected, + setSelected, + ], ); return { 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/static/sb-styles/preview.css b/static/sb-styles/preview.css index 5b859e65d5..3c222287a1 100644 --- a/static/sb-styles/preview.css +++ b/static/sb-styles/preview.css @@ -59,7 +59,7 @@ } /* only include the counter to the stories titles */ -.sb-anchor > h3::before { +.sb-anchor>h3::before { counter-increment: story; color: var(--color-text-64); content: counter(story) "."; @@ -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