From 26a6217c78c339dcb9f36c698c2777ebd0c17f6c Mon Sep 17 00:00:00 2001 From: adamviktora Date: Wed, 20 Mar 2024 10:43:39 +0100 Subject: [PATCH 01/15] fix(SelectTypeahead example): make "no results" option aria-disabled --- .../src/components/Select/examples/SelectTypeahead.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx b/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx index 8d21541211f..ab168912694 100644 --- a/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx +++ b/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx @@ -44,7 +44,7 @@ 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' } ]; } From 0d1a389c2b6a9c07525a73edfa47a5ef03a3ab55 Mon Sep 17 00:00:00 2001 From: adamviktora Date: Wed, 20 Mar 2024 10:44:59 +0100 Subject: [PATCH 02/15] fix(SelectTypeahead example): don't close the menu on input click when there is text --- .../src/components/Select/examples/SelectTypeahead.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx b/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx index ab168912694..8df8217fd79 100644 --- a/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx +++ b/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx @@ -59,6 +59,14 @@ export const SelectBasic: React.FunctionComponent = () => { setFocusedItemIndex(null); }, [filterValue]); + const onInputClick = () => { + if (inputValue) { + setIsOpen(true); + } else { + setIsOpen(!isOpen); + } + }; + const onToggleClick = () => { setIsOpen(!isOpen); }; @@ -154,7 +162,7 @@ export const SelectBasic: React.FunctionComponent = () => { Date: Wed, 20 Mar 2024 10:53:04 +0100 Subject: [PATCH 03/15] 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. --- .../src/components/Select/examples/SelectTypeahead.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx b/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx index 8df8217fd79..400bf4862f9 100644 --- a/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx +++ b/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx @@ -140,6 +140,7 @@ export const SelectBasic: React.FunctionComponent = () => { case 'Tab': case 'Escape': setIsOpen(false); + setFocusedItemIndex(null); setActiveItem(null); break; case 'ArrowUp': @@ -203,6 +204,8 @@ export const SelectBasic: React.FunctionComponent = () => { onSelect={onSelect} onOpenChange={() => { setIsOpen(false); + setFocusedItemIndex(null); + setActiveItem(null); }} toggle={toggle} > From 5a50017ef7613c544583b2ea186782006848b317 Mon Sep 17 00:00:00 2001 From: adamviktora Date: Wed, 27 Mar 2024 14:10:39 +0100 Subject: [PATCH 04/15] fix(SelectTypeahead example): remove check icon from the selected option when input text changes --- .../src/components/Select/examples/SelectTypeahead.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx b/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx index 400bf4862f9..85757d35d1f 100644 --- a/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx +++ b/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx @@ -88,6 +88,10 @@ export const SelectBasic: React.FunctionComponent = () => { const onTextInputChange = (_event: React.FormEvent, value: string) => { setInputValue(value); setFilterValue(value); + + if (value !== selected) { + setSelected(''); + } }; const handleMenuArrowKeys = (key: string) => { From 19ef8ec55641ef2ad9effa70aacb51316bb93831 Mon Sep 17 00:00:00 2001 From: adamviktora Date: Thu, 28 Mar 2024 10:40:19 +0100 Subject: [PATCH 05/15] fix(SelectTypeahead example): rename example --- .../src/components/Select/examples/SelectTypeahead.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx b/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx index 85757d35d1f..35ce0914bee 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(''); From c01f0f3b806b3fb3e518adc16cbab0acb5545c62 Mon Sep 17 00:00:00 2001 From: adamviktora Date: Thu, 28 Mar 2024 11:13:14 +0100 Subject: [PATCH 06/15] 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. --- packages/react-core/src/components/Select/Select.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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(); From 337b5c1bfa1875a9294a57c33ded7537f984b97d Mon Sep 17 00:00:00 2001 From: adamviktora Date: Tue, 2 Apr 2024 09:41:40 +0200 Subject: [PATCH 07/15] refactor(SelectTypeahead example): adaption on first menu item focused --- .../Select/examples/SelectTypeahead.tsx | 67 +++++++++++-------- 1 file changed, 39 insertions(+), 28 deletions(-) diff --git a/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx b/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx index 35ce0914bee..419ef9910a8 100644 --- a/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx +++ b/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx @@ -32,6 +32,8 @@ export const SelectTypeahead: 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 SelectTypeahead: React.FunctionComponent = () => { // When no options are found after filtering, display 'No results found' if (!newSelectOptions.length) { newSelectOptions = [ - { isAriaDisabled: true, 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,34 +58,48 @@ export const SelectTypeahead: React.FunctionComponent = () => { } setSelectOptions(newSelectOptions); - setActiveItem(null); - setFocusedItemIndex(null); }, [filterValue]); - const onInputClick = () => { - if (inputValue) { - setIsOpen(true); - } else { - 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 onToggleClick = () => { - setIsOpen(!isOpen); + 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) => { @@ -116,9 +133,7 @@ export const SelectTypeahead: React.FunctionComponent = () => { } } - setFocusedItemIndex(indexToFocus); - const focusedItem = selectOptions.filter((option) => !option.isDisabled)[indexToFocus]; - setActiveItem(`select-typeahead-${focusedItem.value.replace(' ', '-')}`); + setActiveAndFocusedItem(indexToFocus); } }; @@ -130,22 +145,19 @@ export const SelectTypeahead: 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); - setFocusedItemIndex(null); - setActiveItem(null); + closeMenu(); break; case 'ArrowUp': case 'ArrowDown': @@ -160,7 +172,7 @@ export const SelectTypeahead: React.FunctionComponent = () => { ref={toggleRef} variant="typeahead" aria-label="Typeahead menu toggle" - onClick={onToggleClick} + onClick={() => setIsOpen(!isOpen)} isExpanded={isOpen} isFullWidth > @@ -206,12 +218,11 @@ export const SelectTypeahead: React.FunctionComponent = () => { isOpen={isOpen} selected={selected} onSelect={onSelect} - onOpenChange={() => { - setIsOpen(false); - setFocusedItemIndex(null); - setActiveItem(null); + onOpenChange={(isOpen) => { + !isOpen && closeMenu(); }} toggle={toggle} + shouldFocusFirstMenuItemOnOpen={false} > {selectOptions.map((option, index) => ( From 87d954623f61ad8e43004984139e9f33b1d216b3 Mon Sep 17 00:00:00 2001 From: adamviktora Date: Tue, 2 Apr 2024 11:01:31 +0200 Subject: [PATCH 08/15] feat(MenuToggle): make typeahead toggle button not focusable --- packages/react-core/src/components/MenuToggle/MenuToggle.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-core/src/components/MenuToggle/MenuToggle.tsx b/packages/react-core/src/components/MenuToggle/MenuToggle.tsx index 30e3ce14644..4d87b1b7b49 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} From 80a96991f7e620e1e872b23a5566ca6dcbda16d6 Mon Sep 17 00:00:00 2001 From: adamviktora Date: Tue, 2 Apr 2024 11:02:25 +0200 Subject: [PATCH 09/15] fix(SelectTypeahead example): focus input after toggle button click --- .../src/components/Select/examples/SelectTypeahead.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx b/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx index 419ef9910a8..b7d697e12b6 100644 --- a/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx +++ b/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx @@ -172,7 +172,10 @@ export const SelectTypeahead: React.FunctionComponent = () => { ref={toggleRef} variant="typeahead" aria-label="Typeahead menu toggle" - onClick={() => setIsOpen(!isOpen)} + onClick={() => { + setIsOpen(!isOpen); + textInputRef?.current?.focus(); + }} isExpanded={isOpen} isFullWidth > From 917ca79c131b51b6b3abf3188a25938e913695f0 Mon Sep 17 00:00:00 2001 From: adamviktora Date: Tue, 2 Apr 2024 11:49:23 +0200 Subject: [PATCH 10/15] feat(SelectTypeahead example): change the focused item on hover --- .../src/components/Select/examples/SelectTypeahead.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx b/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx index b7d697e12b6..f907f15cee7 100644 --- a/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx +++ b/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx @@ -233,6 +233,7 @@ export const SelectTypeahead: 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} From ec545d4b92f9d79dd54ce708f19fab1d9a1efb65 Mon Sep 17 00:00:00 2001 From: adamviktora Date: Tue, 2 Apr 2024 12:13:47 +0200 Subject: [PATCH 11/15] fix(SelectTypeahead example): don't focus on first item after tabbing --- .../src/components/Select/examples/SelectTypeahead.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx b/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx index f907f15cee7..2ea2e032ac8 100644 --- a/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx +++ b/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx @@ -154,10 +154,6 @@ export const SelectTypeahead: React.FunctionComponent = () => { setIsOpen((prevIsOpen) => !prevIsOpen); resetActiveAndFocusedItem(); - break; - case 'Tab': - case 'Escape': - closeMenu(); break; case 'ArrowUp': case 'ArrowDown': From bb7e7f8a530fb40b601dc46395f96235e6a0c14c Mon Sep 17 00:00:00 2001 From: adamviktora Date: Tue, 2 Apr 2024 15:47:31 +0200 Subject: [PATCH 12/15] feat(Select): add typeahead select template --- .../src/components/Select/SelectTypeahead.tsx | 293 ++++++++++++++++++ .../Select/examples/SelectTemplates.md | 11 +- .../Select/examples/SelectTypeaheadDemo.tsx | 21 ++ .../src/components/Select/index.ts | 1 + 4 files changed, 324 insertions(+), 2 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-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'; From dacd40dc485497d9466fb66a0e64866bea2effb9 Mon Sep 17 00:00:00 2001 From: adamviktora Date: Tue, 23 Apr 2024 17:51:34 +0200 Subject: [PATCH 13/15] fix(SelectTypeahead): address PR review - new changes were done also based on SelectTypeahead example updates (https://github.com/patternfly/patternfly-react/pull/10207) --- .../src/components/Select/SelectTypeahead.tsx | 148 ++++++++++-------- 1 file changed, 86 insertions(+), 62 deletions(-) diff --git a/packages/react-templates/src/components/Select/SelectTypeahead.tsx b/packages/react-templates/src/components/Select/SelectTypeahead.tsx index 4be92446fd0..f880bad1ac2 100644 --- a/packages/react-templates/src/components/Select/SelectTypeahead.tsx +++ b/packages/react-templates/src/components/Select/SelectTypeahead.tsx @@ -24,9 +24,12 @@ export interface SelectTypeaheadProps { /** @hide Forwarded ref */ innerRef?: React.Ref; /** Initial options of the select. */ - initialOptions?: SelectTypeaheadOption[]; + initialOptions: SelectTypeaheadOption[]; /** Callback triggered on selection. */ - onSelect?: (_event: React.MouseEvent, selection: string | number) => void; + 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. */ @@ -56,7 +59,7 @@ export const SelectTypeaheadBase: React.FunctionComponent const [filterValue, setFilterValue] = React.useState(''); const [selectOptions, setSelectOptions] = React.useState(initialOptions); const [focusedItemIndex, setFocusedItemIndex] = React.useState(null); - const [activeItem, setActiveItem] = React.useState(null); + const [activeItemId, setActiveItemId] = React.useState(null); const textInputRef = React.useRef(); const NO_RESULTS = 'no results'; @@ -80,7 +83,6 @@ export const SelectTypeaheadBase: React.FunctionComponent value: NO_RESULTS } ]; - resetActiveAndFocusedItem(); } // Open the menu when the input value changes and the new value is not empty @@ -92,21 +94,17 @@ export const SelectTypeaheadBase: React.FunctionComponent setSelectOptions(newSelectOptions); }, [filterValue]); - React.useEffect(() => { - if (isOpen && selectOptions.length && selectOptions[0].value !== NO_RESULTS) { - setActiveAndFocusedItem(0); - } - }, [isOpen, filterValue]); + const createItemId = (value: string | number) => `select-typeahead-${String(value).replace(' ', '-')}`; const setActiveAndFocusedItem = (itemIndex: number) => { setFocusedItemIndex(itemIndex); - const focusedItem = selectOptions.filter((option) => !option.isDisabled)[itemIndex]; - setActiveItem(`select-typeahead-${String(focusedItem.value).replace(' ', '-')}`); + const focusedItem = selectOptions[itemIndex]; + setActiveItemId(createItemId(focusedItem.value)); }; const resetActiveAndFocusedItem = () => { setFocusedItemIndex(null); - setActiveItem(null); + setActiveItemId(null); }; const closeMenu = () => { @@ -122,16 +120,24 @@ export const SelectTypeaheadBase: React.FunctionComponent } }; - const _onSelect = (_event: React.MouseEvent | undefined, value: string | number | undefined) => { - onSelect && onSelect(_event, value); + 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 inputText = selectOptions.find((option) => option.value === value).content; - setInputValue(String(inputText)); - setFilterValue(''); - setSelected(String(value)); + const optionToSelect = selectOptions.find((option) => option.value === value); + selectOption(_event, optionToSelect); } - closeMenu(); }; const onTextInputChange = (_event: React.FormEvent, value: string) => { @@ -139,53 +145,73 @@ export const SelectTypeaheadBase: React.FunctionComponent onInputChange && onInputChange(value); setFilterValue(value); + resetActiveAndFocusedItem(); + if (value !== selected) { setSelected(''); } }; const handleMenuArrowKeys = (key: string) => { - let indexToFocus; + let indexToFocus = 0; + + if (!isOpen) { + setIsOpen(true); + } + + if (selectOptions.every((option) => option.isDisabled)) { + return; + } - 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) { + 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; - } 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) { + 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; - } else { - indexToFocus = focusedItemIndex + 1; } } - - setActiveAndFocusedItem(indexToFocus); } + + setActiveAndFocusedItem(indexToFocus); }; const onInputKeyDown = (event: React.KeyboardEvent) => { - const enabledMenuItems = selectOptions.filter((option) => !option.isDisabled); - const [firstMenuItem] = enabledMenuItems; - const focusedItem = focusedItemIndex ? enabledMenuItems[focusedItemIndex] : firstMenuItem; + const focusedItem = focusedItemIndex !== null ? selectOptions[focusedItemIndex] : null; 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)); + if (isOpen && focusedItem && focusedItem.value !== NO_RESULTS && !focusedItem.isAriaDisabled) { + selectOption(event, focusedItem); } - setIsOpen((prevIsOpen) => !prevIsOpen); - resetActiveAndFocusedItem(); + if (!isOpen) { + setIsOpen(true); + } break; case 'ArrowUp': @@ -222,28 +248,27 @@ export const SelectTypeaheadBase: React.FunctionComponent autoComplete="off" innerRef={textInputRef} placeholder={placeholder} - {...(activeItem && { 'aria-activedescendant': activeItem })} + {...(activeItemId && { 'aria-activedescendant': activeItemId })} role="combobox" isExpanded={isOpen} aria-controls="select-typeahead-listbox" /> - - {!!inputValue && ( - - )} + + @@ -273,8 +298,7 @@ export const SelectTypeaheadBase: React.FunctionComponent key={value} value={value} isFocused={focusedItemIndex === index} - onMouseEnter={() => setActiveAndFocusedItem(index)} - id={`select-typeahead-${String(value).replace(' ', '-')}`} + id={createItemId(value)} > {content} From d14853287e1a29ffede3c91af65306a1818db477 Mon Sep 17 00:00:00 2001 From: adamviktora Date: Tue, 30 Apr 2024 17:41:31 +0200 Subject: [PATCH 14/15] fix(SelectTypeahead template): call onToggle every time menu opens/closes --- .../src/components/Select/SelectTypeahead.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/react-templates/src/components/Select/SelectTypeahead.tsx b/packages/react-templates/src/components/Select/SelectTypeahead.tsx index f880bad1ac2..f6a77de4355 100644 --- a/packages/react-templates/src/components/Select/SelectTypeahead.tsx +++ b/packages/react-templates/src/components/Select/SelectTypeahead.tsx @@ -87,7 +87,7 @@ export const SelectTypeaheadBase: React.FunctionComponent // Open the menu when the input value changes and the new value is not empty if (!isOpen) { - setIsOpen(true); + openMenu(); } } @@ -107,14 +107,20 @@ export const SelectTypeaheadBase: React.FunctionComponent setActiveItemId(null); }; + const openMenu = () => { + onToggle && onToggle(true); + setIsOpen(true); + }; + const closeMenu = () => { + onToggle && onToggle(false); setIsOpen(false); resetActiveAndFocusedItem(); }; const onInputClick = () => { if (!isOpen) { - setIsOpen(true); + openMenu(); } else if (!inputValue) { closeMenu(); } @@ -156,7 +162,7 @@ export const SelectTypeaheadBase: React.FunctionComponent let indexToFocus = 0; if (!isOpen) { - setIsOpen(true); + openMenu(); } if (selectOptions.every((option) => option.isDisabled)) { From 9914996d595b898d23b04ebb0f783ab01648478f Mon Sep 17 00:00:00 2001 From: adamviktora Date: Tue, 30 Apr 2024 17:43:10 +0200 Subject: [PATCH 15/15] refactor(SelectTypeahead template) --- .../src/components/Select/SelectTypeahead.tsx | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/react-templates/src/components/Select/SelectTypeahead.tsx b/packages/react-templates/src/components/Select/SelectTypeahead.tsx index f6a77de4355..c67134718e1 100644 --- a/packages/react-templates/src/components/Select/SelectTypeahead.tsx +++ b/packages/react-templates/src/components/Select/SelectTypeahead.tsx @@ -234,6 +234,15 @@ export const SelectTypeaheadBase: React.FunctionComponent textInputRef?.current?.focus(); }; + const onClearButtonClick = () => { + setSelected(''); + setInputValue(''); + onInputChange && onInputChange(''); + setFilterValue(''); + resetActiveAndFocusedItem(); + textInputRef?.current?.focus(); + }; + const toggle = (toggleRef: React.Ref) => ( /> -