Skip to content
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

spec: MultiSelect #1561

Merged
merged 9 commits into from
Jan 9, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 224 additions & 0 deletions documentation/specs/MultiSelect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
# `MultiSelect` Component Specification

## Overview

The `MultiSelect` component allows users to select multiple options from a list.

### Use Cases

- Used in forms requiring multi-selection fields.
- Suitable for workflows needing multiple discrete selections.

### Features

- Dynamic filtering of options via text input.
- Pill-based display of selected items with removable pills.
- Customizable rendering for items and pills.
- Configurable maximum number of visible options before scrolling.
- Keyboard and mouse navigation support.
- Accessibility compliant with ARIA roles and attributes.

### Risks and Challenges

- Performance optimization for large datasets.
- Maintaining accessibility while supporting extensive customization.
- Handling dynamic dropdown positioning within constrained containers.

### Prior Art

- [MUI `<Autocomplete />`](https://mui.com/material-ui/react-autocomplete/)
- [Ant Design `<Select mode="multiple" />`](https://ant.design/components/select/)
- [Chakra UI `<Select />`](https://chakra-ui.com/docs/components/select)
- [React Spectrum `<ComboBox />`](https://react-spectrum.adobe.com/react-spectrum/ComboBox.html)

---

## Design

### API

The `MultiSelect` API is a limited subset of `react-aria-component`'s `ComboBox` component extended to support multiple selected keys. Support for more `ComboBox` functionality may be added in the future.

`MultiSelect` is an entirely controlled component. Support for uncontrolled behavior may be added in the future.

`items` passed to a `MultiSelect` extend `PillProps`. They must contain a `key` and `label`. Optionally they can have an attached `icon` for rendering in the dropdown option and pill.

`MultiSelect` supports dynamic item and pill rendering.

```ts
type MultiSelectProps<T extends object> = {
/** Children or render function for dropdown options. */
children: React.ReactNode | ((item: T) => React.ReactNode);

/** List of disabled item keys. */
disabledKeys?: Array<Key>;

/** Array of dropdown items (dynamic or async). */
dropdownItems: T[];

/** Current input value for filtering dropdown items. */
inputValue: string;

/** Flag indicating if the dropdown is in a loading state. */
isLoading?: boolean;

/** Maximum number of items visible before scrolling. */
maxItemsUntilScroll?: number;

/** Callback fired when the input value changes. */
onInputChange: (value: string) => void;

/** Callback fired when the selection changes. */
onSelectionChange: (items: T[]) => void;

/** Placeholder text when no items are selected. */
placeholder?: string;

/** Render function for each selected pill. */
renderPill: (item: T) => React.ReactNode;

/** Array of currently selected items. */
selectedItems: T[];
};
```

### Example Usage

_Sync list_

```tsx
import { Item, MultiSelect, Key, useListData, useFilter } from "./MultiSelect";

const dropdownItems = [
{ key: "1", label: "Option 1" },
{ key: "2", label: "Option 2" },
{ key: "3", label: "Option 3" },
];

function App() {
const [selectedItems, setSelectedItems] = React.useState<Item[]>([]);
// supports initially selected items
// const [selectedItems, setSelectedItems] = React.useState<Item[]>([
// dropdownItems[0],
// ]);
const { contains } = useFilter({ sensitivity: "base" });
const filter = useCallback(
(item: Item, filterText: string) => contains(item.label, filterText),
[contains],
);
const list = useListData<Item>({
// supports initially selected keys
// initialSelectedKeys: selectedItems.map((i) => i.key),
initialItems: dropdownItems,
filter,
});
return (
<MultiSelect
dropdownItems={list.items}
inputValue={list.filterText}
onInputChange={list.setFilterText}
disabledKeys={selectedItems.map((item) => item.key)}
selectedItems={selectedItems}
onSelectionChange={setSelectedItems}
placeholder="Select an item"
maxItemsUntilScroll={10}
renderPill={(item) => (
<MultiSelect.Pill icon={item.icon} label={item.label} />
)}
>
{(item) => (
<MultiSelect.Option textValue={item.label}>
<HorizontalStack gap="1" blockAlign="center">
{item.icon && <Icon symbol={item.icon} />}
<MultiSelect.OptionText>{item.label}</MultiSelect.OptionText>
</HorizontalStack>
</MultiSelect.Option>
)}
</MultiSelect>
);
}
```

_Async list_

```tsx
import { MultiSelect, Item, Key, useAsyncList, useFilter } from "./MultiSelect";

function App() {
const [selectedItems, setSelectedItems] = React.useState<Item[]>([]);
// supports initially selected items
// const [selectedItems, setSelectedItems] = React.useState<Item[]>([
// { key: 1, label: "Option 1" },
// ]);
const list = useAsyncList<Item>({
initialSelectedKeys: [],
// supports initially selected keys
// initialSelectedKeys: selectedItems.map((i) => i.key),
async load({ filterText }) {
// perform a filtered fetch on the server
const response = await fetch(`/api/endpoint?filter=${filterText}`);
const json = await response.json();
return { items: json.items };
},
});
return (
<MultiSelect
isLoading={list.isLoading}
dropdownItems={list.items}
inputValue={list.filterText}
onInputChange={list.setFilterText}
disabledKeys={selectedItems.map((item) => item.key)}
selectedItems={selectedItems}
onSelectionChange={setSelectedItems}
placeholder="Select an item"
maxItemsUntilScroll={10}
renderPill={(item) => (
<MultiSelect.Pill icon={item.icon} label={item.label} />
)}
>
{(item) => (
<MultiSelect.Option textValue={item.label}>
<HorizontalStack gap="1" blockAlign="center">
{item.icon && <Icon symbol={item.icon} />}
<MultiSelect.OptionText>{item.label}</MultiSelect.OptionText>
</HorizontalStack>
</MultiSelect.Option>
)}
</MultiSelect>
);
}
```

### Anatomy

- **Pill Group**: Displays selected items as removable tags.
- **ComboBox Dropdown**: Contains selectable options.
- **ComboBox Option**: A single item in the dropdown.
- **ComboBox Input**: Allows filtering of dropdown options.
- **ComboBox Trigger**: Activates the dropdown.

### Behavior

#### Accessibility

- ARIA roles (`combobox`, `listbox`, `option`) are applied.
- Keyboard support:
- Arrow keys to navigate options.
- Enter/space to select an option.
- Backspace to remove tags.
- Screen readers announce active options and selection states.

#### Interactions

- Selecting an option adds it to the selected items.
- Removing a tag removes the corresponding item from the selection.
- Input field supports filtering and keyboard interactions.

---

## Dependencies

- `react-aria`—`useFilter`, `ComboBox`, `ListBox`, `Popover`, `Button`
- `react-stately`—`useListData`
- `@easypost/easy-ui-icons`—Icons for visual elements
- `@easypost/easy-ui`—Supporting utilities (e.g., `PillGroup`, `Text`)
Loading