@ -18,46 +18,25 @@ import { NotFoundError } from './MessageRows';
import { OptionListItem } from './OptionListItem' ;
import { SuffixIcon } from './SuffixIcon' ;
import { ValuePill } from './ValuePill' ;
import { itemFilter , item ToString } from './filter' ;
import { itemToString } from './filter' ;
import { getComboboxStyles , MENU_OPTION_HEIGHT , MENU_OPTION_HEIGHT_DESCRIPTION } from './getComboboxStyles' ;
import { getMultiComboboxStyles } from './getMultiComboboxStyles' ;
import { ALL_OPTION_VALUE , ComboboxOption } from './types' ;
import { useComboboxFloat } from './useComboboxFloat' ;
import { MAX_SHOWN_ITEMS , useMeasureMulti } from './useMeasureMulti' ;
import { useMultiInputAutoSize } from './useMultiInputAutoSize' ;
import { useOptions } from './useOptions' ;
interface MultiComboboxBaseProps < T extends string | number > extends Omit < ComboboxBaseProps < T > , 'value' | 'onChange' > {
value? : T [ ] | Array < ComboboxOption < T > > ;
onChange : ( items? : T [ ] ) = > void ;
onChange : ( option : Array < ComboboxOption < T > > ) = > void ;
enableAllOption? : boolean ;
}
export type MultiComboboxProps < T extends string | number > = MultiComboboxBaseProps < T > & AutoSizeConditionals ;
export const MultiCombobox = < T extends string | number > ( props : MultiComboboxProps < T > ) = > {
const {
options ,
placeholder ,
onChange ,
value ,
width ,
enableAllOption ,
invalid ,
loading ,
disabled ,
minWidth ,
maxWidth ,
} = props ;
const isAsync = typeof options === 'function' ;
const selectedItems = useMemo ( ( ) = > {
if ( ! value || isAsync ) {
//TODO handle async
return [ ] ;
}
return getSelectedItemsFromValue < T > ( value , options ) ;
} , [ value , options , isAsync ] ) ;
const { placeholder , onChange , value , width , enableAllOption , invalid , disabled , minWidth , maxWidth } = props ;
const styles = useStyles2 ( getComboboxStyles ) ;
const [ inputValue , setInputValue ] = useState ( '' ) ;
@ -73,19 +52,22 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
} as ComboboxOption < T > ;
} , [ inputValue ] ) ;
const baseItems = useMemo ( ( ) = > {
return isAsync ? [ ] : enableAllOption ? [ allOptionItem , . . . options ] : options ;
} , [ options , enableAllOption , allOptionItem , isAsync ] ) ;
// Handle async options and the 'All' option
const { options : baseOptions , updateOptions , asyncLoading } = useOptions ( props . options ) ;
const options = useMemo ( ( ) = > {
// Only add the 'All' option if there's more than 1 option
const addAllOption = enableAllOption && baseOptions . length > 1 ;
return addAllOption ? [ allOptionItem , . . . baseOptions ] : baseOptions ;
} , [ baseOptions , enableAllOption , allOptionItem ] ) ;
const loading = props . loading || asyncLoading ;
const items = useMemo ( ( ) = > {
const newItems = baseItems . filter ( itemFilter ( inputValue ) ) ;
if ( enableAllOption && newItems . length === 1 && newItems [ 0 ] === allOptionItem ) {
const selectedItems = useMemo ( ( ) = > {
if ( ! value ) {
return [ ] ;
}
return newItems ;
} , [ baseItems , inputValue , enableAllOption , allOptionItem ] ) ;
return getSelectedItemsFromValue < T > ( value , baseOptions ) ;
} , [ value , baseOptions ] ) ;
const { measureRef , counterMeasureRef , suffixMeasureRef , shownItems } = useMeasureMulti (
selectedItems ,
@ -98,48 +80,50 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
[ selectedItems ]
) ;
const { getSelectedItemProps , getDropdownProps , removeSelectedItem } = useMultipleSelection ( {
selectedItems , //initally selected items,
onStateChange : ( { type , selectedItems : newSelectedItems } ) = > {
switch ( type ) {
case useMultipleSelection . stateChangeTypes . SelectedItemKeyDownBackspace :
case useMultipleSelection . stateChangeTypes . SelectedItemKeyDownDelete :
case useMultipleSelection . stateChangeTypes . DropdownKeyDownBackspace :
case useMultipleSelection . stateChangeTypes . FunctionRemoveSelectedItem :
if ( newSelectedItems ) {
onChange ( getComboboxOptionsValues ( newSelectedItems ) ) ;
}
break ;
const { getSelectedItemProps , getDropdownProps , setSelectedItems , addSelectedItem , removeSelectedItem } =
useMultipleSelection ( {
selectedItems , // initally selected items,
onStateChange : ( { type , selectedItems : newSelectedItems } ) = > {
switch ( type ) {
case useMultipleSelection . stateChangeTypes . SelectedItemKeyDownBackspace :
case useMultipleSelection . stateChangeTypes . SelectedItemKeyDownDelete :
case useMultipleSelection . stateChangeTypes . DropdownKeyDownBackspace :
case useMultipleSelection . stateChangeTypes . FunctionRemoveSelectedItem :
case useMultipleSelection . stateChangeTypes . FunctionAddSelectedItem :
case useMultipleSelection . stateChangeTypes . FunctionSetSelectedItems :
// Unclear why newSelectedItems would be undefined, but this seems logical
onChange ( newSelectedItems ? ? [ ] ) ;
break ;
default :
break ;
}
} ,
stateReducer : ( state , actionAndChanges ) = > {
const { changes } = actionAndChanges ;
return {
. . . changes ,
/ * *
* TODO : Fix Hack !
* This prevents the menu from closing when the user unselects an item in the dropdown at the expense
* of breaking keyboard navigation .
*
* Downshift isn ' t really designed to keep selected items in the dropdown menu , so when you unselect an item
* in a multiselect , the stateReducer tries to move focus onto another item which causes the menu to be closed .
* This only seems to happen when you deselect the last item in the selectedItems list .
*
* Check out :
* - FunctionRemoveSelectedItem in the useMultipleSelection reducer https : //github.com/downshift-js/downshift/blob/master/src/hooks/useMultipleSelection/reducer.js#L75
* - The activeIndex useEffect in useMultipleSelection https : //github.com/downshift-js/downshift/blob/master/src/hooks/useMultipleSelection/index.js#L68-L72
*
* Forcing the activeIndex to - 999 both prevents the useEffect that changes the focus from triggering ( value never changes )
* and prevents the if statement in useMultipleSelection from focusing anything .
* /
activeIndex : - 999 ,
} ;
} ,
} ) ;
default :
break ;
}
} ,
stateReducer : ( state , actionAndChanges ) = > {
const { changes } = actionAndChanges ;
return {
. . . changes ,
/ * *
* TODO : Fix Hack !
* This prevents the menu from closing when the user unselects an item in the dropdown at the expense
* of breaking keyboard navigation .
*
* Downshift isn ' t really designed to keep selected items in the dropdown menu , so when you unselect an item
* in a multiselect , the stateReducer tries to move focus onto another item which causes the menu to be closed .
* This only seems to happen when you deselect the last item in the selectedItems list .
*
* Check out :
* - FunctionRemoveSelectedItem in the useMultipleSelection reducer https : //github.com/downshift-js/downshift/blob/master/src/hooks/useMultipleSelection/reducer.js#L75
* - The activeIndex useEffect in useMultipleSelection https : //github.com/downshift-js/downshift/blob/master/src/hooks/useMultipleSelection/index.js#L68-L72
*
* Forcing the activeIndex to - 999 both prevents the useEffect that changes the focus from triggering ( value never changes )
* and prevents the if statement in useMultipleSelection from focusing anything .
* /
activeIndex : - 999 ,
} ;
} ,
} ) ;
const {
getToggleButtonProps ,
@ -150,12 +134,25 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
getInputProps ,
getItemProps ,
} = useCombobox ( {
items ,
items : options ,
itemToString ,
inputValue ,
selectedItem : null ,
stateReducer : ( state , actionAndChanges ) = > {
const { changes , type } = actionAndChanges ;
const { type } = actionAndChanges ;
let { changes } = actionAndChanges ;
const menuBeingOpened = state . isOpen === false && changes . isOpen === true ;
// 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 : '' ,
} ;
}
switch ( type ) {
case useCombobox . stateChangeTypes . InputKeyDownEnter :
case useCombobox . stateChangeTypes . ItemClick :
@ -171,39 +168,50 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
}
} ,
onIsOpenChange : ( { isOpen , inputValue } ) = > {
if ( isOpen && inputValue === '' ) {
updateOptions ( inputValue ) ;
}
} ,
onStateChange : ( { inputValue : newInputValue , type , selectedItem : newSelectedItem } ) = > {
switch ( type ) {
case useCombobox . stateChangeTypes . InputKeyDownEnter :
case useCombobox . stateChangeTypes . ItemClick :
// Handle All functionality
if ( newSelectedItem ? . value === ALL_OPTION_VALUE ) {
const allFilteredSelected = selectedItems . length === items . length - 1 ;
let newSelectedItems = allFilteredSelected && inputValue === '' ? [ ] : baseItems . slice ( 1 ) ;
// TODO: fix bug where if the search filtered items list is the
// same length, but different, than the selected items (ask tobias)
const isAllFilteredSelected = selectedItems . length === options . length - 1 ;
// if every option is already selected, clear the selection.
// otherwise, select all the options (excluding the first ALL_OTION)
const realOptions = options . slice ( 1 ) ;
let newSelectedItems = isAllFilteredSelected && inputValue === '' ? [ ] : realOptions ;
if ( ! allFilteredSelected && inputValue !== '' ) {
if ( ! isA llFilteredSelected && inputValue !== '' ) {
// Select all currently filtered items and deduplicate
newSelectedItems = [ . . . new Set ( [ . . . selectedItems , . . . items . slice ( 1 ) ] ) ] ;
newSelectedItems = [ . . . new Set ( [ . . . selectedItems , . . . realOptions ] ) ] ;
}
if ( a llFilteredSelected && inputValue !== '' ) {
if ( isA llFilteredSelected && inputValue !== '' ) {
// Deselect all currently filtered items
const filteredSet = new Set ( items . slice ( 1 ) . map ( ( item ) = > item . value ) ) ;
const filteredSet = new Set ( realOptions . map ( ( item ) = > item . value ) ) ;
newSelectedItems = selectedItems . filter ( ( item ) = > ! filteredSet . has ( item . value ) ) ;
}
onChange ( getComboboxOptionsValues ( newSelectedItems ) ) ;
break ;
}
if ( newSelectedItem ) {
if ( ! isOptionSelected ( newSelectedItem ) ) {
onChange ( getComboboxOptionsValues ( [ . . . selectedItems , newSelectedItem ] ) ) ;
break ;
}
removeSelectedItem ( newSelectedItem ) ; // onChange is handled by multiselect here
setSelectedItems ( newSelectedItems ) ;
} else if ( newSelectedItem && isOptionSelected ( newSelectedItem ) ) {
removeSelectedItem ( newSelectedItem ) ;
} else if ( newSelectedItem ) {
addSelectedItem ( newSelectedItem ) ;
}
break ;
case useCombobox . stateChangeTypes . InputChange :
setInputValue ( newInputValue ? ? '' ) ;
updateOptions ( newInputValue ? ? '' ) ;
break ;
default :
break ;
@ -211,14 +219,14 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
} ,
} ) ;
const { inputRef : containerRef , floatingRef , floatStyles , scrollRef } = useComboboxFloat ( item s, isOpen ) ;
const { inputRef : containerRef , floatingRef , floatStyles , scrollRef } = useComboboxFloat ( option s, isOpen ) ;
const multiStyles = useStyles2 ( getMultiComboboxStyles , isOpen , invalid , disabled , width , minWidth , maxWidth ) ;
const virtualizerOptions = {
count : item s.length,
count : option s.length,
getScrollElement : ( ) = > scrollRef . current ,
estimateSize : ( index : number ) = >
'description' in item s[ index ] ? MENU_OPTION_HEIGHT_DESCRIPTION : MENU_OPTION_HEIGHT ,
'description' in option s[ index ] ? MENU_OPTION_HEIGHT_DESCRIPTION : MENU_OPTION_HEIGHT ,
overscan : VIRTUAL_OVERSCAN_ITEMS ,
} ;
@ -291,13 +299,16 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
< ul style = { { height : rowVirtualizer.getTotalSize ( ) } } className = { styles . menuUlContainer } >
{ rowVirtualizer . getVirtualItems ( ) . map ( ( virtualRow ) = > {
const index = virtualRow . index ;
const item = item s[ index ] ;
const item = option s[ index ] ;
const itemProps = getItemProps ( { item , index } ) ;
const isSelected = isOptionSelected ( item ) ;
const id = 'multicombobox-option-' + item . value . toString ( ) ;
const isAll = item . value === ALL_OPTION_VALUE ;
// TODO: fix bug where if the search filtered items list is the
// same length, but different, than the selected items (ask tobias)
const allItemsSelected =
items [ 0 ] ? . value === ALL_OPTION_VALUE && selectedItems . length === items . length - 1 ;
option s[ 0 ] ? . value === ALL_OPTION_VALUE && selectedItems . length === option s. length - 1 ;
return (
< li
@ -321,7 +332,7 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
label = {
isAll
? ( item . label ? ? item . value . toString ( ) ) +
( isAll && inputValue !== '' ? ` ( ${ item s. length - 1 } ) ` : '' )
( isAll && inputValue !== '' ? ` ( ${ option s. length - 1 } ) ` : '' )
: ( item . label ? ? item . value . toString ( ) )
}
description = { item ? . description }
@ -332,7 +343,7 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
) ;
} ) }
< / ul >
< div aria - live = "polite" > { item s. length === 0 && < NotFoundError / > } < / div >
< div aria - live = "polite" > { option s. length === 0 && < NotFoundError / > } < / div >
< / ScrollContainer >
) }
< / div >
@ -375,7 +386,3 @@ function isComboboxOptions<T extends string | number>(
) : value is Array < ComboboxOption < T > > {
return typeof value [ 0 ] === 'object' ;
}
function getComboboxOptionsValues < T extends string | number > ( optionArray : Array < ComboboxOption < T > > ) {
return optionArray . map ( ( option ) = > option . value ) ;
}