Skip to content
Draft
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
154 changes: 89 additions & 65 deletions src/components/shared/DropDown.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect } from "react";
import React, { useEffect, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
dropDownSpacingTheme,
Expand All @@ -10,6 +10,7 @@
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,
Expand Down Expand Up @@ -74,25 +75,14 @@

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) }));
}
Expand Down Expand Up @@ -131,13 +121,46 @@
}

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<DropDownOption, false>) => {
const MenuList = useMemo(() => (props: MenuListProps<DropDownOption, false>) => {
const { children, maxHeight } = props;

console.log("Menu List render");
Expand All @@ -155,75 +178,76 @@
</FixedSizeList>
</div>
) : 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

Check failure on line 200 in src/components/shared/DropDown.tsx

View workflow job for this annotation

GitHub Actions / check-npm-build

Parameter 'theme' implicitly has an 'any' type.
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 ? (
<AsyncCreatableSelect
ref={selectRef}

Check failure on line 232 in src/components/shared/DropDown.tsx

View workflow job for this annotation

GitHub Actions / check-npm-build

Type 'RefObject<SelectInstance<any, boolean, GroupBase<any>> | null>' is not assignable to type 'Ref<Select<DropDownOption, false, GroupBase<any>>> | undefined'.
{...commonProps}
/>
) : (
) : fetchOptions ? (
<AsyncSelect
ref={selectRef}

Check failure on line 237 in src/components/shared/DropDown.tsx

View workflow job for this annotation

GitHub Actions / check-npm-build

Type 'RefObject<SelectInstance<any, boolean, GroupBase<any>> | null>' is not assignable to type 'Ref<Select<DropDownOption, false, GroupBase<any>>> | undefined'.
{...commonProps}
openMenuOnFocus={false}
noOptionsMessage={() => t("SELECT_NO_MATCHING_RESULTS")}
/>
) : (
// @ts-expect-error: Ref typing gap for static Select (same pattern the original file already used for components)

Check failure on line 243 in src/components/shared/DropDown.tsx

View workflow job for this annotation

GitHub Actions / check-npm-build

Unused '@ts-expect-error' directive.
<Select
ref={selectRef}

Check failure on line 245 in src/components/shared/DropDown.tsx

View workflow job for this annotation

GitHub Actions / check-npm-build

Type 'RefObject<SelectInstance<any, boolean, GroupBase<any>> | null>' is not assignable to type 'Ref<Select<DropDownOption, false, GroupBase<any>>> | undefined'.
{...commonProps}
options={formattedOptions}
openMenuOnFocus={false}
noOptionsMessage={() => t("SELECT_NO_MATCHING_RESULTS")}
/>
);
};

Expand Down
Loading