import React , { useRef , useState , KeyboardEvent , FocusEvent , FocusEventHandler , useMemo , useCallback } from 'react' ; import { useCombobox , useMultipleSelection } from 'downshift' ; import isEqual from 'lodash.isequal' ; import uniqueId from 'lodash.uniqueid' ; import { useVirtual } from 'react-virtual' ; import 'hds-core' ; import styles from './Combobox.module.scss' ; import { FieldLabel } from '../../../internal/field-label/FieldLabel' ; import classNames from '../../../utils/classNames' ; import { IconAlertCircleFill , IconAngleDown } from '../../../icons' ; import { SelectedItems } from '../../../internal/selectedItems/SelectedItems' ; import { multiSelectReducer , onMultiSelectStateChange , SelectCustomTheme , SelectProps } from '../select' ; import { DROPDOWN_MENU_ITEM_HEIGHT , getIsInSelectedOptions } from '../dropdownUtils' ; import { DropdownMenu } from '../../../internal/dropdownMenu/DropdownMenu' ; import getIsElementFocused from '../../../utils/getIsElementFocused' ; import getIsElementBlurred from '../../../utils/getIsElementBlurred' ; import { useTheme } from '../../../hooks/useTheme' ; type FilterFunction < OptionType > = ( options : OptionType [ ] , search : string ) => OptionType [ ] ; export type ComboboxProps < OptionType > = SelectProps < OptionType > & { /**
     * Custom aria-describedby added to the input element
     */ 'aria-describedby' ? : string ; /**
     * Prevents further propagation of the 'Escape' onKeyDown event when the menu is closed by pressing Esc.
     * Useful e.g. when the component is used inside a modal.
     */ catchEscapeKey ? : boolean ; /**
     * If provided, this filter function will be used for filtering the
     * combobox suggestions. If this prop is not provided, the default
     * filter implementation is used. The default implementation assumes
     * that the `optionLabelField` prop points to a string value that it
     * can compare with the search value.
     */ filter ? : FilterFunction < OptionType > ; /**
     * If `true`, displays a menu toggle button in the combobox.
     */ showToggleButton ? : boolean ; /**
     * aria-label for the menu toggle button. The label for the combobox will be prepended to the given value.
     */ toggleButtonAriaLabel : string ; } ; function getDefaultFilter < OptionType > ( labelField ) { return ( options , search ) => { return options . filter ( ( option ) => { const label = option [ labelField ] ; const isLabelString = typeof label === 'string' ; if ( ! label ) { console . warn ( `Filtering failed because field ${ labelField } could not be found from OptionType` ) ; return false ; } if ( ! isLabelString ) { console . warn ( `Filtering failed because field ${ labelField } has a value that is not a string` ) ; return false ; } return label . toLowerCase ( ) . indexOf ( search . toLowerCase ( ) ) > - 1 ; } ) ; } ; } export const Combobox = < OptionType > ( props ) => { const { 'aria-describedby' : customAriaDescribedBy , catchEscapeKey , circularNavigation = false , className , clearable = true , disabled = false , error , getA11ySelectionMessage = ( ) => '' , getA11yStatusMessage = ( ) => '' , helper , id = uniqueId ( 'hds-combobox-' ) as string , invalid = false , isOptionDisabled , label , onBlur = ( ) => null , onFocus = ( ) => null , optionLabelField = 'label' , options = [ ] , placeholder , required , showToggleButton = true , style , theme , virtualized = false , visibleOptions = 5 , filter : userLandFilter , toggleButtonAriaLabel , tooltipLabel , tooltipButtonLabel , tooltipText , } = props ; const controlled = props . multiselect && props . value !== undefined ; const customThemeClass = useTheme < SelectCustomTheme > ( styles . root , theme ) ; const selectedItemsContainerRef = useRef < HTMLDivElement > ( ) ; const inputRef = useRef < HTMLInputElement > ( ) ; const menuRef = React . useRef < HTMLUListElement > ( ) ; const [ hasFocus , setFocus ] = useState < boolean > ( false ) ; const [ isClicking , setIsClicking ] = useState < boolean > ( false ) ; const [ search , setSearch ] = useState < string > ( '' ) ; const getFilteredItems = useMemo < OptionType [ ] > ( ( ) => { const filter = userLandFilter || getDefaultFilter ( optionLabelField ) ; return filter ( options , search ) ; } , [ options , search , userLandFilter , optionLabelField ] ) ; const virtualizer = useVirtual < HTMLUListElement > ( { size : getFilteredItems . length , parentRef : menuRef , estimateSize : useCallback ( ( ) => DROPDOWN_MENU_ITEM_HEIGHT , [ ] ) , overscan : visibleOptions , } ) ; const focusInput = ( ) => { if ( inputRef . current ) { inputRef . current . focus ( ) ; } } ; const { activeIndex , addSelectedItem , getDropdownProps , getSelectedItemProps , removeSelectedItem , reset , selectedItems , setActiveIndex , setSelectedItems : _setSelectedItems , } = useMultipleSelection < OptionType > ( { defaultActiveIndex : 0 , initialActiveIndex : 0 , ... ( props . multiselect && { initialSelectedItems : props . defaultValue ?? [ ] } ) , ... ( props . multiselect && props . value !== undefined && { selectedItems : props . value ?? [ ] } ) , getA11yRemovalMessage : ( props . multiselect && props . getA11yRemovalMessage ) ?? ( ( ) => '' ) , onSelectedItemsChange : ( { selectedItems : _selectedItems } ) => props . multiselect && typeof props . onChange === 'function' && props . onChange ( _selectedItems ) , onStateChange : ( changes ) => onMultiSelectStateChange < OptionType > ( changes , activeIndex , selectedItemsContainerRef . current ) , stateReducer : ( state , actionAndChanges ) => multiSelectReducer < OptionType > ( state , actionAndChanges , controlled ) , } ) ; const { getItemProps , getLabelProps , getMenuProps , getToggleButtonProps , highlightedIndex , isOpen , selectedItem , selectItem , closeMenu , setInputValue , getInputProps , getComboboxProps , } = useCombobox < OptionType > ( { circularNavigation , id , items : getFilteredItems , ... ( props . multiselect === false && { initialSelectedItem : props . defaultValue ?? null } ) , ... ( props . multiselect === false && props . value !== undefined && { selectedItem : props . value } ) , onInputValueChange : ( { inputValue } ) => setSearch ( inputValue ) , getA11ySelectionMessage , getA11yStatusMessage , itemToString : ( item ) : string => ( item ? item [ optionLabelField ] ?? '' : '' ) , onHighlightedIndexChange : ( { highlightedIndex : _highlightedIndex } ) => virtualizer . scrollToIndex ( _highlightedIndex ) , onSelectedItemChange : ( { selectedItem : _selectedItem } ) => props . multiselect === false && typeof props . onChange === 'function' && props . onChange ( _selectedItem ) , onStateChange ( { type , selectedItem : _selectedItem } ) { const { InputBlur , InputKeyDownEnter , ItemClick } = useCombobox . stateChangeTypes ; if ( ( type === InputBlur || type === InputKeyDownEnter || type === ItemClick ) && props . multiselect && _selectedItem ) { getIsInSelectedOptions ( selectedItems , _selectedItem ) ? _setSelectedItems ( selectedItems . filter ( ( item ) => ! isEqual ( item , _selectedItem ) ) ) : addSelectedItem ( _selectedItem ) ; selectItem ( null ) ; } } , stateReducer ( state , { type , changes } ) { const { ItemClick , InputBlur } = useCombobox . stateChangeTypes ; const { selectedItem : _selectedItem , inputValue } = changes ; if ( ! props . multiselect && _selectedItem && _selectedItem [ optionLabelField ] !== inputValue ) { return { ... changes , selectedItem : null , } ; } if ( type === InputBlur && ! props . multiselect && ! _selectedItem ) { return { ... changes , inputValue : '' , } ; } if ( type === ItemClick && props . multiselect ) { return { ... changes , isOpen : state . isOpen , highlightedIndex : state . highlightedIndex , inputValue : '' , } ; } return changes ; } , } ) ; const setSelectedItems = ( itemToBeSelected ) => { getIsInSelectedOptions ( selectedItems , itemToBeSelected ) ? _setSelectedItems ( selectedItems . filter ( ( item ) => ! isEqual ( item , itemToBeSelected ) ) ) : addSelectedItem ( itemToBeSelected ) ; } ; const handleWrapperClick = ( e ) => { const selectedItemsContainerEl = selectedItemsContainerRef . current ; const selectedItemClicked = selectedItemsContainerEl !== e . target && selectedItemsContainerEl ?. contains ( e . target ) ; if ( ! selectedItemClicked ) { focusInput ( ) ; } } ; const ignoreFocusHandlerWhenClickingItem = ( handler ) => ( event ) => { if ( ! isClicking ) { handler ( event ) ; } } ; const handleWrapperFocus = ( e ) => { if ( getIsElementFocused ( e ) ) { setFocus ( true ) ; onFocus ( ) ; } } ; const handleWrapperBlur = ( e ) => { if ( getIsElementBlurred ( e ) ) { setFocus ( false ) ; onBlur ( ) ; } } ; const handleMultiSelectInputKeyDown = ( e ) => { if ( catchEscapeKey && e . key === 'Escape' ) { e . stopPropagation ( ) ; } if ( e . key === ' ' && highlightedIndex > - 1 ) { e . preventDefault ( ) ; const highlightedItem = getFilteredItems [ highlightedIndex ] ; setSelectedItems ( highlightedItem ) ; } if ( isOpen && ( e . key === 'Backspace' || e . key === 'ArrowLeft' ) ) { e . nativeEvent . preventDownshiftDefault = true ; } if ( e . key === 'Tab' && highlightedIndex > - 1 && isOpen ) { closeMenu ( ) ; } } ; if ( ! props . multiselect ) { getDropdownProps ( { } , { suppressRefError : true } ) ; } const isInputVisible = ! props . multiselect || isOpen || ( ! isOpen && selectedItems . length === 0 ) ; const inputAriaLabel = `${ getLabelProps ( ) . id }${ error ? ` ${ id }-error` : '' }${ helper ? ` ${ id }-helper` : '' } ${ getInputProps ( ) . id }` ; const renderInput = ( ) => ( < input { ... getInputProps ( { ... ( invalid && { 'aria-invalid' : true } ) , ... ( props . multiselect && { ... getDropdownProps ( { onKeyDown : handleMultiSelectInputKeyDown , ref : inputRef , } ) , } ) , type : 'text' , disabled , required , role : getComboboxProps ( ) . role , 'aria-expanded' : getComboboxProps ( ) [ 'aria-expanded' ] , 'aria-haspopup' : getComboboxProps ( ) [ 'aria-haspopup' ] , 'aria-owns' : getComboboxProps ( ) [ 'aria-owns' ] , 'aria-labelledby' : inputAriaLabel , 'aria-describedby' : customAriaDescribedBy , } ) } placeholder = { placeholder } className = { classNames ( styles . input , ! isInputVisible && styles . hidden , ! showToggleButton && styles . noToggle , hasFocus && selectedItems . length > 0 && styles . adjustSpacing , props . icon && props . multiselect && styles . inputWithIcon ) } autoCorrect = "off" autoComplete = "off" / > ) ; return ( < div className = { classNames ( styles . root , invalid && styles . invalid , disabled && styles . disabled , isOpen && styles . open , props . multiselect && styles . multiselect , customThemeClass , className ) } style = { style } > { } { label && ( < FieldLabel label = { label } required = { required } { ... getLabelProps ( ) } tooltipLabel = { tooltipLabel } tooltipButtonLabel = { tooltipButtonLabel } tooltipText = { tooltipText } / > ) } { } { } < div onFocus = { ignoreFocusHandlerWhenClickingItem ( handleWrapperFocus ) } onBlur = { ignoreFocusHandlerWhenClickingItem ( handleWrapperBlur ) } onClick = { handleWrapperClick } className = { classNames ( styles . wrapper , props . multiselect && props . icon && styles . wrapperWithMultiSelectAndIcon ) } ref = { getComboboxProps ( ) . ref } > { } { props . multiselect && selectedItems . length > 0 && ( < SelectedItems < OptionType > activeIndex = { activeIndex } className = { styles . selectedItems } clearable = { clearable } clearButtonAriaLabel = { props . clearButtonAriaLabel } dropdownId = { id } getSelectedItemProps = { getSelectedItemProps } hideItems = { ! hasFocus } onClear = { ( ) => { reset ( ) ; setInputValue ( '' ) ; } } onRemove = { removeSelectedItem } optionLabelField = { optionLabelField } removeButtonAriaLabel = { props . selectedItemRemoveButtonAriaLabel } selectedItems = { selectedItems } selectedItemSrLabel = { props . selectedItemSrLabel } selectedItemsContainerRef = { selectedItemsContainerRef } setActiveIndex = { setActiveIndex } toggleButtonHidden = { ! showToggleButton } />)} { props . multiselect === false && props . icon && ( < span className = { styles . icon } aria - hidden > { props . icon } < /span>)} { } { props . multiselect && props . icon ? ( < div className = { classNames ( styles . multiselectIconAndInputWrapper , ! isInputVisible && styles . hidden ) } > < span className = { classNames ( styles . icon , hasFocus && selectedItems . length > 0 && styles . adjustSpacingForIcon ) } aria - hidden > { props . icon } < /span> { renderInput ( ) } < /div>) : (renderInput())} { } < button type = "button" { ... getToggleButtonProps ( { disabled , className : classNames ( styles . button , ! showToggleButton && styles . hidden ) , 'aria-label' : `${ label }: ${ toggleButtonAriaLabel }` , 'aria-expanded' : isOpen , ... ( invalid && { 'aria-invalid' : true } ) , } ) } > < IconAngleDown className = { styles . angleIcon } aria - hidden / > < /button> { } < DropdownMenu < OptionType > getItemProps = { ( item , index , selected , optionDisabled , virtualRow ) => getItemProps ( { item , index , disabled : optionDisabled , className : classNames ( styles . menuItem , highlightedIndex === index && styles . highlighted , selected && styles . selected , optionDisabled && styles . disabled , virtualized && styles . virtualized ) , onMouseDown : ( ) => { setIsClicking ( true ) ; } , onClick : ( ) => { setIsClicking ( false ) ; } , ... ( virtualRow && { style : { transform : `translateY(${ virtualRow . start }px` , } , ref : virtualRow . measureRef , } ) , } ) } isOptionDisabled = { isOptionDisabled } menuProps = { getMenuProps ( { ... ( props . multiselect && { 'aria-multiselectable' : true } ) , ... ( required && { 'aria-required' : true } ) , style : { maxHeight : DROPDOWN_MENU_ITEM_HEIGHT * visibleOptions } , ref : menuRef , onMouseLeave : ( event ) => { event . nativeEvent . preventDownshiftDefault = true ; } , } ) } menuStyles = { styles } multiselect = { props . multiselect } open = { isOpen } optionLabelField = { optionLabelField } options = { getFilteredItems } selectedItem = { selectedItem } selectedItems = { selectedItems } virtualizer = { virtualized && virtualizer } visibleOptions = { visibleOptions } / > < /div> { } { invalid && error && ( < div id = { `${ id }-error` } className = { styles . errorText } aria - hidden > < IconAlertCircleFill className = { styles . invalidIcon } /> { error } < /div>)} { } { helper && ( < div id = { `${ id }-helper` } className = { styles . helperText } aria - hidden > { helper } < /div>)} < /div>); } ; Combobox . defaultProps = { multiselect : false , } ;