@ -72,6 +72,7 @@ function itemFilter<T extends string | number>(inputValue: string) {
} ;
}
const noop = ( ) = > { } ;
const asyncNoop = ( ) = > Promise . resolve ( [ ] ) ;
/ * *
@ -79,17 +80,20 @@ const asyncNoop = () => Promise.resolve([]);
*
* @alpha
* /
export const Combobox = < T extends string | number > ( {
options ,
onChange ,
value : valueProp ,
isClearable = false ,
createCustomValue = false ,
id ,
width ,
'aria-labelledby' : ariaLabelledBy ,
. . . restProps
} : ComboboxProps < T > ) = > {
export const Combobox = < T extends string | number > ( props : ComboboxProps < T > ) = > {
const {
options ,
onChange ,
value : valueProp ,
placeholder : placeholderProp ,
isClearable = false ,
createCustomValue = false ,
id ,
width ,
'aria-labelledby' : ariaLabelledBy ,
. . . restProps
} = props ;
// Value can be an actual scalar Value (string or number), or an Option (value + label), so
// get a consistent Value from it
const value = typeof valueProp === 'object' ? valueProp?.value : valueProp ;
@ -99,7 +103,31 @@ export const Combobox = <T extends string | number>({
const [ asyncLoading , setAsyncLoading ] = useState ( false ) ;
const [ asyncError , setAsyncError ] = useState ( false ) ;
const [ items , setItems ] = useState ( isAsync ? [ ] : options ) ;
// A custom setter to always prepend the custom value at the beginning, if needed
const [ items , baseSetItems ] = useState ( isAsync ? [ ] : options ) ;
const setItems = useCallback (
( items : Array < ComboboxOption < T > > , inputValue : string | undefined ) = > {
let itemsToSet = items ;
if ( inputValue && createCustomValue ) {
const optionMatchingInput = items . find ( ( opt ) = > opt . label === inputValue || opt . value === inputValue ) ;
if ( ! optionMatchingInput ) {
const customValueOption = {
// Type casting needed to make this work when T is a number
value : inputValue as unknown as T ,
description : t ( 'combobox.custom-value.create' , 'Create custom value' ) ,
} ;
itemsToSet = items . slice ( 0 ) ;
itemsToSet . unshift ( customValueOption ) ;
}
}
baseSetItems ( itemsToSet ) ;
} ,
[ createCustomValue ]
) ;
const selectedItemIndex = useMemo ( ( ) = > {
if ( isAsync ) {
@ -142,10 +170,10 @@ export const Combobox = <T extends string | number>({
const debounceAsync = useMemo (
( ) = >
debounce ( ( inputValue : string , customValueOption : ComboboxOption < T > | null ) = > {
debounce ( ( inputValue : string ) = > {
loadOptions ( inputValue )
. then ( ( opts ) = > {
setItems ( customValueOption ? [ customValueOption , . . . opts ] : opts ) ;
setItems ( opts , inputValue ) ;
setAsyncLoading ( false ) ;
setAsyncError ( false ) ;
} )
@ -156,16 +184,17 @@ export const Combobox = <T extends string | number>({
}
} ) ;
} , 200 ) ,
[ loadOptions ]
[ loadOptions , setItems ]
) ;
const {
isOpen ,
highlightedIndex ,
getInputProps ,
getMenuProps ,
getItemProps ,
isOpen ,
highlightedIndex ,
setInputValue ,
openMenu ,
closeMenu ,
selectItem ,
@ -176,51 +205,53 @@ export const Combobox = <T extends string | number>({
items ,
itemToString ,
selectedItem ,
// Don't change downshift state in the onBlahChange handlers. Instead, use the stateReducer to make changes.
// Downshift calls change handlers on the render after so you can get sync/flickering issues if you change its state
// in them.
// Instead, stateReducer is called in the same tick as state changes, before that state is committed and rendered.
onSelectedItemChange : ( { selectedItem } ) = > {
onChange ( selectedItem ) ;
} ,
defaultHighlightedIndex : selectedItemIndex ? ? 0 ,
scrollIntoView : ( ) = > { } ,
onInputValueChange : ( { inputValue } ) = > {
const customValueOption =
createCustomValue &&
inputValue &&
items . findIndex ( ( opt ) = > opt . label === inputValue || opt . value === inputValue ) === - 1
? {
// Type casting needed to make this work when T is a number
value : inputValue as unknown as T ,
description : t ( 'combobox.custom-value.create' , 'Create custom value' ) ,
}
: null ;
if ( isAsync ) {
if ( customValueOption ) {
setItems ( [ customValueOption ] ) ;
onInputValueChange : ( { inputValue , isOpen } ) = > {
if ( ! isOpen ) {
// Prevent stale options from showing on reopen
if ( isAsync ) {
setItems ( [ ] , '' ) ;
}
setAsyncLoading ( true ) ;
debounceAsync ( inputValue , customValueOption ) ;
// Otherwise there's nothing else to do when the menu isnt open
return ;
}
const filteredItems = options . filter ( itemFilter ( inputValue ) ) ;
if ( ! isAsync ) {
const filteredItems = options . filter ( itemFilter ( inputValue ) ) ;
setItems ( filteredItems , inputValue ) ;
} else {
if ( inputValue && createCustomValue ) {
setItems ( [ ] , inputValue ) ;
}
setItems ( customValueOption ? [ customValueOption , . . . filteredItems ] : filteredItems ) ;
setAsyncLoading ( true ) ;
debounceAsync ( inputValue ) ;
}
} ,
onIsOpenChange : ( { isOpen , inputValue } ) = > {
// Default to displaying all values when opening
if ( isOpen && ! isAsync ) {
setItems ( options ) ;
return ;
}
if ( isOpen && isAsync ) {
// Loading async options mostly happens in onInputValueChange, but if the menu is opened with an empty input
// then onInputValueChange isn't called (because the input value hasn't changed)
if ( isAsync && isOpen && inputValue === '' ) {
setAsyncLoading ( true ) ;
loadOptions ( inputValue ? ? '' )
. then ( ( options ) = > {
setItems ( options ) ;
// TODO: dedupe this loading logic with debounceAsync
loadOptions ( inputValue )
. then ( ( opts ) = > {
setItems ( opts , inputValue ) ;
setAsyncLoading ( false ) ;
setAsyncError ( false ) ;
} )
@ -230,22 +261,52 @@ export const Combobox = <T extends string | number>({
setAsyncLoading ( false ) ;
}
} ) ;
return ;
}
} ,
onHighlightedIndexChange : ( { highlightedIndex , type } ) = > {
if ( type !== useCombobox . stateChangeTypes . MenuMouseLeave ) {
rowVirtualizer . scrollToIndex ( highlightedIndex ) ;
}
} ,
stateReducer ( state , actionAndChanges ) {
let { changes } = actionAndChanges ;
const menuBeingOpened = state . isOpen === false && changes . isOpen === true ;
const menuBeingClosed = state . isOpen === true && changes . isOpen === false ;
// Reset the input value when the menu is opened. If the menu is opened due to an input change
// then make sure we keep that.
// This will trigger onInputValueChange to load async options
if ( menuBeingOpened && changes . inputValue === state . inputValue ) {
changes = {
. . . changes ,
inputValue : '' ,
} ;
}
if ( menuBeingClosed ) {
// Flush the selected item to the input when the menu is closed
if ( changes . selectedItem ) {
changes = {
. . . changes ,
inputValue : itemToString ( changes . selectedItem ) ,
} ;
} else if ( changes . inputValue !== '' ) {
// Otherwise if no selected value, clear any search from the input
changes = {
. . . changes ,
inputValue : '' ,
} ;
}
}
return changes ;
} ,
} ) ;
const { inputRef , floatingRef , floatStyles , scrollRef } = useComboboxFloat ( items , rowVirtualizer . range , isOpen ) ;
const onBlur = useCallback ( ( ) = > {
setInputValue ( selectedItem ? . label ? ? value ? . toString ( ) ? ? '' ) ;
} , [ selectedItem , setInputValue , value ] ) ;
const handleSuffixClick = useCallback ( ( ) = > {
isOpen ? closeMenu ( ) : openMenu ( ) ;
} , [ isOpen , openMenu , closeMenu ] ) ;
@ -259,6 +320,8 @@ export const Combobox = <T extends string | number>({
? 'search'
: 'angle-down' ;
const placeholder = ( isOpen ? itemToString ( selectedItem ) : null ) || placeholderProp ;
return (
< div >
< InputComponent
@ -297,9 +360,9 @@ export const Combobox = <T extends string | number>({
* See issue here : https : //github.com/downshift-js/downshift/issues/718
* Downshift repo : https : //github.com/downshift-js/downshift/tree/master
* /
onChange : ( ) = > { } ,
onBlur ,
onChange : noop ,
'aria-labelledby' : ariaLabelledBy , // Label should be handled with the Field component
placeholder ,
} ) }
/ >
< div