diff --git a/packages/react-templates/src/components/Select/SelectTypeahead.tsx b/packages/react-templates/src/components/Select/SelectTypeahead.tsx new file mode 100644 index 00000000000..4be92446fd0 --- /dev/null +++ b/packages/react-templates/src/components/Select/SelectTypeahead.tsx @@ -0,0 +1,293 @@ +import React from 'react'; +import { + Select, + SelectOption, + SelectList, + SelectOptionProps, + MenuToggle, + MenuToggleElement, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, + Button +} from '@patternfly/react-core'; +import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; + +export interface SelectTypeaheadOption extends Omit { + /** Content of the select option. */ + content: string | number; + /** Value of the select option. */ + value: string | number; +} + +export interface SelectTypeaheadProps { + /** @hide Forwarded ref */ + innerRef?: React.Ref; + /** Initial options of the select. */ + initialOptions?: SelectTypeaheadOption[]; + /** Callback triggered on selection. */ + onSelect?: (_event: React.MouseEvent, selection: 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; + /** 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; +} + +export const SelectTypeaheadBase: React.FunctionComponent = ({ + innerRef, + initialOptions, + onSelect, + onToggle, + onInputChange, + placeholder = 'Select an option', + noOptionsFoundMessage = (filter) => `No results found for "${filter}"`, + isDisabled, + ...props +}: SelectTypeaheadProps) => { + const [isOpen, setIsOpen] = React.useState(false); + const [selected, setSelected] = React.useState(''); + const [inputValue, setInputValue] = React.useState(''); + const [filterValue, setFilterValue] = React.useState(''); + const [selectOptions, setSelectOptions] = React.useState(initialOptions); + const [focusedItemIndex, setFocusedItemIndex] = React.useState(null); + const [activeItem, setActiveItem] = React.useState(null); + const textInputRef = React.useRef(); + + const NO_RESULTS = 'no results'; + + React.useEffect(() => { + let newSelectOptions: SelectTypeaheadOption[] = initialOptions; + + // Filter menu items based on the text input value when one exists + if (filterValue) { + newSelectOptions = initialOptions.filter((option) => + String(option.content).toLowerCase().includes(filterValue.toLowerCase()) + ); + + // 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 + } + ]; + resetActiveAndFocusedItem(); + } + + // Open the menu when the input value changes and the new value is not empty + if (!isOpen) { + setIsOpen(true); + } + } + + setSelectOptions(newSelectOptions); + }, [filterValue]); + + React.useEffect(() => { + if (isOpen && selectOptions.length && selectOptions[0].value !== NO_RESULTS) { + setActiveAndFocusedItem(0); + } + }, [isOpen, filterValue]); + + const setActiveAndFocusedItem = (itemIndex: number) => { + setFocusedItemIndex(itemIndex); + const focusedItem = selectOptions.filter((option) => !option.isDisabled)[itemIndex]; + setActiveItem(`select-typeahead-${String(focusedItem.value).replace(' ', '-')}`); + }; + + const resetActiveAndFocusedItem = () => { + setFocusedItemIndex(null); + setActiveItem(null); + }; + + const closeMenu = () => { + setIsOpen(false); + resetActiveAndFocusedItem(); + }; + + const onInputClick = () => { + if (!isOpen) { + setIsOpen(true); + } else if (!inputValue) { + closeMenu(); + } + }; + + const _onSelect = (_event: React.MouseEvent | undefined, value: string | number | undefined) => { + onSelect && onSelect(_event, value); + + if (value && value !== NO_RESULTS) { + const inputText = selectOptions.find((option) => option.value === value).content; + setInputValue(String(inputText)); + setFilterValue(''); + setSelected(String(value)); + } + closeMenu(); + }; + + const onTextInputChange = (_event: React.FormEvent, value: string) => { + setInputValue(value); + onInputChange && onInputChange(value); + setFilterValue(value); + + if (value !== selected) { + setSelected(''); + } + }; + + 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; + } + } + + setActiveAndFocusedItem(indexToFocus); + } + }; + + const onInputKeyDown = (event: React.KeyboardEvent) => { + const enabledMenuItems = selectOptions.filter((option) => !option.isDisabled); + const [firstMenuItem] = enabledMenuItems; + const focusedItem = focusedItemIndex ? enabledMenuItems[focusedItemIndex] : firstMenuItem; + + switch (event.key) { + // Select the first available option + case 'Enter': + if (isOpen && focusedItem.value !== NO_RESULTS) { + setInputValue(String(focusedItem.content)); + setFilterValue(''); + setSelected(String(focusedItem.value)); + } + + setIsOpen((prevIsOpen) => !prevIsOpen); + resetActiveAndFocusedItem(); + + break; + case 'ArrowUp': + case 'ArrowDown': + event.preventDefault(); + handleMenuArrowKeys(event.key); + break; + } + }; + + const onToggleClick = () => { + onToggle && onToggle(!isOpen); + setIsOpen(!isOpen); + textInputRef?.current?.focus(); + }; + + const toggle = (toggleRef: React.Ref) => ( + + + + + + {!!inputValue && ( + + )} + + + + ); + + return ( + + ); +}; +SelectTypeaheadBase.displayName = 'SelectTypeaheadBase'; + +export const SelectTypeahead = React.forwardRef((props: SelectTypeaheadProps, ref: React.Ref) => ( + +)); + +SelectTypeahead.displayName = 'SelectTypeahead'; diff --git a/packages/react-templates/src/components/Select/examples/SelectTemplates.md b/packages/react-templates/src/components/Select/examples/SelectTemplates.md index db7af312d29..db22de4f084 100644 --- a/packages/react-templates/src/components/Select/examples/SelectTemplates.md +++ b/packages/react-templates/src/components/Select/examples/SelectTemplates.md @@ -4,7 +4,7 @@ section: components subsection: menus template: true beta: true -propComponents: ['SelectSimple', 'CheckboxSelect'] +propComponents: ['SelectSimple', 'CheckboxSelect', 'SelectTypeahead'] --- Note: Templates live in their own package at [@patternfly/react-templates](https://www.npmjs.com/package/@patternfly/react-templates)! @@ -12,7 +12,7 @@ Note: Templates live in their own package at [@patternfly/react-templates](https For custom use cases, please see the select component suite from [@patternfly/react-core](https://www.npmjs.com/package/@patternfly/react-core). import { SelectOption, Checkbox } from '@patternfly/react-core'; -import { SelectSimple, CheckboxSelect } from '@patternfly/react-templates'; +import { SelectSimple, CheckboxSelect, SelectTypeahead } from '@patternfly/react-templates'; ## Select template examples @@ -25,4 +25,11 @@ import { SelectSimple, CheckboxSelect } from '@patternfly/react-templates'; ### Checkbox ```ts file="CheckboxSelectDemo.tsx" + +``` + +### Typeahead + +```ts file="SelectTypeaheadDemo.tsx" + ``` diff --git a/packages/react-templates/src/components/Select/examples/SelectTypeaheadDemo.tsx b/packages/react-templates/src/components/Select/examples/SelectTypeaheadDemo.tsx new file mode 100644 index 00000000000..d189783d3f0 --- /dev/null +++ b/packages/react-templates/src/components/Select/examples/SelectTypeaheadDemo.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { SelectTypeahead, SelectTypeaheadOption } from '@patternfly/react-templates'; + +export const SelectTypeaheadDemo: React.FunctionComponent = () => { + const initialOptions: SelectTypeaheadOption[] = [ + { content: 'Alabama', value: 'option1' }, + { content: 'Florida', value: 'option2' }, + { content: 'New Jersey', value: 'option3' }, + { content: 'New Mexico', value: 'option4' }, + { content: 'New York', value: 'option5' }, + { content: 'North Carolina', value: 'option6' } + ]; + + return ( + `No state was found for "${filter}"`} + /> + ); +}; diff --git a/packages/react-templates/src/components/Select/index.ts b/packages/react-templates/src/components/Select/index.ts index c8752c8faa5..c01277b9ccc 100644 --- a/packages/react-templates/src/components/Select/index.ts +++ b/packages/react-templates/src/components/Select/index.ts @@ -1,2 +1,3 @@ export * from './SelectSimple'; export * from './CheckboxSelect'; +export * from './SelectTypeahead';