import React, { ReactElement, useRef, ReactNode, useMemo, useState, useEffect, useCallback, CSSProperties, FocusEvent, } from 'react'; import { useMultipleSelection, useCombobox } from 'downshift'; import css from '../../utils/css'; import TagInput from '../TagInput'; import Menu from '../Menu'; import Icon from '../Icon'; import Typography from '../Typography'; import Divider from '../Divider'; import Dropdown from '../Dropdown'; import SuffixIcon from './SuffixIcon'; import { SelectWrapper, MenuWrapper, CategoryWrapper, HelpTextWrapper, CheckmarkWrapper, } from './StyledSelect'; import { checkAtBottom, mapOptions, optionPredicate, filterGroupedOptions, getAccumulatedIndex, } from './utils'; import { BaseOption, GroupedOption } from './types'; import { invokeWith, noop, pipe } from '../../fp/function'; import { fromUndefinedable, getOrElse, map } from '../../fp/Option'; import { map as arrayMap, flat } from '../../fp/Array'; import { useResizeObserver } from '../../utils/hooks'; import { CommonProps } from '../common'; export interface MultiSelectProps extends Omit { /** * Specify the [automated assistance](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) in filling out form field values by the browser. */ autoComplete?: string; /** * Whether the select is disabled. */ disabled?: boolean; /** * Id of element. */ id?: string; /** * Whether the input is invalid. */ invalid?: boolean; /** * Loading state of Select, which will render a spinner at bottom of the option list. */ loading?: boolean; /** * Name of element, is used to refer to the form data for submission. */ name?: string; /** * Content to render when filtering items returns zero results. */ noResults?: ReactNode; /** * Blur event handler. */ onBlur?: (e: FocusEvent) => void; /** * onChange event handler. */ onChange: (value: (string | number)[]) => void; /** * Callback to allow to create new option when option not found based on query (only when the callback is defined). */ onCreateNewOption?: (optionText: string) => void; /** * Focus event handler. */ onFocus?: (e: FocusEvent) => void; /** * Callback invoked when the query string changes. */ onQueryChange?: (query?: string) => void; /** * Handle scroll event when scrolling to the bottom of the option list. */ onScrollListToBottom?: () => void; /** * Additonal inline style for option menu dropdown. */ optionMenuStyle?: CSSProperties; /** * Customise option renderer. */ optionRenderer?: (renderOpts: { index: number; option: T }) => ReactElement; /** * An array of (grouped) options to be selected. * * The generic parameter T should extend BaseOption: * * type BaseOption = { * disabled?: boolean; * helpText?: string; * text: string; * value: string | number; * }; * * type GroupedOption = { * category: string; * options: T[]; * }; */ options: Array> | Array; /** * Placeholder text in the absence of any value. */ placeholder?: string; /** * Name of Icon or an Icon element to render on the left side of the input. */ prefix?: string | ReactElement; /** * Query string to filter options. This value is controlled: its state if defined must be managed externally. */ query?: string; /** * Customise selected option renderer. */ selectedOptionRenderer?: (renderOpts: { option: T }) => string | ReactElement; /** * The size of the input box. */ size?: 'small' | 'medium' | 'large'; /** * Current selected value. */ value?: (string | number)[]; } const MultiSelect = ({ options, value, onBlur, onChange, onFocus, optionRenderer, selectedOptionRenderer, disabled = false, size = 'medium', invalid, placeholder, prefix, onScrollListToBottom, loading, query, onQueryChange, noResults, name, onCreateNewOption, id, className, style, sx = {}, autoComplete, optionMenuStyle, 'data-test-id': dataTestId, }: MultiSelectProps): ReactElement => { const [newOption, setNewOption] = useState({ value: '', text: '', }); const [wrapperElement, setWrapperElement] = useState( null ); const [optionMenuWidth, setOptionMenuWidth] = useState(); const mappedOptions = useMemo(() => { return filterGroupedOptions(optionPredicate(query), mapOptions(options)); }, [options, query]); const flatMappedOptions = useMemo( () => pipe( mappedOptions, arrayMap(opt => opt.options), flat ), [mappedOptions] ); const flatOptions = useMemo( () => pipe( options, opts => mapOptions(opts), arrayMap(opt => opt.options), flat ), [options] ); const hasResults = flatMappedOptions.length > 0; const selectedItemsFromValue = value !== undefined ? value.flatMap(optionVal => { const foundItem = flatOptions.find(opt => opt.value === optionVal); return foundItem !== undefined ? [foundItem] : []; }) : []; const { getDropdownProps, selectedItems } = useMultipleSelection({ itemToString: (item): string => (item !== null ? item.text : ''), selectedItems: selectedItemsFromValue, }); useEffect(() => { if (hasResults === false && query !== undefined) { setNewOption({ value: query, text: query }); } }, [hasResults, query, setNewOption]); const resizeCallback = useCallback( ({ width }) => { setOptionMenuWidth(width); }, [setOptionMenuWidth] ); useResizeObserver(resizeCallback, wrapperElement); const { isOpen, getComboboxProps, getToggleButtonProps, getMenuProps, getInputProps, getItemProps, highlightedIndex, toggleMenu, } = useCombobox({ items: hasResults === true ? flatMappedOptions : [newOption], stateReducer: (_state, actionAndChanges) => { const { changes, type } = actionAndChanges; const { selectedItem } = changes; const callOnChange = (): void => { if (selectedItem !== undefined && selectedItem !== null) { if (value?.includes(selectedItem.value) === true) { // Remove item onChange( selectedItems .filter(item => item.value !== selectedItem.value) .map(item => item.value) ); } else if (hasResults === false && onCreateNewOption !== undefined) { onCreateNewOption(selectedItem.text); } else { // Add item onChange([ ...selectedItems.map(item => item.value), selectedItem.value, ]); } } if ( onQueryChange !== undefined && query !== undefined && query !== '' ) { onQueryChange(undefined); } }; switch (type) { case useCombobox.stateChangeTypes.InputKeyDownEnter: callOnChange(); return { ...changes, isOpen: true, }; case useCombobox.stateChangeTypes.ItemClick: callOnChange(); return changes; default: return changes; } }, }); const onItemRemove = ({ value: removedItemValue, }: { value: string | number; }): void => { onChange( selectedItems .filter(item => item.value !== removedItemValue) .map(item => item.value) ); toggleMenu(); }; const menuRef = useRef(null); const onScrollToBottom = (): void => { const isAtBottom = checkAtBottom(menuRef.current); if ( isAtBottom === true && loading !== true && onScrollListToBottom !== undefined ) { onScrollListToBottom(); } }; const readonly = onQueryChange === undefined; const tagInputValue = query !== undefined ? query : ''; const tagInputOnChange = React.useCallback( e => onQueryChange !== undefined && onQueryChange(e.target.value), [onQueryChange] ); const tags = React.useMemo( () => selectedItems.map(item => ({ value: item.value, text: pipe( selectedOptionRenderer, fromUndefinedable, map(invokeWith({ option: item })), getOrElse(() => item.text) ), })), [selectedItems, selectedOptionRenderer] ); useEffect(() => { if (isOpen === false && query !== undefined) toggleMenu(); }, [query]); const selectInput = ( } size={size} placeholder={placeholder} onRemove={onItemRemove} tags={tags} {...getInputProps( getDropdownProps({ value: undefined, // not yet support input value disabled, }) )} invalid={invalid} name={name} readonly={readonly} value={tagInputValue} onChange={tagInputOnChange} id={id} autoComplete={autoComplete} onFocus={onFocus} onBlur={onBlur} /> ); const optionMenu = ( {mappedOptions.map((opt, catIndex) => { const accumulatedIndex = getAccumulatedIndex(mappedOptions, catIndex); return ( {opt.category !== '' && ( {opt.category} )} {opt.options.map((item, index) => { const actualIndex = accumulatedIndex + index; const isActiveItem = value?.includes(item.value) ?? false; const helpTextElement = ( {item.helpText !== undefined && ( {item.helpText} )} ); return ( ); })} {catIndex < mappedOptions.length - 1 && } ); })} {hasResults === false && loading !== true && onCreateNewOption !== undefined && query !== undefined && ( )} {hasResults === false && onCreateNewOption === undefined && noResults} ); return ( ); }; export default MultiSelect;