-
Notifications
You must be signed in to change notification settings - Fork 39
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
Changes from all commits
d4ea81c
18ed839
b4af622
b7632ad
1c738b1
3243720
f553621
9a601ea
69e40bf
d85bbd3
f803d7e
f39f1ce
5a07da7
2191827
29054d2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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(() => { | ||
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())) | ||
) | ||
.map((currentValue, index) => ( | ||
<MenuItem key={currentValue} itemId={index}> | ||
{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; | ||
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 { | ||
// use the input for the first part, otherwise case difference could make things look wrong | ||
setHint(inputValue + hint.substr(inputValue.length)); | ||
} | ||
} else { | ||
setHint(""); | ||
} | ||
|
||
/** add a heading to the menu */ | ||
const headingItem = ( | ||
<MenuItem isDisabled key="heading"> | ||
{menuHeader} | ||
</MenuItem> | ||
); | ||
|
||
const divider = <Divider key="divider" />; | ||
|
||
if (menuHeader) { | ||
setMenuItems([headingItem, divider, ...filteredMenuItems]); | ||
} else { | ||
setMenuItems(filteredMenuItems); | ||
} | ||
}; | ||
|
||
/** 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(); | ||
}; | ||
|
||
/** callback for removing a chip from the chip selections */ | ||
const deleteChip = (chipToDelete: string) => { | ||
const newChips = new Set(currentChips); | ||
newChips.delete(chipToDelete); | ||
setCurrentChips(newChips); | ||
}; | ||
|
||
/** 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( | ||
(o) => o.toLowerCase() === (hint || newChipText).toLowerCase() | ||
); | ||
if (!matchingOption) { | ||
return; | ||
} | ||
newChipText = matchingOption; | ||
} | ||
setCurrentChips(new Set([...currentChips, newChipText])); | ||
setInputValue(""); | ||
setMenuIsOpen(false); | ||
}; | ||
|
||
/** add the current input value as a chip */ | ||
const handleEnter = () => { | ||
if (inputValue.length) { | ||
addChip(inputValue); | ||
} | ||
}; | ||
|
||
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(); | ||
} | ||
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)" | ||
); | ||
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 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(); | ||
} | ||
}; | ||
|
||
/** apply focus to the text input */ | ||
const focusTextInput = (closeMenu = false) => { | ||
searchInputRef.current?.querySelector("input")?.focus(); | ||
closeMenu && setMenuIsOpen(false); | ||
}; | ||
|
||
/** add the text of the selected item as a new chip */ | ||
const onSelect = (event?: React.MouseEvent<Element, MouseEvent>) => { | ||
if (!event) { | ||
return; | ||
} | ||
const selectedText = (event.target as HTMLElement).innerText; | ||
addChip(selectedText); | ||
event.stopPropagation(); | ||
focusTextInput(true); | ||
}; | ||
|
||
/** close the menu when a click occurs outside of the menu or text input group */ | ||
const handleClick = (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) | ||
Comment on lines
+237
to
+240
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ? |
||
) { | ||
setMenuIsOpen(false); | ||
} | ||
}; | ||
|
||
/** 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; | ||
} | ||
}; | ||
|
||
const inputGroup = ( | ||
<div ref={searchInputRef}> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)} | ||
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)}> | ||
{currentChip} | ||
</Label> | ||
</FlexItem> | ||
))} | ||
</Flex> | ||
</FlexItem> | ||
</Flex> | ||
); | ||
}; |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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!