or run

npx @tessl/cli init
Log in

Version

Tile

Overview

Evals

Files

docs

class-component.mdcombobox.mdindex.mdmultiple-selection.mdselect.md
tile.json

multiple-selection.mddocs/

Multiple Selection

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.

Capabilities

useMultipleSelection Hook

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>
  );
}

State and Actions

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

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;
}

State Change Types

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;
  }
};

Integration with Other Hooks

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>
  );
}

Accessibility Features

The hook provides built-in accessibility features:

  • Keyboard Navigation: Arrow keys navigate between selected items
  • Screen Reader Support: Announces selection changes and provides proper roles
  • Focus Management: Maintains focus state during item addition/removal
  • ARIA Labels: Provides appropriate ARIA attributes for assistive technologies

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.`;
  },
});