Skip to content

Commit

Permalink
Update autocomplete to use a shared hook
Browse files Browse the repository at this point in the history
Signed-off-by: Ian Bolton <[email protected]>
  • Loading branch information
ibolton336 committed Apr 25, 2024
1 parent 8585dc1 commit d815290
Show file tree
Hide file tree
Showing 7 changed files with 424 additions and 504 deletions.
319 changes: 80 additions & 239 deletions client/src/app/components/Autocomplete/Autocomplete.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState, useRef, useMemo } from "react";
import React, { useRef } from "react";
import {
Label,
LabelProps,
Expand All @@ -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 */
Expand All @@ -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 */
Expand Down Expand Up @@ -62,233 +63,83 @@ export const Autocomplete: React.FC<IAutocompleteProps> = ({
menuHeader = "",
noResultsMessage = "No results found",
}) => {
const [inputValue, setInputValue] = useState(searchString);
const [tabSelectedItemId, setTabSelectedItemId] = useState<number>();
const [menuIsOpen, setMenuIsOpen] = useState(false);

/** refs used to detect when clicks occur inside vs outside of the textInputGroup and menu popper */
const menuRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>,
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<HTMLButtonElement>(
"li > button:not(:disabled)"
const inputGroup = (
<SearchInputComponent
id={id}
placeholderText={placeholderText}
searchInputAriaLabel={searchInputAriaLabel}
onSearchChange={handleInputChange}
onClear={() => setInputValue("")}
onKeyHandling={handleKeyDown}
options={options}
inputValue={inputValue}
inputRef={searchInputRef}
/>
);
const renderMenuItems = () => {
const allGroups = Object.entries(groupedFilteredOptions);
if (allGroups.length === 0) {
return (
<MenuList>
<MenuItem isDisabled key="no-options">
{noResultsMessage || "No options available"}
</MenuItem>
</MenuList>
);
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<Element, 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) => (
<React.Fragment key={groupName || `ungrouped-${index}`}>
<MenuGroup label={groupName || undefined}>
<MenuList>
{groupOptions.length > 0 ? (
groupOptions.map((option) => (
<MenuItem
key={getUniqueId(option)}
itemId={getUniqueId(option)}
onClick={(e) => handleMenuItemOnSelect(e, option)}
>
{getString(option.labelName || option.name)}
</MenuItem>
))
) : (
<MenuItem isDisabled key="no result" itemId="-1">
{noResultsMessage}
</MenuItem>
)}
</MenuList>
</MenuGroup>
{index < allGroups.length - 1 && <Divider />}
</React.Fragment>
));
};

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 = (
<div ref={searchInputRef}>
<SearchInput
id={id}
value={inputValue}
hint={hint}
onChange={handleSearchInputOnChange}
onClear={() => setInputValue("")}
onFocus={() => setMenuIsOpen(true)}
onKeyDown={handleSearchInputOnKeyDown}
placeholder={placeholderText}
aria-label={searchInputAriaLabel}
/>
</div>
);

const menu = (
<Menu ref={menuRef} onKeyDown={handleMenuOnKeyDown} isScrollable>
<MenuContent>
<MenuList>
{/* if supplied, add the menu heading */}
{menuHeader ? (
<>
<MenuItem isDisabled key="heading" itemId="-2">
{menuHeader}
</MenuItem>
<Divider key="divider" />
</>
) : undefined}

{/* show a disabled "no result" when all menu items are filtered out */}
{filteredOptions.length === 0 ? (
<MenuItem isDisabled key="no result" itemId="-1">
{noResultsMessage}
</MenuItem>
) : undefined}

{/* only show items that include the text in the input */}
{filteredOptions.map(({ id, name }, _index) => (
<MenuItem
key={id}
itemId={id}
onClick={(e) => handleMenuItemOnSelect(e, id)}
>
{toString(name)}
</MenuItem>
))}
</MenuList>
</MenuContent>
<MenuContent>{renderMenuItems()}</MenuContent>
</Menu>
);

Expand All @@ -307,14 +158,14 @@ export const Autocomplete: React.FC<IAutocompleteProps> = ({
</FlexItem>
<FlexItem key="chips">
<Flex spaceItems={{ default: "spaceItemsXs" }}>
{selectedOptions.map(({ id, name, labelName, tooltip }) => (
<FlexItem key={id}>
<LabelToolip content={tooltip}>
{selectedOptions.map((option) => (
<FlexItem key={getUniqueId(option)}>
<LabelToolip content={option.tooltip}>
<Label
color={labelColor}
onClose={() => deleteSelectionByItemId(id)}
onClose={() => removeSelectionById(getUniqueId(option))}
>
{toString(labelName || name)}
{getString(option.labelName || option.name)}
</Label>
</LabelToolip>
</FlexItem>
Expand All @@ -324,13 +175,3 @@ export const Autocomplete: React.FC<IAutocompleteProps> = ({
</Flex>
);
};

const LabelToolip: React.FC<{
content?: AutocompleteOptionProps["tooltip"];
children: React.ReactElement;
}> = ({ content, children }) =>
content ? (
<Tooltip content={<div>{toString(content)}</div>}>{children}</Tooltip>
) : (
children
);
Loading

0 comments on commit d815290

Please sign in to comment.