-
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
base: main
Are you sure you want to change the base?
Announcer: Part 1 #2362
Changes from all commits
0d9daf9
737fdc5
78f1208
b3a31d4
74ad711
0eb7de3
093c1c5
4563523
d21b853
a613e12
bcb7762
954feb5
518576d
3cdbc65
5791768
47f8300
b19c852
389452a
a283cc7
05018de
925e1b7
4729428
37ac932
9927464
cba83f9
8526915
ea2d136
4b244e5
4702093
0527a99
2db4a42
8289cae
91a9a19
9eac9b2
76c06ff
c0e3d06
55f3ce3
fa29ada
080ee31
c44e59a
7e1912f
7eacfef
b961200
50f263b
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,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 | ||
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)", | ||
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. |
||
}, | ||
}, | ||
} 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", | ||
}, | ||
}; | ||
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 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
|
||
}); |
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(); | ||
}); | ||
}); | ||
}); |
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: