Skip to content

Commit a1439d1

Browse files
authored
This is a feature-branch pull-request from feature/announcer to main (#2515)
## Summary: This PR includes the following commits: - Announcer: Part 1 (#2362) - Announcer: Part 2 - Integrating Announcer into SingleSelect and MultiSelect (#2495) Issue: WB-1891 ## Test plan: 1. Review Announcer, SingleSelect and MultiSelect stories in Safari and VO 2. Ensure live region announcements occur first-time through 3. Ensure announcement messages are relevant to the UX (not stale) Author: marcysutton Reviewers: beaesguerra, marcysutton Required Reviewers: Approved By: beaesguerra Checks: ✅ 16 checks were successful, ⏭️ 2 checks have been skipped Pull Request URL: #2515
2 parents 865c746 + f2014ba commit a1439d1

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+1971
-139
lines changed

.changeset/clean-peas-prove.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@khanacademy/wonder-blocks-announcer": major
3+
---
4+
5+
Introducing WB Announcer API for ARIA Live Regions

.changeset/metal-jokes-promise.md

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

.changeset/plenty-crews-search.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@khanacademy/wonder-blocks-dropdown": minor
3+
---
4+
5+
Integrates Announcer for value announcements in SingleSelect and MultiSelect

.changeset/thirty-ducks-type.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@khanacademy/wonder-blocks-announcer": minor
3+
---
4+
5+
New package for WB Announcer

.storybook/preview.tsx

+28-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as React from "react";
22
import wonderBlocksTheme from "./wonder-blocks-theme";
33
import {Decorator} from "@storybook/react";
44
import {semanticColor} from "@khanacademy/wonder-blocks-tokens";
5+
import {initAnnouncer} from "@khanacademy/wonder-blocks-announcer";
56
import Link from "@khanacademy/wonder-blocks-link";
67
import {ThemeSwitcherContext} from "@khanacademy/wonder-blocks-theming";
78
import {RenderStateRoot} from "../packages/wonder-blocks-core/src";
@@ -148,9 +149,35 @@ const withZoom: Decorator = (Story, context) => {
148149
return <Story />
149150
}
150151

152+
/**
153+
* Injects the Live Region Announcer for various components
154+
*/
155+
const withAnnouncer: Decorator = (
156+
Story,
157+
{parameters: {addBodyClass}},
158+
) => {
159+
// Allow stories to specify a CSS body class
160+
if (addBodyClass) {
161+
document.body.classList.add(addBodyClass);
162+
}
163+
React.useEffect(() => {
164+
// initialize Announcer on load to render Live Regions earlier
165+
initAnnouncer();
166+
return () => {
167+
if (addBodyClass) {
168+
// Remove body class when changing stories
169+
document.body.classList.remove(addBodyClass);
170+
}
171+
};
172+
}, [addBodyClass]);
173+
return (
174+
<Story />
175+
);
176+
};
177+
151178
const preview: Preview = {
152179
parameters,
153-
decorators: [withThemeSwitcher, withLanguageDirection, withZoom],
180+
decorators: [withThemeSwitcher, withLanguageDirection, withZoom, withAnnouncer],
154181
globalTypes: {
155182
// Allow the user to select a theme from the toolbar.
156183
theme: {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import * as React from "react";
2+
import {StyleSheet} from "aphrodite";
3+
import type {Meta, StoryObj} from "@storybook/react";
4+
5+
import {
6+
announceMessage,
7+
type AnnounceMessageProps,
8+
} from "@khanacademy/wonder-blocks-announcer";
9+
import Button from "@khanacademy/wonder-blocks-button";
10+
import {View} from "@khanacademy/wonder-blocks-core";
11+
12+
import ComponentInfo from "../components/component-info";
13+
import packageConfig from "../../packages/wonder-blocks-announcer/package.json";
14+
15+
const AnnouncerExample = ({
16+
message = "Clicked!",
17+
level,
18+
debounceThreshold,
19+
}: AnnounceMessageProps) => {
20+
return (
21+
<Button
22+
onClick={async () => {
23+
const idRef = await announceMessage({
24+
message,
25+
level,
26+
debounceThreshold,
27+
});
28+
/* eslint-disable-next-line */
29+
console.log(idRef);
30+
}}
31+
>
32+
Save
33+
</Button>
34+
);
35+
};
36+
type StoryComponentType = StoryObj<typeof AnnouncerExample>;
37+
38+
/**
39+
* Announcer exposes an API for screen reader messages using ARIA Live Regions.
40+
* It can be used to notify Assistive Technology users without moving focus. Use
41+
* cases include combobox filtering, toast notifications, client-side routing,
42+
* and more.
43+
*
44+
* Calling the `announceMessage` function automatically appends the appropriate live regions
45+
* to the document body. It sends messages at a default `polite` level, with the
46+
* ability to override to `assertive` by passing a `level` argument. You can also
47+
* pass a `debounceThreshold` to wait a specific duration before making another announcement.
48+
*
49+
* To test this API, turn on VoiceOver for Mac/iOS or NVDA on Windows and click the example button.
50+
*
51+
* ### Usage
52+
* ```jsx
53+
* import { appendMessage } from "@khanacademy/wonder-blocks-announcer";
54+
*
55+
* <div>
56+
* <button onClick={() => appendMessage({message: 'Saved your work for you.'})}>
57+
* Save
58+
* </button>
59+
* </div>
60+
* ```
61+
*/
62+
export default {
63+
title: "Packages / Announcer",
64+
component: AnnouncerExample,
65+
decorators: [
66+
(Story): React.ReactElement<React.ComponentProps<typeof View>> => (
67+
<View style={styles.example}>
68+
<Story />
69+
</View>
70+
),
71+
],
72+
parameters: {
73+
addBodyClass: "showAnnouncer",
74+
componentSubtitle: (
75+
<ComponentInfo
76+
name={packageConfig.name}
77+
version={packageConfig.version}
78+
/>
79+
),
80+
docs: {
81+
source: {
82+
// See https://github.com/storybookjs/storybook/issues/12596
83+
excludeDecorators: true,
84+
},
85+
},
86+
chromatic: {disableSnapshot: true},
87+
},
88+
argTypes: {
89+
level: {
90+
control: "radio",
91+
options: ["polite", "assertive"],
92+
},
93+
debounceThreshold: {
94+
control: "number",
95+
type: "number",
96+
description: "(milliseconds)",
97+
},
98+
},
99+
} as Meta<typeof AnnouncerExample>;
100+
101+
/**
102+
* This is an example of a live region with all the options set to their default
103+
* values and the `message` argument set to some example text.
104+
*/
105+
export const SendMessage: StoryComponentType = {
106+
args: {
107+
message: "Here is some example text.",
108+
level: "polite",
109+
},
110+
};
111+
112+
const styles = StyleSheet.create({
113+
example: {
114+
alignItems: "center",
115+
justifyContent: "center",
116+
},
117+
});

__docs__/wonder-blocks-dropdown/combobox.stories.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,7 @@ export const ControlledMultilpleCombobox: Story = {
305305
return (
306306
<Combobox
307307
{...args}
308+
testId="test-combobox"
308309
opened={opened}
309310
onToggle={() => {
310311
setOpened(!opened);
@@ -341,7 +342,7 @@ export const ControlledMultilpleCombobox: Story = {
341342
await userEvent.keyboard("{Enter}");
342343

343344
// Assert
344-
expect(canvas.getByRole("log")).toHaveTextContent(
345+
expect(canvas.getByTestId("test-combobox-status")).toHaveTextContent(
345346
"Pineapple selected, 4 of 10. 10 results available.",
346347
);
347348
},

__docs__/wonder-blocks-dropdown/multi-select.accessibility.mdx

+27
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,30 @@ receives focus. This can be useful when the options contain icons or other infor
4444
that would need to be omitted from the visible label.
4545

4646
<Canvas of={MultiSelectAccessibilityStories.UsingOpenerAriaLabel} />
47+
48+
## Automatic screen reader announcements in `MultiSelect`
49+
50+
`MultiSelect` uses the [Wonder Blocks Announcer](/?path=/docs/packages-announcer--docs)
51+
under the hood for content updates in screen readers, such as the number of items
52+
and the selected value.
53+
54+
This integration works around 2 bugs in VoiceOver and Safari on Mac OSX 14 and 15
55+
where the combobox opener value is cut off and cached incorrectly. The value is
56+
buggy when announced, differing from its current visual presentation and DOM content.
57+
58+
Bugs filed in WebKit include:
59+
60+
1. AX: combobox button value text clipped https://bugs.webkit.org/show_bug.cgi?id=285047
61+
2. AX: VoiceOver does not perceive changes to combobox value in an opener
62+
https://bugs.webkit.org/show_bug.cgi?id=286828
63+
64+
### Testing the Announcer
65+
66+
To observe the affect of the Announcer, you have a few options:
67+
68+
1. Turn on a screen reader such as VoiceOver or NVDA while using the `MultiSelect`
69+
2. Inspect the DOM in the browser and look at the `#wbAnnounce` DIV element
70+
3. Look at the `With visible Announcer` story to see messages appended
71+
visually to the DOM
72+
73+
<Canvas of={MultiSelectAccessibilityStories.WithVisibleAnnouncer} />

__docs__/wonder-blocks-dropdown/multi-select.accessibility.stories.tsx

+33-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {OptionItem, MultiSelect} from "@khanacademy/wonder-blocks-dropdown";
44
import {LabeledField} from "@khanacademy/wonder-blocks-labeled-field";
55
import {View} from "@khanacademy/wonder-blocks-core";
66
import {PhosphorIcon} from "@khanacademy/wonder-blocks-icon";
7+
import {allCountries} from "./option-item-examples";
78

89
export default {
910
title: "Packages / Dropdown / MultiSelect / Accessibility",
@@ -48,7 +49,7 @@ const MultiSelectAriaLabel = () => (
4849
<View>
4950
<MultiSelect
5051
aria-label="Class options"
51-
id="unique-single-select"
52+
id="unique-multi-select"
5253
selectedValues={["one"]}
5354
onChange={() => {}}
5455
>
@@ -129,3 +130,34 @@ export const UsingCustomOpenerAriaLabel = {
129130
render: MultiSelectCustomOpenerLabel.bind({}),
130131
name: "Using aria-label on custom opener",
131132
};
133+
134+
const optionItems = allCountries.map(([code, translatedName]) => (
135+
<OptionItem key={code} value={code} label={translatedName} />
136+
));
137+
138+
const MultiSelectWithVisibleAnnouncer = () => {
139+
const [selectedValues, setSelectedValues] = React.useState<Array<string>>(
140+
[],
141+
);
142+
return (
143+
<View>
144+
<MultiSelect
145+
aria-label="Country"
146+
onChange={setSelectedValues}
147+
isFilterable={true}
148+
selectedValues={selectedValues}
149+
>
150+
{optionItems}
151+
</MultiSelect>
152+
</View>
153+
);
154+
};
155+
156+
export const WithVisibleAnnouncer = {
157+
render: MultiSelectWithVisibleAnnouncer.bind({}),
158+
name: "With visible Announcer",
159+
parameters: {
160+
addBodyClass: "showAnnouncer",
161+
chromatic: {disableSnapshot: true},
162+
},
163+
};

__docs__/wonder-blocks-dropdown/multi-select.stories.tsx

-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import {StyleSheet} from "aphrodite";
44
import {action} from "@storybook/addon-actions";
55
import type {Meta, StoryObj} from "@storybook/react";
66
import {PropsFor, View} from "@khanacademy/wonder-blocks-core";
7-
87
import Button from "@khanacademy/wonder-blocks-button";
98
import {Checkbox} from "@khanacademy/wonder-blocks-form";
109
import {OnePaneDialog, ModalLauncher} from "@khanacademy/wonder-blocks-modal";

__docs__/wonder-blocks-dropdown/single-select.accessibility.mdx

+27
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,30 @@ receives focus. This can be useful when the options contain icons or other infor
4949
that would need to be omitted from the visible label.
5050

5151
<Canvas of={SingleSelectAccessibilityStories.UsingOpenerAriaLabel} />
52+
53+
## Automatic screen reader announcements in `SingleSelect`
54+
55+
`SingleSelect` uses the [Wonder Blocks Announcer](/?path=/docs/packages-announcer--docs)
56+
under the hood for content updates in screen readers, such as the number of items
57+
and the selected value.
58+
59+
This integration works around 2 bugs in VoiceOver and Safari on Mac OSX 14 and 15
60+
where the combobox opener value is cut off and cached incorrectly. The value is
61+
buggy when announced, differing from its current visual presentation and DOM content.
62+
63+
Bugs filed in WebKit include:
64+
65+
1. AX: combobox button value text clipped https://bugs.webkit.org/show_bug.cgi?id=285047
66+
2. AX: VoiceOver does not perceive changes to combobox value in an opener
67+
https://bugs.webkit.org/show_bug.cgi?id=286828
68+
69+
### Testing the Announcer
70+
71+
To observe the affect of the Announcer, you have a few options:
72+
73+
1. Turn on a screen reader such as VoiceOver or NVDA while using the `SingleSelect`
74+
2. Inspect the DOM in the browser and look at the `wbAnnounce` DIV element
75+
3. Look at the `With visible Announcer` story to see messages appended
76+
visually to the DOM
77+
78+
<Canvas of={SingleSelectAccessibilityStories.WithVisibleAnnouncer} />

__docs__/wonder-blocks-dropdown/single-select.accessibility.stories.tsx

+31
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {OptionItem, SingleSelect} from "@khanacademy/wonder-blocks-dropdown";
44
import {View} from "@khanacademy/wonder-blocks-core";
55
import {LabeledField} from "@khanacademy/wonder-blocks-labeled-field";
66
import {PhosphorIcon} from "@khanacademy/wonder-blocks-icon";
7+
import {allCountries} from "./option-item-examples";
78

89
export default {
910
title: "Packages / Dropdown / SingleSelect / Accessibility",
@@ -130,6 +131,36 @@ export const UsingCustomOpenerAriaLabel = {
130131
name: "Using aria-label on custom opener",
131132
};
132133

134+
const optionItems = allCountries.map(([code, translatedName]) => (
135+
<OptionItem key={code} value={code} label={translatedName} />
136+
));
137+
138+
const SingleSelectWithVisibleAnnouncer = () => {
139+
const [selectedValue, setSelectedValue] = React.useState("");
140+
return (
141+
<View>
142+
<SingleSelect
143+
aria-label="Country"
144+
onChange={setSelectedValue}
145+
isFilterable={true}
146+
placeholder="Select a country"
147+
selectedValue={selectedValue}
148+
>
149+
{optionItems}
150+
</SingleSelect>
151+
</View>
152+
);
153+
};
154+
155+
export const WithVisibleAnnouncer = {
156+
render: SingleSelectWithVisibleAnnouncer.bind({}),
157+
name: "With visible Announcer",
158+
parameters: {
159+
addBodyClass: "showAnnouncer",
160+
chromatic: {disableSnapshot: true},
161+
},
162+
};
163+
133164
// This story exists for debugging automated unit tests.
134165
const SingleSelectKeyboardSelection = () => {
135166
const [selectedValue, setSelectedValue] = React.useState("");
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"name": "@khanacademy/wonder-blocks-announcer",
3+
"version": "0.0.1",
4+
"design": "v1",
5+
"description": "Live Region Announcer for Wonder Blocks.",
6+
"main": "dist/index.js",
7+
"module": "dist/es/index.js",
8+
"source": "src/index.js",
9+
"scripts": {
10+
"test": "echo \"Error: no test specified\" && exit 1"
11+
},
12+
"types": "dist/index.d.ts",
13+
"author": "",
14+
"license": "MIT",
15+
"publishConfig": {
16+
"access": "public"
17+
},
18+
"dependencies": {
19+
"@babel/runtime": "catalog:",
20+
"@khanacademy/wonder-blocks-core": "^9.0.0"
21+
},
22+
"peerDependencies": {
23+
"aphrodite": "catalog:",
24+
"react": "catalog:"
25+
},
26+
"devDependencies": {
27+
}
28+
}

0 commit comments

Comments
 (0)