61
common/resources/client/ui/forms/combobox/combobox-end-adornment.tsx
Executable file
61
common/resources/client/ui/forms/combobox/combobox-end-adornment.tsx
Executable file
@@ -0,0 +1,61 @@
|
||||
import React, {ReactElement, useEffect, useRef, useState} from 'react';
|
||||
import {SvgIconProps} from '@common/icons/svg-icon';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {useListboxContext} from '@common/ui/forms/listbox/listbox-context';
|
||||
import {ProgressCircle} from '@common/ui/progress/progress-circle';
|
||||
import {KeyboardArrowDownIcon} from '@common/icons/material/KeyboardArrowDown';
|
||||
|
||||
interface Props {
|
||||
isLoading?: boolean;
|
||||
icon?: ReactElement<SvgIconProps>;
|
||||
}
|
||||
export function ComboboxEndAdornment({isLoading, icon}: Props) {
|
||||
const timeout = useRef<any>(null);
|
||||
const {trans} = useTrans();
|
||||
const [showLoading, setShowLoading] = useState(false);
|
||||
|
||||
const {
|
||||
state: {isOpen, inputValue},
|
||||
} = useListboxContext();
|
||||
|
||||
const lastInputValue = useRef(inputValue);
|
||||
useEffect(() => {
|
||||
if (isLoading && !showLoading) {
|
||||
if (timeout.current === null) {
|
||||
timeout.current = setTimeout(() => {
|
||||
setShowLoading(true);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// If user is typing, clear the timer and restart since it is a new request
|
||||
if (inputValue !== lastInputValue.current) {
|
||||
clearTimeout(timeout.current);
|
||||
timeout.current = setTimeout(() => {
|
||||
setShowLoading(true);
|
||||
}, 500);
|
||||
}
|
||||
} else if (!isLoading) {
|
||||
// If loading is no longer happening, clear any timers and hide the loading circle
|
||||
setShowLoading(false);
|
||||
clearTimeout(timeout.current);
|
||||
timeout.current = null;
|
||||
}
|
||||
|
||||
lastInputValue.current = inputValue;
|
||||
}, [isLoading, showLoading, inputValue]);
|
||||
|
||||
// loading circle should only be displayed if menu is open, if menuTrigger is "manual", or first time load (to stop circle from showing up when user selects an option)
|
||||
const showLoadingIndicator = showLoading && (isOpen || isLoading);
|
||||
|
||||
if (showLoadingIndicator) {
|
||||
return (
|
||||
<ProgressCircle
|
||||
aria-label={trans({message: 'Loading'})}
|
||||
size="sm"
|
||||
isIndeterminate
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return icon || <KeyboardArrowDownIcon />;
|
||||
}
|
||||
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};
|
||||
32
common/resources/client/ui/forms/combobox/form-combobox.tsx
Executable file
32
common/resources/client/ui/forms/combobox/form-combobox.tsx
Executable file
@@ -0,0 +1,32 @@
|
||||
import {useController} from 'react-hook-form';
|
||||
import {mergeProps} from '@react-aria/utils';
|
||||
import React from 'react';
|
||||
import {ComboBox, ComboboxProps} from './combobox';
|
||||
|
||||
type Props<T extends object> = ComboboxProps<T> & {
|
||||
name: string;
|
||||
selectionMode?: 'single';
|
||||
};
|
||||
export function FormComboBox<T extends object>({children, ...props}: Props<T>) {
|
||||
const {
|
||||
field: {onChange, onBlur, value = '', ref},
|
||||
fieldState: {invalid, error},
|
||||
} = useController({
|
||||
name: props.name,
|
||||
});
|
||||
|
||||
const formProps: Partial<ComboboxProps<T>> = {
|
||||
onSelectionChange: onChange,
|
||||
onBlur,
|
||||
selectedValue: value,
|
||||
defaultInputValue: value,
|
||||
invalid,
|
||||
errorMessage: error?.message,
|
||||
};
|
||||
|
||||
return (
|
||||
<ComboBox ref={ref} {...mergeProps(formProps, props)}>
|
||||
{children}
|
||||
</ComboBox>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user