A set of primitives to build simple, flexible, WAI-ARIA compliant React autocomplete, combobox or select dropdown components.
—
Quality
Pending
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
The useMultipleSelection hook provides functionality for managing multiple item selection state. It's designed to compose with other downshift hooks to create complex multi-selection scenarios like tag inputs, multi-select dropdowns, and transfer lists.
Creates a multiple selection component that manages selected items and active index state.
/**
* Hook for managing multiple item selection state
* @param props - Configuration options for multiple selection
* @returns Object containing state, actions, and prop getters
*/
function useMultipleSelection<Item>(
props?: UseMultipleSelectionProps<Item>
): UseMultipleSelectionReturnValue<Item>;
interface UseMultipleSelectionProps<Item> {
/** Array of currently selected items */
selectedItems?: Item[];
/** Initial selected items when component mounts */
initialSelectedItems?: Item[];
/** Default selected items for uncontrolled usage */
defaultSelectedItems?: Item[];
/** Function to generate a unique key for each item */
itemToKey?: (item: Item | null) => any;
/** Function to generate accessibility status messages */
getA11yStatusMessage?: (options: UseMultipleSelectionState<Item>) => string;
/** Custom state reducer for advanced state management */
stateReducer?: (
state: UseMultipleSelectionState<Item>,
actionAndChanges: UseMultipleSelectionStateChangeOptions<Item>
) => Partial<UseMultipleSelectionState<Item>>;
/** Index of currently active selected item */
activeIndex?: number;
/** Initial active index when component mounts */
initialActiveIndex?: number;
/** Default active index for uncontrolled usage */
defaultActiveIndex?: number;
/** Callback when active index changes */
onActiveIndexChange?: (changes: UseMultipleSelectionActiveIndexChange<Item>) => void;
/** Callback when selected items change */
onSelectedItemsChange?: (changes: UseMultipleSelectionSelectedItemsChange<Item>) => void;
/** Callback when any state changes */
onStateChange?: (changes: UseMultipleSelectionStateChange<Item>) => void;
/** Key for navigating to next item (default: 'ArrowRight') */
keyNavigationNext?: string;
/** Key for navigating to previous item (default: 'ArrowLeft') */
keyNavigationPrevious?: string;
/** Environment object for SSR/testing scenarios */
environment?: Environment;
}
interface UseMultipleSelectionReturnValue<Item> {
/** Array of currently selected items */
selectedItems: Item[];
/** Index of currently active selected item (-1 if none) */
activeIndex: number;
/** Actions for controlling the selection programmatically */
reset: () => void;
addSelectedItem: (item: Item) => void;
removeSelectedItem: (item: Item) => void;
setSelectedItems: (items: Item[]) => void;
setActiveIndex: (index: number) => void;
/** Prop getters for UI elements */
getSelectedItemProps: <Options>(
options: UseMultipleSelectionGetSelectedItemPropsOptions<Item> & Options
) => UseMultipleSelectionGetSelectedItemReturnValue;
getDropdownProps: <Options>(
options?: UseMultipleSelectionGetDropdownPropsOptions & Options,
extraOptions?: GetPropsCommonOptions
) => UseMultipleSelectionGetDropdownReturnValue;
}Usage Examples:
import { useMultipleSelection } from 'downshift';
// Basic tag input example
function TagInput() {
const [availableItems] = useState(['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry']);
const {
getSelectedItemProps,
getDropdownProps,
addSelectedItem,
removeSelectedItem,
selectedItems,
activeIndex,
} = useMultipleSelection({
initialSelectedItems: [],
});
const [inputValue, setInputValue] = useState('');
const handleKeyDown = (e) => {
if (e.key === 'Enter' && inputValue) {
const item = availableItems.find(
item => item.toLowerCase().includes(inputValue.toLowerCase()) &&
!selectedItems.includes(item)
);
if (item) {
addSelectedItem(item);
setInputValue('');
}
}
};
return (
<div>
<label>Selected items:</label>
<div {...getDropdownProps()} style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
{selectedItems.map((selectedItem, index) => (
<span
key={`selected-item-${index}`}
{...getSelectedItemProps({ selectedItem, index })}
style={{
backgroundColor: activeIndex === index ? '#bde4ff' : '#e1e5e9',
padding: '4px 8px',
borderRadius: '4px',
cursor: 'pointer',
}}
>
{selectedItem}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeSelectedItem(selectedItem);
}}
style={{ marginLeft: '4px', border: 'none', background: 'transparent' }}
>
×
</button>
</span>
))}
<input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type to add items..."
/>
</div>
<div>Available: {availableItems.filter(item => !selectedItems.includes(item)).join(', ')}</div>
</div>
);
}
// Advanced example with custom state reducer
function AdvancedMultipleSelection() {
const items = [
{ id: 1, name: 'Apple', category: 'fruit' },
{ id: 2, name: 'Banana', category: 'fruit' },
{ id: 3, name: 'Carrot', category: 'vegetable' },
{ id: 4, name: 'Broccoli', category: 'vegetable' },
];
const stateReducer = (state, actionAndChanges) => {
const { changes, type } = actionAndChanges;
switch (type) {
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownBackspace:
// Prevent deletion of certain items
if (changes.selectedItems && changes.selectedItems.length !== state.selectedItems.length) {
const removedItem = state.selectedItems.find(item => !changes.selectedItems.includes(item));
if (removedItem && removedItem.category === 'fruit') {
return state; // Prevent removal of fruits
}
}
return changes;
default:
return changes;
}
};
const {
getSelectedItemProps,
getDropdownProps,
addSelectedItem,
removeSelectedItem,
selectedItems,
activeIndex,
} = useMultipleSelection({
initialSelectedItems: [],
stateReducer,
onSelectedItemsChange: ({ selectedItems }) => {
console.log('Selected items changed:', selectedItems);
},
});
return (
<div>
<label>Multiple Selection Example:</label>
<div {...getDropdownProps()} style={{ border: '1px solid #ccc', padding: '8px', minHeight: '40px' }}>
{selectedItems.map((selectedItem, index) => (
<span
key={selectedItem.id}
{...getSelectedItemProps({ selectedItem, index })}
style={{
backgroundColor: activeIndex === index ? '#bde4ff' : '#e1e5e9',
padding: '4px 8px',
margin: '2px',
borderRadius: '4px',
display: 'inline-block',
}}
>
{selectedItem.name} ({selectedItem.category})
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeSelectedItem(selectedItem);
}}
style={{ marginLeft: '4px', border: 'none', background: 'transparent' }}
>
×
</button>
</span>
))}
</div>
<div style={{ marginTop: '8px' }}>
<strong>Available items:</strong>
{items
.filter(item => !selectedItems.some(selected => selected.id === item.id))
.map(item => (
<button
key={item.id}
onClick={() => addSelectedItem(item)}
style={{ margin: '2px', padding: '4px 8px' }}
>
Add {item.name}
</button>
))}
</div>
</div>
);
}The hook returns current state and action functions for programmatic control.
interface UseMultipleSelectionState<Item> {
/** Array of currently selected items */
selectedItems: Item[];
/** Index of currently active selected item (-1 if none) */
activeIndex: number;
}
interface UseMultipleSelectionActions<Item> {
/** Reset to initial state */
reset: () => void;
/** Add an item to the selection */
addSelectedItem: (item: Item) => void;
/** Remove an item from the selection */
removeSelectedItem: (item: Item) => void;
/** Replace all selected items */
setSelectedItems: (items: Item[]) => void;
/** Set the active index */
setActiveIndex: (index: number) => void;
}Prop getters return props to spread onto DOM elements with proper event handlers and accessibility attributes.
interface UseMultipleSelectionGetSelectedItemPropsOptions<Item> extends React.HTMLProps<HTMLElement> {
/** The selected item this props object is for */
selectedItem: Item;
/** Index of the selected item in the selectedItems array */
index?: number;
/** Custom ref key (defaults to 'ref') */
refKey?: string;
}
interface UseMultipleSelectionGetSelectedItemReturnValue {
/** Tab index for keyboard navigation */
tabIndex: 0 | -1;
ref?: React.RefObject<any>;
onClick: React.MouseEventHandler;
onKeyDown: React.KeyboardEventHandler;
}
interface UseMultipleSelectionGetDropdownPropsOptions extends React.HTMLProps<HTMLElement> {
/** Whether to prevent default keyboard actions */
preventKeyAction?: boolean;
}
interface UseMultipleSelectionGetDropdownReturnValue {
ref?: React.RefObject<any>;
onClick?: React.MouseEventHandler;
onKeyDown?: React.KeyboardEventHandler;
}Constants for identifying different types of state changes in the state reducer.
enum UseMultipleSelectionStateChangeTypes {
SelectedItemClick = '__selected_item_click__',
SelectedItemKeyDownDelete = '__selected_item_keydown_delete__',
SelectedItemKeyDownBackspace = '__selected_item_keydown_backspace__',
SelectedItemKeyDownNavigationNext = '__selected_item_keydown_navigation_next__',
SelectedItemKeyDownNavigationPrevious = '__selected_item_keydown_navigation_previous__',
DropdownKeyDownNavigationPrevious = '__dropdown_keydown_navigation_previous__',
DropdownKeyDownBackspace = '__dropdown_keydown_backspace__',
DropdownClick = '__dropdown_click__',
FunctionAddSelectedItem = '__function_add_selected_item__',
FunctionRemoveSelectedItem = '__function_remove_selected_item__',
FunctionSetSelectedItems = '__function_set_selected_items__',
FunctionSetActiveIndex = '__function_set_active_index__',
FunctionReset = '__function_reset__',
}Access via useMultipleSelection.stateChangeTypes:
import { useMultipleSelection } from 'downshift';
const stateReducer = (state, actionAndChanges) => {
switch (actionAndChanges.type) {
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownBackspace:
// Handle backspace key on selected items
return actionAndChanges.changes;
case useMultipleSelection.stateChangeTypes.FunctionAddSelectedItem:
// Handle programmatic addition of items
return {
...actionAndChanges.changes,
activeIndex: actionAndChanges.changes.selectedItems.length - 1,
};
default:
return actionAndChanges.changes;
}
};The useMultipleSelection hook is designed to compose with other downshift hooks:
import { useCombobox, useMultipleSelection } from 'downshift';
// Example: Multi-select combobox
function MultiSelectCombobox() {
const items = ['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry'];
const [inputItems, setInputItems] = useState(items);
const {
getSelectedItemProps,
getDropdownProps,
addSelectedItem,
removeSelectedItem,
selectedItems,
} = useMultipleSelection({
initialSelectedItems: [],
});
const getFilteredItems = (items, selectedItems, inputValue) => {
return items.filter(
item =>
!selectedItems.includes(item) &&
item.toLowerCase().startsWith(inputValue.toLowerCase())
);
};
const {
isOpen,
getToggleButtonProps,
getLabelProps,
getMenuProps,
getInputProps,
highlightedIndex,
getItemProps,
selectItem,
} = useCombobox({
items: inputItems,
itemToString: (item) => item || '',
stateReducer: (state, actionAndChanges) => {
const { changes, type } = actionAndChanges;
switch (type) {
case useCombobox.stateChangeTypes.InputKeyDownEnter:
case useCombobox.stateChangeTypes.ItemClick:
return {
...changes,
isOpen: true, // Keep menu open after selection
highlightedIndex: 0,
inputValue: '',
};
default:
return changes;
}
},
onStateChange: ({ inputValue, type, selectedItem }) => {
switch (type) {
case useCombobox.stateChangeTypes.InputKeyDownEnter:
case useCombobox.stateChangeTypes.ItemClick:
if (selectedItem) {
addSelectedItem(selectedItem);
setInputItems(getFilteredItems(items, [...selectedItems, selectedItem], ''));
}
break;
case useCombobox.stateChangeTypes.InputChange:
setInputItems(getFilteredItems(items, selectedItems, inputValue));
break;
default:
break;
}
},
});
return (
<div>
<label {...getLabelProps()}>Choose multiple items:</label>
<div {...getDropdownProps()}>
{selectedItems.map((selectedItem, index) => (
<span
key={`selected-item-${index}`}
{...getSelectedItemProps({ selectedItem, index })}
style={{
backgroundColor: '#e1e5e9',
padding: '2px 6px',
margin: '2px',
borderRadius: '3px',
}}
>
{selectedItem}
<span
onClick={() => removeSelectedItem(selectedItem)}
style={{ marginLeft: '4px', cursor: 'pointer' }}
>
×
</span>
</span>
))}
<input {...getInputProps()} />
</div>
<button type="button" {...getToggleButtonProps()} aria-label="toggle menu">
{isOpen ? '↑' : '↓'}
</button>
<ul {...getMenuProps()}>
{isOpen &&
inputItems.map((item, index) => (
<li
style={highlightedIndex === index ? { backgroundColor: '#bde4ff' } : {}}
key={`${item}${index}`}
{...getItemProps({ item, index })}
>
{item}
</li>
))}
</ul>
</div>
);
}The hook provides built-in accessibility features:
Custom accessibility messages can be provided:
const { selectedItems } = useMultipleSelection({
getA11yStatusMessage: ({ selectedItems }) => {
return `${selectedItems.length} items selected. Use arrow keys to navigate, backspace to remove items.`;
},
});Install with Tessl CLI
npx tessl i tessl/npm-downshift