From 28c695d272df94bc194189ae9597a7afe3c1df19 Mon Sep 17 00:00:00 2001 From: yaacov Date: Tue, 18 Jun 2024 11:24:37 +0300 Subject: [PATCH] Add FilterableSelect component Signed-off-by: yaacov --- .../FilterableSelect/FilterableSelect.tsx | 299 ++++++++++++++++++ .../src/components/FilterableSelect/index.ts | 3 + .../src/components/index.ts | 1 + 3 files changed, 303 insertions(+) create mode 100644 packages/forklift-console-plugin/src/components/FilterableSelect/FilterableSelect.tsx create mode 100644 packages/forklift-console-plugin/src/components/FilterableSelect/index.ts diff --git a/packages/forklift-console-plugin/src/components/FilterableSelect/FilterableSelect.tsx b/packages/forklift-console-plugin/src/components/FilterableSelect/FilterableSelect.tsx new file mode 100644 index 000000000..e22267a82 --- /dev/null +++ b/packages/forklift-console-plugin/src/components/FilterableSelect/FilterableSelect.tsx @@ -0,0 +1,299 @@ +import React, { ReactNode } from 'react'; + +import { + Button, + Divider, + MenuToggle, + MenuToggleElement, + Text, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, +} from '@patternfly/react-core'; +import { Select, SelectList, SelectOption, SelectOptionProps } from '@patternfly/react-core/next'; +import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; + +/** + * Props for the FilterableSelect component. + */ +export interface FilterableSelectProps { + /** Array of options to display in the select dropdown */ + selectOptions: SelectOptionProps[]; + /** The currently selected value */ + value: string; + /** Callback function when an option is selected */ + onSelect: (value: string | number) => void; + /** Whether the user can create new options */ + canCreate?: boolean; + /** Placeholder text for the input field */ + placeholder?: string; + /** Label to display when no results are found */ + noResultFoundLabel?: ReactNode; + /** Label to display for the option to create a new item */ + createNewOptionLabel?: ReactNode; +} + +/** + * A filterable select component that allows users to select from a list of options, + * with the ability to filter the options and create new ones if `canCreate` is enabled. + * + * @param {FilterableSelectProps} props The props for the FilterableSelect component. + * @returns {JSX.Element} The rendered FilterableSelect component. + */ +export const FilterableSelect: React.FunctionComponent = ({ + selectOptions: initialSelectOptions, + value, + onSelect: onSelect, + canCreate, + placeholder = 'Select item', + noResultFoundLabel = 'No results found', + createNewOptionLabel = 'Create new option:', +}) => { + const [isOpen, setIsOpen] = React.useState(false); + const [selectedItem, setSelectedItem] = React.useState(value); + /** + * inputValue: The current value displayed in the input field. + * This is the value the user types in. + */ + const [inputValue, setInputValue] = React.useState(value); + /** + * filterValue: The value used to filter the options. + * This is typically synchronized with inputValue, but they can be different if needed. + */ + const [filterValue, setFilterValue] = React.useState(''); + const [selectOptions, setSelectOptions] = + React.useState(initialSelectOptions); + const [focusedItemIndex, setFocusedItemIndex] = React.useState(null); + + const menuRef = React.useRef(null); + const textInputRef = React.useRef(); + + /** + * Sets the selected item and triggers the onSelect callback. + * + * @param {string} value The value to set as selected. + */ + const setSelected = (value: string) => { + setSelectedItem(value); + setFilterValue(''); + + // Call the external on select hook. + onSelect(value); + }; + + /** + * Updates the select options based on the filter value. + */ + React.useEffect(() => { + let newSelectOptions: SelectOptionProps[] = initialSelectOptions; + + // Filter menu items based on the text input value when one exists + if (filterValue) { + newSelectOptions = initialSelectOptions.filter((menuItem) => + String(menuItem.itemId).toLowerCase().includes(filterValue.toLowerCase()), + ); + + // When no options are found after filtering, display 'No results found' + if (!newSelectOptions.length) { + newSelectOptions = [{ isDisabled: true, children: noResultFoundLabel }]; + } + } + + setSelectOptions(newSelectOptions); + }, [filterValue, initialSelectOptions, noResultFoundLabel]); + + /** + * Toggles the open state of the select dropdown. + */ + const onToggleClick = () => { + setIsOpen(!isOpen); + }; + + /** + * Handles item selection from the dropdown. + * + * @param {React.MouseEvent | undefined} _event The click event. + * @param {string | number | undefined} itemId The id of the selected item. + */ + const onItemSelect = ( + _event: React.MouseEvent | undefined, + itemId: string | number | undefined, + ) => { + if (itemId !== undefined) { + setInputValue(itemId as string); + setFilterValue(itemId as string); + setSelected(itemId as string); + } + setIsOpen(false); + setFocusedItemIndex(null); + }; + + /** + * Handles changes in the text input. + * + * @param {React.FormEvent} _event The input event. + * @param {string} value The new input value. + */ + const onTextInputChange = (_event: React.FormEvent, value: string) => { + setInputValue(value); + setFilterValue(value); + }; + + /** + * Handles arrow key navigation within the dropdown. + * + * @param {string} key The key pressed. + */ + const handleMenuArrowKeys = (key: string) => { + let indexToFocus; + + if (isOpen) { + 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; + } + } + + 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; + } + } + + setFocusedItemIndex(indexToFocus); + } + }; + + /** + * Handles keydown events in the text input. + * + * @param {React.KeyboardEvent} event The keyboard event. + */ + const onInputKeyDown = (event: React.KeyboardEvent) => { + const enabledMenuItems = selectOptions.filter((menuItem) => !menuItem.isDisabled); + const [firstMenuItem] = enabledMenuItems; + const focusedItem = focusedItemIndex ? enabledMenuItems[focusedItemIndex] : firstMenuItem; + + switch (event.key) { + // Select the first available option + case 'Enter': + event.preventDefault(); + + if (isOpen) { + setInputValue(String(focusedItem?.itemId || filterValue)); + setSelected(String(focusedItem?.itemId || filterValue)); + } + + setIsOpen((prevIsOpen) => !prevIsOpen); + setFocusedItemIndex(null); + + break; + case 'Tab': + case 'Escape': + setIsOpen(false); + break; + case 'ArrowUp': + case 'ArrowDown': + handleMenuArrowKeys(event.key); + break; + default: + !isOpen && setIsOpen(true); + } + }; + + /** + * Renders the toggle component for the dropdown. + * + * @param {React.Ref} toggleRef The reference to the toggle component. + * @returns {JSX.Element} The rendered toggle component. + */ + const toggle = (toggleRef: React.Ref) => ( + + + + + + {!!inputValue && ( + + )} + + + + ); + + return ( + + ); +}; diff --git a/packages/forklift-console-plugin/src/components/FilterableSelect/index.ts b/packages/forklift-console-plugin/src/components/FilterableSelect/index.ts new file mode 100644 index 000000000..7a9f73e5f --- /dev/null +++ b/packages/forklift-console-plugin/src/components/FilterableSelect/index.ts @@ -0,0 +1,3 @@ +// @index(['./*', /style/g], f => `export * from '${f.path}';`) +export * from './FilterableSelect'; +// @endindex diff --git a/packages/forklift-console-plugin/src/components/index.ts b/packages/forklift-console-plugin/src/components/index.ts index 21cefc9d2..2b701cbcf 100644 --- a/packages/forklift-console-plugin/src/components/index.ts +++ b/packages/forklift-console-plugin/src/components/index.ts @@ -2,6 +2,7 @@ export * from './actions'; export * from './cells'; export * from './empty-states'; +export * from './FilterableSelect'; export * from './headers'; export * from './images'; export * from './InputList';