UNPKG

@itwin/itwinui-react

Version:

A react component library for iTwinUI

446 lines (445 loc) 14 kB
import * as React from 'react'; import { MenuExtraContent } from '../Menu/MenuExtraContent.js'; import { SelectTag } from '../Select/SelectTag.js'; import { Text } from '../Typography/Text.js'; import { mergeRefs, useLatestRef, useLayoutEffect, AutoclearingHiddenLiveRegion, useId, useControlledState, } from '../../utils/index.js'; import { usePopover } from '../Popover/Popover.js'; import { ComboBoxRefsContext, ComboBoxStateContext } from './helpers.js'; import { ComboBoxEndIcon } from './ComboBoxEndIcon.js'; import { ComboBoxInput } from './ComboBoxInput.js'; import { ComboBoxInputContainer } from './ComboBoxInputContainer.js'; import { ComboBoxMenu } from './ComboBoxMenu.js'; import { ComboBoxMenuItem } from './ComboBoxMenuItem.js'; let isMultipleEnabled = (variable, multiple) => multiple && (Array.isArray(variable) || null == variable); let isSingleOnChange = (onChange, multiple) => !multiple; let getOptionId = (option, idPrefix) => option.id ?? `${idPrefix}-option-${option.label.replace(/\s/g, '-')}`; export const ComboBox = React.forwardRef((props, forwardedRef) => { let idPrefix = useId(); let defaultFilterFunction = React.useCallback( (options, inputValue) => options.filter((option) => option.label.toLowerCase().includes(inputValue.toLowerCase()), ), [], ); let { options, value: valueProp, onChange, filterFunction = defaultFilterFunction, inputProps, endIconProps, dropdownMenuProps: { middleware, ...dropdownMenuProps } = {}, emptyStateMessage = 'No options found', itemRenderer, enableVirtualization = false, multiple = false, onShow: onShowProp, onHide: onHideProp, id = inputProps?.id ? `iui-${inputProps.id}-cb` : idPrefix, defaultValue, clearFilterOnOptionToggle = true, ...rest } = props; let inputRef = React.useRef(null); let menuRef = React.useRef(null); let onChangeProp = useLatestRef(onChange); let optionsRef = useLatestRef(options); let filterFunctionRef = useLatestRef(filterFunction); let optionsExtraInfo = React.useMemo(() => { let newOptionsExtraInfo = {}; options.forEach((option, index) => { newOptionsExtraInfo[getOptionId(option, id)] = { __originalIndex: index, }; }); return newOptionsExtraInfo; }, [id, options]); let getSelectedIndexes = React.useCallback( (value) => { if (void 0 === value) return; if (!isMultipleEnabled(value, multiple)) return options.findIndex((option) => option.value === value); { let indexArray = []; value?.forEach((value) => { let indexToAdd = options.findIndex( (option) => option.value === value, ); if (indexToAdd > -1) indexArray.push(indexToAdd); }); return indexArray; } }, [multiple, options], ); let [selectedIndexes, setSelectedIndexes] = useControlledState( getSelectedIndexes(defaultValue) ?? (multiple ? [] : -1), getSelectedIndexes(valueProp), ); let previousValue = React.useRef(valueProp); useLayoutEffect(() => { if (valueProp !== previousValue.current) { previousValue.current = valueProp; if (void 0 === valueProp) isMultipleEnabled(selectedIndexes, multiple) ? setSelectedIndexes([]) : setSelectedIndexes(-1); } }, [multiple, selectedIndexes, setSelectedIndexes, valueProp]); let [isOpen, setIsOpen] = React.useState(false); let [focusedIndex, setFocusedIndex] = React.useState(-1); let onShowRef = useLatestRef(onShowProp); let onHideRef = useLatestRef(onHideProp); let show = React.useCallback(() => { setIsOpen(true); onShowRef.current?.(); }, [onShowRef]); let hide = React.useCallback(() => { setIsOpen(false); onHideRef.current?.(); }, [onHideRef]); useLayoutEffect(() => { if (isOpen) { inputRef.current?.focus(); if (!isMultipleEnabled(selectedIndexes, multiple)) setFocusedIndex(selectedIndexes ?? -1); } else { setFocusedIndex(-1); isMultipleEnabled(selectedIndexes, multiple) ? setInputValue('') : setInputValue( selectedIndexes >= 0 ? optionsRef.current[selectedIndexes]?.label ?? '' : '', ); setIsInputDirty(false); } }, [isOpen, multiple, optionsRef, selectedIndexes]); let previousOptions = React.useRef(options); React.useEffect(() => { if (options !== previousOptions.current) { previousOptions.current = options; onOptionsChange(); } function onOptionsChange() { isMultipleEnabled(selectedIndexes, multiple) ? setFocusedIndex(-1) : setFocusedIndex(selectedIndexes); if (!isMultipleEnabled(selectedIndexes, multiple) && !isOpen) setInputValue( selectedIndexes >= 0 ? options[selectedIndexes]?.label : '', ); } }, [options, isOpen, multiple, selectedIndexes]); let [inputValue, setInputValue] = React.useState( inputProps?.value?.toString() ?? '', ); let [isInputDirty, setIsInputDirty] = React.useState(false); let filteredOptions = React.useMemo(() => { if (!isInputDirty) return options; return filterFunctionRef.current?.(options, inputValue); }, [filterFunctionRef, inputValue, options, isInputDirty]); let [liveRegionSelection, setLiveRegionSelection] = React.useState(''); let handleOnInput = React.useCallback( (event) => { let { value } = event.currentTarget; setInputValue(value); show(); setIsInputDirty(true); if (-1 != focusedIndex) setFocusedIndex(-1); inputProps?.onChange?.(event); }, [focusedIndex, inputProps, show], ); let isMenuItemSelected = React.useCallback( (index) => { if (isMultipleEnabled(selectedIndexes, multiple)) return selectedIndexes.includes(index); return selectedIndexes === index; }, [multiple, selectedIndexes], ); let selectedChangeHandler = React.useCallback( (__originalIndex, action) => { if (!isMultipleEnabled(selectedIndexes, multiple)) return; if ('added' === action) return [...selectedIndexes, __originalIndex]; return selectedIndexes?.filter((index) => index !== __originalIndex); }, [selectedIndexes, multiple], ); let onChangeHandler = React.useCallback( (__originalIndex, actionType, newSelectedIndexes) => { if (isSingleOnChange(onChangeProp.current, multiple)) onChangeProp.current?.(optionsRef.current[__originalIndex]?.value); else actionType && newSelectedIndexes && onChangeProp.current?.( newSelectedIndexes?.map( (index) => optionsRef.current[index]?.value, ), { value: optionsRef.current[__originalIndex]?.value, type: actionType, }, ); }, [multiple, onChangeProp, optionsRef], ); let handleOptionSelection = React.useCallback( (__originalIndex) => { inputRef.current?.focus({ preventScroll: true, }); if (optionsRef.current[__originalIndex]?.disabled) return; if (multiple) { let actionType = isMenuItemSelected(__originalIndex) ? 'removed' : 'added'; let newSelectedIndexes = selectedChangeHandler( __originalIndex, actionType, ); if (null == newSelectedIndexes) return; setSelectedIndexes(newSelectedIndexes); onChangeHandler(__originalIndex, actionType, newSelectedIndexes); setLiveRegionSelection( newSelectedIndexes .map((item) => optionsRef.current[item]?.label) .filter(Boolean) .join(', '), ); if (clearFilterOnOptionToggle) { setInputValue(''); setIsInputDirty(false); } } else { setSelectedIndexes(__originalIndex); hide(); onChangeHandler(__originalIndex); } }, [ optionsRef, multiple, isMenuItemSelected, selectedChangeHandler, setSelectedIndexes, onChangeHandler, clearFilterOnOptionToggle, hide, ], ); let getMenuItem = React.useCallback( (option, filteredIndex) => { let optionId = getOptionId(option, id); let { __originalIndex } = optionsExtraInfo[optionId]; let { icon, startIcon: startIconProp, label, ...restOptions } = option; let startIcon = startIconProp ?? icon; let customItem = itemRenderer ? itemRenderer(option, { isFocused: focusedIndex === __originalIndex, isSelected: isMenuItemSelected(__originalIndex), index: __originalIndex, id: optionId, }) : null; return customItem ? React.cloneElement(customItem, { onClick: (e) => { handleOptionSelection(__originalIndex); customItem.props.onClick?.(e); }, focused: focusedIndex === __originalIndex, 'data-iui-index': __originalIndex, 'data-iui-filtered-index': filteredIndex, ref: mergeRefs(customItem.props.ref, (el) => { if (!enableVirtualization && focusedIndex === __originalIndex) el?.scrollIntoView({ block: 'nearest', }); }), }) : React.createElement( ComboBoxMenuItem, { key: optionId, id: optionId, startIcon: startIcon, ...restOptions, isSelected: isMenuItemSelected(__originalIndex), onClick: () => { handleOptionSelection(__originalIndex); }, index: __originalIndex, 'data-iui-filtered-index': filteredIndex, }, label, ); }, [ enableVirtualization, focusedIndex, id, isMenuItemSelected, itemRenderer, handleOptionSelection, optionsExtraInfo, ], ); let emptyContent = React.useMemo( () => React.createElement( React.Fragment, null, React.isValidElement(emptyStateMessage) ? emptyStateMessage : React.createElement( MenuExtraContent, null, React.createElement( Text, { isMuted: true, }, emptyStateMessage, ), ), ), [emptyStateMessage], ); let popover = usePopover({ visible: isOpen, onVisibleChange: (open) => (open ? show() : hide()), matchWidth: true, middleware: { size: { maxHeight: 'var(--iui-menu-max-height)', }, ...middleware, }, closeOnOutsideClick: true, interactions: { click: false, focus: true, }, }); return React.createElement( ComboBoxRefsContext.Provider, { value: React.useMemo( () => ({ inputRef, menuRef, optionsExtraInfo, }), [optionsExtraInfo], ), }, React.createElement( ComboBoxStateContext.Provider, { value: React.useMemo( () => ({ id, isOpen, focusedIndex, setFocusedIndex, onClickHandler: handleOptionSelection, enableVirtualization, filteredOptions, getMenuItem, multiple, popover, show, hide, }), [ enableVirtualization, filteredOptions, focusedIndex, getMenuItem, handleOptionSelection, hide, id, isOpen, multiple, popover, show, ], ), }, React.createElement( ComboBoxInputContainer, { ref: forwardedRef, disabled: inputProps?.disabled, ...rest, }, React.createElement(ComboBoxInput, { value: inputValue, disabled: inputProps?.disabled, ...inputProps, onChange: handleOnInput, 'aria-describedby': [ multiple ? `${id}-selected-live` : void 0, inputProps?.['aria-describedby'], ] .filter(Boolean) .join(' '), selectTags: isMultipleEnabled(selectedIndexes, multiple) ? selectedIndexes ?.map((index) => { let option = options[index]; let optionId = getOptionId(option, id); let { __originalIndex } = optionsExtraInfo[optionId]; return React.createElement(SelectTag, { key: option.label, label: option.label, onRemove: inputProps?.disabled ? void 0 : () => { handleOptionSelection(__originalIndex); hide(); }, }); }) .filter(Boolean) : void 0, }), React.createElement(ComboBoxEndIcon, { ...endIconProps, disabled: inputProps?.disabled, isOpen: isOpen, }), multiple ? React.createElement(AutoclearingHiddenLiveRegion, { text: liveRegionSelection, id: `${id}-selected-live`, }) : null, ), React.createElement( ComboBoxMenu, { as: 'div', ...dropdownMenuProps, }, filteredOptions.length > 0 && !enableVirtualization ? filteredOptions.map(getMenuItem) : emptyContent, ), ), ); }); if ('development' === process.env.NODE_ENV) ComboBox.displayName = 'ComboBox';