Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ add autocomplete component #1295

Merged
merged 15 commits into from
Aug 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
312 changes: 312 additions & 0 deletions client/src/app/components/Autocomplete.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,312 @@
import React, { useState, useRef } from "react";
import {
Label,
LabelProps,
Flex,
FlexItem,
Menu,
MenuContent,
MenuItem,
MenuList,
Popper,
SearchInput,
Divider,
} from "@patternfly/react-core";

export interface IAutocompleteProps {
onChange: (selections: string[]) => void;
allowUserOptions?: boolean;
options?: string[];
placeholderText?: string;
searchString?: string;
searchInputAriaLabel?: string;
labelColor?: LabelProps["color"];
selections?: string[];
menuHeader?: string;
noResultsMessage?: string;
}

export const Autocomplete: React.FC<IAutocompleteProps> = ({
onChange,
options = [],
allowUserOptions = false,
placeholderText = "Search",
searchString = "",
searchInputAriaLabel = "Search input",
labelColor,
selections = [],
menuHeader = "",
noResultsMessage = "No results found",
}) => {
const [inputValue, setInputValue] = useState(searchString);
const [menuIsOpen, setMenuIsOpen] = useState(false);
const [currentChips, setCurrentChips] = useState<Set<string>>(
new Set(selections)
);
const [hint, setHint] = useState("");
const [menuItems, setMenuItems] = useState<React.ReactElement[]>([]);

/** 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);

React.useEffect(() => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please explain why is this useEffect hook needed?
useEffect is meant to be used with side effects, this looks like the things running here can be inserted into the handleInputChange method, as it changes on every change of inputValue as it seem on the dependency array at line 116

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

side effect of both a change in current chips or a change in the search now

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you want to update a component’s state when some props or state change, You shouldn’t need an Effect. Removing unnecessary Effects will make your code easier to follow, faster to run, and less error-prone.
https://react.dev/learn/you-might-not-need-an-effect

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gitdallas Please refer to this comment in your follow-up PR, Thanks!

onChange([...currentChips]);
buildMenu();
}, [currentChips]);

React.useEffect(() => {
buildMenu();
}, [options]);

const buildMenu = () => {
/** in the menu only show items that include the text in the input */
const filteredMenuItems = options
.filter(
(item: string, index: number, arr: string[]) =>
arr.indexOf(item) === index &&
!currentChips.has(item) &&
(!inputValue || item.toLowerCase().includes(inputValue.toLowerCase()))

Check warning on line 69 in client/src/app/components/Autocomplete.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/Autocomplete.tsx#L68-L69

Added lines #L68 - L69 were not covered by tests
)
.map((currentValue, index) => (
<MenuItem key={currentValue} itemId={index}>

Check warning on line 72 in client/src/app/components/Autocomplete.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/Autocomplete.tsx#L72

Added line #L72 was not covered by tests
{currentValue}
</MenuItem>
));

/** in the menu show a disabled "no result" when all menu items are filtered out */
if (filteredMenuItems.length === 0) {
const noResultItem = (
<MenuItem isDisabled key="no result">
{noResultsMessage}
</MenuItem>
);
setMenuItems([noResultItem]);
setHint("");
return;
}

/** The hint is set whenever there is only one autocomplete option left. */
if (filteredMenuItems.length === 1 && inputValue.length) {
const hint = filteredMenuItems[0].props.children;

Check warning on line 91 in client/src/app/components/Autocomplete.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/Autocomplete.tsx#L91

Added line #L91 was not covered by tests
if (hint.toLowerCase().indexOf(inputValue.toLowerCase())) {
// the match was found in a place other than the start, so typeahead wouldn't work right
setHint("");
} else {

Check warning on line 95 in client/src/app/components/Autocomplete.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/Autocomplete.tsx#L94-L95

Added lines #L94 - L95 were not covered by tests
// use the input for the first part, otherwise case difference could make things look wrong
setHint(inputValue + hint.substr(inputValue.length));

Check warning on line 97 in client/src/app/components/Autocomplete.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/Autocomplete.tsx#L97

Added line #L97 was not covered by tests
}
} else {
setHint("");

Check warning on line 100 in client/src/app/components/Autocomplete.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/Autocomplete.tsx#L99-L100

Added lines #L99 - L100 were not covered by tests
}

/** add a heading to the menu */
const headingItem = (
<MenuItem isDisabled key="heading">

Check warning on line 105 in client/src/app/components/Autocomplete.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/Autocomplete.tsx#L105

Added line #L105 was not covered by tests
{menuHeader}
</MenuItem>
);

const divider = <Divider key="divider" />;

Check warning on line 110 in client/src/app/components/Autocomplete.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/Autocomplete.tsx#L110

Added line #L110 was not covered by tests

if (menuHeader) {
setMenuItems([headingItem, divider, ...filteredMenuItems]);
} else {
setMenuItems(filteredMenuItems);

Check warning on line 115 in client/src/app/components/Autocomplete.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/Autocomplete.tsx#L113-L115

Added lines #L113 - L115 were not covered by tests
}
};

/** callback for updating the inputValue state in this component so that the input can be controlled */
const handleInputChange = (
_event: React.FormEvent<HTMLInputElement>,
value: string
) => {
setInputValue(value);
buildMenu();

Check warning on line 125 in client/src/app/components/Autocomplete.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/Autocomplete.tsx#L123-L125

Added lines #L123 - L125 were not covered by tests
};

/** callback for removing a chip from the chip selections */
const deleteChip = (chipToDelete: string) => {
const newChips = new Set(currentChips);
newChips.delete(chipToDelete);
setCurrentChips(newChips);

Check warning on line 132 in client/src/app/components/Autocomplete.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/Autocomplete.tsx#L130-L132

Added lines #L130 - L132 were not covered by tests
};

/** add the given string as a chip in the chip group and clear the input */
const addChip = (newChipText: string) => {
if (!allowUserOptions) {
const matchingOption = options.find(

Check warning on line 138 in client/src/app/components/Autocomplete.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/Autocomplete.tsx#L138

Added line #L138 was not covered by tests
(o) => o.toLowerCase() === (hint || newChipText).toLowerCase()
);
if (!matchingOption) {
return;

Check warning on line 142 in client/src/app/components/Autocomplete.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/Autocomplete.tsx#L142

Added line #L142 was not covered by tests
}
newChipText = matchingOption;

Check warning on line 144 in client/src/app/components/Autocomplete.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/Autocomplete.tsx#L144

Added line #L144 was not covered by tests
}
setCurrentChips(new Set([...currentChips, newChipText]));
setInputValue("");
setMenuIsOpen(false);

Check warning on line 148 in client/src/app/components/Autocomplete.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/Autocomplete.tsx#L146-L148

Added lines #L146 - L148 were not covered by tests
};

/** add the current input value as a chip */
const handleEnter = () => {
if (inputValue.length) {
addChip(inputValue);

Check warning on line 154 in client/src/app/components/Autocomplete.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/Autocomplete.tsx#L154

Added line #L154 was not covered by tests
}
};

const handleTab = (event: React.KeyboardEvent) => {
const firstItemIndex = menuHeader ? 2 : 0;
// if only 1 item (possibly including menu heading and divider)
if (menuItems.length === 1 + firstItemIndex) {
setInputValue(menuItems[firstItemIndex].props.children);
event.preventDefault();

Check warning on line 163 in client/src/app/components/Autocomplete.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/Autocomplete.tsx#L162-L163

Added lines #L162 - L163 were not covered by tests
}
setMenuIsOpen(false);

Check warning on line 165 in client/src/app/components/Autocomplete.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/Autocomplete.tsx#L165

Added line #L165 was not covered by tests
};

/** close the menu when escape is hit */
const handleEscape = () => {
setMenuIsOpen(false);

Check warning on line 170 in client/src/app/components/Autocomplete.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/Autocomplete.tsx#L170

Added line #L170 was not covered by tests
};

/** 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>(

Check warning on line 176 in client/src/app/components/Autocomplete.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/Autocomplete.tsx#L176

Added line #L176 was not covered by tests
"li > button:not(:disabled)"
);
firstElement?.focus();

Check warning on line 179 in client/src/app/components/Autocomplete.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/Autocomplete.tsx#L179

Added line #L179 was not covered by tests
}
};

/** reopen the menu if it's closed and any un-designated keys are hit */
const handleDefault = () => {
if (!menuIsOpen) {
setMenuIsOpen(true);

Check warning on line 186 in client/src/app/components/Autocomplete.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/Autocomplete.tsx#L186

Added line #L186 was not covered by tests
}
};

/** enable keyboard only usage while focused on the text input */
const handleTextInputKeyDown = (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();

Check warning on line 207 in client/src/app/components/Autocomplete.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/Autocomplete.tsx#L193-L207

Added lines #L193 - L207 were not covered by tests
}
};

/** apply focus to the text input */
const focusTextInput = (closeMenu = false) => {
searchInputRef.current?.querySelector("input")?.focus();

Check warning on line 213 in client/src/app/components/Autocomplete.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/Autocomplete.tsx#L213

Added line #L213 was not covered by tests
closeMenu && setMenuIsOpen(false);
};

/** add the text of the selected item as a new chip */
const onSelect = (event?: React.MouseEvent<Element, MouseEvent>) => {
if (!event) {
return;

Check warning on line 220 in client/src/app/components/Autocomplete.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/Autocomplete.tsx#L220

Added line #L220 was not covered by tests
}
const selectedText = (event.target as HTMLElement).innerText;
addChip(selectedText);
event.stopPropagation();
focusTextInput(true);

Check warning on line 225 in client/src/app/components/Autocomplete.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/Autocomplete.tsx#L222-L225

Added lines #L222 - L225 were not covered by tests
};

/** close the menu when a click occurs outside of the menu or text input group */
const handleClick = (event?: MouseEvent) => {
if (!event) {
return;

Check warning on line 231 in client/src/app/components/Autocomplete.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/Autocomplete.tsx#L231

Added line #L231 was not covered by tests
}
if (searchInputRef.current?.contains(event.target as HTMLElement)) {
setMenuIsOpen(true);

Check warning on line 234 in client/src/app/components/Autocomplete.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/Autocomplete.tsx#L234

Added line #L234 was not covered by tests
}
if (
menuRef.current &&
!menuRef.current.contains(event.target as HTMLElement) &&
searchInputRef.current &&
!searchInputRef.current.contains(event.target as HTMLElement)

Check warning on line 240 in client/src/app/components/Autocomplete.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/Autocomplete.tsx#L238-L240

Added lines #L238 - L240 were not covered by tests
Comment on lines +237 to +240
Copy link
Collaborator

@avivtur avivtur Aug 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here too, you can use the optional chaining operator to shortend this condition:
!menuRef?.current.contains(event.target as HTMLElement) &&
!searchInputRef?.current.contains(event.target as HTMLElement)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

?

) {
setMenuIsOpen(false);

Check warning on line 242 in client/src/app/components/Autocomplete.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/Autocomplete.tsx#L242

Added line #L242 was not covered by tests
}
};

/** enable keyboard only usage while focused on the menu */
const handleMenuKeyDown = (event: React.KeyboardEvent) => {
switch (event.key) {
case "Tab":
case "Escape":
event.preventDefault();
focusTextInput();
setMenuIsOpen(false);
break;

Check warning on line 254 in client/src/app/components/Autocomplete.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/Autocomplete.tsx#L249-L254

Added lines #L249 - L254 were not covered by tests
}
};

const inputGroup = (
<div ref={searchInputRef}>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can assign the ref directly to the SearchInput component and drop the div as it's not needed.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for whatever reason, if i do this here it will not open the menu

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder why that happens, I'll try to checkout this PR locally and see if I can figure out what's happening to the ref here

<SearchInput
value={inputValue}
hint={hint}
onChange={handleInputChange}
onClear={() => setInputValue("")}
onFocus={() => setMenuIsOpen(true)}

Check warning on line 265 in client/src/app/components/Autocomplete.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/Autocomplete.tsx#L264-L265

Added lines #L264 - L265 were not covered by tests
onKeyDown={handleTextInputKeyDown}
placeholder={placeholderText}
aria-label={searchInputAriaLabel}
/>
</div>
);

const menu = (
<Menu
ref={menuRef}
onSelect={onSelect}
onKeyDown={handleMenuKeyDown}
isScrollable
>
<MenuContent>
<MenuList>{menuItems}</MenuList>
</MenuContent>
</Menu>
);

return (
<Flex direction={{ default: "column" }}>
<FlexItem>
<Popper
trigger={inputGroup}
triggerRef={searchInputRef}
popper={menu}
popperRef={menuRef}
appendTo={() => searchInputRef.current || document.body}
isVisible={menuIsOpen}
onDocumentClick={handleClick}
/>
</FlexItem>
<FlexItem>
<Flex spaceItems={{ default: "spaceItemsXs" }}>
{Array.from(currentChips).map((currentChip) => (
<FlexItem key={currentChip}>
<Label color={labelColor} onClose={() => deleteChip(currentChip)}>

Check warning on line 303 in client/src/app/components/Autocomplete.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/app/components/Autocomplete.tsx#L302-L303

Added lines #L302 - L303 were not covered by tests
{currentChip}
</Label>
</FlexItem>
))}
</Flex>
</FlexItem>
</Flex>
);
};
Loading
Loading