-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
## 📝 Changes - Add spec for `MultiSelect` component - Provides ability to select multiple items from a searchable list utilizing `PillGroup` and `ComboBox` <img width="474" alt="image" src="https://github.com/user-attachments/assets/c9a9c53e-a623-4782-b497-95da8ae55f55" />
- Loading branch information
1 parent
aa37887
commit f275744
Showing
1 changed file
with
224 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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`) |