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 94a689db399..318dd947723 100644 --- a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.test.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.test.tsx @@ -774,6 +774,24 @@ describe('BuildQuote View', () => { screen.getByTestId(BuildQuoteSelectors.AMOUNT_INPUT), ).toHaveTextContent(`${denomSymbol}2`); }); + + it('shows and hides the live input cursor based on focus', () => { + render(BuildQuote); + + expect( + screen.queryByTestId(BuildQuoteSelectors.AMOUNT_INPUT_CURSOR), + ).not.toBeOnTheScreen(); + + fireEvent.press(screen.getByTestId(BuildQuoteSelectors.AMOUNT_INPUT)); + expect( + screen.getByTestId(BuildQuoteSelectors.AMOUNT_INPUT_CURSOR), + ).toBeOnTheScreen(); + + fireEvent.press(getByRoleButton('Done')); + expect( + screen.queryByTestId(BuildQuoteSelectors.AMOUNT_INPUT_CURSOR), + ).not.toBeOnTheScreen(); + }); }); describe('Amount to sell input', () => { @@ -853,7 +871,7 @@ describe('BuildQuote View', () => { fireEvent.press(getByRoleButton(`${initialAmount} ${symbol}`)); expect( screen.queryByText('This amount is higher than your balance'), - ).toBeNull(); + ).not.toBeOnTheScreen(); }); it('updates the amount input with quick amount buttons', async () => { 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 543e112ee27..eb046197a60 100644 --- a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.testIds.ts +++ b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.testIds.ts @@ -2,6 +2,7 @@ import enContent from '../../../../../../../locales/languages/en.json'; export const BuildQuoteSelectors = { AMOUNT_INPUT: 'amount-input', + AMOUNT_INPUT_CURSOR: 'amount-input-cursor', AMOUNT_TO_BUY_LABEL: enContent.fiat_on_ramp_aggregator.amount_to_buy, AMOUNT_TO_SELL_LABEL: enContent.fiat_on_ramp_aggregator.amount_to_sell, GET_QUOTES_BUTTON: enContent.fiat_on_ramp_aggregator.get_quotes, diff --git a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx index efd01c24328..596ecc504f0 100644 --- a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx @@ -995,6 +995,7 @@ const BuildQuote = () => { amountNumber > 0 && (!amountIsValid || amountIsOverGas) } currencyCode={isBuy ? currentFiatCurrency?.symbol : undefined} + tokenSymbol={isSell ? selectedAsset?.symbol : undefined} onPress={onAmountInputPress} loading={ isFetchingRegions || diff --git a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap index 4ef17dd7d72..86fda671792 100644 --- a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap +++ b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap @@ -1057,6 +1057,7 @@ exports[`BuildQuote View Balance display displays balance from useBalance for no testID="listitemcolumn" > { expect(screen.toJSON()).toMatchSnapshot(); }); + it('shows live cursor when input is highlighted', () => { + renderWithProvider(, { + state: defaultState, + }); + + expect( + screen.getByTestId(BuildQuoteSelectors.AMOUNT_INPUT_CURSOR), + ).toBeOnTheScreen(); + }); + + it('does not show live cursor when input is not highlighted', () => { + renderWithProvider(, { + state: defaultState, + }); + + expect( + screen.queryByTestId(BuildQuoteSelectors.AMOUNT_INPUT_CURSOR), + ).not.toBeOnTheScreen(); + }); + it('does not call onPress when loading', () => { const mockOnPress = jest.fn(); renderWithProvider( diff --git a/app/components/UI/Ramp/Aggregator/components/AmountInput.tsx b/app/components/UI/Ramp/Aggregator/components/AmountInput.tsx index 8879f1e3ecc..c77eb65f81b 100644 --- a/app/components/UI/Ramp/Aggregator/components/AmountInput.tsx +++ b/app/components/UI/Ramp/Aggregator/components/AmountInput.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { StyleSheet, TouchableOpacity } from 'react-native'; +import { Animated, StyleSheet, TouchableOpacity, View } from 'react-native'; import Box from './Box'; import SkeletonText from './SkeletonText'; import DownChevronText from './DownChevronText'; @@ -12,12 +12,23 @@ import Text, { TextColor, } from '../../../../../component-library/components/Texts/Text'; import { BuildQuoteSelectors } from '../Views/BuildQuote/BuildQuote.testIds'; +import { useTheme } from '../../../../../util/theme'; +import { useBlinkingCursor } from '../../hooks/useBlinkingCursor'; const styles = StyleSheet.create({ amount: { fontSize: 24, lineHeight: 32, }, + amountWithCursor: { + alignItems: 'center', + flexDirection: 'row', + }, + cursor: { + height: 24, + marginHorizontal: 1, + width: 1, + }, chevron: { flex: 0, marginLeft: 8, @@ -32,6 +43,7 @@ export interface Props { highlighted?: boolean; loading?: boolean; highlightedError?: boolean; + tokenSymbol?: string; // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any onPress?: () => any; @@ -48,56 +60,134 @@ const AmountInput: React.FC = ({ highlighted, loading, highlightedError, + tokenSymbol, onPress, onCurrencyPress, -}: Props) => ( - - - - - {loading ? ( - - ) : ( +}: Props) => { + const { colors } = useTheme(); + const cursorOpacity = useBlinkingCursor(highlighted); + + const textColor = highlightedError ? TextColor.Error : TextColor.Default; + + const renderAmountContent = () => { + if (loading) { + return ; + } + + if (highlighted) { + const cursorView = ( + + ); + + // For sell: show "12.5 | ETH" with cursor before token symbol + if (tokenSymbol) { + const suffix = ` ${tokenSymbol}`; + const amountWithoutSymbol = amount.endsWith(suffix) + ? amount.slice(0, -suffix.length) + : amount.replace(tokenSymbol, '').trimEnd(); + + return ( + - {currencySymbol || ''} - {amount} + {amountWithoutSymbol} - )} - - - - {onCurrencyPress ? ( - - {loading ? ( - - ) : ( - - - - )} + {' '} + {tokenSymbol} + + + ); + } + + // For buy: show "$100 |" with cursor after amount + return ( + + + {currencySymbol || ''} + {amount} + + {cursorView} + + ); + } + + return ( + + {currencySymbol || ''} + {amount} + + ); + }; + + return ( + + + + + {renderAmountContent()} + - ) : null} - - -); + + {onCurrencyPress ? ( + + {loading ? ( + + ) : ( + + + + )} + + ) : null} + + + ); +}; export default AmountInput; diff --git a/app/components/UI/Ramp/Aggregator/components/__snapshots__/AmountInput.test.tsx.snap b/app/components/UI/Ramp/Aggregator/components/__snapshots__/AmountInput.test.tsx.snap index a21e35a48b3..4b7b32a4ccf 100644 --- a/app/components/UI/Ramp/Aggregator/components/__snapshots__/AmountInput.test.tsx.snap +++ b/app/components/UI/Ramp/Aggregator/components/__snapshots__/AmountInput.test.tsx.snap @@ -69,6 +69,7 @@ exports[`AmountInput renders correctly 1`] = ` testID="listitemcolumn" > { alignItems: 'center', gap: 16, }, + amountRow: { + flexDirection: 'row', + alignItems: 'center', + }, + cursor: { + width: 2, + height: 48, + marginHorizontal: 1, + marginBottom: 12, + backgroundColor: theme.colors.primary.default, + }, actionSection: { paddingBottom: 16, gap: 16, diff --git a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx index b1cf8368963..978101e3eab 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx +++ b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx @@ -5,7 +5,7 @@ import React, { useRef, useState, } from 'react'; -import { View } from 'react-native'; +import { Animated, View } from 'react-native'; import { useNavigation, useFocusEffect } from '@react-navigation/native'; import type { CaipChainId } from '@metamask/utils'; @@ -64,6 +64,7 @@ import { } from '../../../../../reducers/fiatOrders'; import TruncatedError from '../../components/TruncatedError'; import { PROVIDER_LINKS } from '../../Aggregator/types'; +import { useBlinkingCursor } from '../../hooks/useBlinkingCursor'; export interface BuildQuoteParams { assetId?: string; @@ -104,6 +105,7 @@ function BuildQuote() { const navigation = useNavigation(); const { styles } = useStyles(styleSheet, {}); const { formatCurrency } = useFormatters(); + const cursorOpacity = useBlinkingCursor(); const [amount, setAmount] = useState(() => String(DEFAULT_AMOUNT)); const [amountAsNumber, setAmountAsNumber] = useState(DEFAULT_AMOUNT); @@ -185,6 +187,17 @@ function BuildQuote() { useTransakRouting(); const currency = userRegion?.country?.currency || 'USD'; + const { currencyPrefix, currencySuffix } = useMemo(() => { + const formatted = formatCurrency(1, currency, { + currencyDisplay: 'narrowSymbol', + }); + // Match: prefix (non-digit chars), digits/separators, suffix (non-digit chars) + const match = formatted.match(/^([^\d]*?)[\d.,]+\s*([^\d\s].*)?$/); + return { + currencyPrefix: match?.[1] ?? '', + currencySuffix: match?.[2]?.trim() ?? '', + }; + }, [currency, formatCurrency]); const quickAmounts = userRegion?.country?.quickAmounts ?? [50, 100, 200, 400]; const hasTrackedScreenViewRef = useRef(false); @@ -663,22 +676,39 @@ function BuildQuote() { - - {formatCurrency(amountAsNumber, currency, { - currencyDisplay: 'narrowSymbol', - })} - + + + {currencyPrefix} + {amount} + + + {currencySuffix ? ( + + {currencySuffix} + + ) : null} + - - $100 - + + $ + 100 + + + - - $100 - + + $ + 100 + + + { + it('returns an Animated.Value', () => { + const { result } = renderHook(() => useBlinkingCursor()); + expect(result.current).toBeInstanceOf(Animated.Value); + }); + + it('returns the same Animated.Value across re-renders', () => { + const { result, rerender } = renderHook(() => useBlinkingCursor()); + const first = result.current; + rerender({}); + expect(result.current).toBe(first); + }); + + it('returns an Animated.Value when enabled is false', () => { + const { result } = renderHook(() => useBlinkingCursor(false)); + expect(result.current).toBeInstanceOf(Animated.Value); + }); +}); diff --git a/app/components/UI/Ramp/hooks/useBlinkingCursor.ts b/app/components/UI/Ramp/hooks/useBlinkingCursor.ts new file mode 100644 index 00000000000..108cdac709f --- /dev/null +++ b/app/components/UI/Ramp/hooks/useBlinkingCursor.ts @@ -0,0 +1,45 @@ +import { useEffect, useRef } from 'react'; +import { Animated, Easing } from 'react-native'; + +const BLINK_DURATION = 800; +const INITIAL_OPACITY = 0.6; + +/** + * Returns an animated opacity value that blinks between 0 and 1. + * Used for the cosmetic input cursor on Ramp amount screens. + * @param enabled - When false, the animation is stopped. Defaults to true. + */ +export function useBlinkingCursor(enabled = true): Animated.Value { + const cursorOpacity = useRef(new Animated.Value(INITIAL_OPACITY)).current; + + useEffect(() => { + if (process.env.NODE_ENV === 'test' || !enabled) { + return; + } + + const blinkAnimation = Animated.loop( + Animated.sequence([ + Animated.timing(cursorOpacity, { + duration: BLINK_DURATION, + easing: Easing.bounce, + toValue: 0, + useNativeDriver: true, + }), + Animated.timing(cursorOpacity, { + duration: BLINK_DURATION, + easing: Easing.bounce, + toValue: 1, + useNativeDriver: true, + }), + ]), + ); + + blinkAnimation.start(); + + return () => { + blinkAnimation.stop(); + }; + }, [cursorOpacity, enabled]); + + return cursorOpacity; +}