import { forwardRef, useEffect, useState, useRef, FunctionComponent, Ref, MouseEvent as ReactMouseEvent, KeyboardEvent as ReactKeyboardEvent, FormEvent as ReactFormEvent, CSSProperties } from 'react'; import { Button } from '@patternfly/react-core/dist/esm/components/Button'; import { Label, LabelGroup, LabelProps } from '@patternfly/react-core/dist/esm/components/Label'; import { MenuToggle, MenuToggleElement, MenuToggleProps } from '@patternfly/react-core/dist/esm/components/MenuToggle'; import { Select, SelectList, SelectOption, SelectOptionProps, SelectProps } from '@patternfly/react-core/dist/esm/components/Select'; import { TextInputGroup, TextInputGroupMain, TextInputGroupUtilities } from '@patternfly/react-core/dist/esm/components/TextInputGroup'; import RhMicronsCloseIcon from '@patternfly/react-icons/dist/esm/icons/rh-microns-close-icon'; export interface MultiTypeaheadSelectOption extends Omit { /** Content of the select option. */ content: string | number; /** Value of the select option. */ value: string | number; } export interface MultiTypeaheadSelectProps extends Omit { /** @hide Forwarded ref */ innerRef?: React.Ref; /** Initial options of the select. */ initialOptions: MultiTypeaheadSelectOption[]; /** Callback triggered on selection. */ onSelectionChange?: ( _event: React.MouseEvent | React.KeyboardEvent, selections: (string | number)[] ) => void; /** Callback triggered when the select opens or closes. */ onToggle?: (nextIsOpen: boolean) => void; /** Callback triggered when the text in the input field changes. */ onInputChange?: (newValue: string) => void; /** Custom callback triggered when the input field has focus and a keyboard event is triggered. * This will override the default keydown behavior for the input field. */ onInputKeyDown?: (event: React.KeyboardEvent) => void; /** Placeholder text for the select input. */ placeholder?: string; /** Message to display when no options match the filter. */ noOptionsFoundMessage?: string | ((filter: string) => string); /** Flag indicating the select should be disabled. */ isDisabled?: boolean; /** Width of the toggle. */ toggleWidth?: string; /** Additional props passed to the toggle. */ toggleProps?: MenuToggleProps; /** Additional props passed to each label of the selected option. */ labelProps?: LabelProps; /** Initial value of the typeahead text input. */ initialInputValue?: string; } export const MultiTypeaheadSelectBase: FunctionComponent = ({ innerRef, initialOptions, onSelectionChange, onToggle, onInputChange, onInputKeyDown: onInputKeyDownProp, placeholder = 'Select an option', noOptionsFoundMessage = (filter) => `No results found for "${filter}"`, isDisabled, toggleWidth, toggleProps, labelProps, initialInputValue, ...props }: MultiTypeaheadSelectProps) => { const [isOpen, setIsOpen] = useState(false); const [selected, setSelected] = useState<(string | number)[]>( (initialOptions?.filter((o) => o.selected) ?? []).map((o) => o.value) ); const [inputValue, setInputValue] = useState(initialInputValue); const [selectOptions, setSelectOptions] = useState(initialOptions); const [focusedItemIndex, setFocusedItemIndex] = useState(null); const [activeItemId, setActiveItemId] = useState(null); const textInputRef = useRef(undefined); const NO_RESULTS = 'no results'; const openMenu = () => { onToggle && onToggle(true); setIsOpen(true); }; useEffect(() => { let newSelectOptions: MultiTypeaheadSelectOption[] = initialOptions; // Filter menu items based on the text input value when one exists if (inputValue) { newSelectOptions = initialOptions.filter((option) => String(option.content).toLowerCase().includes(inputValue.toLowerCase()) ); // When no options are found after filtering, display 'No results found' if (!newSelectOptions.length) { newSelectOptions = [ { isAriaDisabled: true, content: typeof noOptionsFoundMessage === 'string' ? noOptionsFoundMessage : noOptionsFoundMessage(inputValue), value: NO_RESULTS } ]; } } setSelectOptions(newSelectOptions); // eslint-disable-next-line react-hooks/exhaustive-deps }, [inputValue, initialOptions]); useEffect(() => setSelected((initialOptions?.filter((o) => o.selected) ?? []).map((o) => o.value)), [initialOptions]); const setActiveAndFocusedItem = (itemIndex: number) => { setFocusedItemIndex(itemIndex); const focusedItem = selectOptions[itemIndex]; setActiveItemId(focusedItem.value as string); }; const resetActiveAndFocusedItem = () => { setFocusedItemIndex(null); setActiveItemId(null); }; const closeMenu = () => { onToggle && onToggle(false); setIsOpen(false); resetActiveAndFocusedItem(); setInputValue(''); }; const onInputClick = () => { if (!isOpen) { openMenu(); } else if (!inputValue) { closeMenu(); } }; const selectOption = ( _event: ReactMouseEvent | ReactKeyboardEvent | undefined, option: string | number ) => { const selections = selected.includes(option) ? selected.filter((o) => option !== o) : [...selected, option]; onSelectionChange && onSelectionChange(_event, selections); setSelected(selections); }; const clearOption = ( _event: ReactMouseEvent | ReactKeyboardEvent | undefined, option: string | number ) => { const selections = selected.filter((o) => option !== o); onSelectionChange && onSelectionChange(_event, selections); setSelected(selections); }; const _onSelect = (_event: ReactMouseEvent | undefined, value: string | number | undefined) => { if (value && value !== NO_RESULTS) { selectOption(_event, value); } }; const onTextInputChange = (_event: ReactFormEvent, value: string) => { setInputValue(value); onInputChange && onInputChange(value); if (value && !isOpen) { openMenu(); } resetActiveAndFocusedItem(); }; const handleMenuArrowKeys = (key: string) => { let indexToFocus = 0; if (!isOpen) { openMenu(); } if (selectOptions.every((option) => option.isDisabled)) { return; } if (key === 'ArrowUp') { // When no index is set or at the first index, focus to the last, otherwise decrement focus index if (focusedItemIndex === null || focusedItemIndex === 0) { indexToFocus = selectOptions.length - 1; } else { indexToFocus = focusedItemIndex - 1; } // Skip disabled options while (selectOptions[indexToFocus].isDisabled) { indexToFocus--; if (indexToFocus === -1) { indexToFocus = selectOptions.length - 1; } } } if (key === 'ArrowDown') { // When no index is set or at the last index, focus to the first, otherwise increment focus index if (focusedItemIndex === null || focusedItemIndex === selectOptions.length - 1) { indexToFocus = 0; } else { indexToFocus = focusedItemIndex + 1; } // Skip disabled options while (selectOptions[indexToFocus].isDisabled) { indexToFocus++; if (indexToFocus === selectOptions.length) { indexToFocus = 0; } } } setActiveAndFocusedItem(indexToFocus); }; const defaultOnInputKeyDown = (event: ReactKeyboardEvent) => { const focusedItem = focusedItemIndex !== null ? selectOptions[focusedItemIndex] : null; switch (event.key) { case 'Enter': event.preventDefault(); if (isOpen && focusedItem && focusedItem.value !== NO_RESULTS && !focusedItem.isAriaDisabled) { selectOption(event, focusedItem?.value); } if (!isOpen) { onToggle && onToggle(true); setIsOpen(true); } break; case 'ArrowUp': case 'ArrowDown': event.preventDefault(); handleMenuArrowKeys(event.key); break; } }; const onInputKeyDown = (event: ReactKeyboardEvent) => { if (onInputKeyDownProp) { onInputKeyDownProp(event); } else { defaultOnInputKeyDown(event); } }; const onToggleClick = () => { onToggle && onToggle(!isOpen); setIsOpen(!isOpen); textInputRef?.current?.focus(); }; const onClearButtonClick = (ev: ReactMouseEvent) => { setSelected([]); onInputChange && onInputChange(''); resetActiveAndFocusedItem(); textInputRef?.current?.focus(); onSelectionChange && onSelectionChange(ev, []); }; const toggle = (toggleRef: Ref) => ( {selected.map((selection, index) => ( ))} ); return ( ); }; MultiTypeaheadSelectBase.displayName = 'MultiTypeaheadSelectBase'; export const MultiTypeaheadSelect = forwardRef((props: MultiTypeaheadSelectProps, ref: Ref) => ( )); MultiTypeaheadSelect.displayName = 'MultiTypeaheadSelect';