import React, { useState, type ReactNode, type FocusEventHandler } from 'react'; import { useCombobox } from 'downshift'; import { DropdownItem } from '../DropdownItem'; import { CheckboxInput } from '../CheckboxInput'; import { Combobox } from '../Combobox'; import { ALL_KEY, isAllOption, getIsOptionSelected, type Option, type OptionGroup, } from './option'; import { classNames, type Placement } from '../../utils'; import styles from './AutocompleteMultiple.module.css'; import commonStyles from '../../theme/common.module.css'; const { stateChangeTypes: comboboxStateChangeTypes } = useCombobox; export type AutocompleteMultipleProps = { /** * className for the element */ className?: string; /** * Maximum height of the dropdown menu. */ dropdownMenuContentMaxHeight?: string; /** * Whether the AutocompleteMultiple should fit its parents or content. * @default content */ fit?: 'content' | 'parent'; /** * Whether the AutocompleteMultiple is disabled. */ isDisabled?: boolean; /** * Whether the AutocompleteMultiple is invalid. */ isInvalid?: boolean; /** * Whether the AutocompleteMultiple is loading. */ isLoading?: boolean; /** * The name of the Combobox, used when submitting an HTML form. */ name?: string; /** * The current value. */ values: T[]; /** * The list of ComboBox options. */ options: (T | OptionGroup)[]; /** * Temporary text that occupies the Combobox when it is empty. */ placeholder?: string; /** * Whether the combobox should show a clear button. * @default false */ showClearSelectionButton?: boolean; /** * Translations for the selected states of the AutocompleteMultiple. */ translations: { selectAll?: string; selected: string; }; /** * Handler that is called when the input changes. */ onSearch(value: string | undefined): void | Promise; /** * Handler that is called when a option is selected. */ onSelect(values: T[]): void; /** * Handler that is called when the element receives focus. */ onFocus?: FocusEventHandler; /** * Handler that is called when the element loses focus. */ onBlur?: FocusEventHandler; /** * Function to render option group. */ renderOptionGroup?(optionGroup: OptionGroup): ReactNode; /** * Function to render when no options are available */ renderNoOptions?(rawValue: string): ReactNode; /** * Placement the menu will render relative to the field */ placement?: Placement; }; export const AutocompleteMultiple = ({ className, isDisabled, isInvalid, isLoading, fit = 'content', name, values, options, placeholder, showClearSelectionButton = false, translations, onSearch, onSelect, onFocus, onBlur, renderNoOptions, renderOptionGroup, placement, ...rest }: AutocompleteMultipleProps) => { const withSelectAllOption = translations.selectAll; const allOption = { key: ALL_KEY, label: translations.selectAll } as T; const internalOptions = withSelectAllOption ? [allOption, ...options] : options; const keys = options .flatMap((option) => ('options' in option ? option.options : option)) .map((option) => option.key); const internalSelectedOptions = values.length === keys.length && keys.length > 0 && values.every((value) => keys.includes(value.key)) ? [allOption, ...values] : values; const [internalIsLoading, setInternalIsLoading] = useState(false); const addSelectedOption = (selectedItem: T) => { if (isAllOption(selectedItem)) { return internalOptions.flatMap((option) => 'options' in option ? option.options : option, ); } const newSelectedOptions = [...internalSelectedOptions, selectedItem]; return newSelectedOptions; }; const removeSelectedOption = (selectedOption: T): T[] => { if (isAllOption(selectedOption)) { return []; } const newSelectedOptions = internalSelectedOptions.filter((item) => { return item.key !== selectedOption?.key && !isAllOption(item); }); return newSelectedOptions; }; const handleOnSelect = (selectedItem: T) => { const isOptionSelected = getIsOptionSelected( internalSelectedOptions, selectedItem, ); const newSelectedOptions = isOptionSelected ? removeSelectedOption(selectedItem) : addSelectedOption(selectedItem); const optionsWithoutAll = newSelectedOptions.filter( (item) => !isAllOption(item), ); onSelect(optionsWithoutAll); }; const { isOpen, inputValue, openMenu, getMenuProps, getItemProps, getInputProps, getToggleButtonProps, setInputValue, reset, } = useCombobox({ selectedItem: null, items: internalOptions.flatMap((option) => 'options' in option ? option.options : option, ), initialHighlightedIndex: -1, stateReducer: (state, { changes, type }) => { switch (type) { case comboboxStateChangeTypes.InputKeyDownEnter: case comboboxStateChangeTypes.ItemClick: return { ...changes, isOpen: true, inputValue: state.inputValue, highlightedIndex: state.highlightedIndex, }; case comboboxStateChangeTypes.FunctionReset: onSelect([]); return changes; default: return changes; } }, itemToString: (option) => option?.label ?? '', onStateChange: async ({ type, selectedItem, inputValue: newInputValue, }) => { switch (type) { case comboboxStateChangeTypes.ItemClick: case comboboxStateChangeTypes.InputKeyDownEnter: if (selectedItem) { handleOnSelect(selectedItem); } break; case comboboxStateChangeTypes.InputChange: setInternalIsLoading(true); try { await onSearch(newInputValue?.trim()); } finally { setInternalIsLoading(false); } break; case comboboxStateChangeTypes.InputBlur: { setInputValue(''); onSearch(undefined); break; } default: } }, }); const isInputVisible = internalSelectedOptions.length === 0 || isOpen; return ( { if (isInputVisible) { return null; } return ( ); }} renderOption={(option) => { const isSelected = getIsOptionSelected(internalSelectedOptions, option); return ( {}} /> } /> ); }} renderOptionGroup={renderOptionGroup} renderNoOptions={() => renderNoOptions ? renderNoOptions(inputValue) : null } propsGetters={{ getInputProps: () => getInputProps({ name, placeholder, }), getToggleButtonProps, getItemProps: ({ index, item }) => getItemProps({ index, item }), getMenuProps, }} onClearSelection={ values.length !== 0 && showClearSelectionButton ? reset : undefined } placement={placement} {...rest} /> ); };