fix(suite): scroll to selected option in select

This commit is contained in:
Tomáš Boďa
2025-06-02 12:10:03 +02:00
committed by Tomáš Klíma
parent 21dd8df4f3
commit 9513e1d6de
6 changed files with 92 additions and 89 deletions

View File

@@ -1,18 +1,5 @@
import {
ComponentProps,
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import ReactSelect, {
Props as ReactSelectProps,
SelectInstance,
StylesConfig,
components as reactSelectComponents,
} from 'react-select';
import { ReactNode, useCallback, useMemo, useRef, useState } from 'react';
import ReactSelect, { Props as ReactSelectProps, SelectInstance, StylesConfig } from 'react-select';
import styled, { CSSObject, DefaultTheme, css, useTheme } from 'styled-components';
@@ -26,6 +13,11 @@ import {
zIndices,
} from '@trezor/theme';
import { FrameProps } from '../../../utils/frameProps';
import { TransientProps } from '../../../utils/transientProps';
import { useElevation } from '../../ElevationContext/ElevationContext';
import { DROPDOWN_MENU, menuStyle } from '../../Menu/menuStyle';
import { Spinner } from '../../loaders/Spinner/Spinner';
import {
FormCell,
FormCellProps,
@@ -43,11 +35,6 @@ import {
} from './customComponents';
import { useDetectPortalTarget } from './useDetectPortalTarget';
import { useOnKeyDown } from './useOnKeyDown';
import { FrameProps } from '../../../utils/frameProps';
import { TransientProps } from '../../../utils/transientProps';
import { useElevation } from '../../ElevationContext/ElevationContext';
import { DROPDOWN_MENU, menuStyle } from '../../Menu/menuStyle';
import { Spinner } from '../../loaders/Spinner/Spinner';
const reactSelectClassNamePrefix = 'react-select';
@@ -303,25 +290,6 @@ export type SelectProps = KeyPressScrollProps &
'data-testid'?: string;
};
type MenuProps = ComponentProps<typeof reactSelectComponents.Menu> & {
selectRef: React.RefObject<SelectInstance<Option, boolean>>;
scrollIndex?: number;
};
const Menu = ({ selectRef, scrollIndex, ...rest }: MenuProps) => {
useEffect(() => {
if (scrollIndex) {
const menuList = selectRef.current?.menuListRef;
const option = menuList?.children[scrollIndex];
option?.scrollIntoView({ behavior: 'instant' });
}
}, [selectRef, scrollIndex]);
return <reactSelectComponents.Menu {...rest} />;
};
const OPTION_INDEX_NAME = 'optionIndex';
export const Select = ({
isClean = false,
label,
@@ -346,34 +314,14 @@ export const Select = ({
const formCellProps = pickFormCellProps(rest);
const { isDisabled } = formCellProps;
const isRenderedInModal = menuPortalTarget !== null;
const [scrollIndex, setScrollIndex] = useState<number>(0);
const isGrouped =
Array.isArray(rest.options) && rest.options.some(opt => 'label' in opt && 'options' in opt);
const indexedOptions = useMemo(() => {
if (isGrouped) {
return rest.options?.map((group, index) => ({
...group,
options: group.options.map((option: Option) => ({
...option,
[OPTION_INDEX_NAME]: index,
})),
}));
}
return rest.options?.map((option, index) => ({ ...option, [OPTION_INDEX_NAME]: index }));
}, [rest.options, isGrouped]);
const [selectedOption, setSelectedOption] = useState<Option | undefined>(rest.value);
const handleOnChange = useCallback<Required<ReactSelectProps<Option>>['onChange']>(
(value, { action }) => {
if (value) {
const optionIndex = value[OPTION_INDEX_NAME] as number;
const option = { ...value };
delete option[OPTION_INDEX_NAME];
onChange?.(option as Option, selectRef.current);
setScrollIndex(optionIndex);
onChange?.(value, selectRef.current);
setSelectedOption(value);
if (!isMenuOpen && action === 'select-option') {
selectRef.current?.blur();
@@ -385,11 +333,6 @@ export const Select = ({
[onChange, isMenuOpen],
);
const MenuWithProps = useCallback(
(props: any) => <Menu {...props} selectRef={selectRef} scrollIndex={scrollIndex} />,
[selectRef, scrollIndex],
);
/**
* This memoization is necessary to prevent jumping of the Select
* to the corder. This may happen when parent component re-renders
@@ -401,13 +344,12 @@ export const Select = ({
<Control {...controlProps} data-testid={dataTest} />
),
Option: (optionProps: OptionComponentProps) => (
<Option {...optionProps} data-testid={dataTest} />
<Option {...optionProps} data-testid={dataTest} selectedOption={selectedOption} />
),
GroupHeading,
Menu: MenuWithProps,
...components,
}),
[components, dataTest, MenuWithProps],
[components, dataTest, selectedOption],
);
return (
@@ -442,7 +384,6 @@ export const Select = ({
placeholder={placeholder || ''}
{...rest}
components={memoizedComponents}
options={indexedOptions}
/>
{isLoading && (

View File

@@ -1,4 +1,8 @@
import { useRef } from 'react';
import { ControlProps, GroupHeadingProps, OptionProps, components } from 'react-select';
import { useDeepCompareEffect } from 'react-use';
import { deepEqual } from '@trezor/utils';
import type { Option as OptionType } from './Select';
@@ -22,23 +26,37 @@ export const Control = ({ 'data-testid': dataTest, ...controlProps }: ControlCom
export interface OptionComponentProps extends OptionProps<OptionType, boolean> {
'data-testid'?: string;
selectedOption?: any;
}
export const Option = ({ 'data-testid': dataTest, ...optionProps }: OptionComponentProps) => (
<components.Option
{...optionProps}
innerProps={
{
...optionProps.innerProps,
'data-testid': `${dataTest}/option/${
typeof optionProps.data.value === 'string'
? optionProps.data.value
: optionProps.label
}`,
} as OptionProps<OptionType, boolean>['innerProps']
export const Option = ({
selectedOption,
'data-testid': dataTest,
...props
}: OptionComponentProps) => {
const ref = useRef<HTMLDivElement>();
useDeepCompareEffect(() => {
if (deepEqual(props.data, selectedOption)) {
ref.current?.scrollIntoView();
}
/>
);
}, [{}]);
return (
<components.Option
{...props}
innerRef={ref as any}
innerProps={
{
...props.innerProps,
'data-testid': `${dataTest}/option/${
typeof props.data.value === 'string' ? props.data.value : props.label
}`,
} as OptionProps<OptionType, boolean>['innerProps']
}
/>
);
};
export const GroupHeading = (groupHeadingProps: GroupHeadingProps<OptionType>) => (
<components.GroupHeading {...groupHeadingProps} />

View File

@@ -3,12 +3,17 @@ import { createFilter } from 'react-select';
import { FiatCurrencyCode } from 'invity-api';
import type { TradingExchangeFormProps, TradingSellFormProps } from '@suite-common/trading';
import {
type TradingExchangeFormProps,
type TradingSellFormProps,
selectTradingLoadingAndTimestamp,
} from '@suite-common/trading';
import { Row, Select, Text } from '@trezor/components';
import { spacings } from '@trezor/theme';
import { Translation } from 'src/components/suite';
import { AccountTypeBadge } from 'src/components/suite/AccountTypeBadge';
import { useSelector } from 'src/hooks/suite';
import { useTradingBuildAccountGroups } from 'src/hooks/wallet/trading/form/common/useTradingBuildAccountGroups';
import { useTradingFiatValues } from 'src/hooks/wallet/trading/form/common/useTradingFiatValues';
import { useTradingFormContext } from 'src/hooks/wallet/trading/form/useTradingCommonForm';
@@ -37,6 +42,8 @@ export const TradingFormInputAccount = <
} = useTradingFormContext<TradingTradeSellExchangeType>();
const optionGroups = useTradingBuildAccountGroups(type);
const { isLoading } = useSelector(selectTradingLoadingAndTimestamp);
const { control, getValues } = methods;
const selectedOption = getValues(accountSelectName) as
| TradingAccountOptionsGroupOptionProps
@@ -53,6 +60,8 @@ export const TradingFormInputAccount = <
render={({ field: { value } }) => (
<Select
value={value}
isDisabled={isLoading}
isLoading={isLoading}
labelLeft={label && <Translation id={label} />}
options={optionGroups}
onChange={async (selected: TradingAccountOptionsGroupOptionProps) => {

View File

@@ -13,6 +13,7 @@ import {
cryptoIdToNetwork,
isCryptoIdForNativeToken,
parseCryptoId,
selectTradingLoadingAndTimestamp,
tradingActions,
useTradingInfo,
} from '@suite-common/trading';
@@ -29,7 +30,7 @@ import {
import { spacings } from '@trezor/theme';
import { Translation } from 'src/components/suite';
import { useDispatch, useTranslation } from 'src/hooks/suite';
import { useDispatch, useSelector, useTranslation } from 'src/hooks/suite';
import { useTradingFormContext } from 'src/hooks/wallet/trading/form/useTradingCommonForm';
import {
SelectAssetOptionProps,
@@ -97,6 +98,8 @@ export const TradingFormInputCryptoSelect = <
const [search, setSearch] = useState('');
const { translationString } = useTranslation();
const { isLoading } = useSelector(selectTradingLoadingAndTimestamp);
const sendCryptoSelectValue = isTradingExchangeContext(context)
? context.getValues()?.sendCryptoSelect?.value
: null;
@@ -256,7 +259,8 @@ export const TradingFormInputCryptoSelect = <
data-testid={dataTestId ?? '@trading/form/select-crypto'}
isClearable={false}
isMenuOpen={false}
isDisabled={isDisabled}
isDisabled={isDisabled || isLoading}
isLoading={isLoading}
/>
)}
/>

View File

@@ -1,3 +1,35 @@
export const deepEqual = (a: any, b: any): boolean => {
if (a === b) return true;
if (typeof a !== typeof b) return false;
if (a === null || b === null) return false;
if (typeof a !== 'object') return false;
if (Array.isArray(a) !== Array.isArray(b)) return false;
if (Array.isArray(a)) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!deepEqual(a[i], b[i])) return false;
}
return true;
}
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
if (!deepEqual(a[key], b[key])) return false;
}
return true;
};
export const isChanged = (prev?: any, current?: any, filter?: { [k: string]: string[] }) => {
// 1. both objects are the same (solves simple types like string, boolean and number)
if (prev === current) return false;

View File

@@ -60,7 +60,6 @@ export const loadInitialDataThunk = createThunk(
if (isDifferentAccount || !buyInfo) {
const buyInfoData = await dispatch(buyThunks.loadInfoThunk()).unwrap();
dispatch(tradingBuyActions.saveBuyInfo(buyInfoData));
}