diff --git a/src/components/shared/DropDown.tsx b/src/components/shared/DropDown.tsx index 03dd4a8078..1f83acd0d6 100644 --- a/src/components/shared/DropDown.tsx +++ b/src/components/shared/DropDown.tsx @@ -1,10 +1,17 @@ -import React, { useEffect } from "react"; +import React, { useCallback, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { dropDownSpacingTheme, dropDownStyle, } from "../../utils/componentStyles"; -import { GroupBase, MenuListProps, SelectInstance } from "react-select"; +import { + components as SelectComponents, + GroupBase, + MenuListProps, + OptionProps, + SelectInstance, + ValueContainerProps, +} from "react-select"; import { ParseKeys } from "i18next"; import { List, RowComponentProps } from "react-window"; import AsyncSelect, { AsyncProps } from "react-select/async"; @@ -16,11 +23,25 @@ export type DropDownOption = { order?: number } +// How long to wait after the user stops typing before firing a fetchOptions() search request. +const SEARCH_DEBOUNCE_MS = 300; + +function MenuListRow({ + index, + names, + style, +}: RowComponentProps<{ + names: string[]; +}>) { + const name = names[index]; + return
{name}
; +} + /** * This component renders a dropdown menu using react-select */ const DropDown = ({ - ref = React.createRef, boolean, GroupBase>>>(), + ref, value, text, options, @@ -69,7 +90,8 @@ const DropDown = ({ }) => { const { t } = useTranslation(); - const selectRef = ref; + const internalRef = useRef, boolean, GroupBase>> | null>(null); + const selectRef = ref ?? internalRef; const style = dropDownStyle(customCSS ?? {}); @@ -91,10 +113,11 @@ const DropDown = ({ required: boolean, ) => { // Translate - // Translating is expensive, skip it if it is not required - if (!skipTranslate) { - unformattedOptions = unformattedOptions.map(option => ({ ...option, label: t(option.label as ParseKeys) })); - } + // Translating is expensive, skip it if it is not required. + // Either way, copy the array so the input is not transmuted later. + unformattedOptions = skipTranslate + ? [...unformattedOptions] + : unformattedOptions.map(option => ({ ...option, label: t(option.label as ParseKeys) })); // Add "No value" option if (!required) { @@ -126,11 +149,33 @@ const DropDown = ({ return unformattedOptions; }; + /** + * Wrapper that adds the title attribute to options, which should result + * in a native tooltip that is intended to help with very long labels. + */ + const OptionWithTitle = useCallback(( + props: OptionProps, boolean, GroupBase>>, + ) => ( + + ), []); + + /** + * Same wrapper as above, but for the input field. + */ + const ValueContainerWithTitle = useCallback(( + props: ValueContainerProps, boolean, GroupBase>>, + ) => ( + + ), []); + const itemHeight = optionHeight; /** * Custom component for list virtualization */ - const MenuList = (props: MenuListProps, false>) => { + const MenuList = useCallback((props: MenuListProps, false>) => { const { children, maxHeight } = props; return Array.isArray(children) ? ( @@ -149,18 +194,7 @@ const DropDown = ({ /> ) : null; - }; - - function MenuListRow({ - index, - names, - style, - }: RowComponentProps<{ - names: string[]; - }>) { - const name = names[index]; - return
{name}
; - } + }, [itemHeight]); const filterOptions = (inputValue: string) => { if (options) { @@ -171,14 +205,23 @@ const DropDown = ({ return []; }; + const debounceTimeoutRef = useRef>(undefined); + + useEffect(() => { + return () => clearTimeout(debounceTimeoutRef.current); + }, []); + const loadOptionsAsync = (inputValue: string, callback: (options: DropDownOption[]) => void) => { - const timeout = async () => { - callback(formatOptions( - fetchOptions ? await fetchOptions(inputValue) : filterOptions(inputValue), - required, - )); - }; - setTimeout(() => { timeout(); }, 1000); + clearTimeout(debounceTimeoutRef.current); + debounceTimeoutRef.current = setTimeout(() => { + const timeout = async () => { + callback(formatOptions( + fetchOptions ? await fetchOptions(inputValue) : filterOptions(inputValue), + required, + )); + }; + void timeout(); + }, SEARCH_DEBOUNCE_MS); }; const loadOptions = ( @@ -216,8 +259,12 @@ const DropDown = ({ onMenuClose: () => openMenu(false), isDisabled: disabled, openMenuOnFocus: openMenuOnFocus, - menuPlacement: menuPlacement ?? "auto", - components: { MenuList }, + menuPlacement: menuPlacement, + components: { + MenuList, + Option: OptionWithTitle, + ValueContainer: ValueContainerWithTitle, + }, }; return creatable ? ( @@ -229,7 +276,6 @@ const DropDown = ({ t("SELECT_NO_MATCHING_RESULTS")} /> ); diff --git a/src/components/shared/wizard/RenderField.tsx b/src/components/shared/wizard/RenderField.tsx index d2c72ea52f..eb6800cdae 100644 --- a/src/components/shared/wizard/RenderField.tsx +++ b/src/components/shared/wizard/RenderField.tsx @@ -422,7 +422,9 @@ const EditableSingleSelectDropDown = ({ } customCSS={{ isMetadataStyle: focused ? false : true, width: "100%" }} handleMenuIsOpen={(open: boolean) => setFocused(open)} - openMenuOnFocus + // Deliberately false: with the menu open, Tab breaks keyboard + // navigation through the metadata form fields (see f44c9b7). + openMenuOnFocus={false} autoFocus={isFirstField} skipTranslate={!metadataField.translatable} />