From 2d19e26b5fa95f6da0fa6b16f5bf9a299cf58df8 Mon Sep 17 00:00:00 2001 From: adamviktora <84135613+adamviktora@users.noreply.github.com> Date: Wed, 8 May 2024 17:26:26 +0200 Subject: [PATCH] feat(Select): Typeahead example (#10207) * refactor(Select): rename shouldFocusFirstMenuItemOnOpen * feat(SelectTypeahead example): better arrow up/down keys handling - does not apply visual focus on the first menu option - handles disabled options - opens menu on pressing up/down arrow keys * feat(SelectTypeahead example): don't close menu on clicking clear button when open * refactor(SelectTypeahead example) * refactor(SelectTypeahead example) * fix(SelectTypeaheadCreatable example): changes based on SelectTypeahead * fix(SelectMultiTypeahead example): changes based on SelectTypeahead * fix(SelectTypeaheadCreatable example): don't show create option if that exact option exists * fix(SelectMultiTypeaheadCreatable): changes based on SelectTypeahead * fix(SelectMultiTypeaheadCheckbox): changes based on SelectTypeahead * fix(SelectTypeaheadCreatable): close menu after creating option * fix(SelectTypeahead template): rename prop back to shouldFocusFirstItemOnOpen --- .../src/components/Select/Select.tsx | 8 +- .../Select/examples/SelectMultiTypeahead.tsx | 177 +++++++++------ .../examples/SelectMultiTypeaheadCheckbox.tsx | 163 ++++++++------ .../SelectMultiTypeaheadCreatable.tsx | 204 +++++++++++------- .../Select/examples/SelectTypeahead.tsx | 146 +++++++------ .../examples/SelectTypeaheadCreatable.tsx | 190 +++++++++------- .../src/components/Select/SelectTypeahead.tsx | 2 +- 7 files changed, 541 insertions(+), 349 deletions(-) diff --git a/packages/react-core/src/components/Select/Select.tsx b/packages/react-core/src/components/Select/Select.tsx index f9c25d170d7..82bcda7bf99 100644 --- a/packages/react-core/src/components/Select/Select.tsx +++ b/packages/react-core/src/components/Select/Select.tsx @@ -53,8 +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; + /** @beta Flag indicating the first menu item should be focused after opening the menu. */ + shouldFocusFirstItemOnOpen?: 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. @@ -88,7 +88,7 @@ const SelectBase: React.FunctionComponent = ({ selected, toggle, shouldFocusToggleOnSelect = false, - shouldFocusFirstMenuItemOnOpen = true, + shouldFocusFirstItemOnOpen = true, onOpenChange, onOpenChangeKeys = ['Escape', 'Tab'], isPlain, @@ -128,7 +128,7 @@ const SelectBase: React.FunctionComponent = ({ const handleClick = (event: MouseEvent) => { // toggle was opened, focus on first menu item - if (isOpen && shouldFocusFirstMenuItemOnOpen && toggleRef.current?.contains(event.target as Node)) { + if (isOpen && shouldFocusFirstItemOnOpen && 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/SelectMultiTypeahead.tsx b/packages/react-core/src/components/Select/examples/SelectMultiTypeahead.tsx index 9df0c8978e7..57f2b4d27f6 100644 --- a/packages/react-core/src/components/Select/examples/SelectMultiTypeahead.tsx +++ b/packages/react-core/src/components/Select/examples/SelectMultiTypeahead.tsx @@ -30,9 +30,11 @@ export const SelectMultiTypeahead: React.FunctionComponent = () => { const [selected, setSelected] = React.useState([]); const [selectOptions, setSelectOptions] = React.useState(initialSelectOptions); 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'; + React.useEffect(() => { let newSelectOptions: SelectOptionProps[] = initialSelectOptions; @@ -45,7 +47,7 @@ export const SelectMultiTypeahead: React.FunctionComponent = () => { // When no options are found after filtering, display 'No results found' if (!newSelectOptions.length) { newSelectOptions = [ - { isDisabled: false, children: `No results found for "${inputValue}"`, value: 'no results' } + { isAriaDisabled: true, children: `No results found for "${inputValue}"`, value: NO_RESULTS } ]; } @@ -56,56 +58,113 @@ export const SelectMultiTypeahead: React.FunctionComponent = () => { } setSelectOptions(newSelectOptions); - setFocusedItemIndex(null); - setActiveItem(null); }, [inputValue]); + const createItemId = (value: any) => `select-multi-typeahead-${value.replace(' ', '-')}`; + + const setActiveAndFocusedItem = (itemIndex: number) => { + setFocusedItemIndex(itemIndex); + const focusedItem = selectOptions[itemIndex]; + setActiveItemId(createItemId(focusedItem.value)); + }; + + const resetActiveAndFocusedItem = () => { + setFocusedItemIndex(null); + setActiveItemId(null); + }; + + const closeMenu = () => { + setIsOpen(false); + resetActiveAndFocusedItem(); + }; + + const onInputClick = () => { + if (!isOpen) { + setIsOpen(true); + } else if (!inputValue) { + closeMenu(); + } + }; + + const onSelect = (value: string) => { + if (value && value !== NO_RESULTS) { + // eslint-disable-next-line no-console + console.log('selected', value); + + setSelected( + selected.includes(value) ? selected.filter((selection) => selection !== value) : [...selected, value] + ); + } + + textInputRef.current?.focus(); + }; + + const onTextInputChange = (_event: React.FormEvent, value: string) => { + setInputValue(value); + resetActiveAndFocusedItem(); + }; + const handleMenuArrowKeys = (key: string) => { - let indexToFocus; + let indexToFocus = 0; - 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 (!isOpen) { + setIsOpen(true); + } + + 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; - } 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; } } - - setFocusedItemIndex(indexToFocus); - const focusedItem = selectOptions.filter((option) => !option.isDisabled)[indexToFocus]; - setActiveItem(`select-multi-typeahead-${focusedItem.value.replace(' ', '-')}`); } + + setActiveAndFocusedItem(indexToFocus); }; const onInputKeyDown = (event: React.KeyboardEvent) => { - const enabledMenuItems = selectOptions.filter((menuItem) => !menuItem.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 && focusedItem.value !== NO_RESULTS && !focusedItem.isAriaDisabled) { + onSelect(focusedItem.value); + } + if (!isOpen) { - setIsOpen((prevIsOpen) => !prevIsOpen); - } else if (isOpen && focusedItem.value !== 'no results') { - onSelect(focusedItem.value as string); + setIsOpen(true); } - break; - case 'Tab': - case 'Escape': - setIsOpen(false); - setActiveItem(null); + break; case 'ArrowUp': case 'ArrowDown': @@ -117,24 +176,17 @@ export const SelectMultiTypeahead: React.FunctionComponent = () => { const onToggleClick = () => { setIsOpen(!isOpen); + textInputRef?.current?.focus(); }; - const onTextInputChange = (_event: React.FormEvent, value: string) => { - setInputValue(value); + const onClearButtonClick = () => { + setSelected([]); + setInputValue(''); + resetActiveAndFocusedItem(); + textInputRef?.current?.focus(); }; - const onSelect = (value: string) => { - // eslint-disable-next-line no-console - console.log('selected', value); - - if (value && value !== 'no results') { - setSelected( - selected.includes(value) ? selected.filter((selection) => selection !== value) : [...selected, value] - ); - } - - textInputRef.current?.focus(); - }; + const getChildren = (value: string) => initialSelectOptions.find((option) => option.value === value)?.children; const toggle = (toggleRef: React.Ref) => ( { { onSelect(selection); }} > - {selection} + {getChildren(selection)} ))} - - {selected.length > 0 && ( - - )} + + @@ -198,9 +240,12 @@ export const SelectMultiTypeahead: React.FunctionComponent = () => { id="multi-typeahead-select" isOpen={isOpen} selected={selected} - onSelect={(ev, selection) => onSelect(selection as string)} - onOpenChange={() => setIsOpen(false)} + onSelect={(_event, selection) => onSelect(selection as string)} + onOpenChange={(isOpen) => { + !isOpen && closeMenu(); + }} toggle={toggle} + shouldFocusFirstItemOnOpen={false} > {selectOptions.map((option, index) => ( @@ -208,7 +253,7 @@ export const SelectMultiTypeahead: React.FunctionComponent = () => { key={option.value || option.children} isFocused={focusedItemIndex === index} className={option.className} - id={`select-multi-typeahead-${option.value.replace(' ', '-')}`} + id={createItemId(option.value)} {...option} ref={null} /> diff --git a/packages/react-core/src/components/Select/examples/SelectMultiTypeaheadCheckbox.tsx b/packages/react-core/src/components/Select/examples/SelectMultiTypeaheadCheckbox.tsx index 7a88b6f0a1a..da38e0930e1 100644 --- a/packages/react-core/src/components/Select/examples/SelectMultiTypeaheadCheckbox.tsx +++ b/packages/react-core/src/components/Select/examples/SelectMultiTypeaheadCheckbox.tsx @@ -28,10 +28,12 @@ export const SelectMultiTypeaheadCheckbox: React.FunctionComponent = () => { const [selected, setSelected] = React.useState([]); const [selectOptions, setSelectOptions] = React.useState(initialSelectOptions); const [focusedItemIndex, setFocusedItemIndex] = React.useState(null); - const [activeItem, setActiveItem] = React.useState(null); + const [activeItemId, setActiveItemId] = React.useState(null); const [placeholder, setPlaceholder] = React.useState('0 items selected'); const textInputRef = React.useRef(); + const NO_RESULTS = 'no results'; + React.useEffect(() => { let newSelectOptions: SelectOptionProps[] = initialSelectOptions; @@ -45,9 +47,9 @@ export const SelectMultiTypeaheadCheckbox: React.FunctionComponent = () => { if (!newSelectOptions.length) { newSelectOptions = [ { - isDisabled: false, + isAriaDisabled: true, children: `No results found for "${inputValue}"`, - value: 'no results', + value: NO_RESULTS, hasCheckbox: false } ]; @@ -60,56 +62,99 @@ export const SelectMultiTypeaheadCheckbox: React.FunctionComponent = () => { } setSelectOptions(newSelectOptions); - setFocusedItemIndex(null); - setActiveItem(null); }, [inputValue]); + React.useEffect(() => { + setPlaceholder(`${selected.length} item${selected.length !== 1 ? 's' : ''} selected`); + }, [selected]); + + const createItemId = (value: any) => `select-multi-typeahead-${value.replace(' ', '-')}`; + + const setActiveAndFocusedItem = (itemIndex: number) => { + setFocusedItemIndex(itemIndex); + const focusedItem = selectOptions[itemIndex]; + setActiveItemId(createItemId(focusedItem.value)); + }; + + const resetActiveAndFocusedItem = () => { + setFocusedItemIndex(null); + setActiveItemId(null); + }; + + const closeMenu = () => { + setIsOpen(false); + resetActiveAndFocusedItem(); + }; + + const onInputClick = () => { + if (!isOpen) { + setIsOpen(true); + } else if (!inputValue) { + closeMenu(); + } + }; + const handleMenuArrowKeys = (key: string) => { - let indexToFocus; + let indexToFocus = 0; + + if (!isOpen) { + setIsOpen(true); + } + + 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; + } - 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) { + // 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) { + indexToFocus = 0; + } 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) { + // Skip disabled options + while (selectOptions[indexToFocus].isDisabled) { + indexToFocus++; + if (indexToFocus === selectOptions.length) { indexToFocus = 0; - } else { - indexToFocus = focusedItemIndex + 1; } } - - setFocusedItemIndex(indexToFocus); - const focusedItem = selectOptions.filter((option) => !option.isDisabled)[indexToFocus]; - setActiveItem(`select-multi-typeahead-checkbox-${focusedItem.value.replace(' ', '-')}`); } + + setActiveAndFocusedItem(indexToFocus); }; const onInputKeyDown = (event: React.KeyboardEvent) => { - const enabledMenuItems = selectOptions.filter((menuItem) => !menuItem.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 && focusedItem.value !== NO_RESULTS && !focusedItem.isAriaDisabled) { + onSelect(focusedItem.value); + } + if (!isOpen) { - setIsOpen((prevIsOpen) => !prevIsOpen); - } else if (isOpen && focusedItem.value !== 'no results') { - onSelect(focusedItem.value as string); + setIsOpen(true); } - break; - case 'Tab': - case 'Escape': - setIsOpen(false); - setActiveItem(null); + break; case 'ArrowUp': case 'ArrowDown': @@ -121,17 +166,19 @@ export const SelectMultiTypeaheadCheckbox: React.FunctionComponent = () => { const onToggleClick = () => { setIsOpen(!isOpen); + textInputRef?.current?.focus(); }; const onTextInputChange = (_event: React.FormEvent, value: string) => { setInputValue(value); + resetActiveAndFocusedItem(); }; const onSelect = (value: string) => { - // eslint-disable-next-line no-console - console.log('selected', value); + if (value && value !== NO_RESULTS) { + // eslint-disable-next-line no-console + console.log('selected', value); - if (value && value !== 'no results') { setSelected( selected.includes(value) ? selected.filter((selection) => selection !== value) : [...selected, value] ); @@ -140,9 +187,12 @@ export const SelectMultiTypeaheadCheckbox: React.FunctionComponent = () => { textInputRef.current?.focus(); }; - React.useEffect(() => { - setPlaceholder(`${selected.length} items selected`); - }, [selected]); + const onClearButtonClick = () => { + setSelected([]); + setInputValue(''); + resetActiveAndFocusedItem(); + textInputRef?.current?.focus(); + }; const toggle = (toggleRef: React.Ref) => ( { - - {selected.length > 0 && ( - - )} + + @@ -193,19 +233,22 @@ export const SelectMultiTypeaheadCheckbox: React.FunctionComponent = () => { id="multi-typeahead-checkbox-select" isOpen={isOpen} selected={selected} - onSelect={(ev, selection) => onSelect(selection as string)} - onOpenChange={() => setIsOpen(false)} + onSelect={(_event, selection) => onSelect(selection as string)} + onOpenChange={(isOpen) => { + !isOpen && closeMenu(); + }} toggle={toggle} + shouldFocusFirstItemOnOpen={false} > - + {selectOptions.map((option, index) => ( diff --git a/packages/react-core/src/components/Select/examples/SelectMultiTypeaheadCreatable.tsx b/packages/react-core/src/components/Select/examples/SelectMultiTypeaheadCreatable.tsx index 9be7a6d6152..16f3afb2c63 100644 --- a/packages/react-core/src/components/Select/examples/SelectMultiTypeaheadCreatable.tsx +++ b/packages/react-core/src/components/Select/examples/SelectMultiTypeaheadCreatable.tsx @@ -30,10 +30,12 @@ export const SelectMultiTypeaheadCreatable: React.FunctionComponent = () => { const [selected, setSelected] = React.useState([]); const [selectOptions, setSelectOptions] = React.useState(initialSelectOptions); const [focusedItemIndex, setFocusedItemIndex] = React.useState(null); - const [activeItem, setActiveItem] = React.useState(null); + const [activeItemId, setActiveItemId] = React.useState(null); const [onCreation, setOnCreation] = React.useState(false); // Boolean to refresh filter state after new option is created const textInputRef = React.useRef(); + const CREATE_NEW = 'create'; + React.useEffect(() => { let newSelectOptions: SelectOptionProps[] = initialSelectOptions; @@ -43,9 +45,9 @@ export const SelectMultiTypeaheadCreatable: React.FunctionComponent = () => { String(menuItem.children).toLowerCase().includes(inputValue.toLowerCase()) ); - // When no options are found after filtering, display creation option - if (!newSelectOptions.length) { - newSelectOptions = [{ isDisabled: false, children: `Create new option "${inputValue}"`, value: 'create' }]; + // If no option matches the filter exactly, display creation option + if (!initialSelectOptions.some((option) => option.value === inputValue)) { + newSelectOptions = [...newSelectOptions, { children: `Create new option "${inputValue}"`, value: CREATE_NEW }]; } // Open the menu when the input value changes and the new value is not empty @@ -55,56 +57,125 @@ export const SelectMultiTypeaheadCreatable: React.FunctionComponent = () => { } setSelectOptions(newSelectOptions); - setFocusedItemIndex(null); - setActiveItem(null); }, [inputValue, onCreation]); + const createItemId = (value: any) => `select-multi-create-typeahead-${value.replace(' ', '-')}`; + + const setActiveAndFocusedItem = (itemIndex: number) => { + setFocusedItemIndex(itemIndex); + const focusedItem = selectOptions[itemIndex]; + setActiveItemId(createItemId(focusedItem.value)); + }; + + const resetActiveAndFocusedItem = () => { + setFocusedItemIndex(null); + setActiveItemId(null); + }; + + const closeMenu = () => { + setIsOpen(false); + resetActiveAndFocusedItem(); + }; + + const onInputClick = () => { + if (!isOpen) { + setIsOpen(true); + } else if (!inputValue) { + closeMenu(); + } + }; + + const onSelect = (value: string) => { + if (value) { + if (value === CREATE_NEW) { + if (!initialSelectOptions.some((item) => item.value === inputValue)) { + initialSelectOptions = [...initialSelectOptions, { value: inputValue, children: inputValue }]; + } + setSelected( + selected.includes(inputValue) + ? selected.filter((selection) => selection !== inputValue) + : [...selected, inputValue] + ); + setOnCreation(!onCreation); + resetActiveAndFocusedItem(); + } else { + // eslint-disable-next-line no-console + console.log('selected', value); + setSelected( + selected.includes(value) ? selected.filter((selection) => selection !== value) : [...selected, value] + ); + } + } + + textInputRef.current?.focus(); + }; + + const onTextInputChange = (_event: React.FormEvent, value: string) => { + setInputValue(value); + resetActiveAndFocusedItem(); + }; + const handleMenuArrowKeys = (key: string) => { - let indexToFocus; + let indexToFocus = 0; + + if (!isOpen) { + setIsOpen(true); + } - 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 (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; - } 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; } } - - setFocusedItemIndex(indexToFocus); - const focusedItem = selectOptions.filter((option) => !option.isDisabled)[indexToFocus]; - setActiveItem(`select-multi-create-typeahead-${focusedItem.value.replace(' ', '-')}`); } + + setActiveAndFocusedItem(indexToFocus); }; const onInputKeyDown = (event: React.KeyboardEvent) => { - const enabledMenuItems = selectOptions.filter((menuItem) => !menuItem.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) { - setIsOpen((prevIsOpen) => !prevIsOpen); - } else if (isOpen && focusedItem.value !== 'no results') { + if (isOpen && focusedItem && !focusedItem.isAriaDisabled) { onSelect(focusedItem.value as string); } - break; - case 'Tab': - case 'Escape': - setIsOpen(false); - setActiveItem(null); + + if (!isOpen) { + setIsOpen(true); + } + break; case 'ArrowUp': case 'ArrowDown': @@ -116,35 +187,17 @@ export const SelectMultiTypeaheadCreatable: React.FunctionComponent = () => { const onToggleClick = () => { setIsOpen(!isOpen); + textInputRef?.current?.focus(); }; - const onTextInputChange = (_event: React.FormEvent, value: string) => { - setInputValue(value); + const onClearButtonClick = () => { + setSelected([]); + setInputValue(''); + resetActiveAndFocusedItem(); + textInputRef?.current?.focus(); }; - const onSelect = (value: string) => { - if (value) { - if (value === 'create') { - if (!initialSelectOptions.some((item) => item.value === inputValue)) { - initialSelectOptions = [...initialSelectOptions, { value: inputValue, children: inputValue }]; - } - setSelected( - selected.includes(inputValue) - ? selected.filter((selection) => selection !== inputValue) - : [...selected, inputValue] - ); - setOnCreation(!onCreation); - } else { - // eslint-disable-next-line no-console - console.log('selected', value); - setSelected( - selected.includes(value) ? selected.filter((selection) => selection !== value) : [...selected, value] - ); - } - } - - textInputRef.current?.focus(); - }; + const getChildren = (value: string) => initialSelectOptions.find((option) => option.value === value)?.children; const toggle = (toggleRef: React.Ref) => ( { { onSelect(selection); }} > - {selection} + {getChildren(selection)} ))} - - {selected.length > 0 && ( - - )} + + @@ -208,9 +251,12 @@ export const SelectMultiTypeaheadCreatable: React.FunctionComponent = () => { id="multi-create-typeahead-select" isOpen={isOpen} selected={selected} - onSelect={(ev, selection) => onSelect(selection as string)} - onOpenChange={() => setIsOpen(false)} + onSelect={(_event, selection) => onSelect(selection as string)} + onOpenChange={(isOpen) => { + !isOpen && closeMenu(); + }} toggle={toggle} + shouldFocusFirstItemOnOpen={false} > {selectOptions.map((option, index) => ( @@ -218,7 +264,7 @@ export const SelectMultiTypeaheadCreatable: React.FunctionComponent = () => { key={option.value || option.children} isFocused={focusedItemIndex === index} className={option.className} - id={`select-multi-create-typeahead-${option.value.replace(' ', '-')}`} + id={createItemId(option.value)} {...option} ref={null} /> diff --git a/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx b/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx index 2ea2e032ac8..2c67674115e 100644 --- a/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx +++ b/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx @@ -29,7 +29,7 @@ export const SelectTypeahead: React.FunctionComponent = () => { const [filterValue, setFilterValue] = React.useState(''); const [selectOptions, setSelectOptions] = React.useState(initialSelectOptions); 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'; @@ -48,7 +48,6 @@ export const SelectTypeahead: React.FunctionComponent = () => { newSelectOptions = [ { 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 @@ -60,21 +59,17 @@ export const SelectTypeahead: React.FunctionComponent = () => { setSelectOptions(newSelectOptions); }, [filterValue]); - React.useEffect(() => { - if (isOpen && selectOptions.length && selectOptions[0].value !== NO_RESULTS) { - setActiveAndFocusedItem(0); - } - }, [isOpen, filterValue]); + const createItemId = (value: any) => `select-typeahead-${value.replace(' ', '-')}`; const setActiveAndFocusedItem = (itemIndex: number) => { setFocusedItemIndex(itemIndex); - const focusedItem = selectOptions.filter((option) => !option.isDisabled)[itemIndex]; - setActiveItem(`select-typeahead-${focusedItem.value.replace(' ', '-')}`); + const focusedItem = selectOptions[itemIndex]; + setActiveItemId(createItemId(focusedItem.value)); }; const resetActiveAndFocusedItem = () => { setFocusedItemIndex(null); - setActiveItem(null); + setActiveItemId(null); }; const closeMenu = () => { @@ -90,69 +85,95 @@ export const SelectTypeahead: React.FunctionComponent = () => { } }; - const onSelect = (_event: React.MouseEvent | undefined, value: string | number | undefined) => { + const selectOption = (value: string | number, content: string | number) => { // eslint-disable-next-line no-console - console.log('selected', value); + console.log('selected', content); + + setInputValue(String(content)); + setFilterValue(''); + setSelected(String(value)); + closeMenu(); + }; + + const onSelect = (_event: React.MouseEvent | undefined, value: string | number | undefined) => { if (value && value !== NO_RESULTS) { - setInputValue(value as string); - setFilterValue(''); - setSelected(value as string); + const optionText = selectOptions.find((option) => option.value === value)?.children; + selectOption(value, optionText as string); } - closeMenu(); }; const onTextInputChange = (_event: React.FormEvent, value: string) => { setInputValue(value); setFilterValue(value); + resetActiveAndFocusedItem(); + if (value !== selected) { setSelected(''); } }; const handleMenuArrowKeys = (key: string) => { - let indexToFocus; + let indexToFocus = 0; + + if (!isOpen) { + setIsOpen(true); + } - 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 (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; - } 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; + } - 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) { + // 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.children)); - setFilterValue(''); - setSelected(String(focusedItem.children)); + if (isOpen && focusedItem && focusedItem.value !== NO_RESULTS && !focusedItem.isAriaDisabled) { + selectOption(focusedItem.value, focusedItem.children as string); } - setIsOpen((prevIsOpen) => !prevIsOpen); - resetActiveAndFocusedItem(); + if (!isOpen) { + setIsOpen(true); + } break; case 'ArrowUp': @@ -163,15 +184,25 @@ export const SelectTypeahead: React.FunctionComponent = () => { } }; + const onToggleClick = () => { + setIsOpen(!isOpen); + textInputRef?.current?.focus(); + }; + + const onClearButtonClick = () => { + setSelected(''); + setInputValue(''); + setFilterValue(''); + resetActiveAndFocusedItem(); + textInputRef?.current?.focus(); + }; + const toggle = (toggleRef: React.Ref) => ( { - setIsOpen(!isOpen); - textInputRef?.current?.focus(); - }} + onClick={onToggleClick} isExpanded={isOpen} isFullWidth > @@ -185,27 +216,16 @@ export const SelectTypeahead: React.FunctionComponent = () => { autoComplete="off" innerRef={textInputRef} placeholder="Select a state" - {...(activeItem && { 'aria-activedescendant': activeItem })} + {...(activeItemId && { 'aria-activedescendant': activeItemId })} role="combobox" isExpanded={isOpen} aria-controls="select-typeahead-listbox" /> - - {!!inputValue && ( - - )} + + @@ -221,7 +241,7 @@ export const SelectTypeahead: React.FunctionComponent = () => { !isOpen && closeMenu(); }} toggle={toggle} - shouldFocusFirstMenuItemOnOpen={false} + shouldFocusFirstItemOnOpen={false} > {selectOptions.map((option, index) => ( @@ -229,9 +249,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(' ', '-')}`} + id={createItemId(option.value)} {...option} ref={null} /> diff --git a/packages/react-core/src/components/Select/examples/SelectTypeaheadCreatable.tsx b/packages/react-core/src/components/Select/examples/SelectTypeaheadCreatable.tsx index b079b1fb507..7b0d9df194d 100644 --- a/packages/react-core/src/components/Select/examples/SelectTypeaheadCreatable.tsx +++ b/packages/react-core/src/components/Select/examples/SelectTypeaheadCreatable.tsx @@ -29,10 +29,11 @@ export const SelectTypeaheadCreatable: React.FunctionComponent = () => { const [filterValue, setFilterValue] = React.useState(''); const [selectOptions, setSelectOptions] = React.useState(initialSelectOptions); const [focusedItemIndex, setFocusedItemIndex] = React.useState(null); - const [activeItem, setActiveItem] = React.useState(null); - const [onCreation, setOnCreation] = React.useState(false); // Boolean to refresh filter state after new option is created + const [activeItemId, setActiveItemId] = React.useState(null); const textInputRef = React.useRef(); + const CREATE_NEW = 'create'; + React.useEffect(() => { let newSelectOptions: SelectOptionProps[] = initialSelectOptions; @@ -42,9 +43,9 @@ export const SelectTypeaheadCreatable: React.FunctionComponent = () => { String(menuItem.children).toLowerCase().includes(filterValue.toLowerCase()) ); - // When no options are found after filtering, display creation option - if (!newSelectOptions.length) { - newSelectOptions = [{ isDisabled: false, children: `Create new option "${filterValue}"`, value: 'create' }]; + // If no option matches the filter exactly, display creation option + if (!initialSelectOptions.some((option) => option.value === filterValue)) { + newSelectOptions = [...newSelectOptions, { children: `Create new option "${filterValue}"`, value: CREATE_NEW }]; } // Open the menu when the input value changes and the new value is not empty @@ -54,96 +55,133 @@ export const SelectTypeaheadCreatable: React.FunctionComponent = () => { } setSelectOptions(newSelectOptions); - setActiveItem(null); + }, [filterValue]); + + const createItemId = (value: any) => `select-typeahead-${value.replace(' ', '-')}`; + + const setActiveAndFocusedItem = (itemIndex: number) => { + setFocusedItemIndex(itemIndex); + const focusedItem = selectOptions[itemIndex]; + setActiveItemId(createItemId(focusedItem.value)); + }; + + const resetActiveAndFocusedItem = () => { setFocusedItemIndex(null); - }, [filterValue, onCreation]); + setActiveItemId(null); + }; - const onToggleClick = () => { - setIsOpen(!isOpen); + const closeMenu = () => { + setIsOpen(false); + resetActiveAndFocusedItem(); }; - const onSelect = (_event: React.MouseEvent | undefined, value: string | number | undefined) => { + const onInputClick = () => { + if (!isOpen) { + setIsOpen(true); + } else if (!inputValue) { + closeMenu(); + } + }; + + const selectOption = (value: string | number, content: string | number) => { // eslint-disable-next-line no-console + console.log('selected', content); + + setInputValue(String(content)); + setFilterValue(''); + setSelected(String(value)); + + closeMenu(); + }; + const onSelect = (_event: React.MouseEvent | undefined, value: string | number | undefined) => { if (value) { - if (value === 'create') { - if (!initialSelectOptions.some((item) => item.value === filterValue)) { + if (value === CREATE_NEW) { + if (!initialSelectOptions.some((item) => item.children === filterValue)) { initialSelectOptions = [...initialSelectOptions, { value: filterValue, children: filterValue }]; } setSelected(filterValue); - setOnCreation(!onCreation); setFilterValue(''); + closeMenu(); } else { - // eslint-disable-next-line no-console - console.log('selected', value); - setInputValue(value as string); - setFilterValue(''); - setSelected(value as string); + const optionText = selectOptions.find((option) => option.value === value)?.children; + selectOption(value, optionText as string); } } - - setIsOpen(false); - setFocusedItemIndex(null); - setActiveItem(null); }; const onTextInputChange = (_event: React.FormEvent, value: string) => { setInputValue(value); setFilterValue(value); + + resetActiveAndFocusedItem(); + + if (value !== selected) { + setSelected(''); + } }; const handleMenuArrowKeys = (key: string) => { - let indexToFocus; + let indexToFocus = 0; + + if (!isOpen) { + setIsOpen(true); + } - 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 (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; - } 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; + } - 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) { + // Skip disabled options + while (selectOptions[indexToFocus].isDisabled) { + indexToFocus++; + if (indexToFocus === selectOptions.length) { indexToFocus = 0; - } else { - indexToFocus = focusedItemIndex + 1; } } - - setFocusedItemIndex(indexToFocus); - const focusedItem = selectOptions.filter((option) => !option.isDisabled)[indexToFocus]; - setActiveItem(`select-create-typeahead-${focusedItem.value.replace(' ', '-')}`); } + + 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) { + if (isOpen && focusedItem && !focusedItem.isAriaDisabled) { onSelect(undefined, focusedItem.value as string); - setIsOpen((prevIsOpen) => !prevIsOpen); - setFocusedItemIndex(null); - setActiveItem(null); } - setIsOpen((prevIsOpen) => !prevIsOpen); - setFocusedItemIndex(null); - setActiveItem(null); + if (!isOpen) { + setIsOpen(true); + } - break; - case 'Tab': - case 'Escape': - setIsOpen(false); - setActiveItem(null); break; case 'ArrowUp': case 'ArrowDown': @@ -153,6 +191,19 @@ export const SelectTypeaheadCreatable: React.FunctionComponent = () => { } }; + const onToggleClick = () => { + setIsOpen(!isOpen); + textInputRef?.current?.focus(); + }; + + const onClearButtonClick = () => { + setSelected(''); + setInputValue(''); + setFilterValue(''); + resetActiveAndFocusedItem(); + textInputRef?.current?.focus(); + }; + const toggle = (toggleRef: React.Ref) => ( { - - {!!inputValue && ( - - )} + + @@ -204,10 +244,11 @@ export const SelectTypeaheadCreatable: React.FunctionComponent = () => { isOpen={isOpen} selected={selected} onSelect={onSelect} - onOpenChange={() => { - setIsOpen(false); + onOpenChange={(isOpen) => { + !isOpen && closeMenu(); }} toggle={toggle} + shouldFocusFirstItemOnOpen={false} > {selectOptions.map((option, index) => ( @@ -215,8 +256,7 @@ export const SelectTypeaheadCreatable: React.FunctionComponent = () => { key={option.value || option.children} isFocused={focusedItemIndex === index} className={option.className} - onClick={() => setSelected(option.value)} - id={`select-typeahead-${option.value.replace(' ', '-')}`} + id={createItemId(option.value)} {...option} ref={null} /> diff --git a/packages/react-templates/src/components/Select/SelectTypeahead.tsx b/packages/react-templates/src/components/Select/SelectTypeahead.tsx index c67134718e1..4d2657ad2bf 100644 --- a/packages/react-templates/src/components/Select/SelectTypeahead.tsx +++ b/packages/react-templates/src/components/Select/SelectTypeahead.tsx @@ -288,7 +288,7 @@ export const SelectTypeaheadBase: React.FunctionComponent !isOpen && closeMenu(); }} toggle={toggle} - shouldFocusFirstMenuItemOnOpen={false} + shouldFocusFirstItemOnOpen={false} ref={innerRef} {...props} >