-
Notifications
You must be signed in to change notification settings - Fork 10
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
Announcer: Part 1 #2362
Changes from all commits
edae08a
3d7d789
4742d4a
a4743eb
00c5a9b
3ecbcf3
a6fd1b2
af5676e
c3b0964
2078efb
420e334
517c579
e412461
1cd7615
eadc8f4
5bec710
7c832e6
7a4b91e
4f56d91
4397a20
c2d028c
9d95f45
ca72294
db16895
b031eef
d421d5b
38dbf45
0428895
06ec023
d54f4be
1b7d452
f036fde
f7455c3
1137aad
c326858
8f85a86
4927991
b1c8466
ca255de
ef8f87a
de339d4
46d2d03
c48d7ba
28a5027
38cb940
6844066
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"@khanacademy/wonder-blocks-announcer": minor | ||
--- | ||
|
||
New package for WB Announcer |
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 | ||
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)", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 cc: @jandrade in case you have come across similar things before! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 For example, if we add |
||
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
|
||
}); |
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Question: Can we make these There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(); | ||
}); | ||
}); | ||
}); |
There was a problem hiding this comment.
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:
There was a problem hiding this comment.
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!