UNPKG

mod-arch-shared

Version:

Shared UI components and utilities for modular architecture micro-frontend projects

255 lines 11.7 kB
import React from 'react'; import { /** * The Select component is used to build another generic component here */ // eslint-disable-next-line no-restricted-imports Select, SelectOption, SelectList, MenuToggle, TextInputGroup, TextInputGroupMain, TextInputGroupUtilities, Button, FormHelperText, HelperTextItem, HelperText, } from '@patternfly/react-core'; import { TimesIcon } from '@patternfly/react-icons'; import TruncatedText from '../components/TruncatedText'; const defaultNoOptionsFoundMessage = (filter) => `No results found for "${filter}"`; const defaultCreateOptionMessage = (newValue) => `Create "${newValue}"`; const defaultFilterFunction = (filterValue, options) => options.filter((o) => String(o.content).toLowerCase().includes(filterValue.toLowerCase())); const TypeaheadSelect = ({ innerRef, selectOptions, onSelect, onToggle, onInputChange, filterFunction = defaultFilterFunction, onClearSelection, allowClear, placeholder = 'Select an option', noOptionsAvailableMessage = 'No options are available', noOptionsFoundMessage = defaultNoOptionsFoundMessage, isCreatable = false, isCreateOptionOnTop = false, createOptionMessage = defaultCreateOptionMessage, isDisabled, toggleWidth, toggleProps, isRequired = true, dataTestId, previewDescription = true, ...props }) => { const [isOpen, setIsOpen] = React.useState(false); const [filterValue, setFilterValue] = React.useState(''); const [isFiltering, setIsFiltering] = React.useState(false); const [focusedItemIndex, setFocusedItemIndex] = React.useState(null); const [activeItemId, setActiveItemId] = React.useState(null); const textInputRef = React.useRef(); const NO_RESULTS = 'no results'; const selected = React.useMemo(() => selectOptions.find((option) => option.value === props.selected || option.isSelected), [props.selected, selectOptions]); const filteredSelections = React.useMemo(() => { let newSelectOptions = selectOptions; // Filter menu items based on the text input value when one exists if (isFiltering && filterValue) { newSelectOptions = filterFunction(filterValue, selectOptions); if (isCreatable && filterValue.trim() && !newSelectOptions.find((o) => String(o.content).toLowerCase() === filterValue.toLowerCase())) { const createOption = { content: typeof createOptionMessage === 'string' ? createOptionMessage : createOptionMessage(filterValue), value: filterValue, }; newSelectOptions = isCreateOptionOnTop ? [createOption, ...newSelectOptions] : [...newSelectOptions, createOption]; } // When no options are found after filtering, display 'No results found' if (!newSelectOptions.length) { newSelectOptions = [ { isAriaDisabled: true, content: typeof noOptionsFoundMessage === 'string' ? noOptionsFoundMessage : noOptionsFoundMessage(filterValue), value: NO_RESULTS, }, ]; } } // When no options are available, display 'No options available' if (!newSelectOptions.length) { newSelectOptions = [ { isAriaDisabled: true, content: noOptionsAvailableMessage, value: NO_RESULTS, }, ]; } return newSelectOptions; }, [ isFiltering, filterValue, filterFunction, selectOptions, noOptionsFoundMessage, isCreatable, isCreateOptionOnTop, createOptionMessage, noOptionsAvailableMessage, ]); React.useEffect(() => { if (isFiltering) { openMenu(); } // Don't update on openMenu changes // eslint-disable-next-line react-hooks/exhaustive-deps }, [isFiltering]); const setActiveAndFocusedItem = (itemIndex) => { setFocusedItemIndex(itemIndex); const focusedItem = selectOptions[itemIndex]; setActiveItemId(String(focusedItem.value)); }; const resetActiveAndFocusedItem = () => { setFocusedItemIndex(null); setActiveItemId(null); }; const openMenu = () => { if (!isOpen) { if (onToggle) { onToggle(true); } setIsOpen(true); } }; const closeMenu = () => { if (onToggle) { onToggle(false); } setIsOpen(false); resetActiveAndFocusedItem(); setIsFiltering(false); setFilterValue(String(selected?.content ?? '')); }; const onInputClick = () => { if (!isOpen) { openMenu(); } setTimeout(() => { textInputRef.current?.focus(); }, 100); }; const selectOption = (_event, option) => { if (onSelect) { onSelect(_event, option.value); } closeMenu(); }; const notAllowEmpty = !isCreatable && isRequired; // Only when the field is required, not creatable and there is one option, we auto select the first option const isSingleOption = selectOptions.length === 1 && notAllowEmpty; const singleOptionValue = isSingleOption ? selectOptions[0].value : null; // If there is only one option, call the onChange function React.useEffect(() => { if (singleOptionValue && onSelect) { onSelect(undefined, singleOptionValue); } // We don't want the callback function to be a dependency // eslint-disable-next-line react-hooks/exhaustive-deps }, [singleOptionValue]); const handleSelect = (_event, value) => { if (value && value !== NO_RESULTS) { const optionToSelect = selectOptions.find((option) => option.value === value); if (optionToSelect) { selectOption(_event, optionToSelect); } else if (isCreatable) { selectOption(_event, { value, content: value }); } } }; const onTextInputChange = (_event, value) => { setFilterValue(value || ''); setIsFiltering(true); if (onInputChange) { onInputChange(value); } resetActiveAndFocusedItem(); }; const handleMenuArrowKeys = (key) => { let indexToFocus = 0; openMenu(); if (filteredSelections.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 = filteredSelections.length - 1; } else { indexToFocus = focusedItemIndex - 1; } // Skip disabled options while (filteredSelections[indexToFocus].isDisabled) { indexToFocus--; if (indexToFocus === -1) { indexToFocus = filteredSelections.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 === filteredSelections.length - 1) { indexToFocus = 0; } else { indexToFocus = focusedItemIndex + 1; } // Skip disabled options while (filteredSelections[indexToFocus].isDisabled) { indexToFocus++; if (indexToFocus === filteredSelections.length) { indexToFocus = 0; } } } setActiveAndFocusedItem(indexToFocus); }; const onInputKeyDown = (event) => { const focusedItem = focusedItemIndex !== null ? filteredSelections[focusedItemIndex] : null; switch (event.key) { case 'Enter': if (isOpen && focusedItem && focusedItem.value !== NO_RESULTS && !focusedItem.isAriaDisabled) { selectOption(event, focusedItem); } openMenu(); break; case 'ArrowUp': case 'ArrowDown': event.preventDefault(); handleMenuArrowKeys(event.key); break; } }; const onToggleClick = () => { if (!isOpen) { openMenu(); } else { closeMenu(); } textInputRef.current?.focus(); }; const onClearButtonClick = () => { if (isFiltering && filterValue) { if (selected && onSelect) { onSelect(undefined, selected.value); } setFilterValue(''); if (onInputChange) { onInputChange(''); } setIsFiltering(false); } resetActiveAndFocusedItem(); textInputRef.current?.focus(); if (onClearSelection) { onClearSelection(); } }; const toggle = (toggleRef) => (React.createElement(MenuToggle, { ref: toggleRef, variant: "typeahead", "aria-label": "Typeahead menu toggle", "data-testid": dataTestId, onClick: onToggleClick, isExpanded: isOpen, isDisabled: isDisabled || (selectOptions.length <= 1 && notAllowEmpty), isFullWidth: true, style: { width: toggleWidth }, ...toggleProps }, React.createElement(TextInputGroup, { isPlain: true }, React.createElement(TextInputGroupMain, { value: isFiltering ? filterValue : (selected?.content ?? ''), onClick: onInputClick, onChange: onTextInputChange, onKeyDown: onInputKeyDown, autoComplete: "off", innerRef: textInputRef, placeholder: placeholder, ...(activeItemId && { 'aria-activedescendant': activeItemId }), role: "combobox", isExpanded: isOpen, "aria-controls": "select-typeahead-listbox" }), (isFiltering && filterValue) || (allowClear && selected) ? (React.createElement(TextInputGroupUtilities, null, React.createElement(Button, { icon: React.createElement(TimesIcon, { "aria-hidden": true }), variant: "plain", onClick: onClearButtonClick, "aria-label": "Clear input value" }))) : null))); return (React.createElement(React.Fragment, null, React.createElement(Select, { isOpen: isOpen, selected: selected, onSelect: handleSelect, onOpenChange: (open) => !open && closeMenu(), toggle: toggle, shouldFocusFirstItemOnOpen: false, ref: innerRef, ...props }, React.createElement(SelectList, null, filteredSelections.map((option, index) => { const { content, value, ...optionProps } = option; return (React.createElement(SelectOption, { key: value, value: value, isFocused: focusedItemIndex === index, ...optionProps }, content)); }))), previewDescription && isSingleOption && selected?.description ? (React.createElement(FormHelperText, null, React.createElement(HelperText, null, React.createElement(HelperTextItem, null, React.createElement(TruncatedText, { maxLines: 2, content: selected.description }))))) : null)); }; export default TypeaheadSelect; //# sourceMappingURL=TypeaheadSelect.js.map