183
common/resources/client/ui/forms/combobox/combobox.tsx
Executable file
183
common/resources/client/ui/forms/combobox/combobox.tsx
Executable file
@@ -0,0 +1,183 @@
|
||||
import React, {ReactElement, Ref} from 'react';
|
||||
import {BaseFieldPropsWithDom} from '../input-field/base-field-props';
|
||||
import {Item} from '../listbox/item';
|
||||
import {useListbox} from '../listbox/use-listbox';
|
||||
import {IconButton} from '../../buttons/icon-button';
|
||||
import {TextField} from '../input-field/text-field/text-field';
|
||||
import {Listbox} from '../listbox/listbox';
|
||||
import {SvgIconProps} from '@common/icons/svg-icon';
|
||||
import {useListboxKeyboardNavigation} from '@common/ui/forms/listbox/use-listbox-keyboard-navigation';
|
||||
import {createEventHandler} from '@common/utils/dom/create-event-handler';
|
||||
import {ListBoxChildren, ListboxProps} from '../listbox/types';
|
||||
import {Popover} from '../../overlays/popover';
|
||||
import {ComboboxEndAdornment} from '@common/ui/forms/combobox/combobox-end-adornment';
|
||||
|
||||
export {Item as Option};
|
||||
|
||||
export type ComboboxProps<T extends object> = Omit<
|
||||
BaseFieldPropsWithDom<HTMLInputElement>,
|
||||
'endAdornment'
|
||||
> &
|
||||
ListBoxChildren<T> &
|
||||
ListboxProps & {
|
||||
selectionMode?: 'single' | 'none';
|
||||
isAsync?: boolean;
|
||||
isLoading?: boolean;
|
||||
openMenuOnFocus?: boolean;
|
||||
endAdornmentIcon?: ReactElement<SvgIconProps>;
|
||||
useOptionLabelAsInputValue?: boolean;
|
||||
hideEndAdornment?: boolean;
|
||||
onEndAdornmentClick?: () => void;
|
||||
prependListbox?: boolean;
|
||||
listboxClassName?: string;
|
||||
};
|
||||
|
||||
function ComboBox<T extends object>(
|
||||
props: ComboboxProps<T> & {selectionMode: 'single'},
|
||||
ref: Ref<HTMLInputElement>,
|
||||
) {
|
||||
const {
|
||||
children,
|
||||
items,
|
||||
isAsync,
|
||||
isLoading,
|
||||
openMenuOnFocus = true,
|
||||
endAdornmentIcon,
|
||||
onItemSelected,
|
||||
maxItems,
|
||||
clearInputOnItemSelection,
|
||||
inputValue: userInputValue,
|
||||
selectedValue,
|
||||
onSelectionChange,
|
||||
allowCustomValue = false,
|
||||
onInputValueChange,
|
||||
defaultInputValue,
|
||||
selectionMode = 'single',
|
||||
useOptionLabelAsInputValue,
|
||||
showEmptyMessage,
|
||||
floatingMaxHeight,
|
||||
hideEndAdornment = false,
|
||||
blurReferenceOnItemSelection,
|
||||
isOpen: propsIsOpen,
|
||||
onOpenChange: propsOnOpenChange,
|
||||
prependListbox,
|
||||
listboxClassName,
|
||||
onEndAdornmentClick,
|
||||
autoFocusFirstItem = true,
|
||||
...textFieldProps
|
||||
} = props;
|
||||
|
||||
const listbox = useListbox(
|
||||
{
|
||||
...props,
|
||||
floatingMaxHeight,
|
||||
blurReferenceOnItemSelection,
|
||||
selectionMode,
|
||||
role: 'listbox',
|
||||
virtualFocus: true,
|
||||
clearSelectionOnInputClear: true,
|
||||
},
|
||||
ref,
|
||||
);
|
||||
|
||||
const {
|
||||
reference,
|
||||
listboxId,
|
||||
onInputChange,
|
||||
state: {
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
inputValue,
|
||||
setInputValue,
|
||||
selectValues,
|
||||
selectedValues,
|
||||
setActiveCollection,
|
||||
},
|
||||
collection,
|
||||
} = listbox;
|
||||
|
||||
const textLabel = selectedValues[0]
|
||||
? collection.get(selectedValues[0])?.textLabel
|
||||
: undefined;
|
||||
|
||||
const {handleListboxSearchFieldKeydown} =
|
||||
useListboxKeyboardNavigation(listbox);
|
||||
|
||||
const handleFocusAndClick = createEventHandler(
|
||||
(e: React.FocusEvent<HTMLInputElement>) => {
|
||||
if (openMenuOnFocus && !isOpen) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
e.target.select();
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Listbox
|
||||
prepend={prependListbox}
|
||||
className={listboxClassName}
|
||||
listbox={listbox}
|
||||
mobileOverlay={Popover}
|
||||
isLoading={isLoading}
|
||||
onPointerDown={e => {
|
||||
// prevent focus from leaving input when scrolling listbox via mouse
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
inputRef={reference}
|
||||
{...textFieldProps}
|
||||
endAdornment={
|
||||
!hideEndAdornment ? (
|
||||
<IconButton
|
||||
size="md"
|
||||
tabIndex={-1}
|
||||
disabled={textFieldProps.disabled}
|
||||
className="pointer-events-auto"
|
||||
onPointerDown={e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (onEndAdornmentClick) {
|
||||
onEndAdornmentClick();
|
||||
} else {
|
||||
setActiveCollection('all');
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ComboboxEndAdornment
|
||||
isLoading={isLoading}
|
||||
icon={endAdornmentIcon}
|
||||
/>
|
||||
</IconButton>
|
||||
) : null
|
||||
}
|
||||
aria-expanded={isOpen ? 'true' : 'false'}
|
||||
aria-haspopup="listbox"
|
||||
aria-controls={isOpen ? listboxId : undefined}
|
||||
aria-autocomplete="list"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck="false"
|
||||
onChange={onInputChange}
|
||||
value={useOptionLabelAsInputValue && textLabel ? textLabel : inputValue}
|
||||
onBlur={e => {
|
||||
if (allowCustomValue) {
|
||||
selectValues(e.target.value);
|
||||
} else if (!clearInputOnItemSelection) {
|
||||
const val = selectedValues[0];
|
||||
setInputValue(selectValues.length && val != null ? `${val}` : '');
|
||||
}
|
||||
}}
|
||||
onFocus={handleFocusAndClick}
|
||||
onClick={handleFocusAndClick}
|
||||
onKeyDown={e => handleListboxSearchFieldKeydown(e)}
|
||||
/>
|
||||
</Listbox>
|
||||
);
|
||||
}
|
||||
|
||||
const ComboBoxForwardRef = React.forwardRef(ComboBox) as <T extends object>(
|
||||
props: ComboboxProps<T> & {ref?: Ref<HTMLInputElement>},
|
||||
) => ReactElement;
|
||||
export {ComboBoxForwardRef as ComboBox};
|
||||
Reference in New Issue
Block a user