From 61bb9ebd7630f2dde07adfa42f26149b473491bb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Jul 2026 04:31:54 +0000 Subject: [PATCH 01/10] fix(ramp): migrate sell amount keypad to MMDS BottomSheetDialog Replace the custom Reanimated keypad overlay in Ramp Aggregator BuildQuote with MMDS BottomSheetDialog, following the Swaps numpad migration pattern. - Remove keypadContainer animation and surface styling hacks - Use isKeypadOpen state for bottom sheet lifecycle - Drop custom QuickAmounts background; MMDS owns the surface - Mock BottomSheetDialog in unit tests for synchronous rendering - Fix native-asset gas tests to render after mock setup Fixes TMCU-1084 Co-authored-by: George Marshall --- .../Views/BuildQuote/BuildQuote.styles.ts | 16 +- .../Views/BuildQuote/BuildQuote.test.tsx | 28 +++- .../Views/BuildQuote/BuildQuote.tsx | 151 ++++++++---------- .../Aggregator/components/QuickAmounts.tsx | 26 ++- 4 files changed, 107 insertions(+), 114 deletions(-) diff --git a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.styles.ts b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.styles.ts index e1aece00dfde..ea586444596c 100644 --- a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.styles.ts +++ b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.styles.ts @@ -1,10 +1,7 @@ import { Theme } from '../../../../../../util/theme/models'; import { StyleSheet } from 'react-native'; -const styleSheet = (params: { theme: Theme }) => { - const { theme } = params; - const { colors } = theme; - +const styleSheet = (_params: { theme: Theme }) => { return StyleSheet.create({ viewContainer: { flex: 1, @@ -17,17 +14,6 @@ const styleSheet = (params: { theme: Theme }) => { spacer: { minWidth: 8, }, - keypadContainer: { - position: 'absolute', - bottom: 0, - left: 0, - right: 0, - paddingBottom: 50, - backgroundColor: colors.background.section, - }, - keypad: { - paddingHorizontal: 16, - }, cta: { paddingTop: 12, }, diff --git a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.test.tsx b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.test.tsx index 7f147141e6c6..b5a3d280b1e4 100644 --- a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.test.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.test.tsx @@ -45,6 +45,30 @@ jest.mock('../../../../../../core/Engine', () => ({ }, })); +// Mock MMDS BottomSheetDialog so children render synchronously in tests. +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + const MockReact = jest.requireActual('react'); + + return { + ...actual, + BottomSheetDialog: MockReact.forwardRef( + ( + { + children, + onClose, + }: { children: React.ReactNode; onClose?: () => void }, + dialogRef: React.Ref<{ onCloseDialog: () => void }>, + ) => { + MockReact.useImperativeHandle(dialogRef, () => ({ + onCloseDialog: () => onClose?.(), + })); + return children; + }, + ), + }; +}); + const getByRoleButton = (name?: string | RegExp) => screen.getByRole('button', { name }); @@ -999,7 +1023,6 @@ describe('BuildQuote View', () => { }); it('updates the amount input up to the max considering gas for native asset', () => { - render(BuildQuote); const initialAmount = '0'; const quickAmount = 'Max'; mockUseRampSDKValues = { @@ -1026,6 +1049,7 @@ describe('BuildQuote View', () => { mockUseRampSDKValues.selectedAsset?.decimals || 18, ) as BN4, }; + render(BuildQuote); const symbol = mockUseRampSDKValues.selectedAsset?.symbol; fireEvent.press(getByRoleButton(`${initialAmount} ${symbol}`)); fireEvent.press(getByRoleButton(quickAmount)); @@ -1033,7 +1057,6 @@ describe('BuildQuote View', () => { }); it('updates the amount input up to the percentage considering gas', () => { - render(BuildQuote); const initialAmount = '0'; mockUseRampSDKValues = { ...mockUseRampSDKInitialValues, @@ -1059,6 +1082,7 @@ describe('BuildQuote View', () => { mockUseRampSDKValues.selectedAsset?.decimals || 18, ) as BN4, }; + render(BuildQuote); const symbol = mockUseRampSDKValues.selectedAsset?.symbol; fireEvent.press(getByRoleButton(`${initialAmount} ${symbol}`)); fireEvent.press(getByRoleButton('75%')); diff --git a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx index 0614d9914df8..e09f52cf3c3a 100644 --- a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx @@ -5,12 +5,7 @@ import React, { useRef, useState, } from 'react'; -import { Pressable, View, BackHandler, LayoutChangeEvent } from 'react-native'; -import Animated, { - useAnimatedStyle, - useSharedValue, - withTiming, -} from 'react-native-reanimated'; +import { Pressable, View, BackHandler } from 'react-native'; import { useSelector } from 'react-redux'; import { useFocusEffect, useNavigation } from '@react-navigation/native'; import type { AppNavigationProp } from '../../../../../../core/NavigationService/types'; @@ -18,6 +13,8 @@ import BN4 from 'bnjs4'; import { AvatarToken, AvatarTokenSize, + BottomSheetDialog, + Box, HeaderStandard, } from '@metamask/design-system-react-native'; @@ -137,14 +134,13 @@ const BuildQuote = () => { const { colors, themeAppearance } = theme; const trackEvent = useAnalytics(); const [amountFocused, setAmountFocused] = useState(false); + const [isKeypadOpen, setIsKeypadOpen] = useState(false); const [amount, setAmount] = useState('0'); const [amountNumber, setAmountNumber] = useState(0); const [amountBNMinimalUnit, setAmountBNMinimalUnit] = useState(); const [error, setError] = useState(null); const [isKeyboardFreshlyOpened, setIsKeyboardFreshlyOpened] = useState(false); const [intentHandled, setIntentHandled] = useState(false); - const keyboardHeight = useRef(1000); - const keypadOffset = useSharedValue(1000); const nativeSymbol = useSelector(selectTicker); const networkConfigurationsByCaipChainId = useSelector( selectNetworkConfigurationsByCaipChainId, @@ -244,6 +240,7 @@ const BuildQuote = () => { setAmountNumber(0); setAmountBNMinimalUnit(undefined); setAmountFocused(false); + setIsKeypadOpen(false); setIsKeyboardFreshlyOpened(false); }, []); @@ -495,21 +492,6 @@ const BuildQuote = () => { navigation.pop(); }, [handleCancelPress, navigation]); - /** - * * Keypad style, handlers and effects - */ - const keypadContainerStyle = useAnimatedStyle(() => ({ - transform: [ - { - translateY: withTiming(keypadOffset.value), - }, - ], - })); - - useEffect(() => { - keypadOffset.value = amountFocused ? 40 : keyboardHeight.current + 80; - }, [amountFocused, keyboardHeight, keypadOffset]); - /** * Back handler to dismiss keypad */ @@ -517,8 +499,9 @@ const BuildQuote = () => { const backHandler = BackHandler.addEventListener( 'hardwareBackPress', () => { - if (amountFocused) { + if (amountFocused || isKeypadOpen) { setAmountFocused(false); + setIsKeypadOpen(false); setIsKeyboardFreshlyOpened(false); return true; } @@ -526,14 +509,20 @@ const BuildQuote = () => { ); return () => backHandler.remove(); - }, [amountFocused]); + }, [amountFocused, isKeypadOpen]); - const handleKeypadDone = useCallback(() => { + const handleKeypadClose = useCallback(() => { setAmountFocused(false); + setIsKeypadOpen(false); setIsKeyboardFreshlyOpened(false); }, []); + + const handleKeypadDone = useCallback(() => { + handleKeypadClose(); + }, [handleKeypadClose]); const onAmountInputPress = useCallback(() => { setAmountFocused(true); + setIsKeypadOpen(true); setIsKeyboardFreshlyOpened(true); }, []); @@ -611,17 +600,12 @@ const BuildQuote = () => { ], ); - const onKeypadLayout = useCallback((event: LayoutChangeEvent) => { - const { height } = event.nativeEvent.layout; - keyboardHeight.current = height; - }, []); - /** * * Region handlers */ const handleChangeRegion = useCallback(() => { - setAmountFocused(false); + handleKeypadClose(); if (regions && regions.length > 0) { navigateWithDetails( navigation, @@ -630,50 +614,47 @@ const BuildQuote = () => { }), ); } - }, [navigation, regions, setAmountFocused]); + }, [handleKeypadClose, navigation, regions]); /** * * CryptoCurrency handlers */ const handleAssetSelectorPress = useCallback(() => { - setAmountFocused(false); - navigateWithDetails( - navigation, - createTokenSelectModalNavigationDetails({ + handleKeypadClose(); + navigation.navigate( + ...createTokenSelectModalNavigationDetails({ tokens: cryptoCurrencies ?? [], }), ); - }, [navigation, cryptoCurrencies]); + }, [handleKeypadClose, navigation, cryptoCurrencies]); /** * * FiatCurrency handlers */ const handleFiatSelectorPress = useCallback(() => { - setAmountFocused(false); - navigateWithDetails( - navigation, - createFiatSelectorModalNavigationDetails({ + handleKeypadClose(); + navigation.navigate( + ...createFiatSelectorModalNavigationDetails({ currencies: fiatCurrencies ?? [], }), ); - }, [navigation, fiatCurrencies]); + }, [handleKeypadClose, navigation, fiatCurrencies]); /** * * PaymentMethod handlers */ const handleShowPaymentMethodsModal = useCallback(() => { - setAmountFocused(false); - navigateWithDetails( - navigation, - createPaymentMethodSelectorModalNavigationDetails({ + handleKeypadClose(); + navigation.navigate( + ...createPaymentMethodSelectorModalNavigationDetails({ paymentMethods, location: screenLocation, }), ); - }, [navigation, paymentMethods, screenLocation]); + }, [handleKeypadClose, navigation, paymentMethods, screenLocation]); /** * * Get Quote handlers @@ -1177,39 +1158,49 @@ const BuildQuote = () => { - - - + // Prevents the native gesture system from bubbling up + // the event to BottomSheetDialog, causing keypad to close + // when user click anywhere inside the keypad area that is + // not a pressable component. + true } - /> - - ); }; @@ -52,29 +42,21 @@ interface Props { amounts: QuickAmount[]; isBuy: boolean; disabled?: boolean; - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onAmountPress: (amount: QuickAmount) => any; + onAmountPress: (amount: QuickAmount) => void; } const QuickAmounts = ({ amounts, onAmountPress, isBuy, disabled }: Props) => ( - - - {amounts.map((amount, index: number) => ( - - ))} - - + + {amounts.map((amount, index) => ( + + ))} + ); export default QuickAmounts; From 2fa46bcfbb61f47d633df8dbef650b304644f027 Mon Sep 17 00:00:00 2001 From: georgewrmarshall Date: Fri, 7 Aug 2026 15:11:20 -0700 Subject: [PATCH 08/10] fix(ramp): align Aggregator BuildQuote keypad UI with Swap MMDS patterns Swap quick amounts for Done in the same keypad slot, migrate selectors/buttons/text to MMDS, and update AccountSelector to SelectButton. Co-authored-by: Cursor --- .../Views/BuildQuote/BuildQuote.styles.ts | 4 - .../Views/BuildQuote/BuildQuote.test.tsx | 82 +++++++++++- .../Views/BuildQuote/BuildQuote.testIds.ts | 1 + .../Views/BuildQuote/BuildQuote.tsx | 124 ++++++++++-------- .../Aggregator/components/AccountSelector.tsx | 81 ++++++------ 5 files changed, 183 insertions(+), 109 deletions(-) diff --git a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.styles.ts b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.styles.ts index b009793a3ccf..3a45f4bb75da 100644 --- a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.styles.ts +++ b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.styles.ts @@ -20,10 +20,6 @@ const styleSheet = (_params: { theme: Theme }) => flexRow: { flexDirection: 'row', }, - flagText: { - marginVertical: 3, - marginHorizontal: 0, - }, }); export default styleSheet; diff --git a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.test.tsx b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.test.tsx index 5f391b50382e..3501978794d6 100644 --- a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.test.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.test.tsx @@ -786,9 +786,15 @@ describe('BuildQuote View', () => { mockUseFiatCurrenciesValues.currentFiatCurrency?.denomSymbol; fireEvent.press(getByRoleButton(`${symbol}${initialAmount}`)); fireEvent.press(getByRoleButton(`${symbol}${quickAmount}`)); + expect(getByRoleButton(`${symbol}${quickAmount}`)).toBeOnTheScreen(); expect( - screen.queryAllByRole('button', { name: `${symbol}${quickAmount}` }), - ).toHaveLength(2); + screen.getByTestId(BuildQuoteSelectors.AMOUNT_KEYPAD_CONFIRM_BUTTON), + ).toBeOnTheScreen(); + expect( + screen.queryByRole('button', { + name: `${symbol}${mockUseLimitsInitialValues?.limits?.quickAmounts?.[1]}`, + }), + ).not.toBeOnTheScreen(); }); it('validates the max limit', () => { @@ -855,6 +861,7 @@ describe('BuildQuote View', () => { screen.getByTestId(BuildQuoteSelectors.AMOUNT_INPUT_CURSOR), ).toBeOnTheScreen(); + fireEvent.press(getByRoleButton('1')); fireEvent.press(getByRoleButton('Done')); expect( screen.queryByTestId(BuildQuoteSelectors.AMOUNT_KEYPAD_BOTTOM_SHEET), @@ -864,6 +871,32 @@ describe('BuildQuote View', () => { ).not.toBeOnTheScreen(); }); + it('replaces quick amounts with Done confirm when an amount is entered', () => { + render(BuildQuote); + const denomSymbol = + mockUseFiatCurrenciesValues.currentFiatCurrency?.denomSymbol; + const quickAmount = + mockUseLimitsInitialValues?.limits?.quickAmounts?.[0]?.toString(); + + fireEvent.press(getByRoleButton(`${denomSymbol}0`)); + + expect(getByRoleButton(`${denomSymbol}${quickAmount}`)).toBeOnTheScreen(); + expect( + screen.queryByTestId(BuildQuoteSelectors.AMOUNT_KEYPAD_CONFIRM_BUTTON), + ).not.toBeOnTheScreen(); + + fireEvent.press(getByRoleButton('1')); + + expect( + screen.queryByRole('button', { + name: `${denomSymbol}${quickAmount}`, + }), + ).not.toBeOnTheScreen(); + expect( + screen.getByTestId(BuildQuoteSelectors.AMOUNT_KEYPAD_CONFIRM_BUTTON), + ).toBeOnTheScreen(); + }); + it('dismisses the amount keypad when hardware back is pressed', () => { let backPressHandler: (() => boolean | undefined) | undefined; jest @@ -1148,10 +1181,53 @@ describe('BuildQuote View', () => { fireEvent.press(getByRoleButton(`${initialAmount} ${symbol}`)); fireEvent.press(getByRoleButton('25%')); expect(getByRoleButton(`0.25 ${symbol}`)).toBeTruthy(); + expect( + screen.getByTestId(BuildQuoteSelectors.AMOUNT_KEYPAD_CONFIRM_BUTTON), + ).toBeOnTheScreen(); + expect( + screen.queryByRole('button', { name: 'Max' }), + ).not.toBeOnTheScreen(); + }); - fireEvent.press(getByRoleButton(`0.25 ${symbol}`)); + it('sets max amount from quick amount when amount is zero', () => { + render(BuildQuote); + + mockUseBalanceValues.balanceBN = toTokenMinimalUnit( + '1', + mockUseRampSDKValues.selectedAsset?.decimals || 18, + ) as BN4; + const symbol = mockUseRampSDKValues.selectedAsset?.symbol; + fireEvent.press(getByRoleButton(`0 ${symbol}`)); fireEvent.press(getByRoleButton('Max')); expect(getByRoleButton(`1 ${symbol}`)).toBeTruthy(); + expect( + screen.getByTestId(BuildQuoteSelectors.AMOUNT_KEYPAD_CONFIRM_BUTTON), + ).toBeOnTheScreen(); + }); + + it('replaces quick amounts with Done confirm when a sell amount is entered', () => { + mockUseBalanceValues.balanceBN = toTokenMinimalUnit( + '1', + mockUseRampSDKValues.selectedAsset?.decimals || 18, + ) as BN4; + render(BuildQuote); + const symbol = mockUseRampSDKValues.selectedAsset?.symbol; + + fireEvent.press(getByRoleButton(`0 ${symbol}`)); + + expect(getByRoleButton('25%')).toBeOnTheScreen(); + expect( + screen.queryByTestId(BuildQuoteSelectors.AMOUNT_KEYPAD_CONFIRM_BUTTON), + ).not.toBeOnTheScreen(); + + fireEvent.press(getByRoleButton('1')); + + expect( + screen.queryByRole('button', { name: '25%' }), + ).not.toBeOnTheScreen(); + expect( + screen.getByTestId(BuildQuoteSelectors.AMOUNT_KEYPAD_CONFIRM_BUTTON), + ).toBeOnTheScreen(); }); it('updates the amount input up to the max considering gas for native asset', () => { diff --git a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.testIds.ts b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.testIds.ts index b82e186683c3..82ef7e5828cf 100644 --- a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.testIds.ts +++ b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.testIds.ts @@ -20,4 +20,5 @@ export const BuildQuoteSelectors = { INSUFFICIENT_BALANCE_ERROR: 'insufficient-balance-error', KEYPAD_DELETE_BUTTON: 'keypad-delete-button', AMOUNT_KEYPAD_BOTTOM_SHEET: 'build-quote-amount-keypad-bottom-sheet', + AMOUNT_KEYPAD_CONFIRM_BUTTON: 'build-quote-amount-keypad-confirm-button', }; diff --git a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx index 36e9edb7d7d0..6ffdc72aba17 100644 --- a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx @@ -15,7 +15,17 @@ import { AvatarTokenSize, BottomSheetDialog, Box, + Button, + ButtonSize, + ButtonVariant, HeaderStandard, + IconName, + SelectButton, + SelectButtonSize, + SelectButtonVariant, + Text, + TextColor, + TextVariant, } from '@metamask/design-system-react-native'; import { useRampSDK } from '../../sdk'; @@ -29,8 +39,6 @@ import useBalance from '../../hooks/useBalance'; import useAddressBalance from '../../../../../hooks/useAddressBalance/useAddressBalance'; import { Asset } from '../../../../../hooks/useAddressBalance/useAddressBalance.types'; -import BaseSelectorButton from '../../../../../Base/SelectorButton'; - import ScreenLayout from '../../components/ScreenLayout'; import Row from '../../components/Row'; import AssetSelectorButton from '../../components/AssetSelectorButton'; @@ -87,16 +95,6 @@ import useGasPriceEstimation from '../../hooks/useGasPriceEstimation'; import useIntentAmount from '../../hooks/useIntentAmount'; import useERC20GasLimitEstimation from '../../hooks/useERC20GasLimitEstimation'; -import Text, { - TextColor, - TextVariant, -} from '../../../../../../component-library/components/Texts/Text'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../../component-library/components/Buttons/Button'; -import { IconName } from '../../../../../../component-library/components/Icons/Icon'; import { BuildQuoteSelectors } from './BuildQuote.testIds'; import { isNonEvmAddress } from '../../../../../../core/Multichain/utils'; @@ -107,10 +105,6 @@ import { createUnsupportedRegionModalNavigationDetails } from '../../components/ import { regex } from '../../../../../../util/regex'; import { createBuySettingsModalNavigationDetails } from '../Modals/Settings/SettingsModal'; -// TODO: Replace "any" with type -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const SelectorButton = BaseSelectorButton as any; - export interface BuildQuoteParams extends RampIntent { showBack?: boolean; } @@ -935,14 +929,17 @@ const BuildQuote = () => { {isFetchingRegions ? ( ) : ( - - {selectedRegion?.emoji} - + accessibilityRole="button" + /> )} {isSell ? ( <> @@ -952,15 +949,16 @@ const BuildQuote = () => { !selectedFiatCurrencyId ? ( ) : ( - - - {currentFiatCurrency?.symbol} - - + accessibilityRole="button" + /> )} ) : null} @@ -1009,8 +1007,8 @@ const BuildQuote = () => { ) : ( {displayBalance !== null && ( <> @@ -1048,7 +1046,10 @@ const BuildQuote = () => { !hasInsufficientBalance && amountIsOverGas && ( - + {strings('fiat_on_ramp_aggregator.enter_lower_gas_fees')} @@ -1056,8 +1057,8 @@ const BuildQuote = () => { {hasInsufficientBalance && ( {strings('fiat_on_ramp_aggregator.insufficient_balance')} @@ -1066,7 +1067,10 @@ const BuildQuote = () => { )} {!hasInsufficientBalance && hasInsufficientNativeBalanceForGas && ( - + {strings( 'fiat_on_ramp_aggregator.insufficient_native_balance', { currency: nativeSymbol }, @@ -1077,8 +1081,8 @@ const BuildQuote = () => { {!hasInsufficientBalance && amountIsBelowMinimum && limits && ( {isBuy ? ( @@ -1096,8 +1100,8 @@ const BuildQuote = () => { {!hasInsufficientBalance && amountIsAboveMaximum && limits && ( {isBuy ? ( @@ -1146,14 +1150,14 @@ const BuildQuote = () => { @@ -1172,11 +1176,23 @@ const BuildQuote = () => { } > - + {amount && amount !== '0' ? ( + + ) : ( + + )} { isBuy ? currentFiatCurrency?.decimals : selectedAsset?.decimals } /> -