import { useCombobox } from 'downshift'; import React, { ReactElement, ReactNode, useState, useCallback, useMemo, CSSProperties, FocusEvent, } from 'react'; import css from '../../../utils/css'; import { CommonProps } from '../../common'; import { IconName } from '../../Icon'; import { BaseOption, GroupedOption } from '../types'; import { SelectWrapper } from '../StyledSelect'; import { map as arrayMap, flat } from '../../../fp/Array'; import { mapOptions, optionPredicate, filterGroupedOptions } from '../utils'; import { pipe, noop } from '../../../fp/function'; import { useResizeObserver } from '../../../utils/hooks'; import Dropdown from '../../Dropdown'; import HiddenInput from './HiddenInput'; import OptionList from './OptionList'; import QueryInput from './QueryInput'; export interface SelectProps extends CommonProps { /** * 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; /** * Allow to clear value after selecting an item. */ clearable?: boolean; /** * Whether the select is disabled. */ disabled?: boolean; /** * 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?: ({ option, index, }: { 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?: IconName | 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?: ({ option }: { option: T }) => string | ReactElement; /** * The size of the input box. */ size?: 'small' | 'medium' | 'large'; /** * Current selected value. */ value?: string | number; } const Select = ({ options, value, onBlur, onChange, onFocus, query, onQueryChange, optionRenderer, selectedOptionRenderer, noResults, disabled = false, size = 'medium', invalid, placeholder, prefix, onScrollListToBottom, loading, name, onCreateNewOption, id, className, style, sx = {}, autoComplete, optionMenuStyle, clearable = false, 'data-test-id': dataTestId, }: SelectProps): ReactElement => { 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 hasResults = mappedOptions.length > 0; const selectedItem = useMemo( () => flatMappedOptions.find(item => item.value === value), [flatMappedOptions, value] ); const resizeCallback = useCallback( ({ width }) => { setOptionMenuWidth(width); }, [setOptionMenuWidth] ); useResizeObserver(resizeCallback, wrapperElement); const newOption = query !== undefined ? { value: query, text: query } : { value: '', text: '' }; const { getComboboxProps, getInputProps, getMenuProps, getItemProps, isOpen, highlightedIndex, getToggleButtonProps, } = useCombobox({ items: hasResults === true ? flatMappedOptions : [newOption], itemToString: (item): string => (item !== null ? item.text : ''), selectedItem: selectedItem ?? null, onSelectedItemChange: ({ selectedItem: newItem }): void => { if (newItem != null) { if (hasResults === false) { onCreateNewOption?.(newItem.text); } else { onChange(newItem.value); } } if (query !== undefined) { onQueryChange?.(undefined); } }, onStateChange: state => { if (state.type === useCombobox.stateChangeTypes.InputBlur) { if (query !== undefined) { onQueryChange?.(undefined); } } }, }); const selectInput = ( ); const optionMenu = ( categories={mappedOptions} hasResults={hasResults} newOption={newOption} selectedItem={selectedItem} getItemProps={getItemProps} getMenuProps={getMenuProps} highlightedIndex={highlightedIndex} onScrollListToBottom={onScrollListToBottom} onCreateNewOption={onCreateNewOption} loading={loading} noResults={noResults} optionRenderer={optionRenderer} style={{ width: optionMenuWidth, ...optionMenuStyle }} /> ); return ( {name !== undefined && ( )} ); }; export default Select;