mod-arch-shared
Version:
Shared UI components and utilities for modular architecture micro-frontend projects
255 lines • 11.7 kB
JavaScript
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