From d815290b67ca031b68497b6d8106d05f31515753 Mon Sep 17 00:00:00 2001 From: Ian Bolton Date: Thu, 18 Apr 2024 10:23:39 -0400 Subject: [PATCH] Update autocomplete to use a shared hook Signed-off-by: Ian Bolton --- .../components/Autocomplete/Autocomplete.tsx | 319 +++++------------ .../Autocomplete/GroupedAutocomplete.tsx | 320 +++--------------- .../components/Autocomplete/SearchInput.tsx | 62 ++++ .../app/components/Autocomplete/type-utils.ts | 30 ++ .../Autocomplete/useAutocompleteHandlers.ts | 180 ++++++++++ client/src/app/components/LabelTooltip.tsx | 14 + client/src/app/utils/utils.ts | 3 + 7 files changed, 424 insertions(+), 504 deletions(-) create mode 100644 client/src/app/components/Autocomplete/SearchInput.tsx create mode 100644 client/src/app/components/Autocomplete/type-utils.ts create mode 100644 client/src/app/components/Autocomplete/useAutocompleteHandlers.ts create mode 100644 client/src/app/components/LabelTooltip.tsx diff --git a/client/src/app/components/Autocomplete/Autocomplete.tsx b/client/src/app/components/Autocomplete/Autocomplete.tsx index 9d49485eb4..b5d6630215 100644 --- a/client/src/app/components/Autocomplete/Autocomplete.tsx +++ b/client/src/app/components/Autocomplete/Autocomplete.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef, useMemo } from "react"; +import React, { useRef } from "react"; import { Label, LabelProps, @@ -9,13 +9,14 @@ import { MenuItem, MenuList, Popper, - SearchInput, Divider, - Tooltip, + MenuGroup, } from "@patternfly/react-core"; - -const toString = (input: string | (() => string)) => - typeof input === "function" ? input() : input; +import { LabelToolip } from "../LabelTooltip"; +import { getString } from "@app/utils/utils"; +import { useAutocompleteHandlers } from "./useAutocompleteHandlers"; +import { SearchInputComponent } from "./SearchInput"; +import { AnyAutocompleteOptionProps, getUniqueId } from "./type-utils"; export interface AutocompleteOptionProps { /** id for the option */ @@ -32,7 +33,7 @@ export interface AutocompleteOptionProps { } export interface IAutocompleteProps { - onChange: (selections: AutocompleteOptionProps[]) => void; + onChange: (selections: AnyAutocompleteOptionProps[]) => void; id?: string; /** The set of options to use for selection */ @@ -62,233 +63,83 @@ export const Autocomplete: React.FC = ({ menuHeader = "", noResultsMessage = "No results found", }) => { - const [inputValue, setInputValue] = useState(searchString); - const [tabSelectedItemId, setTabSelectedItemId] = useState(); - const [menuIsOpen, setMenuIsOpen] = useState(false); - - /** refs used to detect when clicks occur inside vs outside of the textInputGroup and menu popper */ const menuRef = useRef(null); const searchInputRef = useRef(null); + const { + setInputValue, + inputValue, + menuIsOpen, + groupedFilteredOptions, + removeSelectionById, + handleMenuItemOnSelect, + handleMenuOnKeyDown, + handleOnDocumentClick, + handleInputChange, + handleKeyDown, + selectedOptions, + } = useAutocompleteHandlers({ + options, + searchString, + selections, + onChange, + menuRef, + searchInputRef, + }); - const selectedOptions = useMemo(() => { - if (!selections || selections.length === 0) { - return []; - } - return options.filter( - ({ id }) => selections.findIndex((s) => s.id === id) > -1 - ); - }, [options, selections]); - - const filteredOptions = useMemo(() => { - return options.filter( - ({ id, name }) => - selections.findIndex((s) => s.id === id) === -1 && - toString(name).toLowerCase().includes(inputValue.toLocaleLowerCase()) - ); - }, [options, selections, inputValue]); - - /** callback for removing a selection */ - const deleteSelectionByItemId = (idToDelete: number) => { - onChange(selections.filter(({ id }) => id !== idToDelete)); - }; - - /** lookup the option matching the itemId and add as a selection */ - const addSelectionByItemId = (itemId: string | number) => { - const asNumber = typeof itemId === "string" ? parseInt(itemId, 10) : itemId; - const matchingOption = options.find(({ id }) => id === asNumber); - - onChange([...selections, matchingOption].filter(Boolean)); - setInputValue(""); - setMenuIsOpen(false); - }; - - /** callback for updating the inputValue state in this component so that the input can be controlled */ - const handleSearchInputOnChange = ( - _event: React.FormEvent, - value: string - ) => { - setInputValue(value); - }; - - /** add the current input value as a selection */ - const handleEnter = () => { - if (tabSelectedItemId) { - addSelectionByItemId(tabSelectedItemId); - setTabSelectedItemId(undefined); - } - }; - - /** close the menu, and if only 1 filtered option exists, select it */ - const handleTab = (event: React.KeyboardEvent) => { - if (filteredOptions.length === 1) { - setInputValue(toString(filteredOptions[0].name)); - setTabSelectedItemId(filteredOptions[0].id); - event.preventDefault(); - } - setMenuIsOpen(false); - }; - - /** close the menu when escape is hit */ - const handleEscape = () => { - setMenuIsOpen(false); - }; - - /** allow the user to focus on the menu and navigate using the arrow keys */ - const handleArrowKey = () => { - if (menuRef.current) { - const firstElement = menuRef.current.querySelector( - "li > button:not(:disabled)" + const inputGroup = ( + setInputValue("")} + onKeyHandling={handleKeyDown} + options={options} + inputValue={inputValue} + inputRef={searchInputRef} + /> + ); + const renderMenuItems = () => { + const allGroups = Object.entries(groupedFilteredOptions); + if (allGroups.length === 0) { + return ( + + + {noResultsMessage || "No options available"} + + ); - firstElement?.focus(); } - }; - - /** reopen the menu if it's closed and any un-designated keys are hit */ - const handleDefault = () => { - if (!menuIsOpen) { - setMenuIsOpen(true); - } - }; - - /** enable keyboard only usage while focused on the text input */ - const handleSearchInputOnKeyDown = (event: React.KeyboardEvent) => { - switch (event.key) { - case "Enter": - handleEnter(); - break; - case "Escape": - handleEscape(); - break; - case "Tab": - handleTab(event); - break; - case "ArrowUp": - case "ArrowDown": - handleArrowKey(); - break; - default: - handleDefault(); - } - }; - /** apply focus to the text input */ - const focusTextInput = (closeMenu = false) => { - searchInputRef.current?.querySelector("input")?.focus(); - closeMenu && setMenuIsOpen(false); - }; - - /** add the text of the selected menu item to the selected items */ - const handleMenuItemOnSelect = ( - event: React.MouseEvent | undefined, - itemId: number - ) => { - if (!event || !itemId) { - return; - } - event.stopPropagation(); - focusTextInput(true); - addSelectionByItemId(itemId); - }; - - /** close the menu when a click occurs outside of the menu or text input group */ - const handleOnDocumentClick = (event?: MouseEvent) => { - if (!event) { - return; - } - if (searchInputRef.current?.contains(event.target as HTMLElement)) { - setMenuIsOpen(true); - } - if ( - menuRef.current && - !menuRef.current.contains(event.target as HTMLElement) && - searchInputRef.current && - !searchInputRef.current.contains(event.target as HTMLElement) - ) { - setMenuIsOpen(false); - } - }; - - /** enable keyboard only usage while focused on the menu */ - const handleMenuOnKeyDown = (event: React.KeyboardEvent) => { - switch (event.key) { - case "Tab": - case "Escape": - event.preventDefault(); - focusTextInput(); - setMenuIsOpen(false); - break; - } + return allGroups.map(([groupName, groupOptions], index) => ( + + + + {groupOptions.length > 0 ? ( + groupOptions.map((option) => ( + handleMenuItemOnSelect(e, option)} + > + {getString(option.labelName || option.name)} + + )) + ) : ( + + {noResultsMessage} + + )} + + + {index < allGroups.length - 1 && } + + )); }; - const hint = useMemo(() => { - if (filteredOptions.length === 0) { - return ""; - } - - if (filteredOptions.length === 1 && inputValue) { - const fullHint = toString(filteredOptions[0].name); - - if (fullHint.toLowerCase().indexOf(inputValue.toLowerCase())) { - // the match was found in a place other than the start, so typeahead wouldn't work right - return ""; - } else { - // use the input for the first part, otherwise case difference could make things look wrong - return inputValue + fullHint.substring(inputValue.length); - } - } - - return ""; - }, [filteredOptions, inputValue]); - - const inputGroup = ( -
- setInputValue("")} - onFocus={() => setMenuIsOpen(true)} - onKeyDown={handleSearchInputOnKeyDown} - placeholder={placeholderText} - aria-label={searchInputAriaLabel} - /> -
- ); - const menu = ( - - - {/* if supplied, add the menu heading */} - {menuHeader ? ( - <> - - {menuHeader} - - - - ) : undefined} - - {/* show a disabled "no result" when all menu items are filtered out */} - {filteredOptions.length === 0 ? ( - - {noResultsMessage} - - ) : undefined} - - {/* only show items that include the text in the input */} - {filteredOptions.map(({ id, name }, _index) => ( - handleMenuItemOnSelect(e, id)} - > - {toString(name)} - - ))} - - + {renderMenuItems()} ); @@ -307,14 +158,14 @@ export const Autocomplete: React.FC = ({ - {selectedOptions.map(({ id, name, labelName, tooltip }) => ( - - + {selectedOptions.map((option) => ( + + @@ -324,13 +175,3 @@ export const Autocomplete: React.FC = ({ ); }; - -const LabelToolip: React.FC<{ - content?: AutocompleteOptionProps["tooltip"]; - children: React.ReactElement; -}> = ({ content, children }) => - content ? ( - {toString(content)}}>{children} - ) : ( - children - ); diff --git a/client/src/app/components/Autocomplete/GroupedAutocomplete.tsx b/client/src/app/components/Autocomplete/GroupedAutocomplete.tsx index 61bd5458aa..fcd621bcdc 100644 --- a/client/src/app/components/Autocomplete/GroupedAutocomplete.tsx +++ b/client/src/app/components/Autocomplete/GroupedAutocomplete.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef, useMemo } from "react"; +import React, { useRef } from "react"; import { Label, LabelProps, @@ -9,14 +9,14 @@ import { MenuItem, MenuList, Popper, - SearchInput, Divider, - Tooltip, MenuGroup, } from "@patternfly/react-core"; - -const toString = (input: string | (() => string)) => - typeof input === "function" ? input() : input; +import { getString } from "@app/utils/utils"; +import { LabelToolip } from "../LabelTooltip"; +import { SearchInputComponent } from "./SearchInput"; +import { useAutocompleteHandlers } from "./useAutocompleteHandlers"; +import { AnyAutocompleteOptionProps, getUniqueId } from "./type-utils"; export interface GroupedAutocompleteOptionProps { /** id for the option - unique id not the ref id */ @@ -35,7 +35,7 @@ export interface GroupedAutocompleteOptionProps { } export interface IGroupedAutocompleteProps { - onChange: (selections: GroupedAutocompleteOptionProps[]) => void; + onChange: (selections: AnyAutocompleteOptionProps[]) => void; id?: string; /** The set of options to use for selection */ @@ -51,10 +51,6 @@ export interface IGroupedAutocompleteProps { noResultsMessage?: string; } -interface GroupedOptions { - [key: string]: GroupedAutocompleteOptionProps[]; -} - /** * Multiple type-ahead with table complete and selection labels */ @@ -67,239 +63,46 @@ export const GroupedAutocomplete: React.FC = ({ searchInputAriaLabel = "Search input", labelColor, selections = [], - menuHeader = "", noResultsMessage = "No results found", }) => { - const [inputValue, setInputValue] = useState(searchString); - const [tabSelectedItemId, setTabSelectedItemId] = useState(); - - const [menuIsOpen, setMenuIsOpen] = useState(false); - - /** refs used to detect when clicks occur inside vs outside of the textInputGroup and menu popper */ const menuRef = useRef(null); const searchInputRef = useRef(null); - - const selectedOptions = useMemo(() => { - if (!selections || selections.length === 0) { - return []; - } - return options.filter((option) => { - return selections.some((selection) => { - return ( - selection.uniqueId === option.uniqueId && - selection.group === option.group - ); - }); - }); - }, [options, selections]); - - const filteredOptions = useMemo(() => { - return options.filter((option) => { - const isOptionSelected = selections.some( - (selection) => selection.uniqueId === option.uniqueId - ); - - const isNameMatch = toString(option.name) - .toLowerCase() - .includes(inputValue.toLowerCase()); - return !isOptionSelected && isNameMatch; - }); - }, [options, selections, inputValue]); - - const groupedOptions = useMemo((): GroupedOptions => { - return filteredOptions.reduce((groups: GroupedOptions, option) => { - const groupName = option.group || undefined; - if (!groupName) { - return groups; - } - const groupOptions = groups[groupName] || []; - groups[groupName] = [...groupOptions, option]; - return groups; - }, {}); - }, [filteredOptions]); - - /** callback for removing a selection */ - const deleteSelectionByItemId = (idToDelete: string) => { - const newSelections = selections.filter((selection) => { - return selection.uniqueId !== idToDelete; - }); - - onChange(newSelections); - }; - - /** lookup the option matching the itemId and add as a selection */ - const addSelectionByItemId = (identifier: string) => { - const matchingOption = options.find( - (option) => option.uniqueId === identifier - ); - - if (matchingOption) { - onChange([...selections, matchingOption].filter(Boolean)); - setInputValue(""); - setMenuIsOpen(false); - } - }; - - /** callback for updating the inputValue state in this component so that the input can be controlled */ - const handleSearchInputOnChange = ( - _event: React.FormEvent, - value: string - ) => { - setInputValue(value); - }; - - /** add the current input value as a selection */ - const handleEnter = () => { - if (tabSelectedItemId) { - addSelectionByItemId(tabSelectedItemId); - setTabSelectedItemId(undefined); - } - }; - - /** close the menu, and if only 1 filtered option exists, select it */ - const handleTab = (event: React.KeyboardEvent) => { - if (filteredOptions.length === 1) { - const option = filteredOptions[0]; - - const identifier = option.uniqueId; - setInputValue(toString(option.name)); - setTabSelectedItemId(identifier); - event.preventDefault(); - } - setMenuIsOpen(false); - }; - - /** close the menu when escape is hit */ - const handleEscape = (event: React.KeyboardEvent) => { - event.stopPropagation(); - - setMenuIsOpen(false); - }; - - /** allow the user to focus on the menu and navigate using the arrow keys */ - const handleArrowKey = () => { - if (menuRef.current) { - const firstElement = menuRef.current.querySelector( - "li > button:not(:disabled)" - ); - firstElement?.focus(); - } - }; - - /** reopen the menu if it's closed and any un-designated keys are hit */ - const handleDefault = () => { - if (!menuIsOpen) { - setMenuIsOpen(true); - } - }; - - /** enable keyboard only usage while focused on the text input */ - const handleSearchInputOnKeyDown = (event: React.KeyboardEvent) => { - switch (event.key) { - case "Enter": - handleEnter(); - break; - case "Escape": - handleEscape(event); - break; - case "Tab": - handleTab(event); - break; - case "ArrowUp": - case "ArrowDown": - handleArrowKey(); - break; - default: - handleDefault(); - } - }; - - /** apply focus to the text input */ - const focusTextInput = (closeMenu = false) => { - searchInputRef.current?.querySelector("input")?.focus(); - closeMenu && setMenuIsOpen(false); - }; - - const handleMenuItemOnSelect = ( - event: React.MouseEvent | undefined, - option: GroupedAutocompleteOptionProps - ) => { - if (!event) return; - event.stopPropagation(); - focusTextInput(true); - - const identifier = option.uniqueId; - addSelectionByItemId(identifier); - }; - - /** close the menu when a click occurs outside of the menu or text input group */ - const handleOnDocumentClick = (event?: MouseEvent) => { - if (!event) { - return; - } - if (searchInputRef.current?.contains(event.target as HTMLElement)) { - setMenuIsOpen(true); - } - if ( - menuRef.current && - !menuRef.current.contains(event.target as HTMLElement) && - searchInputRef.current && - !searchInputRef.current.contains(event.target as HTMLElement) - ) { - setMenuIsOpen(false); - } - }; - - /** enable keyboard only usage while focused on the menu */ - const handleMenuOnKeyDown = (event: React.KeyboardEvent) => { - switch (event.key) { - case "Tab": - case "Escape": - event.preventDefault(); - focusTextInput(); - setMenuIsOpen(false); - break; - } - }; - - const hint = useMemo(() => { - if (filteredOptions.length === 0) { - return ""; - } - - if (filteredOptions.length === 1 && inputValue) { - const fullHint = toString(filteredOptions[0].name); - - if (fullHint.toLowerCase().indexOf(inputValue.toLowerCase())) { - // the match was found in a place other than the start, so typeahead wouldn't work right - return ""; - } else { - // use the input for the first part, otherwise case difference could make things look wrong - return inputValue + fullHint.substring(inputValue.length); - } - } - - return ""; - }, [filteredOptions, inputValue]); + const { + setInputValue, + inputValue, + menuIsOpen, + groupedFilteredOptions, + removeSelectionById, + handleMenuItemOnSelect, + handleMenuOnKeyDown, + handleOnDocumentClick, + handleInputChange, + handleKeyDown, + selectedOptions, + } = useAutocompleteHandlers({ + options, + searchString, + selections, + onChange, + menuRef, + searchInputRef, + }); const inputGroup = ( -
- setInputValue("")} - onFocus={() => setMenuIsOpen(true)} - onKeyDown={handleSearchInputOnKeyDown} - placeholder={placeholderText} - aria-label={searchInputAriaLabel} - /> -
+ setInputValue("")} + onKeyHandling={handleKeyDown} + options={options} + inputValue={inputValue} + inputRef={searchInputRef} + /> ); - const renderMenuItems = () => { - const allGroups = Object.entries(groupedOptions); + const allGroups = Object.entries(groupedFilteredOptions); if (allGroups.length === 0) { return ( @@ -317,11 +120,11 @@ export const GroupedAutocomplete: React.FC = ({ {groupOptions.length > 0 ? ( groupOptions.map((option) => ( handleMenuItemOnSelect(e, option)} > - {toString(option.labelName || option.name)} + {getString(option.labelName || option.name)} )) ) : ( @@ -341,6 +144,7 @@ export const GroupedAutocomplete: React.FC = ({ {renderMenuItems()} ); + return ( @@ -356,34 +160,20 @@ export const GroupedAutocomplete: React.FC = ({ - {selectedOptions.map( - ({ uniqueId, name, group, labelName, tooltip }) => ( - - - - - - ) - )} + {selectedOptions.map((option) => ( + + + + + + ))} ); }; - -const LabelToolip: React.FC<{ - content?: GroupedAutocompleteOptionProps["tooltip"]; - children: React.ReactElement; -}> = ({ content, children }) => - content ? ( - {toString(content)}}>{children} - ) : ( - children - ); diff --git a/client/src/app/components/Autocomplete/SearchInput.tsx b/client/src/app/components/Autocomplete/SearchInput.tsx new file mode 100644 index 0000000000..ab8a867b44 --- /dev/null +++ b/client/src/app/components/Autocomplete/SearchInput.tsx @@ -0,0 +1,62 @@ +import React from "react"; +import { SearchInput } from "@patternfly/react-core"; +import { getString } from "@app/utils/utils"; +import { AnyAutocompleteOptionProps } from "./type-utils"; + +export interface SearchInputProps { + id: string; + placeholderText: string; + searchInputAriaLabel: string; + onSearchChange: (value: string) => void; + onClear: () => void; + onKeyHandling: (event: React.KeyboardEvent) => void; + inputValue: string; + inputRef: React.RefObject; + options: AnyAutocompleteOptionProps[]; +} + +export const SearchInputComponent: React.FC = ({ + id, + placeholderText, + searchInputAriaLabel, + onSearchChange, + onClear, + onKeyHandling, + options, + inputValue, + inputRef, +}) => { + const getHint = (): string => { + if (options.length === 0) { + return ""; + } + + if (options.length === 1 && inputValue) { + const fullHint = getString(options[0].name); + + if (fullHint.toLowerCase().indexOf(inputValue.toLowerCase()) === 0) { + return inputValue + fullHint.substring(inputValue.length); + } + } + + return ""; + }; + + const hint = getHint(); + + return ( +
+ onSearchChange(value)} + onClear={onClear} + onFocus={() => onKeyHandling(event as any)} + onKeyDown={onKeyHandling} + placeholder={placeholderText} + aria-label={searchInputAriaLabel} + /> +
+ ); +}; diff --git a/client/src/app/components/Autocomplete/type-utils.ts b/client/src/app/components/Autocomplete/type-utils.ts new file mode 100644 index 0000000000..847b40f436 --- /dev/null +++ b/client/src/app/components/Autocomplete/type-utils.ts @@ -0,0 +1,30 @@ +interface BaseOptionProps { + name: string | (() => string); + labelName?: string | (() => string); + tooltip?: string | (() => string); +} + +export interface GroupedAutocompleteOptionProps extends BaseOptionProps { + uniqueId: string; + group?: string; +} + +export interface AutocompleteOptionProps extends BaseOptionProps { + id: number; +} + +// Helper type for use in the hook and components +export type AnyAutocompleteOptionProps = + | GroupedAutocompleteOptionProps + | AutocompleteOptionProps; + +// Function to get the unique identifier from either type +export const getUniqueId = ( + option: AnyAutocompleteOptionProps +): string | number => { + return "uniqueId" in option ? option.uniqueId : option.id; +}; + +export interface GroupMap { + [key: string]: AnyAutocompleteOptionProps[]; +} diff --git a/client/src/app/components/Autocomplete/useAutocompleteHandlers.ts b/client/src/app/components/Autocomplete/useAutocompleteHandlers.ts new file mode 100644 index 0000000000..f57517813b --- /dev/null +++ b/client/src/app/components/Autocomplete/useAutocompleteHandlers.ts @@ -0,0 +1,180 @@ +import { useMemo, useState } from "react"; +import { + AnyAutocompleteOptionProps, + GroupMap, + getUniqueId, +} from "./type-utils"; + +interface AutocompleteLogicProps { + options: AnyAutocompleteOptionProps[]; + searchString: string; + selections: AnyAutocompleteOptionProps[]; + onChange: (selections: AnyAutocompleteOptionProps[]) => void; + menuRef: React.RefObject; + searchInputRef: React.RefObject; +} + +export const useAutocompleteHandlers = ({ + options, + searchString, + selections, + onChange, + menuRef, + searchInputRef, +}: AutocompleteLogicProps) => { + const [inputValue, setInputValue] = useState(searchString); + const [menuIsOpen, setMenuIsOpen] = useState(false); + const [tabSelectedItemId, setTabSelectedItemId] = useState< + string | number | null + >(null); + + const groupedFilteredOptions = useMemo(() => { + const groups: GroupMap = {}; + + options.forEach((option) => { + const isOptionSelected = selections.some( + (selection) => getUniqueId(selection) === getUniqueId(option) + ); + + const optionName = + typeof option.name === "function" ? option.name() : option.name; + + if ( + !isOptionSelected && + optionName.toLowerCase().includes(inputValue.toLowerCase()) + ) { + const groupName = "group" in option && option.group ? option.group : ""; + + if (!groups[groupName]) { + groups[groupName] = []; + } + + // Add the option to the appropriate group + groups[groupName].push(option); + } + }); + + return groups; + }, [options, selections, inputValue]); + const allOptions = Object.values(groupedFilteredOptions).flat(); + + const handleInputChange = (value: string) => { + setInputValue(value); + }; + + const addSelectionByItemId = (itemId: string | number) => { + const matchingOption = options.find( + (option) => getUniqueId(option) === itemId + ); + + if (matchingOption) { + const updatedSelections = [...selections, matchingOption].filter(Boolean); + onChange(updatedSelections); + setInputValue(""); + setMenuIsOpen(false); + } + }; + + const removeSelectionById = (idToDelete: string | number) => { + const updatedSelections = selections.filter( + (selection) => getUniqueId(selection) !== idToDelete + ); + + onChange(updatedSelections); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + switch (event.key) { + case "enter": + if (tabSelectedItemId) { + addSelectionByItemId(tabSelectedItemId); + setTabSelectedItemId(null); + } + break; + case "Escape": + event.stopPropagation(); + setMenuIsOpen(false); + break; + case "Tab": + break; + + case "ArrowUp": + case "ArrowDown": + if (menuRef.current) { + const firstElement = menuRef.current.querySelector( + "li > button:not(:disabled)" + ); + firstElement?.focus(); + } + break; + default: + if (!menuIsOpen) setMenuIsOpen(true); + break; + } + }; + + // Click handling outside of component to close menu + const handleOnDocumentClick = (event?: MouseEvent) => { + if (!event) { + return; + } + if (searchInputRef.current?.contains(event.target as HTMLElement)) { + setMenuIsOpen(true); + } + if ( + menuRef.current && + !menuRef.current.contains(event.target as HTMLElement) && + searchInputRef.current && + !searchInputRef.current.contains(event.target as HTMLElement) + ) { + setMenuIsOpen(false); + } + }; + + // Menu-specific key handling + const handleMenuOnKeyDown = (event: React.KeyboardEvent) => { + if (["Tab", "Escape"].includes(event.key)) { + event.preventDefault(); + searchInputRef.current?.querySelector("input")?.focus(); + setMenuIsOpen(false); + } + }; + + // Selecting an item from the menu + const handleMenuItemOnSelect = ( + event: React.MouseEvent | undefined, + option: AnyAutocompleteOptionProps + ) => { + if (!event) return; + event.stopPropagation(); + searchInputRef.current?.querySelector("input")?.focus(); + addSelectionByItemId(getUniqueId(option)); + }; + + const selectedOptions = useMemo(() => { + if (!selections || selections.length === 0) { + return []; + } + return options.filter((option) => { + return selections.some((selection) => { + return getUniqueId(selection) === getUniqueId(option); + }); + }); + }, [options, selections]); + + return { + setInputValue, + inputValue, + menuIsOpen, + groupedFilteredOptions, + handleInputChange, + handleKeyDown, + handleMenuItemOnSelect, + handleOnDocumentClick, + handleMenuOnKeyDown, + menuRef, + searchInputRef, + removeSelectionById, + selectedOptions, + }; +}; diff --git a/client/src/app/components/LabelTooltip.tsx b/client/src/app/components/LabelTooltip.tsx new file mode 100644 index 0000000000..1436d95dee --- /dev/null +++ b/client/src/app/components/LabelTooltip.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import { Tooltip } from "@patternfly/react-core"; +import { getString } from "@app/utils/utils"; +import { AutocompleteOptionProps } from "./Autocomplete/Autocomplete"; + +export const LabelToolip: React.FC<{ + content?: AutocompleteOptionProps["tooltip"]; + children: React.ReactElement; +}> = ({ content, children }) => + content ? ( + {getString(content)}}>{children} + ) : ( + children + ); diff --git a/client/src/app/utils/utils.ts b/client/src/app/utils/utils.ts index bbdc611fa5..a9c91d18a1 100644 --- a/client/src/app/utils/utils.ts +++ b/client/src/app/utils/utils.ts @@ -194,3 +194,6 @@ export const localeNumericCompare = ( b: string, locale: string ): number => a.localeCompare(b, locale, { numeric: true }); + +export const getString = (input: string | (() => string)) => + typeof input === "function" ? input() : input;