import React, {
HTMLAttributes,
Key,
ReactElement,
ReactNode,
Ref,
RefObject,
useCallback,
useMemo,
useRef,
useState,
} from 'react';
import {useFocusManager} from '@react-aria/focus';
import clsx from 'clsx';
import {mergeProps, useLayoutEffect, useObjectRef} from '@react-aria/utils';
import {useControlledState} from '@react-stately/utils';
import {ChipList} from './chip-list';
import {Field} from '../field';
import {Input} from '../input';
import {Chip, ChipProps} from './chip';
import {NormalizedModel} from '@common/datatable/filters/normalized-model';
import {getInputFieldClassNames} from '../get-input-field-class-names';
import {ProgressCircle} from '../../../progress/progress-circle';
import {useField} from '../use-field';
import {Avatar} from '../../../images/avatar';
import {Listbox} from '../../listbox/listbox';
import {useListbox} from '../../listbox/use-listbox';
import {BaseFieldPropsWithDom} from '../base-field-props';
import {useListboxKeyboardNavigation} from '../../listbox/use-listbox-keyboard-navigation';
import {createEventHandler} from '@common/utils/dom/create-event-handler';
import {ListBoxChildren, ListboxProps} from '../../listbox/types';
import {stringToChipValue} from './string-to-chip-value';
import {Popover} from '../../../overlays/popover';
import {KeyboardArrowDownIcon} from '@common/icons/material/KeyboardArrowDown';
export interface ChipValue extends Omit {
invalid?: boolean;
errorMessage?: string;
}
export type ChipFieldProps = Omit<
ListboxProps,
'selectionMode' | 'displayWith'
> &
Omit<
BaseFieldPropsWithDom,
'value' | 'onChange' | 'defaultValue'
> & {
value?: (ChipValue | string)[];
defaultValue?: (ChipValue | string)[];
displayWith?: (value: ChipValue) => ReactNode;
validateWith?: (value: ChipValue) => ChipValue;
allowCustomValue?: boolean;
showDropdownArrow?: boolean;
onChange?: (value: ChipValue[]) => void;
suggestions?: T[];
children?: ListBoxChildren['children'];
placeholder?: string;
chipSize?: ChipProps['size'];
openMenuOnFocus?: boolean;
valueKey?: 'id' | 'name';
onChipClick?: (value: ChipValue) => void;
};
function ChipFieldInner(
props: ChipFieldProps,
ref: Ref,
) {
const fieldRef = useRef(null);
const inputRef = useObjectRef(ref);
const {
displayWith = v => v.name,
validateWith,
children,
suggestions,
isLoading,
inputValue,
onInputValueChange,
onItemSelected,
placeholder,
onOpenChange,
chipSize = 'md',
openMenuOnFocus = true,
showEmptyMessage,
value: propsValue,
defaultValue,
onChange: propsOnChange,
valueKey,
isAsync,
allowCustomValue = true,
showDropdownArrow,
onChipClick,
...inputFieldProps
} = props;
const fieldClassNames = getInputFieldClassNames({
...props,
flexibleHeight: true,
});
const [value, onChange] = useChipFieldValueState(props);
const [listboxIsOpen, setListboxIsOpen] = useState(false);
const loadingIndicator = (
);
const dropdownArrow = showDropdownArrow ? : null;
const {fieldProps, inputProps} = useField({
...inputFieldProps,
focusRef: inputRef,
endAdornment: isLoading && listboxIsOpen ? loadingIndicator : dropdownArrow,
});
return (
{
// refocus input when clicking outside it, but while still inside chip field
inputRef.current?.focus();
}}
>
{children}
);
}
interface ListWrapperProps {
items: ChipValue[];
setItems: (items: ChipValue[]) => void;
displayChipUsing: (value: ChipValue) => ReactNode;
chipSize?: ChipProps['size'];
onChipClick?: (value: ChipValue) => void;
}
function ListWrapper({
items,
setItems,
displayChipUsing,
chipSize,
onChipClick,
}: ListWrapperProps) {
const manager = useFocusManager();
const removeItem = useCallback(
(key: Key) => {
const i = items.findIndex(cr => cr.id === key);
const newItems = [...items];
if (i > -1) {
newItems.splice(i, 1);
setItems(newItems);
}
return newItems;
},
[items, setItems],
);
return (
{items.map(item => (
: null}
onClick={() => onChipClick?.(item)}
onRemove={() => {
const newItems = removeItem(item.id);
if (newItems.length) {
// focus previous chip
manager?.focusPrevious({tabbable: true});
} else {
// focus input
manager?.focusLast();
}
}}
>
{displayChipUsing(item)}
))}
);
}
interface ChipInputProps {
showEmptyMessage?: boolean;
inputProps: ReturnType['inputProps'];
inputValue?: string;
onInputValueChange?: (value: string) => void;
fieldRef: RefObject;
inputRef: RefObject;
chips: ChipValue[];
setChips: (items: ChipValue[]) => void;
validateWith?: (value: ChipValue) => ChipValue;
isLoading?: boolean;
suggestions?: T[];
placeholder?: string;
openMenuOnFocus?: boolean;
listboxIsOpen: boolean;
setListboxIsOpen: (value: boolean) => void;
allowCustomValue: boolean;
children: ListBoxChildren['children'];
}
function ChipInput(props: ChipInputProps) {
const {
inputRef,
fieldRef,
validateWith,
setChips,
chips,
suggestions,
inputProps,
placeholder,
openMenuOnFocus,
listboxIsOpen,
setListboxIsOpen,
allowCustomValue,
isLoading,
} = props;
const inputClassName = 'outline-none text-sm mx-8 my-4 h-30 flex-auto';
const manager = useFocusManager();
const addItems = useCallback(
(items?: ChipValue[]) => {
items = (items || []).filter(item => {
const invalid = !item || !item.id || !item.name;
const alreadyExists = chips.findIndex(cr => cr.id === item?.id) > -1;
return !alreadyExists && !invalid;
});
if (!items.length) return;
if (validateWith) {
items = items.map(item => validateWith(item));
}
setChips([...chips, ...items]);
},
[chips, setChips, validateWith],
);
const listbox = useListbox({
...props,
clearInputOnItemSelection: true,
isOpen: listboxIsOpen,
onOpenChange: setListboxIsOpen,
items: suggestions,
selectionMode: 'none',
role: 'listbox',
virtualFocus: true,
onItemSelected: value => {
handleItemSelection(value as string);
},
});
const {
state: {
activeIndex,
setActiveIndex,
isOpen,
setIsOpen,
inputValue,
setInputValue,
},
refs,
listboxId,
collection,
onInputChange,
} = listbox;
const handleItemSelection = (textValue: string) => {
const option =
collection.size && activeIndex != null
? [...collection.values()][activeIndex]
: null;
if (option?.item) {
addItems([option.item]);
} else if (allowCustomValue) {
addItems([stringToChipValue(option ? option.value : textValue)]);
}
setInputValue('');
setActiveIndex(null);
setIsOpen(false);
};
// position dropdown relative to whole chip field, not the input
useLayoutEffect(() => {
if (fieldRef.current && refs.reference.current !== fieldRef.current) {
listbox.reference(fieldRef.current);
}
}, [fieldRef, listbox, refs]);
const {handleTriggerKeyDown, handleListboxKeyboardNavigation} =
useListboxKeyboardNavigation(listbox);
const handleFocusAndClick = createEventHandler(() => {
if (openMenuOnFocus && !isOpen) {
setIsOpen(true);
}
});
return (
{
// prevent focus from leaving input when scrolling listbox via mouse
e.preventDefault();
}}
>
{
const paste = e.clipboardData.getData('text');
const emails = paste.match(
/([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9._-]+)/gi,
);
if (emails) {
e.preventDefault();
const selection = window.getSelection();
if (selection?.rangeCount) {
selection.deleteFromDocument();
addItems(emails.map(email => stringToChipValue(email)));
}
}
},
'aria-autocomplete': 'list',
'aria-controls': isOpen ? listboxId : undefined,
autoComplete: 'off',
autoCorrect: 'off',
spellCheck: 'false',
onKeyDown: e => {
const input = e.target as HTMLInputElement;
if (e.key === 'Enter') {
// prevent form submitting
e.preventDefault();
// add chip from selected listbox option or current input text value
handleItemSelection(input.value);
return;
}
// on escape, clear input and close dropdown
if (e.key === 'Escape' && isOpen) {
setIsOpen(false);
setInputValue('');
}
// move focus to input when focus is on first item and prevent arrow up from cycling listbox
if (
e.key === 'ArrowUp' &&
isOpen &&
(activeIndex === 0 || activeIndex == null)
) {
setActiveIndex(null);
return;
}
// block left and right arrows from navigating in input, if focus is on listbox
if (
activeIndex != null &&
(e.key === 'ArrowLeft' || e.key === 'ArrowRight')
) {
e.preventDefault();
return;
}
// move focus on the last chip, if focus is at the start of input
if (
(e.key === 'ArrowLeft' ||
e.key === 'Backspace' ||
e.key === 'Delete') &&
input.selectionStart === 0 &&
activeIndex == null &&
chips.length
) {
manager?.focusPrevious({tabbable: true});
return;
}
// fallthrough to listbox navigation handlers for arrow keys
const handled = handleTriggerKeyDown(e);
if (!handled) {
handleListboxKeyboardNavigation(e);
}
},
onFocus: handleFocusAndClick,
onClick: handleFocusAndClick,
} as HTMLAttributes)}
/>
);
}
function useChipFieldValueState({
onChange,
value,
defaultValue,
valueKey,
}: ChipFieldProps) {
// convert value from string[] to ChipValue[], if needed
const propsValue = useMemo(() => {
return mixedValueToChipValue(value);
}, [value]);
// convert defaultValue from string[] to ChipValue[], if needed
const propsDefaultValue = useMemo(() => {
return mixedValueToChipValue(defaultValue);
}, [defaultValue]);
// emit string[] or ChipValue[] on change, based on "valueKey" prop
const handleChange = useCallback(
(value: ChipValue[]) => {
const newValue = valueKey ? value.map(v => v[valueKey]) : value;
onChange?.(newValue as any);
},
[onChange, valueKey],
);
return useControlledState(
!propsValue ? undefined : propsValue,
propsDefaultValue || [],
handleChange,
);
}
function mixedValueToChipValue(
value?: (string | number | ChipValue)[] | null,
): ChipValue[] | undefined {
if (value == null) {
return undefined;
}
return value.map(v => {
return typeof v !== 'object' ? stringToChipValue(v as string) : v;
});
}
export const ChipField = React.forwardRef(ChipFieldInner) as (
props: ChipFieldProps & {ref?: Ref},
) => ReactElement;