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