diff --git a/client/src/app/components/Autocomplete.tsx b/client/src/app/components/Autocomplete.tsx new file mode 100644 index 000000000..ea4b5ed52 --- /dev/null +++ b/client/src/app/components/Autocomplete.tsx @@ -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 = ({ + 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>( + new Set(selections) + ); + const [hint, setHint] = useState(""); + const [menuItems, setMenuItems] = useState([]); + + /** refs used to detect when clicks occur inside vs outside of the textInputGroup and menu popper */ + const menuRef = useRef(null); + const searchInputRef = useRef(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) => ( + + {currentValue} + + )); + + /** in the menu show a disabled "no result" when all menu items are filtered out */ + if (filteredMenuItems.length === 0) { + const noResultItem = ( + + {noResultsMessage} + + ); + 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 = ( + + {menuHeader} + + ); + + const 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, + 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( + "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) => { + 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) + ) { + 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 = ( +
+ setInputValue("")} + onFocus={() => setMenuIsOpen(true)} + onKeyDown={handleTextInputKeyDown} + placeholder={placeholderText} + aria-label={searchInputAriaLabel} + /> +
+ ); + + const menu = ( + + + {menuItems} + + + ); + + return ( + + + searchInputRef.current || document.body} + isVisible={menuIsOpen} + onDocumentClick={handleClick} + /> + + + + {Array.from(currentChips).map((currentChip) => ( + + + + ))} + + + + ); +}; diff --git a/client/src/app/pages/applications/components/application-form/application-form.tsx b/client/src/app/pages/applications/components/application-form/application-form.tsx index cbbc76221..2b50e3da9 100644 --- a/client/src/app/pages/applications/components/application-form/application-form.tsx +++ b/client/src/app/pages/applications/components/application-form/application-form.tsx @@ -39,6 +39,7 @@ import { import { QuestionCircleIcon } from "@patternfly/react-icons"; import { useFetchStakeholders } from "@app/queries/stakeholders"; import { NotificationsContext } from "@app/components/NotificationsContext"; +import { Autocomplete } from "@app/components/Autocomplete"; export interface FormValues { name: string; @@ -445,50 +446,75 @@ export const ApplicationForm: React.FC = ({ label={t("terms.tags")} fieldId="tags" renderInput={({ field: { value, name, onChange } }) => ( - { + onChange( + selections + .map((sel) => getTagRef(sel)) + .filter((sel) => sel !== undefined) as TagRef[] + ); + }} + options={tagOptions.map((o) => o.value)} placeholderText={t("composed.selectMany", { what: t("terms.tags").toLowerCase(), })} - id="tags-select" - variant="typeaheadmulti" - toggleId="tags-select-toggle" - toggleAriaLabel="tags dropdown toggle" - aria-label={name} - value={value - .map((formTag) => - tags?.find((tagRef) => tagRef.name === formTag.name) - ) - .map((matchingTag) => - matchingTag - ? { - value: matchingTag.name, - toString: () => matchingTag.name, - } - : undefined - ) - .filter((e) => e !== undefined)} - options={tagOptions} - onChange={(selection) => { - const selectionWithValue = selection.toString(); - - const currentValue = value || []; - const e = currentValue.find( - (f) => f.name === selectionWithValue - ); - if (e) { - onChange( - currentValue.filter((f) => f.name !== selectionWithValue) - ); - } else { - const tag = getTagRef(selectionWithValue); - if (currentValue && typeof tag !== "undefined") - onChange([...currentValue, tag]); - } - }} - onClear={() => onChange([])} - noResultsFoundText={t("message.noResultsFoundTitle")} + searchInputAriaLabel="tags-select-toggle" + selections={ + value + .map((formTag) => + tags?.find((tagRef) => tagRef.name === formTag.name) + ) + .map((matchingTag) => + matchingTag ? matchingTag.name : undefined + ) + .filter((e) => e !== undefined) as string[] + } /> + // + // tags?.find((tagRef) => tagRef.name === formTag.name) + // ) + // .map((matchingTag) => + // matchingTag + // ? { + // value: matchingTag.name, + // toString: () => matchingTag.name, + // } + // : undefined + // ) + // .filter((e) => e !== undefined)} + // options={tagOptions} + // onChange={(selection) => { + // const selectionWithValue = selection.toString(); + + // const currentValue = value || []; + // const e = currentValue.find( + // (f) => f.name === selectionWithValue + // ); + // if (e) { + // onChange( + // currentValue.filter((f) => f.name !== selectionWithValue) + // ); + // } else { + // const tag = getTagRef(selectionWithValue); + // if (currentValue && typeof tag !== "undefined") + // onChange([...currentValue, tag]); + // } + // }} + // onClear={() => onChange([])} + // noResultsFoundText={t("message.noResultsFoundTitle")} + // /> )} />