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

Announcer: Part 1 #2362

Open
wants to merge 44 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
0d9daf9
Initial commit of Announcer
marcysutton Nov 13, 2024
737fdc5
WIP: append messages
marcysutton Nov 15, 2024
78f1208
Leverage React for tests
marcysutton Nov 15, 2024
b3a31d4
Refactor to use dictionary
marcysutton Nov 15, 2024
74ad711
Cleanup, move types, add comments
marcysutton Nov 15, 2024
0eb7de3
Fix outdated param in story
marcysutton Nov 15, 2024
093c1c5
Put testing styles in Storybook preview.css
marcysutton Nov 19, 2024
4563523
Remove console.log
marcysutton Nov 19, 2024
d21b853
Add working test for auto-removal of messages
marcysutton Nov 19, 2024
a613e12
Added changeset
marcysutton Nov 20, 2024
bcb7762
Remove manually created changelog file
marcysutton Nov 22, 2024
954feb5
Restructure files based on PR feedback
marcysutton Nov 22, 2024
518576d
Rename sendMessage to announceMessage
marcysutton Nov 22, 2024
3cdbc65
Rename clear-messages file to match function
marcysutton Nov 22, 2024
5791768
Move utility functions into separate files
marcysutton Nov 22, 2024
47f8300
Append regions to end of document.body
marcysutton Nov 22, 2024
b19c852
Renaming timeouts, adding comments for clarity
marcysutton Nov 22, 2024
389452a
Reformat files for linter
marcysutton Nov 22, 2024
a283cc7
Try kicking the linter one more time
marcysutton Nov 22, 2024
05018de
Expand tests
marcysutton Nov 23, 2024
925e1b7
Make document check more consistent
marcysutton Nov 23, 2024
4729428
Add comments, types, and a few more tests
marcysutton Nov 23, 2024
37ac932
Implement debounce / async logic
marcysutton Nov 26, 2024
9927464
Implement debounce / async logic
marcysutton Nov 26, 2024
cba83f9
Get async tests working
marcysutton Nov 26, 2024
8526915
Add missing test utility file
marcysutton Nov 26, 2024
ea2d136
Update docs in Storybook for latest API changes
marcysutton Nov 26, 2024
4b244e5
Firm up debounce logic
marcysutton Nov 27, 2024
4702093
Clean up stray log and setTimeout testing approach
marcysutton Nov 27, 2024
0527a99
Add test file I somehow missed
marcysutton Nov 27, 2024
2db4a42
Suppress story artifacts from announcements
marcysutton Dec 3, 2024
8289cae
Add initial timeout back to help Safari/VO
marcysutton Dec 3, 2024
91a9a19
Fix typo in reattachment selector
marcysutton Dec 10, 2024
9eac9b2
Rename Announcer filenames to lowercase
marcysutton Dec 10, 2024
76c06ff
Remove console.log
marcysutton Dec 10, 2024
c0e3d06
Remove commented-out test code
marcysutton Dec 10, 2024
55f3ce3
Update tests from review feedback
marcysutton Dec 10, 2024
fa29ada
Clean up WIP wonder-blocks-style code
marcysutton Dec 12, 2024
080ee31
Refactor debounce logic and tests
marcysutton Dec 12, 2024
c44e59a
Clean up storybook styling with custom body class
marcysutton Dec 12, 2024
7e1912f
Update jsdoc comments for debounce utility
marcysutton Dec 12, 2024
7eacfef
Fix incorrect object in debounce test
marcysutton Dec 12, 2024
b961200
Allow node access for vanilla JS testing
marcysutton Dec 12, 2024
50f263b
Update dependencies for React 18
marcysutton Dec 12, 2024
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/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
13 changes: 13 additions & 0 deletions .storybook/preview.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as React from "react";

Check warning on line 1 in .storybook/preview.tsx

View workflow job for this annotation

GitHub Actions / Lint / Lint (ubuntu-latest, 20.x)

File ignored by default.

Check warning on line 1 in .storybook/preview.tsx

View workflow job for this annotation

GitHub Actions / Lint / Lint (ubuntu-latest, 20.x)

File ignored by default.
import wonderBlocksTheme from "./wonder-blocks-theme";

import {color} from "@khanacademy/wonder-blocks-tokens";
Expand Down Expand Up @@ -101,6 +101,19 @@
const enableRenderStateRootDecorator =
context.parameters.enableRenderStateRootDecorator;

// Allow stories to specify a CSS body class
if (context.parameters.addBodyClass) {
document.body.classList.add(context.parameters.addBodyClass);
}
// Remove body class when changing stories
React.useEffect(() => {
return () => {
if (context.parameters.addBodyClass) {
document.body.classList.remove(context.parameters.addBodyClass);
}
};
}, [context.parameters.addBodyClass]);

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

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

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

const AnnouncerExample = ({
message = "Clicked!",
level,
debounceThreshold,
}: AnnounceMessageProps) => {
return (
<Button
Copy link
Member

Choose a reason for hiding this comment

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

(suggestion, no changes necessary) - I was curious about other scenarios that we could add to the story (or another story) so we can test different cases easily:

  • A button that triggers an announcement and another button that clears the specific announcement and/or all announcements
  • 1 button that triggers a polite message, another button that triggers an assertive message to see the behaviour for different announcement levels
  • buttons with different debounceThreshold values to show how that option changes the behaviour

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}>
marcysutton marked this conversation as resolved.
Show resolved Hide resolved
<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,
},
},
},
argTypes: {
level: {
control: "radio",
options: ["polite", "assertive"],
},
debounceThreshold: {
control: "number",
type: "number",
description: "(milliseconds)",
Copy link
Member

Choose a reason for hiding this comment

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

I'm wondering if there's a way for us to use the function docs for the storybook docs! Normally we're able to get the prop docs automatically from setting component in this block, though this is different since these docs are for functions rather than components!

cc: @jandrade in case you have come across similar things before!

Copy link
Member

Choose a reason for hiding this comment

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

If there isn't a way to do this, it would be helpful to add a description for the different options so it shows up in the docs! This can help developers know when to use what level or when to use debounceThreshold. Same for documenting the clear-messages utility!

image

},
},
} 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.",
Copy link
Member

Choose a reason for hiding this comment

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

suggestion: As we discussed offline, it would be nice if this message could change dynamically on every click to be able to test that the announcer works properly when the inner text changes.

Copy link
Member Author

Choose a reason for hiding this comment

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

So I played around with adding Date.now() or Math.random() to the default message, and it caches it so nothing changes with repeated clicks. The message can be changed manually for this purpose. Any ideas on how to change it dynamically in the way you're imagining?

Copy link
Member

Choose a reason for hiding this comment

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

I think to have dynamic messages each time the button is clicked, we would need to update it in the AnnouncerExample component. When we configure the story args here, it will only initialize the storybook control values for the first render

For example, if we add Date.now() to the message when we call announceMessage in the AnnouncerExample component, it should be a different message each time!

level: "polite",
},
};
Copy link
Member

Choose a reason for hiding this comment

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

We can disable this story for chromatic since we don't need visual regression tests for it! https://www.chromatic.com/docs/disable-snapshots/#with-storybook


const styles = StyleSheet.create({
example: {
alignItems: "center",
justifyContent: "center",
},
container: {
width: "100%",
},
narrowBanner: {
maxWidth: 400,
},
rightToLeft: {
width: "100%",
direction: "rtl",
},
marcysutton marked this conversation as resolved.
Show resolved Hide resolved
});
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": "^2.0.0"
}
}
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 = await announceMessage({
message: message1,
initialTimeout: 0,
debounceThreshold: 0,
});
jest.advanceTimersByTime(500);

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

test("creates the live region elements when called", () => {
// ARRANGE
const message = "Ta-da!";
render(
<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(
1,
expect.any(Function),
5250,
);

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