diff --git a/src/components/shared/DropDown.tsx b/src/components/shared/DropDown.tsx index 66971b59ac..4de80ad25e 100644 --- a/src/components/shared/DropDown.tsx +++ b/src/components/shared/DropDown.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +import React, { useEffect, useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { dropDownSpacingTheme, @@ -10,6 +10,7 @@ import { ParseKeys } from "i18next"; import { FixedSizeList, ListChildComponentProps } from "react-window"; import AsyncSelect from "react-select/async"; import AsyncCreatableSelect from "react-select/async-creatable"; +import Select from "react-select"; export type DropDownOption = { label: string, @@ -74,25 +75,14 @@ const DropDown = ({ const style = dropDownStyle(customCSS ?? {}); - useEffect(() => { - // Ensure menu has focus when opened programmatically - if (menuIsOpen) { - selectRef.current?.focus(); - } - }, [menuIsOpen, selectRef]); - - const openMenu = (open: boolean) => { - if (handleMenuIsOpen !== undefined) { - handleMenuIsOpen(open); - } - }; - - const formatOptions = ( + // ────────────────────────────────────────────────────────────── + // Stable helpers (required by exhaustive-deps) + // ────────────────────────────────────────────────────────────── + const formatOptions = useCallback(( unformattedOptions: DropDownOption[], required: boolean, ) => { - // Translate - // Translating is expensive, skip it if it is not required + // Translate (expensive, skip if not required) if (!skipTranslate) { unformattedOptions = unformattedOptions.map(option => ({ ...option, label: t(option.label as ParseKeys) })); } @@ -131,13 +121,46 @@ const DropDown = ({ } return unformattedOptions; - }; + }, [t, skipTranslate]); + + const filterOptions = useCallback((inputValue: string) => { + if (options) { + return options.filter(option => + option.label.toLowerCase().includes(inputValue.toLowerCase()), + ); + } + return []; + }, [options]); + + const formattedOptions = useMemo(() => { + return options ? formatOptions(options, required) : []; + }, [options, required, formatOptions]); + + const loadOptions = useCallback(( + _inputValue: string, + callback: (options: DropDownOption[]) => void, + ) => { + callback(formatOptions(filterOptions(_inputValue), required)); + }, [formatOptions, filterOptions, required]); + const loadOptionsAsync = useCallback(( + inputValue: string, + callback: (options: DropDownOption[]) => void, + ) => { + if (!fetchOptions) return; + fetchOptions(inputValue).then(fetched => { + callback(formatOptions(fetched, required)); + }); + }, [fetchOptions, required, formatOptions]); + + // ────────────────────────────────────────────────────────────── + // Full stabilization — this stops the menu jump + // ────────────────────────────────────────────────────────────── const itemHeight = optionHeight; /** * Custom component for list virtualization */ - const MenuList = (props: MenuListProps) => { + const MenuList = useMemo(() => (props: MenuListProps) => { const { children, maxHeight } = props; console.log("Menu List render"); @@ -155,75 +178,76 @@ const DropDown = ({ ) : null; - }; + }, [itemHeight]); - const filterOptions = (inputValue: string) => { - if (options) { - return options.filter(option => - option.label.toLowerCase().includes(inputValue.toLowerCase()), - ); - } - return []; - }; - - const loadOptionsAsync = (inputValue: string, callback: (options: DropDownOption[]) => void) => { - setTimeout(async () => { - callback(formatOptions( - fetchOptions ? await fetchOptions(inputValue) : filterOptions(inputValue), - required, - )); - }, 1000); - }; - - const loadOptions = ( - _inputValue: string, - callback: (options: DropDownOption[]) => void, - ) => { - callback(formatOptions(filterOptions(_inputValue), required)); - }; + const selectValue = useMemo(() => ({ + value, + label: text === "" ? placeholder : text, + }), [value, text, placeholder]); + const onChangeCallback = useCallback((element: unknown) => { + handleChange(element as {value: T, label: string}); + }, [handleChange]); - const commonProps: Props = { - tabIndex: tabIndex, - theme: theme => (dropDownSpacingTheme(theme)), + const openMenuCallback = useCallback((open: boolean) => { + if (handleMenuIsOpen !== undefined) { + handleMenuIsOpen(open); + } + }, [handleMenuIsOpen]); + + const commonProps = useMemo(() => ({ + tabIndex, + theme: theme => (dropDownSpacingTheme(theme)), // exactly as in original styles: style, defaultMenuIsOpen: defaultOpen, - autoFocus: autoFocus, + autoFocus, isSearchable: true, - value: { value: value, label: text === "" ? placeholder : text }, - defaultOptions: options - ? formatOptions( - options, - required, - ) - : true, + value: selectValue, + defaultOptions: formattedOptions, cacheOptions: true, loadOptions: fetchOptions ? loadOptionsAsync : loadOptions, - placeholder: placeholder, - onChange: element => handleChange(element as {value: T, label: string}), - menuIsOpen: menuIsOpen, - onMenuOpen: () => openMenu(true), - onMenuClose: () => openMenu(false), + placeholder, + onChange: onChangeCallback, + menuIsOpen, + onMenuOpen: openMenuCallback, + onMenuClose: openMenuCallback, isDisabled: disabled, - openMenuOnFocus: openMenuOnFocus, + openMenuOnFocus, menuPlacement: menuPlacement ?? "auto", - - // @ts-expect-error: React-Select typing does not account for the typing of option it itself requires components: { MenuList }, - }; + }), [ + tabIndex, style, defaultOpen, autoFocus, selectValue, formattedOptions, + placeholder, onChangeCallback, menuIsOpen, openMenuCallback, disabled, + openMenuOnFocus, menuPlacement, fetchOptions, loadOptionsAsync, loadOptions, MenuList, + ]); + + useEffect(() => { + if (menuIsOpen) { + selectRef.current?.focus(); + } + }, [menuIsOpen, selectRef]); return creatable ? ( - ) : ( + ) : fetchOptions ? ( t("SELECT_NO_MATCHING_RESULTS")} /> + ) : ( + // @ts-expect-error: Ref typing gap for static Select (same pattern the original file already used for components) +