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/__docs__/wonder-blocks-announcer/announcer.stories.tsx b/__docs__/wonder-blocks-announcer/announcer.stories.tsx index 6763cfa194..10d9d18088 100644 --- a/__docs__/wonder-blocks-announcer/announcer.stories.tsx +++ b/__docs__/wonder-blocks-announcer/announcer.stories.tsx @@ -114,14 +114,4 @@ const styles = StyleSheet.create({ alignItems: "center", justifyContent: "center", }, - container: { - width: "100%", - }, - narrowBanner: { - maxWidth: 400, - }, - rightToLeft: { - width: "100%", - direction: "rtl", - }, }); diff --git a/__docs__/wonder-blocks-dropdown/multi-select.accessibility.mdx b/__docs__/wonder-blocks-dropdown/multi-select.accessibility.mdx index bc0ef13657..48a153aa55 100644 --- a/__docs__/wonder-blocks-dropdown/multi-select.accessibility.mdx +++ b/__docs__/wonder-blocks-dropdown/multi-select.accessibility.mdx @@ -44,3 +44,18 @@ receives focus. This can be useful when the options contain icons or other infor that would need to be omitted from the visible label. + +## Automatic screen reader announcements in `MultiSelect` + +`MultiSelect` uses the [Wonder Blocks Announcer](/?path=/docs/packages-announcer--docs) +under the hood for content updates in screen readers, such as the number of items +and the selected value. + +To observe the affect of the Announcer, you have a few options: + +1. Turn on a screen reader such as VoiceOver or NVDA while using the `MultiSelect` +2. Inspect the DOM in the browser and look at the `#wbAnnounce` DIV element +3. Look at the `With visible Announcer` story to see messages appended +visually to the DOM + + \ No newline at end of file diff --git a/__docs__/wonder-blocks-dropdown/multi-select.accessibility.stories.tsx b/__docs__/wonder-blocks-dropdown/multi-select.accessibility.stories.tsx index 0ce838fcdf..8ca3bd8058 100644 --- a/__docs__/wonder-blocks-dropdown/multi-select.accessibility.stories.tsx +++ b/__docs__/wonder-blocks-dropdown/multi-select.accessibility.stories.tsx @@ -1,9 +1,11 @@ import * as React from "react"; import magnifyingGlassIcon from "@phosphor-icons/core/regular/magnifying-glass.svg"; import {OptionItem, MultiSelect} from "@khanacademy/wonder-blocks-dropdown"; +// import {initAnnouncer} from "@khanacademy/wonder-blocks-announcer"; import {LabeledField} from "@khanacademy/wonder-blocks-labeled-field"; import {View} from "@khanacademy/wonder-blocks-core"; import {PhosphorIcon} from "@khanacademy/wonder-blocks-icon"; +import {allCountries} from "./option-item-examples"; export default { title: "Packages / Dropdown / MultiSelect / Accessibility", @@ -48,7 +50,7 @@ const MultiSelectAriaLabel = () => ( {}} > @@ -129,3 +131,42 @@ export const UsingCustomOpenerAriaLabel = { render: MultiSelectCustomOpenerLabel.bind({}), name: "Using aria-label on custom opener", }; + +const optionItems = allCountries.map(([code, translatedName]) => ( + +)); + +const MultiSelectWithVisibleAnnouncer = () => { + // React.useEffect(() => { + // // Inject Announcer into the Storybook iframe instead of body + // const storybookRoot = document.getElementById("storybook-docs"); + // if (storybookRoot) { + // console.log("announcer init"); + // // initAnnouncer({targetNode: storybookRoot}); + // } + // }, []); + const [selectedValues, setSelectedValues] = React.useState>( + [], + ); + return ( + + + {optionItems} + + + ); +}; + +export const WithVisibleAnnouncer = { + render: MultiSelectWithVisibleAnnouncer.bind({}), + name: "With visible Announcer", + parameters: { + addBodyClass: "showAnnouncer", + chromatic: {disableSnapshot: true}, + }, +}; diff --git a/__docs__/wonder-blocks-dropdown/single-select.accessibility.mdx b/__docs__/wonder-blocks-dropdown/single-select.accessibility.mdx index a418f175b1..9ad95e6c82 100644 --- a/__docs__/wonder-blocks-dropdown/single-select.accessibility.mdx +++ b/__docs__/wonder-blocks-dropdown/single-select.accessibility.mdx @@ -49,3 +49,18 @@ receives focus. This can be useful when the options contain icons or other infor that would need to be omitted from the visible label. + +## Automatic screen reader announcements in `SingleSelect` + +`SingleSelect` uses the [Wonder Blocks Announcer](/?path=/docs/packages-announcer--docs) +under the hood for content updates in screen readers, such as the number of items +and the selected value. + +To observe the affect of the Announcer, you have a few options: + +1. Turn on a screen reader such as VoiceOver or NVDA while using the `SingleSelect` +2. Inspect the DOM in the browser and look at the `wbAnnounce` DIV element +3. Look at the `With visible Announcer` story to see messages appended +visually to the DOM + + \ No newline at end of file diff --git a/__docs__/wonder-blocks-dropdown/single-select.accessibility.stories.tsx b/__docs__/wonder-blocks-dropdown/single-select.accessibility.stories.tsx index 9fa9af7423..6a58aba068 100644 --- a/__docs__/wonder-blocks-dropdown/single-select.accessibility.stories.tsx +++ b/__docs__/wonder-blocks-dropdown/single-select.accessibility.stories.tsx @@ -1,9 +1,11 @@ import * as React from "react"; import caretDown from "@phosphor-icons/core/regular/caret-down.svg"; import {OptionItem, SingleSelect} from "@khanacademy/wonder-blocks-dropdown"; +// import {initAnnouncer} from "@khanacademy/wonder-blocks-announcer"; import {View} from "@khanacademy/wonder-blocks-core"; import {LabeledField} from "@khanacademy/wonder-blocks-labeled-field"; import {PhosphorIcon} from "@khanacademy/wonder-blocks-icon"; +import {allCountries} from "./option-item-examples"; export default { title: "Packages / Dropdown / SingleSelect / Accessibility", @@ -130,6 +132,44 @@ export const UsingCustomOpenerAriaLabel = { name: "Using aria-label on custom opener", }; +const optionItems = allCountries.map(([code, translatedName]) => ( + +)); + +const SingleSelectWithVisibleAnnouncer = () => { + // React.useEffect(() => { + // // Inject Announcer into the Storybook iframe instead of body + // const storybookRoot = document.getElementById("storybook-docs"); + // if (storybookRoot) { + // console.log("announcer init"); + // initAnnouncer({targetNode: storybookRoot}); + // } + // }, []); + const [selectedValue, setSelectedValue] = React.useState(""); + return ( + + + {optionItems} + + + ); +}; + +export const WithVisibleAnnouncer = { + render: SingleSelectWithVisibleAnnouncer.bind({}), + name: "With visible Announcer", + parameters: { + addBodyClass: "showAnnouncer", + chromatic: {disableSnapshot: true}, + }, +}; + // This story exists for debugging automated unit tests. const SingleSelectKeyboardSelection = () => { const [selectedValue, setSelectedValue] = React.useState(""); diff --git a/packages/wonder-blocks-announcer/package.json b/packages/wonder-blocks-announcer/package.json index 0f2f53a02c..209cec1459 100644 --- a/packages/wonder-blocks-announcer/package.json +++ b/packages/wonder-blocks-announcer/package.json @@ -23,5 +23,6 @@ "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 index f17ed1ea06..b148492e78 100644 --- a/packages/wonder-blocks-announcer/src/__tests__/announce-message.test.tsx +++ b/packages/wonder-blocks-announcer/src/__tests__/announce-message.test.tsx @@ -19,7 +19,7 @@ describe("Announcer.announceMessage", () => { const message1 = "One Fish Two Fish"; // ACT - const announcement1Id = await announceMessage({ + const announcement1Id = announceMessage({ message: message1, initialTimeout: 0, debounceThreshold: 0, @@ -27,7 +27,7 @@ describe("Announcer.announceMessage", () => { jest.advanceTimersByTime(500); // ASSERT - expect(announcement1Id).toBe("wbARegion-polite1"); + await expect(announcement1Id).resolves.toBe("wbARegion-polite1"); }); test("creates the live region elements when called", () => { @@ -144,7 +144,7 @@ describe("Announcer.announceMessage", () => { test("removes messages after a length of time", async () => { const message1 = "A Thing"; - // default timeout is 5000ms + 250ms (removalDelay + debounceThreshold) + // default debounced content timeout is 5000ms + 250ms (removalDelay + debounceThreshold) render( , ); @@ -158,8 +158,10 @@ describe("Announcer.announceMessage", () => { jest.advanceTimersByTime(500); expect(message1Region).toHaveTextContent(message1); + // This functional setTimeout (2) for the debounce comes after an initialTimeout + // for Safari/VO in the announceMessage function (1). expect(setTimeout).toHaveBeenNthCalledWith( - 1, + 2, expect.any(Function), 5250, ); diff --git a/packages/wonder-blocks-announcer/src/__tests__/announcer.test.ts b/packages/wonder-blocks-announcer/src/__tests__/announcer.test.ts index 4be252e19c..62b2d5956f 100644 --- a/packages/wonder-blocks-announcer/src/__tests__/announcer.test.ts +++ b/packages/wonder-blocks-announcer/src/__tests__/announcer.test.ts @@ -161,6 +161,7 @@ describe("Announcer class", () => { 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); @@ -173,7 +174,7 @@ describe("Announcer class", () => { announcer.dictionary.get(`wbARegion-polite0`)?.element; // ASSERT - await expect(targetElement?.textContent).toBe("a thing"); + await expect(targetElement?.textContent).toBe("two things"); await expect(targetElement2?.textContent).toBe(""); }); }); @@ -206,16 +207,12 @@ describe("Announcer class", () => { // 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", () => { diff --git a/packages/wonder-blocks-announcer/src/__tests__/clear-messages.test.tsx b/packages/wonder-blocks-announcer/src/__tests__/clear-messages.test.tsx index 3387b47081..334160a40d 100644 --- a/packages/wonder-blocks-announcer/src/__tests__/clear-messages.test.tsx +++ b/packages/wonder-blocks-announcer/src/__tests__/clear-messages.test.tsx @@ -2,30 +2,38 @@ import {screen, waitFor} from "@testing-library/react"; import {announceMessage} from "../announce-message"; import {clearMessages} from "../clear-messages"; -jest.useFakeTimers(); - 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 announcement1Id = await announceMessage({ + const announcement1IdPromise = announceMessage({ message: message1, initialTimeout: 0, debounceThreshold: 0, }); + jest.advanceTimersByTime(0); + await Promise.resolve(); const region1 = screen.getByTestId("wbARegion-polite1"); - jest.advanceTimersByTime(250); + const announcement1Id = await announcement1IdPromise; - await waitFor(() => { - expect(region1).toHaveTextContent(message1); - }); + jest.advanceTimersByTime(0); + await Promise.resolve(); - await announceMessage({ + expect(region1).toHaveTextContent(message1); + clearMessages(announcement1Id); + + announceMessage({ message: message2, initialTimeout: 0, debounceThreshold: 0, @@ -33,8 +41,7 @@ describe("Announcer.clearMessages", () => { const region2 = screen.getByTestId("wbARegion-polite0"); - jest.advanceTimersByTime(250); - clearMessages(announcement1Id); + jest.advanceTimersByTime(0); // ASSERT await waitFor(() => { @@ -49,7 +56,7 @@ describe("Announcer.clearMessages", () => { const message2 = "Red fish blue fish"; // ACT - await announceMessage({ + announceMessage({ message: message1, initialTimeout: 0, debounceThreshold: 0, @@ -60,7 +67,7 @@ describe("Announcer.clearMessages", () => { const region1 = screen.queryByTestId("wbARegion-polite1"); expect(region1).toHaveTextContent(message1); - await announceMessage({ + announceMessage({ message: message2, initialTimeout: 0, debounceThreshold: 0, @@ -69,7 +76,7 @@ describe("Announcer.clearMessages", () => { const region2 = screen.getByTestId("wbARegion-polite0"); expect(region2).toHaveTextContent(message2); - await announceMessage({ + announceMessage({ message: message1, level: "assertive", initialTimeout: 0, 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 index cfed9fe466..42cd069cef 100644 --- a/packages/wonder-blocks-announcer/src/__tests__/util/dom.test.ts +++ b/packages/wonder-blocks-announcer/src/__tests__/util/dom.test.ts @@ -5,7 +5,7 @@ import { createRegion, removeMessage, } from "../../util/dom"; -import {PolitenessLevel} from "../../../types/announcer.types"; +import {type PolitenessLevel} from "../../util/announcer.types"; jest.useFakeTimers(); jest.spyOn(global, "setTimeout"); diff --git a/packages/wonder-blocks-announcer/src/__tests__/util/test-utilities.ts b/packages/wonder-blocks-announcer/src/__tests__/util/test-utilities.ts index 1f361ac2bb..20ed2fb986 100644 --- a/packages/wonder-blocks-announcer/src/__tests__/util/test-utilities.ts +++ b/packages/wonder-blocks-announcer/src/__tests__/util/test-utilities.ts @@ -1,4 +1,4 @@ -import type {RegionDef, PolitenessLevel} from "../../../types/announcer.types"; +import type {RegionDef, PolitenessLevel} from "../../util/announcer.types"; export function createTestRegionList( level: PolitenessLevel, diff --git a/packages/wonder-blocks-announcer/src/__tests__/util/util.test.ts b/packages/wonder-blocks-announcer/src/__tests__/util/util.test.ts index 09d3a0ed54..8824a153be 100644 --- a/packages/wonder-blocks-announcer/src/__tests__/util/util.test.ts +++ b/packages/wonder-blocks-announcer/src/__tests__/util/util.test.ts @@ -8,17 +8,17 @@ describe("Debouncing messages", () => { // ARRANGE const announcer = Announcer.getInstance(); const callback = jest.fn((message: string) => message); - const debounced = createDebounceFunction(announcer, callback, 100); + const debounced = createDebounceFunction(announcer, callback, 10); // ACT - const result = await debounced("Hello, World!"); + const result = debounced("Hello, World!"); jest.advanceTimersByTime(100); // ASSERT - expect(result).toBe("Hello, World!"); + await expect(result).resolves.toBe("Hello, World!"); }); - test("resolving with the first argument passed if debounced multiple times", async () => { + test("resolving with the last argument passed if debounced multiple times", async () => { // ARRANGE const announcer = Announcer.getInstance(); const callback = jest.fn((message: string) => message); @@ -34,6 +34,6 @@ describe("Debouncing messages", () => { expect(callback).toHaveBeenCalledTimes(1); // ASSERT - expect(callback).toHaveBeenCalledWith("First message"); + 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 index 8bec5624e8..2a7618be3e 100644 --- a/packages/wonder-blocks-announcer/src/announce-message.ts +++ b/packages/wonder-blocks-announcer/src/announce-message.ts @@ -1,4 +1,4 @@ -import type {PolitenessLevel} from "../types/announcer.types"; +import type {PolitenessLevel} from "./util/announcer.types"; import Announcer from "./announcer"; export type AnnounceMessageProps = { @@ -25,8 +25,8 @@ export function announceMessage({ const announcer = Announcer.getInstance(); if (initialTimeout > 0) { return new Promise((resolve) => { - setTimeout(async () => { - const result = await announcer.announce( + return setTimeout(async () => { + const result = announcer.announce( message, level, debounceThreshold, @@ -35,9 +35,6 @@ export function announceMessage({ }, initialTimeout); }); } else { - const result = announcer.announce(message, level, debounceThreshold); - return new Promise((resolve) => { - resolve(result); - }); + return announcer.announce(message, level, debounceThreshold); } } diff --git a/packages/wonder-blocks-announcer/src/announcer.ts b/packages/wonder-blocks-announcer/src/announcer.ts index 2739c03b83..80cbc6f25c 100644 --- a/packages/wonder-blocks-announcer/src/announcer.ts +++ b/packages/wonder-blocks-announcer/src/announcer.ts @@ -1,9 +1,9 @@ -import { +import type { PolitenessLevel, RegionFactory, RegionDictionary, RegionDef, -} from "../types/announcer.types"; +} from "./util/announcer.types"; import { createRegionWrapper, @@ -20,6 +20,7 @@ export const DEFAULT_WAIT_THRESHOLD = 250; */ class Announcer { private static _instance: Announcer | null; + topLevelId: string = `wbAnnounce`; node: HTMLElement | null = null; regionFactory: RegionFactory = { count: 2, @@ -36,13 +37,12 @@ class Announcer { private constructor() { if (typeof document !== "undefined") { - const topLevelId = `wbAnnounce`; // Check if our top level element already exists - const announcerCheck = document.getElementById(topLevelId); + const announcerCheck = document.getElementById(this.topLevelId); // Init new structure if the coast is clear if (announcerCheck === null) { - this.init(topLevelId); + this.init(this.topLevelId); } // The structure exists but references are lost, so help HMR recover else { @@ -109,7 +109,7 @@ class Announcer { * Announcer exists, but it loses the connection to DOM element Refs */ reattachNodes() { - const announcerCheck = document.getElementById(`wbAnnounce`); + const announcerCheck = document.getElementById(this.topLevelId); if (announcerCheck !== null) { this.node = announcerCheck; const regions = Array.from( diff --git a/packages/wonder-blocks-announcer/src/index.ts b/packages/wonder-blocks-announcer/src/index.ts index c87dd6045d..be5cfe5d9e 100644 --- a/packages/wonder-blocks-announcer/src/index.ts +++ b/packages/wonder-blocks-announcer/src/index.ts @@ -1,4 +1,7 @@ -import {announceMessage, type AnnounceMessageProps} from "./announce-message"; +import {initAnnouncer} from "./init-announcer"; +import {announceMessage} from "./announce-message"; import {clearMessages} from "./clear-messages"; +import type {AnnounceMessageProps} from "./announce-message"; -export {announceMessage, type AnnounceMessageProps, clearMessages}; +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/types/announcer.types.ts b/packages/wonder-blocks-announcer/src/util/announcer.types.ts similarity index 100% rename from packages/wonder-blocks-announcer/types/announcer.types.ts rename to packages/wonder-blocks-announcer/src/util/announcer.types.ts diff --git a/packages/wonder-blocks-announcer/src/util/dom.ts b/packages/wonder-blocks-announcer/src/util/dom.ts index c89e5c8cce..4abb7dfb91 100644 --- a/packages/wonder-blocks-announcer/src/util/dom.ts +++ b/packages/wonder-blocks-announcer/src/util/dom.ts @@ -1,7 +1,4 @@ -import { - type PolitenessLevel, - RegionDictionary, -} from "../../types/announcer.types"; +import type {PolitenessLevel, RegionDictionary} from "./announcer.types"; /** * Create a wrapper element to group regions for a given level diff --git a/packages/wonder-blocks-announcer/src/util/util.ts b/packages/wonder-blocks-announcer/src/util/util.ts index 2270044f9f..99e1516e03 100644 --- a/packages/wonder-blocks-announcer/src/util/util.ts +++ b/packages/wonder-blocks-announcer/src/util/util.ts @@ -28,22 +28,16 @@ export function createDebounceFunction( 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); + const later = () => { + const result = callback.apply(context, args); + if (timeoutId) { + clearTimeout(timeoutId); } - } + return resolve(result); + }; // If the timeout exists, clear it if (timeoutId !== null) { @@ -52,7 +46,7 @@ export function createDebounceFunction( // Trailing edge: Set the timeout for the next allowed execution timeoutId = setTimeout(() => { - executed = false; + later(); }, debounceThreshold); }); }; 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..9045427ef4 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, }); @@ -403,9 +424,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..530ab058ef 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,6 +1668,10 @@ describe("MultiSelect", () => { }); describe("a11y > Live region", () => { + beforeEach(() => { + initAnnouncer({debounceThreshold: 0}); + }); + it("should announce the number of options when the listbox is open", async () => { // Arrange const labels: LabelsValues = { @@ -1677,35 +1682,40 @@ describe("MultiSelect", () => { : `${numOptions} schools`, }; - // Act - const {container} = doRender( - + const {userEvent} = doRender( + , ); + const opener = await screen.findByRole("combobox"); + + jest.advanceTimersByTime(10); + + // 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(); }); 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/__tests__/single-select.test.tsx b/packages/wonder-blocks-dropdown/src/components/__tests__/single-select.test.tsx index 7e540db83b..a938a3a773 100644 --- a/packages/wonder-blocks-dropdown/src/components/__tests__/single-select.test.tsx +++ b/packages/wonder-blocks-dropdown/src/components/__tests__/single-select.test.tsx @@ -13,6 +13,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 SingleSelect from "../single-select"; import type {SingleSelectLabelsValues} from "../single-select"; @@ -1205,9 +1206,10 @@ describe("SingleSelect", () => { }); describe("a11y > Live region", () => { - // TODO (WB-1757.2): Enable this test once the LiveRegion component - // is refactored. - it.skip("should change the number of options after using the search filter", async () => { + beforeEach(() => { + initAnnouncer({debounceThreshold: 0}); + }); + it("should change the number of options after using the search filter", async () => { // Arrange const {userEvent} = doRender( { // Act // NOTE: We search using the lowercased version of the label. await userEvent.type(await screen.findByRole("textbox"), "item 0"); + jest.advanceTimersByTime(10); // Assert - const liveRegionText = ( - await screen.findByTestId("dropdown-live-region") - ).textContent; + const liveRegion = screen.getByTestId("wbAnnounce"); + const announcementText = + await within(liveRegion).findByText("1 item"); - expect(liveRegionText).toEqual("1 item"); + // Assert + 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..667deca700 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,12 @@ const MultiSelect = (props: Props) => { const filteredItems = getMenuItems(allChildren); const isDisabled = numEnabledOptions === 0 || disabled; + React.useEffect(() => { + if (open) { + handleAnnouncement(someSelected(filteredItems.length)); + } + }, [filteredItems.length, someSelected, open]); + 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..852e425f01 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) { @@ -430,6 +442,28 @@ const SingleSelect = (props: Props) => { handleOpenChanged(!open); }; + const handleAnnouncement = (message: string) => { + announceMessage({ + message, + }); + }; + + // Announce when selectedValue or children changes in the opener + React.useEffect(() => { + const optionItems = React.Children.toArray( + children, + ) as OptionItemComponentArray; + const selectedItem = optionItems.find( + (option) => option.props.value === selectedValue, + ); + if (selectedItem) { + const label = getLabel(selectedItem.props); + if (label) { + handleAnnouncement(label); + } + } + }, [selectedValue, children]); + const renderOpener = ( isDisabled: boolean, dropdownId: string, @@ -442,11 +476,22 @@ const SingleSelect = (props: Props) => { const selectedItem = items.find( (option) => option.props.value === selectedValue, ); - // 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 menuStringOrNode = getSelectOpenerLabel( + showOpenerLabelAsText, + selectedItem.props, + ); + // We only need the guaranteed node for SingleSelect here + // As the string label for the Announcer is in a useEffect above + const [, node] = maybeExtractStringFromNode(menuStringOrNode); + menuContent = node; + } else { + // If nothing is selected, or if the selectedValue doesn't match any + // item in the menu, use the placeholder. + menuContent = placeholder; + } const dropdownOpener = ( @@ -461,7 +506,7 @@ const SingleSelect = (props: Props) => { disabled={isDisabled} ref={handleOpenerRef} role="combobox" - text={menuText} + text={menuContent} opened={open} error={hasError} onBlur={onOpenerBlurValidation} @@ -483,7 +528,7 @@ const SingleSelect = (props: Props) => { testId={testId} onBlur={onOpenerBlurValidation} > - {menuText} + {menuContent} ); }} @@ -504,6 +549,16 @@ const SingleSelect = (props: Props) => { const items = getMenuItems(allChildren); const isDisabled = numEnabledOptions === 0 || disabled; + // Extract out someResults. When we put labels in the dependency array, + // useEffect happens on every render (I think because labels is a new object) + // each time so it thinks it has changed + const {someResults} = labels; + + // Announce in a screen reader when the number of filtered items changes + React.useEffect(() => { + handleAnnouncement(someResults(items.length)); + }, [items.length, someResults]); + return ( {(uniqueDropdownId) => ( 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 087f3be0e9..d145be1011 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -369,6 +369,10 @@ importers: 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: @@ -635,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