Skip to content

Commit

Permalink
WB-1676: Combobox - Add multiple Selection support (#2223)
Browse files Browse the repository at this point in the history
## Summary:

Create the multiple version that displays the selected values as individual Pill
components that can be deleted/unchecked.

Added a new hook to manage keyboard navigation for the multiple selection logic.

### Combobox Implementation Plan:

1. #2216
2. #2221
3. **Add multiple selection support to `Combobox` component.[CURRENT]**
4. Improve accessibility support on Combobox (labels, aria-live).
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:

1. Navigate to: /?path=/docs/packages-dropdown-combobox--docs#multiple%20selection
2. Use the multi-select version of the Combobox.
3. Navigate using the keyboard.
4. Verify that you can select multiple options and can delete selected options using the keyboard.

### Keyboard navigation instructions (when the combobox is focused):
- `Arrow Up | Arrow Down`: to navigate the listbox options
- `Arrow left | Arrow right`: to navigate the selected options (pills displayed before the input)
- `Backspace`: removes the last pill in the stack
- `Enter`:
  - When the listbox is focused: unselects an option from the list
  - When the pills are focused: removes the currently focused pill and unselects the associated option in the listbox.


https://github.com/Khan/wonder-blocks/assets/843075/7ad1476a-df73-42e0-87a3-2e7bf80c6b77

Author: jandrade

Reviewers: jeresig

Required Reviewers:

Approved By: jeresig

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

Pull Request URL: #2223
  • Loading branch information
jandrade authored Aug 27, 2024
1 parent ea506e8 commit 5fb863d
Show file tree
Hide file tree
Showing 11 changed files with 748 additions and 54 deletions.
5 changes: 5 additions & 0 deletions .changeset/giant-coats-fail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/wonder-blocks-dropdown": minor
---

Add multiple selection support to `Combobox`
5 changes: 5 additions & 0 deletions .changeset/mean-eagles-explode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/wonder-blocks-pill": minor
---

Add `tabIndex` prop to customize keyboard focusability of clickable pills
78 changes: 78 additions & 0 deletions __docs__/wonder-blocks-dropdown/combobox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,3 +170,81 @@ export const Disabled = {
value: "pear",
},
};

/**
* Combobox supports multiple selection. This means that more than one element
* can be selected from the listbox at a time. In this example, we show how this
* is done by using an array of strings as the value and setting the
* `selectionType` prop to "multiple".
*
* To navigate using the keyboard, use:
* - Arrow keys (`up`, `down`) to navigate through the listbox.
* - `Enter` to select an item.
* - Arrow keys (`left`, `right`) to navigate through the selected items.
*/
export const MultipleSelection = {
render: function Render(args: PropsFor<typeof Combobox>) {
const [value, setValue] = React.useState(args.value);

return (
<Combobox
{...args}
value={value}
onChange={(newValue) => {
setValue(newValue);
action("onChange")(newValue);
}}
/>
);
},
args: {
children: items,
value: ["pear", "grape"],
selectionType: "multiple",
},
parameters: {
chromatic: {
// we don't need screenshots because this story only tests behavior.
disableSnapshot: true,
},
},
};

/**
* This example shows how to use the multi-select `Combobox` component in
* controlled mode.
*/
export const ControlledMultilpleCombobox: Story = {
name: "Controlled Multi-select Combobox (opened state)",
render: function Render(args: PropsFor<typeof Combobox>) {
const [opened, setOpened] = React.useState(args.opened);
const [value, setValue] = React.useState(args.value);

return (
<Combobox
{...args}
opened={opened}
onToggle={() => {
setOpened(!opened);
action("onToggle")();
}}
onChange={(newValue) => {
setValue(newValue);
action("onChange")(newValue);
}}
value={value}
/>
);
},
args: {
children: items,
opened: true,
value: ["pear", "grape"],
selectionType: "multiple",
},
decorators: [
(Story): React.ReactElement<React.ComponentProps<typeof View>> => (
<View style={styles.wrapper}>{Story()}</View>
),
],
};
1 change: 1 addition & 0 deletions packages/wonder-blocks-dropdown/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"@khanacademy/wonder-blocks-icon": "^4.1.4",
"@khanacademy/wonder-blocks-layout": "^2.2.0",
"@khanacademy/wonder-blocks-modal": "^5.1.10",
"@khanacademy/wonder-blocks-pill": "^2.4.6",
"@khanacademy/wonder-blocks-search-field": "^2.2.24",
"@khanacademy/wonder-blocks-timing": "^5.0.1",
"@khanacademy/wonder-blocks-tokens": "^2.0.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -249,4 +249,293 @@ describe("Combobox", () => {
// Assert
expect(screen.getByRole("combobox")).not.toHaveFocus();
});

describe("multiple selection", () => {
it("should render the combobox", () => {
// Arrange

// Act
doRender(
<Combobox selectionType="multiple" value={["option2"]}>
<OptionItem label="option 1" value="option1" />
<OptionItem label="option 2" value="option2" />
<OptionItem label="option 3" value="option3" />
</Combobox>,
);

// Assert
expect(screen.getByRole("combobox")).toBeInTheDocument();
});

it("should assign the correct values when passed in", () => {
// Arrange

// Act
doRender(
<Combobox
selectionType="multiple"
value={["option2", "option3"]}
opened={true}
>
<OptionItem label="option 1" value="option1" />
<OptionItem label="option 2" value="option2" />
<OptionItem label="option 3" value="option3" />
</Combobox>,
);

// Assert
const selectedOptions = screen.getAllByRole("option", {
hidden: true,
selected: true,
});
expect(selectedOptions).toHaveLength(2);
//Also verify that the selected options are correct.
expect(selectedOptions[0]).toHaveTextContent("option 2");
expect(selectedOptions[1]).toHaveTextContent("option 3");
});

it("should display the correct pill when one value is selected", () => {
// Arrange

// Act
doRender(
<Combobox
selectionType="multiple"
testId="test"
value={["option2"]}
>
<OptionItem label="option 1" value="option1" />
<OptionItem label="option 2" value="option2" />
<OptionItem label="option 3" value="option3" />
</Combobox>,
);

// Assert
// pills
expect(screen.getByTestId("test-pill-0")).toHaveTextContent(
"option 2",
);
});

it("should use the correct label for a selected item (pills)", () => {
// Arrange

// Act
doRender(
<Combobox selectionType="multiple" value={["option2"]}>
<OptionItem label="option 1" value="option1" />
<OptionItem label="option 2" value="option2" />
<OptionItem label="option 3" value="option3" />
</Combobox>,
);

// Assert
// pills
expect(
screen.getByRole("button", {name: "Remove option 2"}),
).toBeInTheDocument();
});

it("should call the onChange callback when the value changes", async () => {
// Arrange
const onChange = jest.fn();
const userEvent = doRender(
<Combobox
selectionType="multiple"
value={["option2"]}
onChange={onChange}
>
<OptionItem label="option 1" value="option1" />
<OptionItem label="option 2" value="option2" />
<OptionItem label="option 3" value="option3" />
</Combobox>,
);

await userEvent.click(
screen.getByRole("button", {name: /toggle listbox/i}),
);
await screen.findByRole("listbox", {hidden: true});

// Act
const options = screen.getAllByRole("option", {
hidden: true,
});
await userEvent.click(options[2]);

// Assert
expect(onChange).toHaveBeenCalledWith(["option2", "option3"]);
});

it("should remove visual focus from the listbox when navigating to the selected pills", async () => {
// Arrange
const userEvent = doRender(
<Combobox
testId="combobox"
selectionType="multiple"
value={["option1", "option2"]}
>
<OptionItem label="option 1" value="option1" />
<OptionItem label="option 2" value="option2" />
<OptionItem label="option 3" value="option3" />
</Combobox>,
);

// Focus the combobox
await userEvent.tab();

// Act
// Navigate to the last pill in the stack
await userEvent.keyboard("{ArrowLeft}");

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

it("should remove the last pill using the left arrow key", async () => {
// Arrange
const userEvent = doRender(
<Combobox
testId="combobox"
selectionType="multiple"
value={["option1", "option2"]}
>
<OptionItem label="option 1" value="option1" />
<OptionItem label="option 2" value="option2" />
<OptionItem label="option 3" value="option3" />
</Combobox>,
);

// Focus the combobox
await userEvent.tab();
// Navigate to the last pill in the stack
await userEvent.keyboard("{ArrowLeft}");

// Act
await userEvent.keyboard("{Enter}");

// Assert
expect(
screen.getAllByRole("button", {name: /remove/i}),
).toHaveLength(1);
expect(
screen.getByRole("button", {name: "Remove option 1"}),
).toBeInTheDocument();
});

it("should remove the first pill using the right arrow key", async () => {
// Arrange
const userEvent = doRender(
<Combobox
testId="combobox"
selectionType="multiple"
value={["option1", "option2"]}
>
<OptionItem label="option 1" value="option1" />
<OptionItem label="option 2" value="option2" />
<OptionItem label="option 3" value="option3" />
</Combobox>,
);

// Focus the combobox
await userEvent.tab();
// Navigate to the last pill in the stack
await userEvent.keyboard("{ArrowRight}");

// Act
await userEvent.keyboard("{Enter}");

// Assert
expect(
screen.getAllByRole("button", {name: /remove/i}),
).toHaveLength(1);
expect(
screen.getByRole("button", {name: "Remove option 2"}),
).toBeInTheDocument();
});

it("should remove a pill using the delete key", async () => {
// Arrange
const userEvent = doRender(
<Combobox
testId="combobox"
selectionType="multiple"
value={["option1", "option2"]}
>
<OptionItem label="option 1" value="option1" />
<OptionItem label="option 2" value="option2" />
<OptionItem label="option 3" value="option3" />
</Combobox>,
);

// Focus the combobox
await userEvent.tab();
// Navigate to the last pill in the stack
await userEvent.keyboard("{ArrowLeft}");

// Act
await userEvent.keyboard("{Backspace}");

// Assert
expect(
screen.getAllByRole("button", {name: /remove/i}),
).toHaveLength(1);
});

it("should remove all the pills using the delete key", async () => {
// Arrange
const userEvent = doRender(
<Combobox
testId="combobox"
selectionType="multiple"
value={["option1", "option2"]}
>
<OptionItem label="option 1" value="option1" />
<OptionItem label="option 2" value="option2" />
<OptionItem label="option 3" value="option3" />
</Combobox>,
);

// Focus the combobox
await userEvent.tab();

// Act
await userEvent.keyboard("{Backspace}");
await userEvent.keyboard("{Backspace}");

// Assert
expect(
screen.queryAllByRole("button", {name: /remove/i}),
).toHaveLength(0);
});

it("should remove a pill by pressing it", async () => {
// Arrange
const userEvent = doRender(
<Combobox
testId="combobox"
selectionType="multiple"
value={["option1", "option2"]}
>
<OptionItem label="option 1" value="option1" />
<OptionItem label="option 2" value="option2" />
<OptionItem label="option 3" value="option3" />
</Combobox>,
);

// Act
await userEvent.click(
screen.getByRole("button", {name: "Remove option 1"}),
);

// Assert
expect(
screen.getAllByRole("button", {name: /remove/i}),
).toHaveLength(1);
expect(
screen.getByRole("button", {name: "Remove option 2"}),
).toBeInTheDocument();
});
});
});
Loading

0 comments on commit 5fb863d

Please sign in to comment.