From c7d2e9fcaee50cd17a306a0f25e43e96f78e4217 Mon Sep 17 00:00:00 2001 From: adamviktora <84135613+adamviktora@users.noreply.github.com> Date: Thu, 2 May 2024 16:45:15 +0200 Subject: [PATCH] feat(Select): Typeahead template (#10235) * fix(SelectTypeahead example): make "no results" option aria-disabled * fix(SelectTypeahead example): don't close the menu on input click when there is text * fix(SelectTypeahead example): remove visual focus on item after closing the menu Prevents situation where we open the menu via focusing on the toggle arrow and clicking enter -- then two items can have focus styling, which is not ideal. * fix(SelectTypeahead example): remove check icon from the selected option when input text changes * fix(SelectTypeahead example): rename example * feat(Select): add prop to opt out of focusing first menu item on open Flag prop shouldFocusFirstMenuItemOnOpen has been added, because of typeahead select, which should keep focus on the input. * refactor(SelectTypeahead example): adaption on first menu item focused * feat(MenuToggle): make typeahead toggle button not focusable * fix(SelectTypeahead example): focus input after toggle button click * feat(SelectTypeahead example): change the focused item on hover * fix(SelectTypeahead example): don't focus on first item after tabbing * feat(Select): add typeahead select template * fix(SelectTypeahead): address PR review - new changes were done also based on SelectTypeahead example updates (https://github.com/patternfly/patternfly-react/pull/10207) * fix(SelectTypeahead template): call onToggle every time menu opens/closes * refactor(SelectTypeahead template) --- .../src/components/MenuToggle/MenuToggle.tsx | 1 + .../src/components/Select/Select.tsx | 5 +- .../Select/examples/SelectTypeahead.tsx | 76 +++-- .../src/components/Select/SelectTypeahead.tsx | 321 ++++++++++++++++++ .../Select/examples/SelectTemplates.md | 11 +- .../Select/examples/SelectTypeaheadDemo.tsx | 21 ++ .../src/components/Select/index.ts | 1 + 7 files changed, 408 insertions(+), 28 deletions(-) create mode 100644 packages/react-templates/src/components/Select/SelectTypeahead.tsx create mode 100644 packages/react-templates/src/components/Select/examples/SelectTypeaheadDemo.tsx diff --git a/packages/react-core/src/components/MenuToggle/MenuToggle.tsx b/packages/react-core/src/components/MenuToggle/MenuToggle.tsx index 9e8fef7a78f..05c7b4a1b92 100644 --- a/packages/react-core/src/components/MenuToggle/MenuToggle.tsx +++ b/packages/react-core/src/components/MenuToggle/MenuToggle.tsx @@ -129,6 +129,7 @@ class MenuToggleBase extends React.Component { aria-expanded={isExpanded} onClick={onClick} aria-label={ariaLabel || 'Menu toggle'} + tabIndex={-1} > {toggleControls} diff --git a/packages/react-core/src/components/Select/Select.tsx b/packages/react-core/src/components/Select/Select.tsx index 4a46ab93b12..f9c25d170d7 100644 --- a/packages/react-core/src/components/Select/Select.tsx +++ b/packages/react-core/src/components/Select/Select.tsx @@ -53,6 +53,8 @@ export interface SelectProps extends MenuProps, OUIAProps { toggle: SelectToggleProps | ((toggleRef: React.RefObject) => React.ReactNode); /** Flag indicating the toggle should be focused after a selection. If this use case is too restrictive, the optional toggleRef property with a node toggle may be used to control focus. */ shouldFocusToggleOnSelect?: boolean; + /** Flag indicating the first menu item should be focused after opening the menu. */ + shouldFocusFirstMenuItemOnOpen?: boolean; /** Function callback when user selects an option. */ onSelect?: (event?: React.MouseEvent, value?: string | number) => void; /** Callback to allow the select component to change the open state of the menu. @@ -86,6 +88,7 @@ const SelectBase: React.FunctionComponent = ({ selected, toggle, shouldFocusToggleOnSelect = false, + shouldFocusFirstMenuItemOnOpen = true, onOpenChange, onOpenChangeKeys = ['Escape', 'Tab'], isPlain, @@ -125,7 +128,7 @@ const SelectBase: React.FunctionComponent = ({ const handleClick = (event: MouseEvent) => { // toggle was opened, focus on first menu item - if (isOpen && toggleRef.current?.contains(event.target as Node)) { + if (isOpen && shouldFocusFirstMenuItemOnOpen && toggleRef.current?.contains(event.target as Node)) { setTimeout(() => { const firstElement = menuRef?.current?.querySelector('li button:not(:disabled),li input:not(:disabled)'); firstElement && (firstElement as HTMLElement).focus(); diff --git a/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx b/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx index 8d21541211f..2ea2e032ac8 100644 --- a/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx +++ b/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx @@ -22,7 +22,7 @@ const initialSelectOptions: SelectOptionProps[] = [ { value: 'North Carolina', children: 'North Carolina' } ]; -export const SelectBasic: React.FunctionComponent = () => { +export const SelectTypeahead: React.FunctionComponent = () => { const [isOpen, setIsOpen] = React.useState(false); const [selected, setSelected] = React.useState(''); const [inputValue, setInputValue] = React.useState(''); @@ -32,6 +32,8 @@ export const SelectBasic: React.FunctionComponent = () => { const [activeItem, setActiveItem] = React.useState(null); const textInputRef = React.useRef(); + const NO_RESULTS = 'no results'; + React.useEffect(() => { let newSelectOptions: SelectOptionProps[] = initialSelectOptions; @@ -44,8 +46,9 @@ export const SelectBasic: React.FunctionComponent = () => { // When no options are found after filtering, display 'No results found' if (!newSelectOptions.length) { newSelectOptions = [ - { isDisabled: false, children: `No results found for "${filterValue}"`, value: 'no results' } + { isAriaDisabled: true, children: `No results found for "${filterValue}"`, value: NO_RESULTS } ]; + resetActiveAndFocusedItem(); } // Open the menu when the input value changes and the new value is not empty @@ -55,31 +58,57 @@ export const SelectBasic: React.FunctionComponent = () => { } setSelectOptions(newSelectOptions); - setActiveItem(null); - setFocusedItemIndex(null); }, [filterValue]); - const onToggleClick = () => { - setIsOpen(!isOpen); + 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-${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) => { // eslint-disable-next-line no-console console.log('selected', value); - if (value && value !== 'no results') { + if (value && value !== NO_RESULTS) { setInputValue(value as string); setFilterValue(''); setSelected(value as string); } - setIsOpen(false); - setFocusedItemIndex(null); - setActiveItem(null); + closeMenu(); }; const onTextInputChange = (_event: React.FormEvent, value: string) => { setInputValue(value); setFilterValue(value); + + if (value !== selected) { + setSelected(''); + } }; const handleMenuArrowKeys = (key: string) => { @@ -104,9 +133,7 @@ export const SelectBasic: React.FunctionComponent = () => { } } - setFocusedItemIndex(indexToFocus); - const focusedItem = selectOptions.filter((option) => !option.isDisabled)[indexToFocus]; - setActiveItem(`select-typeahead-${focusedItem.value.replace(' ', '-')}`); + setActiveAndFocusedItem(indexToFocus); } }; @@ -118,21 +145,15 @@ export const SelectBasic: React.FunctionComponent = () => { switch (event.key) { // Select the first available option case 'Enter': - if (isOpen && focusedItem.value !== 'no results') { + if (isOpen && focusedItem.value !== NO_RESULTS) { setInputValue(String(focusedItem.children)); setFilterValue(''); setSelected(String(focusedItem.children)); } setIsOpen((prevIsOpen) => !prevIsOpen); - setFocusedItemIndex(null); - setActiveItem(null); + resetActiveAndFocusedItem(); - break; - case 'Tab': - case 'Escape': - setIsOpen(false); - setActiveItem(null); break; case 'ArrowUp': case 'ArrowDown': @@ -147,14 +168,17 @@ export const SelectBasic: React.FunctionComponent = () => { ref={toggleRef} variant="typeahead" aria-label="Typeahead menu toggle" - onClick={onToggleClick} + onClick={() => { + setIsOpen(!isOpen); + textInputRef?.current?.focus(); + }} isExpanded={isOpen} isFullWidth > { isOpen={isOpen} selected={selected} onSelect={onSelect} - onOpenChange={() => { - setIsOpen(false); + onOpenChange={(isOpen) => { + !isOpen && closeMenu(); }} toggle={toggle} + shouldFocusFirstMenuItemOnOpen={false} > {selectOptions.map((option, index) => ( @@ -204,6 +229,7 @@ export const SelectBasic: React.FunctionComponent = () => { key={option.value || option.children} isFocused={focusedItemIndex === index} className={option.className} + onMouseEnter={() => setActiveAndFocusedItem(index)} onClick={() => setSelected(option.value)} id={`select-typeahead-${option.value.replace(' ', '-')}`} {...option} 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..c67134718e1 --- /dev/null +++ b/packages/react-templates/src/components/Select/SelectTypeahead.tsx @@ -0,0 +1,321 @@ +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 | React.KeyboardEvent, + 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 [activeItemId, setActiveItemId] = 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 + } + ]; + } + + // Open the menu when the input value changes and the new value is not empty + if (!isOpen) { + openMenu(); + } + } + + setSelectOptions(newSelectOptions); + }, [filterValue]); + + const createItemId = (value: string | number) => `select-typeahead-${String(value).replace(' ', '-')}`; + + const setActiveAndFocusedItem = (itemIndex: number) => { + setFocusedItemIndex(itemIndex); + const focusedItem = selectOptions[itemIndex]; + setActiveItemId(createItemId(focusedItem.value)); + }; + + const resetActiveAndFocusedItem = () => { + setFocusedItemIndex(null); + setActiveItemId(null); + }; + + const openMenu = () => { + onToggle && onToggle(true); + setIsOpen(true); + }; + + const closeMenu = () => { + onToggle && onToggle(false); + setIsOpen(false); + resetActiveAndFocusedItem(); + }; + + const onInputClick = () => { + if (!isOpen) { + openMenu(); + } else if (!inputValue) { + closeMenu(); + } + }; + + const selectOption = ( + _event: React.MouseEvent | React.KeyboardEvent | undefined, + option: SelectTypeaheadOption + ) => { + onSelect && onSelect(_event, option.value); + + setInputValue(String(option.content)); + setFilterValue(''); + setSelected(String(option.value)); + + closeMenu(); + }; + + const _onSelect = (_event: React.MouseEvent | undefined, value: string | number | undefined) => { + if (value && value !== NO_RESULTS) { + const optionToSelect = selectOptions.find((option) => option.value === value); + selectOption(_event, optionToSelect); + } + }; + + const onTextInputChange = (_event: React.FormEvent, value: string) => { + setInputValue(value); + onInputChange && onInputChange(value); + setFilterValue(value); + + resetActiveAndFocusedItem(); + + if (value !== selected) { + setSelected(''); + } + }; + + const handleMenuArrowKeys = (key: string) => { + let indexToFocus = 0; + + if (!isOpen) { + openMenu(); + } + + if (selectOptions.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 = selectOptions.length - 1; + } else { + indexToFocus = focusedItemIndex - 1; + } + + // Skip disabled options + while (selectOptions[indexToFocus].isDisabled) { + indexToFocus--; + if (indexToFocus === -1) { + indexToFocus = selectOptions.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 === selectOptions.length - 1) { + indexToFocus = 0; + } else { + indexToFocus = focusedItemIndex + 1; + } + + // Skip disabled options + while (selectOptions[indexToFocus].isDisabled) { + indexToFocus++; + if (indexToFocus === selectOptions.length) { + indexToFocus = 0; + } + } + } + + setActiveAndFocusedItem(indexToFocus); + }; + + const onInputKeyDown = (event: React.KeyboardEvent) => { + const focusedItem = focusedItemIndex !== null ? selectOptions[focusedItemIndex] : null; + + switch (event.key) { + case 'Enter': + if (isOpen && focusedItem && focusedItem.value !== NO_RESULTS && !focusedItem.isAriaDisabled) { + selectOption(event, focusedItem); + } + + if (!isOpen) { + setIsOpen(true); + } + + break; + case 'ArrowUp': + case 'ArrowDown': + event.preventDefault(); + handleMenuArrowKeys(event.key); + break; + } + }; + + const onToggleClick = () => { + onToggle && onToggle(!isOpen); + setIsOpen(!isOpen); + textInputRef?.current?.focus(); + }; + + const onClearButtonClick = () => { + setSelected(''); + setInputValue(''); + onInputChange && onInputChange(''); + setFilterValue(''); + resetActiveAndFocusedItem(); + textInputRef?.current?.focus(); + }; + + const toggle = (toggleRef: React.Ref) => ( + + + + + + + + + + ); + + 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';