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

[FEI-6106]: Integrating Announcer into WB SingleSelect and MultiSelect #2478

Open
wants to merge 24 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
24 commits
Select commit Hold shift + click to select a range
f0f94a0
[feature/announcer] Merge remote-tracking branch 'origin/main' into f…
marcysutton Mar 5, 2025
e71b92a
[feature/announcer] Merge remote-tracking branch 'origin/main' into f…
marcysutton Mar 5, 2025
ba53e40
Announcer: Part 1 (#2362)
marcysutton Jan 16, 2025
f8101cf
[combobox-announcer] Expose string or node from opener
marcysutton Feb 21, 2025
48bd83e
[combobox-announcer] Extract function into helper file
marcysutton Feb 21, 2025
7ab82b8
[combobox-announcer] Move announcer types into src
marcysutton Feb 21, 2025
2487ddc
[combobox-announcer] docs(changeset): Introducing WB Announcer API fo…
marcysutton Feb 24, 2025
8a02ca8
[combobox-announcer] Move types around for package
marcysutton Feb 24, 2025
59fab1c
[combobox-announcer] Add announcer to dropdown dependencies
marcysutton Feb 24, 2025
d03eb98
[combobox-announcer] docs(changeset): Integrates Announcer for value …
marcysutton Feb 24, 2025
fe1732f
[combobox-announcer] Update pnpm lock
marcysutton Feb 24, 2025
ffa975e
[combobox-announcer] Update BirthdayPicker tests
marcysutton Feb 24, 2025
3a6fc7d
[combobox-announcer] Update BirthdayPicker tests
marcysutton Feb 24, 2025
c459f5d
[combobox-announcer] Remove dropdown-core live region
marcysutton Feb 24, 2025
14458ab
[combobox-announcer] Try restoring wb-dev-build-settings
marcysutton Feb 24, 2025
51b2e43
[combobox-announcer] Add system types to announcer
marcysutton Feb 24, 2025
cbd1aa5
[combobox-announcer] Add API to init Announcer on load
marcysutton Feb 26, 2025
f56178b
[combobox-announcer] Hoist uniqueID to top of Announcer
marcysutton Feb 28, 2025
b17979b
[combobox-announcer] Add debounce wait prop to initializer
marcysutton Feb 28, 2025
ce65679
[combobox-announcer] WIP: rework debounce for combobox
marcysutton Feb 28, 2025
3677b39
[combobox-announcer] Show Announcer in Storybook/MultiSelect
marcysutton Feb 28, 2025
f15ea86
[combobox-announcer] Clean up WIP code
marcysutton Feb 28, 2025
ed991ab
[combobox-announcer] Fix clearMessage test w/ debounce
marcysutton Mar 3, 2025
ff24d0c
[combobox-announcer] Clean up unused storybook styles
marcysutton Mar 3, 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
5 changes: 5 additions & 0 deletions .changeset/thirty-ducks-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/wonder-blocks-announcer": minor
---

New package for WB Announcer
15 changes: 14 additions & 1 deletion .storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,21 @@ const parameters = {

const withThemeSwitcher: Decorator = (
Story,
{globals: {theme}, parameters: {enableRenderStateRootDecorator}},
{globals: {theme}, parameters: {enableRenderStateRootDecorator, addBodyClass}},
) => {
// Allow stories to specify a CSS body class
if (addBodyClass) {
document.body.classList.add(addBodyClass);
}
// Remove body class when changing stories
React.useEffect(() => {
return () => {
if (addBodyClass) {
document.body.classList.remove(addBodyClass);
}
};
}, [addBodyClass]);

if (enableRenderStateRootDecorator) {
return (
<RenderStateRoot>
Expand Down
117 changes: 117 additions & 0 deletions __docs__/wonder-blocks-announcer/announcer.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import * as React from "react";
import {StyleSheet} from "aphrodite";
import type {Meta, StoryObj} from "@storybook/react";

import {
announceMessage,
type AnnounceMessageProps,
} from "@khanacademy/wonder-blocks-announcer";
import Button from "@khanacademy/wonder-blocks-button";
import {View} from "@khanacademy/wonder-blocks-core";

import ComponentInfo from "../components/component-info";
import packageConfig from "../../packages/wonder-blocks-announcer/package.json";

const AnnouncerExample = ({
message = "Clicked!",
level,
debounceThreshold,
}: AnnounceMessageProps) => {
return (
<Button
onClick={async () => {
const idRef = await announceMessage({
message,
level,
debounceThreshold,
});
/* eslint-disable-next-line */
console.log(idRef);
}}
>
Save
</Button>
);
};
type StoryComponentType = StoryObj<typeof AnnouncerExample>;

/**
* Announcer exposes an API for screen reader messages using ARIA Live Regions.
* It can be used to notify Assistive Technology users without moving focus. Use
* cases include combobox filtering, toast notifications, client-side routing,
* and more.
*
* Calling the `announceMessage` function automatically appends the appropriate live regions
* to the document body. It sends messages at a default `polite` level, with the
* ability to override to `assertive` by passing a `level` argument. You can also
* pass a `debounceThreshold` to wait a specific duration before making another announcement.
*
* To test this API, turn on VoiceOver for Mac/iOS or NVDA on Windows and click the example button.
*
* ### Usage
* ```jsx
* import { appendMessage } from "@khanacademy/wonder-blocks-announcer";
*
* <div>
* <button onClick={() => appendMessage({message: 'Saved your work for you.'})}>
* Save
* </button>
* </div>
* ```
*/
export default {
title: "Packages / Announcer",
component: AnnouncerExample,
decorators: [
(Story): React.ReactElement<React.ComponentProps<typeof View>> => (
<View style={styles.example}>
<Story />
</View>
),
],
parameters: {
addBodyClass: "showAnnouncer",
componentSubtitle: (
<ComponentInfo
name={packageConfig.name}
version={packageConfig.version}
/>
),
docs: {
source: {
// See https://github.com/storybookjs/storybook/issues/12596
excludeDecorators: true,
},
},
chromatic: {disableSnapshot: true},
},
argTypes: {
level: {
control: "radio",
options: ["polite", "assertive"],
},
debounceThreshold: {
control: "number",
type: "number",
description: "(milliseconds)",
},
},
} as Meta<typeof AnnouncerExample>;

/**
* This is an example of a live region with all the options set to their default
* values and the `message` argument set to some example text.
*/
export const SendMessage: StoryComponentType = {
args: {
message: "Here is some example text.",
level: "polite",
},
};

const styles = StyleSheet.create({
example: {
alignItems: "center",
justifyContent: "center",
},
});
3 changes: 2 additions & 1 deletion __docs__/wonder-blocks-dropdown/multi-select.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export default {
"aria-label": "Planets",
},
parameters: {
addBodyClass: "showAnnouncer",
componentSubtitle: (
<ComponentInfo
name={packageConfig.name}
Expand Down Expand Up @@ -611,7 +612,7 @@ const VirtualizedMultiSelect = function (props: Props): React.ReactElement {
const [selectedValues, setSelectedValues] = React.useState<Array<string>>(
[],
);
const [opened, setOpened] = React.useState(props.opened || false);
const [opened, setOpened] = React.useState(false);

return (
<View style={styles.wrapper}>
Expand Down
28 changes: 28 additions & 0 deletions packages/wonder-blocks-announcer/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"name": "@khanacademy/wonder-blocks-announcer",
"version": "0.0.1",
"design": "v1",
"description": "Live Region Announcer for Wonder Blocks.",
"main": "dist/index.js",
"module": "dist/es/index.js",
"source": "src/index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"types": "dist/index.d.ts",
"author": "",
"license": "MIT",
"publishConfig": {
"access": "public"
},
"dependencies": {
"@khanacademy/wonder-blocks-core": "^9.0.0"
},
"peerDependencies": {
"aphrodite": "^1.2.5",
"react": "18.2.0"
},
"devDependencies": {
"@khanacademy/wb-dev-build-settings": "workspace:*"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import * as React from "react";
import {render, screen, waitFor} from "@testing-library/react";
import Announcer, {REMOVAL_TIMEOUT_DELAY} from "../announcer";
import {AnnounceMessageButton} from "./components/announce-message-button";
import {announceMessage} from "../announce-message";

jest.useFakeTimers();
jest.spyOn(global, "setTimeout");

describe("Announcer.announceMessage", () => {
afterEach(() => {
const announcer = Announcer.getInstance();
jest.advanceTimersByTime(REMOVAL_TIMEOUT_DELAY);
announcer.reset();
});

test("returns a targeted element IDREF", async () => {
// ARRANGE
const message1 = "One Fish Two Fish";

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

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

test("creates the live region elements when called", () => {
// ARRANGE
const message = "Ta-da!";
render(
<AnnounceMessageButton message={message} debounceThreshold={0} />,
);

// ACT: call function
const button = screen.getByRole("button");
button.click();

// ASSERT: expect live regions to exist
const wrapperElement = screen.getByTestId("wbAnnounce");
const regionElements = screen.queryAllByRole("log");
expect(wrapperElement).toBeInTheDocument();
expect(regionElements).toHaveLength(4);
});

test("appends to polite live regions by default", () => {
// ARRANGE
const message = "Ta-da, nicely!";
render(
<AnnounceMessageButton message={message} debounceThreshold={0} />,
);

// ACT: call function
const button = screen.getByRole("button");
button.click();

// ASSERT: expect live regions to exist
const politeRegion1 = screen.queryByTestId("wbARegion-polite0");
const politeRegion2 = screen.queryByTestId("wbARegion-polite1");
expect(politeRegion1).toHaveAttribute("aria-live", "polite");
expect(politeRegion1).toHaveAttribute("id", "wbARegion-polite0");
expect(politeRegion2).toHaveAttribute("aria-live", "polite");
expect(politeRegion2).toHaveAttribute("id", "wbARegion-polite1");
});

test("appends messages in alternating polite live region elements", async () => {
// ARRANGE
const rainierMsg = "Rainier McCheddarton";
const bagleyMsg = "Bagley Fluffpants";
render(
<AnnounceMessageButton
message={rainierMsg}
debounceThreshold={0}
/>,
);
render(
<AnnounceMessageButton message={bagleyMsg} debounceThreshold={0} />,
);

// ACT: post two messages
const button = screen.getAllByRole("button");
button[0].click();

jest.advanceTimersByTime(250);

// ASSERT: check messages were appended to elements
// The second region will be targeted first
const message1Region = screen.queryByTestId("wbARegion-polite1");
await waitFor(() => {
expect(message1Region).toHaveTextContent(rainierMsg);
});

button[1].click();
const message2Region = screen.queryByTestId("wbARegion-polite0");
await waitFor(() => {
expect(message2Region).toHaveTextContent(bagleyMsg);
});
});

test("appends messages in alternating assertive live region elements", async () => {
const rainierMsg = "Rainier McCheese";
const bagleyMsg = "Bagley The Cat";
render(
<AnnounceMessageButton
message={rainierMsg}
level="assertive"
debounceThreshold={0}
/>,
);
render(
<AnnounceMessageButton
message={bagleyMsg}
level="assertive"
debounceThreshold={0}
/>,
);

// ACT: post two messages
const button = screen.getAllByRole("button");
button[0].click();

jest.advanceTimersByTime(250);

// ASSERT: check messages were appended to elements
// The second region will be targeted first
const message1Region = screen.queryByTestId("wbARegion-assertive1");
await waitFor(() => {
expect(message1Region).toHaveTextContent(rainierMsg);
});
button[1].click();
jest.advanceTimersByTime(250);

const message2Region = screen.queryByTestId("wbARegion-assertive0");
await waitFor(() => {
expect(message2Region).toHaveTextContent(bagleyMsg);
});
});

test("removes messages after a length of time", async () => {
const message1 = "A Thing";

// default timeout is 5000ms + 250ms (removalDelay + debounceThreshold)
render(
<AnnounceMessageButton message={message1} debounceThreshold={1} />,
);

const button = screen.getAllByRole("button");
button[0].click();

const message1Region = screen.queryByTestId("wbARegion-polite1");

// Assert
jest.advanceTimersByTime(500);
expect(message1Region).toHaveTextContent(message1);

expect(setTimeout).toHaveBeenNthCalledWith(
2,
expect.any(Function),
5250,
);

jest.advanceTimersByTime(5250);
await waitFor(() => {
expect(screen.queryByText(message1)).not.toBeInTheDocument();
});
});
});
Loading
Loading