Skip to content

Commit

Permalink
WB-1676.2: Add a11y features to announce combobox changes to screen r…
Browse files Browse the repository at this point in the history
…eaders (#2225)

## Summary:

This PR focuses on bringing a better accessibility experience by adding a live region to announce the different combobox statuses:
- When the visual focus changes in the listbox.
- When the visual focus changes in the selected options (multi-select mode).
- When it is closed.
- Describing a state for the focused option. e.g. `selected`, `disabled`.

### Combobox Implementation Plan:

1. #2216
2. #2221
3. #2223
4. **Improve accessibility support on Combobox (labels, aria-live).[CURRENT]**
5. Add autocomplete support to `Combobox` component.
6. Add async/dynamic support to `Combobox` component.


Issue: https://khanacademy.atlassian.net/browse/WB-1676

## Test plan:

Turn on Voice Over and use Safari:

### Single Selection:

1. Navigate to: /iframe.html?args=&id=packages-dropdown-combobox--single-select-combobox&viewMode=story
2. Using the keyboard, open the listbox by focusing on the input then pressing `arrow down`.
3. Verify that the Screen Reader announces the selected item (`Pear selected, 3 of 9. 9 results available`).
4. Navigate on the listbox by using the arrow keys.
5. Verify that it announces the currently focused item (e.g. `Strawberry disabled, 2 of 9. 9 results available`).
6. Using the keyboard, Select one of the enabled options.
7. Verify that it announces the selection (e.g. `Apple selected`) and the listbox is closed.

https://github.com/user-attachments/assets/dd7f6173-e00b-4a96-bed3-c5b0df6c2fbd


### Multiple Selection:

1. Navigate to /iframe.html?args=&id=packages-dropdown-combobox--multiple-selection&viewMode=story
2. Using the keyboard, open the listbox by focusing on the input then pressing `arrow down`.
3. Verify that the Screen Reader announces the selected item (`Pear selected, 3 of 9. 9 results available`).
4. Navigate on the listbox by using the arrow keys.
5. Verify that it announces the currently focused item (e.g. `Strawberry disabled, 2 of 9. 9 results available`).
6. Using the keyboard, Select one of the enabled options (pressing `Enter` on the focused option).
7. Verify that it announces the selection (e.g. `Apple selected, 5 of 9. 9 results available.`) and the listbox remains open).
9. Press arrow left or arrow right to move visual focus to the selected pills.
10. Verify that the focus moves to one of the pills and SR announces that (e.g. `Grape focused, 3 of 3. 3 selected options`).

https://github.com/user-attachments/assets/80a6eabc-ef82-4828-8c18-ed8e05bf8fee

Author: jandrade

Reviewers: beaesguerra, jandrade

Required Reviewers:

Approved By: beaesguerra

Checks: ✅ Chromatic - Get results on regular PRs (ubuntu-latest, 20.x), ✅ Test (ubuntu-latest, 20.x, 2/2), ✅ Test (ubuntu-latest, 20.x, 1/2), ✅ Check build sizes (ubuntu-latest, 20.x), ✅ Lint (ubuntu-latest, 20.x), ✅ Chromatic - Build on regular PRs / chromatic (ubuntu-latest, 20.x), ⏭️  Chromatic - Skip on Release PR (changesets), ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ Prime node_modules cache for primary configuration (ubuntu-latest, 20.x), ✅ gerald, ⏭️  dependabot

Pull Request URL: #2225
  • Loading branch information
jandrade authored Aug 28, 2024
1 parent 5fb863d commit 2ad690b
Show file tree
Hide file tree
Showing 10 changed files with 671 additions and 27 deletions.
5 changes: 5 additions & 0 deletions .changeset/gorgeous-buttons-drop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/wonder-blocks-dropdown": minor
---

Add a11y features to announce combobox changes to screen readers
86 changes: 86 additions & 0 deletions __docs__/wonder-blocks-dropdown/combobox-accessibility.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import {Meta} from "@storybook/blocks";

<Meta title="Packages / Dropdown / Combobox / Accessibility" />

# Combobox Accessibility

The Combobox component is built with accessibility in mind. It includes the
following features (as advised by the <a
href="https://www.w3.org/WAI/ARIA/apg/patterns/combobox/">WAI-ARIA Authoring
Practices</a>):

## Keyboard interaction

#### Combobox (input):

- `Tab`: The combobox is focused.
- `ArrowDown`: Opens the listbox if it is not already displayed and moves
visual focus to the first option. DOM focus remains on the combobox (input).
- `ArrowUp`: Opens the listbox if it is not already displayed and moves visual
focus to the last option. DOM focus remains on the combobox (input).
- `Escape`: Closes the listbox if displayed.

#### Listbox (Dropdown menu):

- `ArrowDown`: Moves visual focus to the next option. If the visual focus is
on the last option, the visual focus moves to the first option (cyclic
navigation).
- `ArrowUp`: Moves visual focus to the previous option. If the visual focus in
on the first option, the visual focus moves to the last option (cyclic
navigation).
- `Home`: Moves visual focus to the first option.
- `End`: Moves visual focus to the last option.
- `Enter`: Sets the Textbox/combobox value to the content of the focused
option in the listbox, then closes the listbox if only one selection can be made.
- `Escape`: Closes the listbox.

#### Button (Arrow down icon):

- It is removed from the tab sequence of the page, but still useful to open
the listbox via touch events (e.g. click, press).

## Screen Reader support

The Combobox component is designed to be screen reader friendly.

We provide a custom live region that will announce changes to the combobox due
to some limitations in browsers (e.g. Safari). This live region will announce
the following:

- When the listbox is opened, the focused option in the listbox, including the
index of the option and the total number of options (e.g. `Pear selected, 3
of 9. 9 results available`).
- When the user navigates through the listbox, the focused option in the listbox,
including the index of the option and the total number of options (e.g.
`Strawberry disabled, 2 of 9. 9 results available`).
- When an option is selected (e.g. `Pear selected`).
- When the listbox is closed.

## ARIA attributes

#### Combobox (input):

- The input[type=text] has a `combobox` role.
- `aria-controls` points to the `listbox` id.
- `aria-expanded` will change based on the listbox state (true = visible, false
= hidden).
- `aria-activedescendant` To announce which option item is visually focused.
(see <a
href="https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_focus_activedescendant">Developing
a Keyboard Interface</a>).
- `autocomplete` is set `off`, so we don't present the default autocomplete
list provided by browsers, and instead rely on the listbox controlled by the
input.

#### Listbox (Dropdown menu):

- The listbox has a `listbox` role.
- `aria-disabled`: Whether the listbox container is disabled.
- Labelling:
- `aria-label`: Provides a label for the listbox.
- `aria-labelledby`: Alternatively, this can be provided by pointing to a
DOM reference (ID) that contains the description of the listbox.
- `aria-multiselectable`: Whether the user can select more than one option.
- `aria-readonly`: The user cannot change which options are selected or
unselected, but the listbox is otherwise operable.
- `aria-required`: Whether an option item must be selected.
20 changes: 19 additions & 1 deletion __docs__/wonder-blocks-dropdown/combobox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {action} from "@storybook/addon-actions";
import {Meta, StoryObj} from "@storybook/react";
import {StyleSheet} from "aphrodite";
import * as React from "react";
import {expect, userEvent, within} from "@storybook/test";
import {color, spacing} from "@khanacademy/wonder-blocks-tokens";
import {Combobox, OptionItem} from "@khanacademy/wonder-blocks-dropdown";
import {PropsFor, View} from "@khanacademy/wonder-blocks-core";
Expand Down Expand Up @@ -182,7 +183,7 @@ export const Disabled = {
* - `Enter` to select an item.
* - Arrow keys (`left`, `right`) to navigate through the selected items.
*/
export const MultipleSelection = {
export const MultipleSelection: Story = {
render: function Render(args: PropsFor<typeof Combobox>) {
const [value, setValue] = React.useState(args.value);

Expand All @@ -208,6 +209,23 @@ export const MultipleSelection = {
disableSnapshot: true,
},
},
play: async ({canvasElement}) => {
const canvas = within(canvasElement);
// focus on the combobox (input)
await userEvent.tab();

// Move to second option item
await userEvent.keyboard("{ArrowDown}");

// Act
// Select the second option item
await userEvent.keyboard("{Enter}");

// Assert
expect(canvas.getByRole("log")).toHaveTextContent(
"Orange selected, 4 of 9. 9 results available.",
);
},
};

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import * as React from "react";
import {render, screen} from "@testing-library/react";
import {ComboboxLiveRegion} from "../combobox-live-region";
import OptionItem from "../option-item";

const options = [
<OptionItem key="1" label="Option 1" value="option1" />,
<OptionItem key="2" label="Option 2" value="option2" />,
];

describe("ComboboxLiveRegion", () => {
it("doesn't announce anything by default", () => {
// Arrange

// Act
render(
<ComboboxLiveRegion
focusedIndex={-1}
focusedMultiSelectIndex={-1}
options={options}
selectedLabels={[]}
selected={null}
opened={false}
/>,
);

// Assert
expect(screen.getByRole("log")).toHaveTextContent("");
});

describe("multi-select", () => {
it("announces when a pill is focused", () => {
// Arrange

// Act
render(
<ComboboxLiveRegion
focusedIndex={-1}
focusedMultiSelectIndex={0}
options={options}
selected={["option2"]}
selectedLabels={["Option 2"]}
opened={true}
/>,
);

// Assert
expect(screen.getByRole("log")).toHaveTextContent(
"Option 2 focused, 1 of 1. 1 selected options.",
);
});

it("does not announce anything when there are no selected pills", () => {
// Arrange
const {rerender} = render(
<ComboboxLiveRegion
focusedIndex={-1}
focusedMultiSelectIndex={-1}
options={options}
selected={["option2"]}
selectedLabels={["Option 2"]}
opened={true}
/>,
);

// Act
rerender(
<ComboboxLiveRegion
focusedIndex={-1}
focusedMultiSelectIndex={-1}
options={options}
selected={null}
selectedLabels={[]}
opened={true}
/>,
);

// Assert
expect(screen.getByRole("log")).toHaveTextContent("");
});

it("announces when an item is selected", () => {
// Arrange
const options = [
<OptionItem
key="1"
label="Option 1"
value="option1"
selected={true}
/>,
<OptionItem key="2" label="Option 2" value="option2" />,
];

// Act
// select the first option
render(
<ComboboxLiveRegion
focusedIndex={0}
focusedMultiSelectIndex={-1}
options={options}
selected={["option1"]}
selectedLabels={["Option 1"]}
opened={true}
/>,
);

// Assert
expect(screen.getByRole("log")).toHaveTextContent(
"Option 1 selected, 1 of 2. 2 results available.",
);
});

it("announces when an item is unselected", () => {
// Arrange
// Focus on the first pill
const {rerender} = render(
<ComboboxLiveRegion
focusedIndex={-1}
focusedMultiSelectIndex={1}
options={options}
selected={["option1", "option2"]}
selectedLabels={["Option 1", "Option 2"]}
opened={true}
/>,
);

// Act
rerender(
<ComboboxLiveRegion
focusedIndex={-1}
focusedMultiSelectIndex={0}
options={options}
selected={["option1"]}
// Option 2 is removed
selectedLabels={["Option 1"]}
opened={true}
/>,
);

// Assert
expect(screen.getByRole("log")).toHaveTextContent(
"Option 1 focused, 1 of 1. 1 selected options.",
);
});

it("announces when it is closed", () => {
// Arrange
const {rerender} = render(
<ComboboxLiveRegion
focusedIndex={-1}
focusedMultiSelectIndex={-1}
options={options}
selectedLabels={[]}
selected={null}
selectionType="multiple"
opened={true}
/>,
);

// Act
rerender(
<ComboboxLiveRegion
focusedIndex={-1}
focusedMultiSelectIndex={-1}
options={options}
selectedLabels={[]}
selected={null}
selectionType="multiple"
opened={false}
/>,
);

// Assert
expect(screen.getByRole("log")).toHaveTextContent(
"Combobox is closed",
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -366,11 +366,12 @@ describe("Combobox", () => {
expect(onChange).toHaveBeenCalledWith(["option2", "option3"]);
});

it("should remove visual focus from the listbox when navigating to the selected pills", async () => {
it("should move visual focus to the selected pill", async () => {
// Arrange
const userEvent = doRender(
<Combobox
testId="combobox"
id="combobox"
selectionType="multiple"
value={["option1", "option2"]}
>
Expand All @@ -388,8 +389,9 @@ describe("Combobox", () => {
await userEvent.keyboard("{ArrowLeft}");

// Assert
expect(screen.getByRole("combobox")).not.toHaveAttribute(
expect(screen.getByRole("combobox")).toHaveAttribute(
"aria-activedescendant",
"combobox-pill-1",
);
});

Expand Down Expand Up @@ -537,5 +539,33 @@ describe("Combobox", () => {
screen.getByRole("button", {name: "Remove option 2"}),
).toBeInTheDocument();
});

describe("LiveRegion", () => {
it("should announce when an item is selected", async () => {
// Arrange
doRender(
<Combobox selectionType="multiple" value={["option1"]}>
<OptionItem label="Option 1" value="option1" />
<OptionItem label="Option 2" value="option2" />
<OptionItem label="Option 3" value="option3" />
</Combobox>,
);

// focus on the combobox (input)
await userEvent.tab();

// Move to second option item
await userEvent.keyboard("{ArrowDown}");

// Act
// Select the second option item
await userEvent.keyboard("{Enter}");

// Assert
expect(screen.getByRole("log")).toHaveTextContent(
"Option 2 selected, 2 of 3. 3 results available.",
);
});
});
});
});
Loading

0 comments on commit 2ad690b

Please sign in to comment.