import * as React from "react"; import Downshift from "downshift"; import Dropdownable from "../../dropdownable/components/Dropdownable"; import PopoverBox from "../../popover/components/PopoverBox"; import PopoverListItem from "../../popover/components/PopoverListItem"; import { margin } from "../../shared/styles/styleUtils"; import resizeEventManager from "../../utilities/resizeEventManager"; import TextInputWithBadges from "../../textInput/components/TextInputWithBadges"; export interface Item { value: string; label: React.ReactNode; disabled?: boolean; } export interface TypeaheadProps { /** * items to render into the menu */ items: Item[]; /** * the text input element */ textField: React.ReactElement; /** * what is displayed when there are no items */ menuEmptyState?: React.ReactElement; /** * maximum height the menu can grow to */ menuMaxHeight?: number; /** * whether more than one item can be selected */ multiSelect?: boolean; /** * callback for when an item is selected */ onSelect?: (selectedItems: string[], lastSelectedItem?: string) => void; /** * which DOM node the dropdown menu will attach to */ overlayRoot?: HTMLElement; /** * an array of item values that are selected */ selectedItems?: string[]; /** * whether the menu stays open on select */ keepOpenOnSelect?: boolean; /** * whether the selected item's value is set as the input's value */ resetInputOnSelect?: boolean; /** * Whether the dropdown node should be portalled to document.body, or open in it's parent DOM node */ disablePortal?: boolean; } const defaultItemToString = item => (item ? item.value : ""); const getSelectedItems = (value, checked, selectedItems) => { if (checked) { return [...selectedItems, value]; } return selectedItems.filter(selectedItem => selectedItem !== value); }; const Typeahead = ({ disablePortal, items, menuEmptyState, menuMaxHeight = 300, overlayRoot, textField, selectedItems, multiSelect, keepOpenOnSelect, resetInputOnSelect, onSelect }: TypeaheadProps) => { const { value, ...textFieldProps } = textField.props; delete textFieldProps.onFocus; delete textFieldProps.onClick; const [menuWidth, setMenuWidth] = React.useState(null); const containerRef = React.createRef(); const setContainerWidth = () => { const container = containerRef.current; if (container && container.getBoundingClientRect().width) { setMenuWidth(container.getBoundingClientRect().width); } }; React.useEffect(() => { resizeEventManager.add(setContainerWidth); return () => { resizeEventManager.remove(setContainerWidth); }; }, []); function handleSelection(selectedItem) { const isItemSelected = selectedItems && selectedItems.includes(selectedItem.value); if (onSelect) { onSelect( getSelectedItems( selectedItem.value, !isItemSelected, selectedItems ?? [] ), selectedItem.value ); } } function stateReducer(state, changes) { switch (changes.type) { case Downshift.stateChangeTypes.keyDownEnter: case Downshift.stateChangeTypes.clickItem: handleSelection(changes.selectedItem); return { ...changes, selectedItem: [changes.selectedItem.value], isOpen: multiSelect && !(keepOpenOnSelect === false), inputValue: multiSelect || resetInputOnSelect ? "" : changes.inputValue }; case Downshift.stateChangeTypes.changeInput: return { ...changes, // Manually setting `isOpen` to make sure the menu closes after the user has made a selection // // By default, Downshift changes it's internal `isOpen` state to `false` when an input changes, // but there are cases where we need the to dispatch an `onChange` event on item selection // so that `onChange` bubbles up to the parent
element's `onChange` event isOpen: multiSelect || ((state.inputValue || changes.inputValue) && changes.inputValue !== state.inputValue), selectedItem: changes.inputValue === state.inputValue ? state.selectedItem : "" }; case Downshift.stateChangeTypes.blurInput: case Downshift.stateChangeTypes.mouseUp: return { ...changes, inputValue: state.inputValue }; default: return changes; } } function handleInputActivation( stateAndHelpers: { openMenu: (cb?: () => void) => void; isOpen: boolean; }, e ) { const { openMenu, isOpen } = stateAndHelpers; if (textField.props.onFocus && e.nativeEvent.type === "focus") { textField.props.onFocus(e); } else if (textField.props.onClick && e.nativeEvent.type === "click") { textField.props.onClick(e); } if (!isOpen) { setContainerWidth(); openMenu(); } } return (
{({ getInputProps, getItemProps, getMenuProps, isOpen, highlightedIndex, openMenu, selectedItem, inputValue, ...other }) => { return (
{!items.length && !menuEmptyState ? null : (
{items.length ? ( items.map((item: Item, index: number) => ( {item.label} )) ) : (
{menuEmptyState}
)}
)}
} disablePortal={disablePortal} > {React.cloneElement( textField, getInputProps({ onFocus: e => { handleInputActivation({ isOpen, openMenu }, e); }, onClick: e => { handleInputActivation({ isOpen, openMenu }, e); }, value: value ?? inputValue ?? "", ...(textField.type === TextInputWithBadges && { downshiftReset: other.reset }), ...textFieldProps }) )}
); }} ); }; export default React.memo(Typeahead);