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

Merged
merged 46 commits into from
Jan 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
edae08a
Initial commit of Announcer
marcysutton Nov 13, 2024
3d7d789
WIP: append messages
marcysutton Nov 15, 2024
4742d4a
Leverage React for tests
marcysutton Nov 15, 2024
a4743eb
Refactor to use dictionary
marcysutton Nov 15, 2024
00c5a9b
Cleanup, move types, add comments
marcysutton Nov 15, 2024
3ecbcf3
Fix outdated param in story
marcysutton Nov 15, 2024
a6fd1b2
Put testing styles in Storybook preview.css
marcysutton Nov 19, 2024
af5676e
Remove console.log
marcysutton Nov 19, 2024
c3b0964
Add working test for auto-removal of messages
marcysutton Nov 19, 2024
2078efb
Added changeset
marcysutton Nov 20, 2024
420e334
Remove manually created changelog file
marcysutton Nov 22, 2024
517c579
Restructure files based on PR feedback
marcysutton Nov 22, 2024
e412461
Rename sendMessage to announceMessage
marcysutton Nov 22, 2024
1cd7615
Rename clear-messages file to match function
marcysutton Nov 22, 2024
eadc8f4
Move utility functions into separate files
marcysutton Nov 22, 2024
5bec710
Append regions to end of document.body
marcysutton Nov 22, 2024
7c832e6
Renaming timeouts, adding comments for clarity
marcysutton Nov 22, 2024
7a4b91e
Reformat files for linter
marcysutton Nov 22, 2024
4f56d91
Try kicking the linter one more time
marcysutton Nov 22, 2024
4397a20
Expand tests
marcysutton Nov 23, 2024
c2d028c
Make document check more consistent
marcysutton Nov 23, 2024
9d95f45
Add comments, types, and a few more tests
marcysutton Nov 23, 2024
ca72294
Implement debounce / async logic
marcysutton Nov 26, 2024
db16895
Implement debounce / async logic
marcysutton Nov 26, 2024
b031eef
Get async tests working
marcysutton Nov 26, 2024
d421d5b
Add missing test utility file
marcysutton Nov 26, 2024
38dbf45
Update docs in Storybook for latest API changes
marcysutton Nov 26, 2024
0428895
Firm up debounce logic
marcysutton Nov 27, 2024
06ec023
Clean up stray log and setTimeout testing approach
marcysutton Nov 27, 2024
d54f4be
Add test file I somehow missed
marcysutton Nov 27, 2024
1b7d452
Suppress story artifacts from announcements
marcysutton Dec 3, 2024
f036fde
Add initial timeout back to help Safari/VO
marcysutton Dec 3, 2024
f7455c3
Fix typo in reattachment selector
marcysutton Dec 10, 2024
1137aad
Rename Announcer filenames to lowercase
marcysutton Dec 10, 2024
c326858
Remove console.log
marcysutton Dec 10, 2024
8f85a86
Remove commented-out test code
marcysutton Dec 10, 2024
4927991
Update tests from review feedback
marcysutton Dec 10, 2024
b1c8466
Clean up WIP wonder-blocks-style code
marcysutton Dec 12, 2024
ca255de
Refactor debounce logic and tests
marcysutton Dec 12, 2024
ef8f87a
Clean up storybook styling with custom body class
marcysutton Dec 12, 2024
de339d4
Update jsdoc comments for debounce utility
marcysutton Dec 12, 2024
46d2d03
Fix incorrect object in debounce test
marcysutton Dec 12, 2024
c48d7ba
Allow node access for vanilla JS testing
marcysutton Dec 12, 2024
28a5027
Update dependencies for React 18
marcysutton Dec 12, 2024
38cb940
[announcer-pt1] Fix path issue in story
marcysutton Jan 10, 2025
6844066
[announcer-pt1] Disable Chromatic snapshot for announcer
marcysutton Jan 13, 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/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.
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
127 changes: 127 additions & 0 deletions __docs__/wonder-blocks-announcer/announcer.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
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
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

Copy link
Member Author

Choose a reason for hiding this comment

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

Love these ideas. I will tackle them in a future PR also! Thanks Bea!

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,
},
},
chromatic: {disableSnapshot: 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

Copy link
Member Author

Choose a reason for hiding this comment

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

Good ideas on docs! I will plan to tackle this in a future PR.

},
},
} 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",
},
};
marcysutton marked this conversation as resolved.
Show resolved Hide resolved

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");
});
Comment on lines +65 to +69
Copy link
Contributor

Choose a reason for hiding this comment

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

Question: Can we make these aria-atomic so that they read the whole message instead of browsers/srs trimming similar starting text? Or is that handled in a different way?

Copy link
Member Author

@marcysutton marcysutton Jan 13, 2025

Choose a reason for hiding this comment

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

We can definitely try it! I wasn't noticing differences with the current implementation. But I will caveat that I'm also planning to experiment with a queue of messages that get appended as children and removed on a staggered delay. So aria-atomic might not make sense in that implementation!

Copy link
Member Author

Choose a reason for hiding this comment

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

I made a note to look into this in a future PR, since I want to do some more cross-platform testing anyway!


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