Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WB-1891]: Integrating Announcer into SingleSelect and MultiSelect #2495

Open
wants to merge 27 commits into
base: feature/announcer
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c65354d
[combobox-announcer] Expose string or node from opener
marcysutton Feb 21, 2025
683d837
[combobox-announcer] Extract function into helper file
marcysutton Feb 21, 2025
59d42f2
[combobox-announcer] Move announcer types into src
marcysutton Feb 21, 2025
10c94a8
[combobox-announcer] docs(changeset): Introducing WB Announcer API fo…
marcysutton Feb 24, 2025
28d786c
[combobox-announcer] Move types around for package
marcysutton Feb 24, 2025
98f576a
[combobox-announcer] Add announcer to dropdown dependencies
marcysutton Feb 24, 2025
6e729b1
[combobox-announcer] docs(changeset): Integrates Announcer for value …
marcysutton Feb 24, 2025
23ccbf3
[combobox-announcer] Update pnpm lock
marcysutton Feb 24, 2025
2237028
[combobox-announcer] Update BirthdayPicker tests
marcysutton Feb 24, 2025
c803896
[combobox-announcer] Update BirthdayPicker tests
marcysutton Feb 24, 2025
97466df
[combobox-announcer] Remove dropdown-core live region
marcysutton Feb 24, 2025
7cc43f4
[combobox-announcer] Try restoring wb-dev-build-settings
marcysutton Feb 24, 2025
04952a1
[combobox-announcer] Add system types to announcer
marcysutton Feb 24, 2025
62a4e72
[combobox-announcer] Add API to init Announcer on load
marcysutton Feb 26, 2025
8f60715
[combobox-announcer] Hoist uniqueID to top of Announcer
marcysutton Feb 28, 2025
32d09b4
[combobox-announcer] Add debounce wait prop to initializer
marcysutton Feb 28, 2025
8000c04
[combobox-announcer] WIP: rework debounce for combobox
marcysutton Feb 28, 2025
667cb74
[combobox-announcer] Show Announcer in Storybook/MultiSelect
marcysutton Feb 28, 2025
90f0ade
[combobox-announcer] Clean up WIP code
marcysutton Feb 28, 2025
fb552e6
[combobox-announcer] Fix clearMessage test w/ debounce
marcysutton Mar 3, 2025
6542633
[combobox-announcer] Clean up unused storybook styles
marcysutton Mar 3, 2025
15ca376
[combobox-announcer-v2] Try restoring BirthdayPicker test
marcysutton Mar 6, 2025
6fd8858
[combobox-announcer-v2] Hide Announcer helper in MultiSelect stories
marcysutton Mar 6, 2025
df5d25f
[combobox-announcer-v2] Restore prop in story
marcysutton Mar 6, 2025
28eacc0
[combobox-announcer-v2] Announce filtered items in SingleSelect
marcysutton Mar 6, 2025
30a34dc
[combobox-announcer-v2] Clean up tests
marcysutton Mar 6, 2025
bf3193a
[combobox-announcer-v2] PR feedback
marcysutton Mar 8, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clean-peas-prove.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/wonder-blocks-announcer": major
---

Introducing WB Announcer API for ARIA Live Regions
5 changes: 5 additions & 0 deletions .changeset/plenty-crews-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/wonder-blocks-dropdown": minor
---

Integrates Announcer for value announcements in SingleSelect and MultiSelect
10 changes: 0 additions & 10 deletions __docs__/wonder-blocks-announcer/announcer.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,14 +114,4 @@ const styles = StyleSheet.create({
alignItems: "center",
justifyContent: "center",
},
container: {
width: "100%",
},
narrowBanner: {
maxWidth: 400,
},
rightToLeft: {
width: "100%",
direction: "rtl",
},
});
15 changes: 15 additions & 0 deletions __docs__/wonder-blocks-dropdown/multi-select.accessibility.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Canvas of={MultiSelectAccessibilityStories.UsingOpenerAriaLabel} />

## 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

<Canvas of={MultiSelectAccessibilityStories.WithVisibleAnnouncer} />
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -48,7 +50,7 @@ const MultiSelectAriaLabel = () => (
<View>
<MultiSelect
aria-label="Class options"
id="unique-single-select"
id="unique-multi-select"
selectedValues={["one"]}
onChange={() => {}}
>
Expand Down Expand Up @@ -129,3 +131,42 @@ export const UsingCustomOpenerAriaLabel = {
render: MultiSelectCustomOpenerLabel.bind({}),
name: "Using aria-label on custom opener",
};

const optionItems = allCountries.map(([code, translatedName]) => (
<OptionItem key={code} value={code} label={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<Array<string>>(
[],
);
return (
<View>
<MultiSelect
aria-label="Country"
onChange={setSelectedValues}
isFilterable={true}
selectedValues={selectedValues}
>
{optionItems}
</MultiSelect>
</View>
);
};

export const WithVisibleAnnouncer = {
render: MultiSelectWithVisibleAnnouncer.bind({}),
name: "With visible Announcer",
parameters: {
addBodyClass: "showAnnouncer",
chromatic: {disableSnapshot: true},
},
};
15 changes: 15 additions & 0 deletions __docs__/wonder-blocks-dropdown/single-select.accessibility.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Canvas of={SingleSelectAccessibilityStories.UsingOpenerAriaLabel} />

## 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

<Canvas of={SingleSelectAccessibilityStories.WithVisibleAnnouncer} />
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -130,6 +132,44 @@ export const UsingCustomOpenerAriaLabel = {
name: "Using aria-label on custom opener",
};

const optionItems = allCountries.map(([code, translatedName]) => (
<OptionItem key={code} value={code} label={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 (
<View>
<SingleSelect
aria-label="Country"
onChange={setSelectedValue}
isFilterable={true}
placeholder="Select a country"
selectedValue={selectedValue}
>
{optionItems}
</SingleSelect>
</View>
);
};

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("");
Expand Down
1 change: 1 addition & 0 deletions packages/wonder-blocks-announcer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,6 @@
"react": "18.2.0"
},
"devDependencies": {
"@khanacademy/wb-dev-build-settings": "workspace:*"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,15 @@ describe("Announcer.announceMessage", () => {
const message1 = "One Fish Two Fish";

// ACT
const announcement1Id = await announceMessage({
const announcement1Id = announceMessage({
message: message1,
initialTimeout: 0,
debounceThreshold: 0,
});
jest.advanceTimersByTime(500);

// ASSERT
expect(announcement1Id).toBe("wbARegion-polite1");
await expect(announcement1Id).resolves.toBe("wbARegion-polite1");
});

test("creates the live region elements when called", () => {
Expand Down Expand Up @@ -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(
<AnnounceMessageButton message={message1} debounceThreshold={1} />,
);
Expand All @@ -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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason why setTimeout is called twice now? Would be helpful to add a comment here so we remember why!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I'll add a comment! This was due to the leading edge / trailing edge change in the debounce function. I think the first setTimeout is the initial one for Safari timing, so this configurable one in the test with specific duration/etc. comes second.

expect.any(Function),
5250,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ describe("Announcer class", () => {
const waitThreshold = 1000;

// Act
// The second call will win out in the trailing edge implementation
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great job setting up these tests with the initial implementation! Makes it easy to see what's changed with the updated behaviour 😄

announcer.announce("a thing", "polite", waitThreshold);
announcer.announce("two things", "polite", waitThreshold);

Expand All @@ -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("");
});
});
Expand Down Expand Up @@ -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", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,46 @@ 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,
});

const region2 = screen.getByTestId("wbARegion-polite0");

jest.advanceTimersByTime(250);
clearMessages(announcement1Id);
jest.advanceTimersByTime(0);

// ASSERT
await waitFor(() => {
Expand All @@ -49,7 +56,7 @@ describe("Announcer.clearMessages", () => {
const message2 = "Red fish blue fish";

// ACT
await announceMessage({
announceMessage({
message: message1,
initialTimeout: 0,
debounceThreshold: 0,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
Loading