-
Notifications
You must be signed in to change notification settings - Fork 22
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1218 from yaacov/add-filterable-select-component
🐾 Add FilterableSelect component
- Loading branch information
Showing
3 changed files
with
303 additions
and
0 deletions.
There are no files selected for viewing
299 changes: 299 additions & 0 deletions
299
packages/forklift-console-plugin/src/components/FilterableSelect/FilterableSelect.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<FilterableSelectProps> = ({ | ||
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<string>(value); | ||
/** | ||
* inputValue: The current value displayed in the input field. | ||
* This is the value the user types in. | ||
*/ | ||
const [inputValue, setInputValue] = React.useState<string>(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<string>(''); | ||
const [selectOptions, setSelectOptions] = | ||
React.useState<SelectOptionProps[]>(initialSelectOptions); | ||
const [focusedItemIndex, setFocusedItemIndex] = React.useState<number | null>(null); | ||
|
||
const menuRef = React.useRef<HTMLDivElement>(null); | ||
const textInputRef = React.useRef<HTMLInputElement>(); | ||
|
||
/** | ||
* 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<Element, MouseEvent> | undefined} _event The click event. | ||
* @param {string | number | undefined} itemId The id of the selected item. | ||
*/ | ||
const onItemSelect = ( | ||
_event: React.MouseEvent<Element, 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<HTMLInputElement>} _event The input event. | ||
* @param {string} value The new input value. | ||
*/ | ||
const onTextInputChange = (_event: React.FormEvent<HTMLInputElement>, 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<HTMLInputElement>} event The keyboard event. | ||
*/ | ||
const onInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { | ||
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<any>} toggleRef The reference to the toggle component. | ||
* @returns {JSX.Element} The rendered toggle component. | ||
*/ | ||
const toggle = (toggleRef: React.Ref<MenuToggleElement>) => ( | ||
<MenuToggle | ||
ref={toggleRef} | ||
variant="typeahead" | ||
onClick={onToggleClick} | ||
isExpanded={isOpen} | ||
isFullWidth | ||
> | ||
<TextInputGroup isPlain> | ||
<TextInputGroupMain | ||
value={inputValue} | ||
onClick={onToggleClick} | ||
onChange={onTextInputChange} | ||
onKeyDown={onInputKeyDown} | ||
id="typeahead-select-input" | ||
autoComplete="off" | ||
innerRef={textInputRef} | ||
placeholder={placeholder} | ||
/> | ||
|
||
<TextInputGroupUtilities> | ||
{!!inputValue && ( | ||
<Button | ||
variant="plain" | ||
onClick={() => { | ||
setSelected(''); | ||
setInputValue(''); | ||
setFilterValue(''); | ||
}} | ||
aria-label="Clear input value" | ||
> | ||
<TimesIcon aria-hidden /> | ||
</Button> | ||
)} | ||
</TextInputGroupUtilities> | ||
</TextInputGroup> | ||
</MenuToggle> | ||
); | ||
|
||
return ( | ||
<Select | ||
id="typeahead-select" | ||
ref={menuRef} | ||
isOpen={isOpen} | ||
selected={selectedItem} | ||
onSelect={onItemSelect} | ||
onOpenChange={() => { | ||
setIsOpen(false); | ||
setFilterValue(''); | ||
setInputValue(selectedItem); | ||
}} | ||
toggle={toggle} | ||
> | ||
<SelectList> | ||
{selectOptions.map((option, index) => ( | ||
<SelectOption | ||
key={option.itemId} | ||
isFocused={focusedItemIndex === index} | ||
className={option.className} | ||
onClick={() => setSelected(option.itemId)} | ||
{...option} | ||
ref={null} | ||
/> | ||
))} | ||
{canCreate && !selectOptions.find((option) => option.itemId === filterValue) && ( | ||
<> | ||
<Divider /> | ||
<SelectOption | ||
itemId={filterValue} | ||
key={filterValue} | ||
onClick={() => setSelected(filterValue)} | ||
ref={null} | ||
> | ||
<> | ||
<Text>{createNewOptionLabel}</Text> | ||
<Text>{`"${filterValue}"`}</Text> | ||
</> | ||
</SelectOption> | ||
</> | ||
)} | ||
</SelectList> | ||
</Select> | ||
); | ||
}; |
3 changes: 3 additions & 0 deletions
3
packages/forklift-console-plugin/src/components/FilterableSelect/index.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
// @index(['./*', /style/g], f => `export * from '${f.path}';`) | ||
export * from './FilterableSelect'; | ||
// @endindex |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters