@ -11,20 +11,27 @@ import { Input, Props as InputProps } from '../Input/Input';
import { getComboboxStyles } from './getComboboxStyles' ;
import { estimateSize , useComboboxFloat } from './useComboboxFloat' ;
import { StaleResultError , useLatestAsyncCall } from './useLatestAsyncCall' ;
export type ComboboxOption < T extends string | number = string > = {
label : string ;
label? : string ;
value : T ;
description? : string ;
} ;
// TODO: It would be great if ComboboxOption["label"] was more generic so that if consumers do pass it in (for async),
// then the onChange handler emits ComboboxOption with the label as non-undefined.
interface ComboboxBaseProps < T extends string | number >
extends Omit < InputProps , ' prefix ' | ' suffix ' | ' value ' | ' addonBefore ' | ' addonAfter ' | ' onChange ' | ' width ' > {
isClearable? : boolean ;
createCustomValue? : boolean ;
options : Array < ComboboxOption < T > > ;
options : Array < ComboboxOption < T > > | ( ( inputValue : string ) = > Promise < Array < ComboboxOption < T > >> ) ;
onChange : ( option : ComboboxOption < T > | null ) = > void ;
value : T | null ;
/ * *
* Most consumers should pass value in as a scalar string | number . However , sometimes with Async because we don ' t
* have the full options loaded to match the value to , consumers may also pass in an Option with a label to display .
* /
value : T | ComboboxOption < T > | null ;
/ * *
* Defaults to 100 % . Number is a multiple of 8 px . 'auto' will size the input to the content .
* * /
@ -45,7 +52,7 @@ type AutoSizeConditionals =
type ComboboxProps < T extends string | number > = ComboboxBaseProps < T > & AutoSizeConditionals ;
function itemToString ( item : ComboboxOption < string | number > | null ) {
function itemToString < T extends string | number > ( item : ComboboxOption < T > | null ) {
return item ? . label ? ? item ? . value . toString ( ) ? ? '' ;
}
@ -61,6 +68,8 @@ function itemFilter<T extends string | number>(inputValue: string) {
} ;
}
const asyncNoop = ( ) = > Promise . resolve ( [ ] ) ;
/ * *
* A performant Select replacement .
*
@ -69,7 +78,7 @@ function itemFilter<T extends string | number>(inputValue: string) {
export const Combobox = < T extends string | number > ( {
options ,
onChange ,
value ,
value : valueProp ,
isClearable = false ,
createCustomValue = false ,
id ,
@ -77,9 +86,21 @@ export const Combobox = <T extends string | number>({
'aria-labelledby' : ariaLabelledBy ,
. . . restProps
} : ComboboxProps < T > ) = > {
const [ items , setItems ] = useState ( options ) ;
// 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 ;
const isAsync = typeof options === 'function' ;
const loadOptions = useLatestAsyncCall ( isAsync ? options : asyncNoop ) ; // loadOptions isn't called at all if not async
const [ asyncLoading , setAsyncLoading ] = useState ( false ) ;
const [ items , setItems ] = useState ( isAsync ? [ ] : options ) ;
const selectedItemIndex = useMemo ( ( ) = > {
if ( isAsync ) {
return null ;
}
if ( value === null ) {
return null ;
}
@ -90,22 +111,15 @@ export const Combobox = <T extends string | number>({
}
return index ;
} , [ options , value ] ) ;
} , [ options , value , isAsync ] ) ;
const selectedItem = useMemo ( ( ) = > {
if ( selectedItemIndex !== null ) {
if ( selectedItemIndex !== null && ! isAsync ) {
return options [ selectedItemIndex ] ;
}
// Custom value
if ( value !== null ) {
return {
label : value.toString ( ) ,
value ,
} ;
}
return null ;
} , [ selectedItemIndex , options , value ] ) ;
return typeof valueProp === 'object' ? valueProp : { value : valueProp , label : valueProp.toString ( ) } ;
} , [ selectedItemIndex , isAsync , valueProp , options ] ) ;
const menuId = ` downshift- ${ useId ( ) . replace ( /:/g , '--' ) } -menu ` ;
const labelId = ` downshift- ${ useId ( ) . replace ( /:/g , '--' ) } -label ` ;
@ -144,27 +158,57 @@ export const Combobox = <T extends string | number>({
defaultHighlightedIndex : selectedItemIndex ? ? 0 ,
scrollIntoView : ( ) = > { } ,
onInputValueChange : ( { inputValue } ) = > {
const filteredItems = options . filter ( itemFilter ( inputValue ) ) ;
if ( createCustomValue && inputValue && filteredItems . findIndex ( ( opt ) = > opt . label === inputValue ) === - 1 ) {
const customValueOption : ComboboxOption < T > = {
label : inputValue ,
// @ts-ignore 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' ) ,
} ;
setItems ( [ . . . filteredItems , customValueOption ] ) ;
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 ] ) ;
}
setAsyncLoading ( true ) ;
loadOptions ( inputValue )
. then ( ( opts ) = > {
setItems ( customValueOption ? [ customValueOption , . . . opts ] : opts ) ;
setAsyncLoading ( false ) ;
} )
. catch ( ( err ) = > {
if ( ! ( err instanceof StaleResultError ) ) {
// TODO: handle error
setAsyncLoading ( false ) ;
}
} ) ;
return ;
} else {
setItems ( filteredItems ) ;
}
const filteredItems = options . filter ( itemFilter ( inputValue ) ) ;
setItems ( customValueOption ? [ customValueOption , . . . filteredItems ] : filteredItems ) ;
} ,
onIsOpenChange : ( { isOpen } ) = > {
// Default to displaying all values when opening
if ( isOpen ) {
if ( isOpen && ! isAsync ) {
setItems ( options ) ;
return ;
}
if ( isOpen && isAsync ) {
setAsyncLoading ( true ) ;
loadOptions ( '' ) . then ( ( options ) = > {
setItems ( options ) ;
setAsyncLoading ( false ) ;
} ) ;
return ;
}
} ,
onHighlightedIndexChange : ( { highlightedIndex , type } ) = > {
if ( type !== useCombobox . stateChangeTypes . MenuMouseLeave ) {
@ -172,6 +216,7 @@ export const Combobox = <T extends string | number>({
}
} ,
} ) ;
const { inputRef , floatingRef , floatStyles } = useComboboxFloat ( items , rowVirtualizer . range , isOpen ) ;
const onBlur = useCallback ( ( ) = > {
@ -215,6 +260,7 @@ export const Combobox = <T extends string | number>({
/ >
< / >
}
loading = { asyncLoading }
{ . . . restProps }
{ . . . getInputProps ( {
ref : inputRef ,
@ -242,7 +288,7 @@ export const Combobox = <T extends string | number>({
{ rowVirtualizer . getVirtualItems ( ) . map ( ( virtualRow ) = > {
return (
< li
key = { items [ virtualRow . index ] . value + items [ virtualRow . index ] . label }
key = { ` ${ items [ virtualRow . index ] . value } - ${ virtualRow . index } ` }
data - index = { virtualRow . index }
className = { cx (
styles . option ,
@ -259,7 +305,9 @@ export const Combobox = <T extends string | number>({
} ) }
>
< div className = { styles . optionBody } >
< span className = { styles . optionLabel } > { items [ virtualRow . index ] . label } < / span >
< span className = { styles . optionLabel } >
{ items [ virtualRow . index ] . label ? ? items [ virtualRow . index ] . value }
< / span >
{ items [ virtualRow . index ] . description && (
< span className = { styles . optionDescription } > { items [ virtualRow . index ] . description } < / span >
) }