Skip to content

Commit afa22a6

Browse files
authored
fix(ramp): Add live blinking cursor to ramp amount input (MetaMask#27292)
## **Description** Adds a live blinking input cursor to the Ramp amount input screen (Buy and Sell flows), matching the cursor behavior seen in the Swap and Send screens. The cursor is purely cosmetic — it blinks next to the amount text to indicate the input is active. For Buy, the cursor appears after the amount (e.g. `$100|`). For Sell, the cursor appears before the token symbol (e.g. `12.5| ETH`). The display now shows the raw amount string (e.g. `$100`) instead of the formatted value with decimals (e.g. `$100.00`), providing a cleaner editing experience. ## **Changelog** CHANGELOG entry: Added a live blinking cursor to the Ramp Buy and Sell amount input screens for a more intuitive input experience. ## **Related issues** Refs: [TRAM-3335](https://consensyssoftware.atlassian.net/browse/TRAM-3335) ## **Manual testing steps** ```gherkin Feature: Live input cursor on Ramp amount input Scenario: Buy screen shows blinking cursor after amount Given the user is in the Buy flow When the amount input screen is displayed (e.g. "$100") Then a blinking cursor appears after the amount text And the cursor is vertically centered with the numerical text Scenario: Sell screen shows blinking cursor before token symbol Given the user is in the Sell flow When the amount input screen is displayed (e.g. "12.5 ETH") Then a blinking cursor appears between the amount and the token symbol (e.g. "12.5| ETH") And the cursor does NOT appear after the token symbol Scenario: Buy screen displays raw amount without trailing decimals Given the user is in the Buy flow with default amount 100 When the amount input screen is displayed Then the amount shows "$100" (not "$100.00") And the cursor blinks after the displayed amount Scenario: Keypad input works correctly with cursor Given the user is on the amount input screen When the user presses keypad digits Then the amount updates and the cursor remains at the end And backspace removes the last character as expected ``` ## **Screenshots/Recordings** ### **Before** <!-- Screenshot/video showing static amount display without cursor --> <img width="355" height="683" alt="Screenshot 2026-03-11 140355" src="https://github.com/user-attachments/assets/c6e090d6-afa0-4b70-9657-bc7517befda0" /> <img width="352" height="676" alt="Screenshot 2026-03-11 140423" src="https://github.com/user-attachments/assets/9e216129-a228-486f-8a10-e781c282349e" /> ### **After** <!-- Screenshot/video showing blinking cursor on Buy and Sell screens --> <img width="342" height="626" alt="Screenshot 2026-03-11 132503" src="https://github.com/user-attachments/assets/91d0fd57-86a3-4438-948d-cfcee011df8e" /> https://github.com/user-attachments/assets/8d813b25-e0bb-4efb-900c-881a3a065e81 https://github.com/user-attachments/assets/c469bef2-5864-4571-ae2f-678bdf4c332c https://github.com/user-attachments/assets/ee150ac8-3028-4155-9cfd-986d534c6c1a https://github.com/user-attachments/assets/2372e1ea-1f0d-4339-a98c-d53e076bf6e5 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Low risk UI-only change that adds a cosmetic blinking cursor and minor formatting tweaks; main risk is small regressions in amount display/accessibility labels or snapshot expectations. > > **Overview** > Adds a reusable `useBlinkingCursor` hook and wires it into Ramp amount entry UIs to render a *cosmetic* blinking cursor. > > Updates `Aggregator` `AmountInput` to show the cursor only when `highlighted`, place it **after the amount for buy** and **before the token symbol for sell** (new `tokenSymbol` prop), and adds an `accessibilityLabel` for the amount. Updates unified Ramp `BuildQuote` to split currency formatting into prefix/suffix and render the amount with an animated cursor between them. > > Extends test IDs and adds/updates unit tests and snapshots to cover cursor visibility and updated rendering expectations. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ef97f00. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 47741a3 commit afa22a6

12 files changed

Lines changed: 384 additions & 87 deletions

File tree

app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.test.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -774,6 +774,24 @@ describe('BuildQuote View', () => {
774774
screen.getByTestId(BuildQuoteSelectors.AMOUNT_INPUT),
775775
).toHaveTextContent(`${denomSymbol}2`);
776776
});
777+
778+
it('shows and hides the live input cursor based on focus', () => {
779+
render(BuildQuote);
780+
781+
expect(
782+
screen.queryByTestId(BuildQuoteSelectors.AMOUNT_INPUT_CURSOR),
783+
).not.toBeOnTheScreen();
784+
785+
fireEvent.press(screen.getByTestId(BuildQuoteSelectors.AMOUNT_INPUT));
786+
expect(
787+
screen.getByTestId(BuildQuoteSelectors.AMOUNT_INPUT_CURSOR),
788+
).toBeOnTheScreen();
789+
790+
fireEvent.press(getByRoleButton('Done'));
791+
expect(
792+
screen.queryByTestId(BuildQuoteSelectors.AMOUNT_INPUT_CURSOR),
793+
).not.toBeOnTheScreen();
794+
});
777795
});
778796

779797
describe('Amount to sell input', () => {
@@ -853,7 +871,7 @@ describe('BuildQuote View', () => {
853871
fireEvent.press(getByRoleButton(`${initialAmount} ${symbol}`));
854872
expect(
855873
screen.queryByText('This amount is higher than your balance'),
856-
).toBeNull();
874+
).not.toBeOnTheScreen();
857875
});
858876

859877
it('updates the amount input with quick amount buttons', async () => {

app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.testIds.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import enContent from '../../../../../../../locales/languages/en.json';
22

33
export const BuildQuoteSelectors = {
44
AMOUNT_INPUT: 'amount-input',
5+
AMOUNT_INPUT_CURSOR: 'amount-input-cursor',
56
AMOUNT_TO_BUY_LABEL: enContent.fiat_on_ramp_aggregator.amount_to_buy,
67
AMOUNT_TO_SELL_LABEL: enContent.fiat_on_ramp_aggregator.amount_to_sell,
78
GET_QUOTES_BUTTON: enContent.fiat_on_ramp_aggregator.get_quotes,

app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -995,6 +995,7 @@ const BuildQuote = () => {
995995
amountNumber > 0 && (!amountIsValid || amountIsOverGas)
996996
}
997997
currencyCode={isBuy ? currentFiatCurrency?.symbol : undefined}
998+
tokenSymbol={isSell ? selectedAsset?.symbol : undefined}
998999
onPress={onAmountInputPress}
9991000
loading={
10001001
isFetchingRegions ||

app/components/UI/Ramp/Aggregator/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1057,6 +1057,7 @@ exports[`BuildQuote View Balance display displays balance from useBalance for no
10571057
testID="listitemcolumn"
10581058
>
10591059
<TouchableOpacity
1060+
accessibilityLabel="$0"
10601061
accessibilityRole="button"
10611062
accessible={true}
10621063
hitSlop={
@@ -5303,6 +5304,7 @@ exports[`BuildQuote View Crypto Currency Data renders the loading page when cryp
53035304
testID="listitemcolumn"
53045305
>
53055306
<TouchableOpacity
5307+
accessibilityLabel="$0"
53065308
accessibilityRole="button"
53075309
accessible={true}
53085310
hitSlop={
@@ -8250,6 +8252,7 @@ exports[`BuildQuote View Fiat Currency Data renders the loading page when fiats
82508252
testID="listitemcolumn"
82518253
>
82528254
<TouchableOpacity
8255+
accessibilityLabel="$0"
82538256
accessibilityRole="button"
82548257
accessible={true}
82558258
hitSlop={
@@ -11281,6 +11284,7 @@ exports[`BuildQuote View Payment Method Data renders no icons if there are no pa
1128111284
testID="listitemcolumn"
1128211285
>
1128311286
<TouchableOpacity
11287+
accessibilityLabel="$0"
1128411288
accessibilityRole="button"
1128511289
accessible={true}
1128611290
hitSlop={
@@ -13680,6 +13684,7 @@ exports[`BuildQuote View Payment Method Data renders the loading page when payme
1368013684
testID="listitemcolumn"
1368113685
>
1368213686
<TouchableOpacity
13687+
accessibilityLabel="$0"
1368313688
accessibilityRole="button"
1368413689
accessible={true}
1368513690
hitSlop={
@@ -16603,6 +16608,7 @@ exports[`BuildQuote View Regions data renders the loading page when regions are
1660316608
testID="listitemcolumn"
1660416609
>
1660516610
<TouchableOpacity
16611+
accessibilityLabel="$0"
1660616612
accessibilityRole="button"
1660716613
accessible={true}
1660816614
hitSlop={
@@ -18975,6 +18981,7 @@ exports[`BuildQuote View renders correctly 1`] = `
1897518981
testID="listitemcolumn"
1897618982
>
1897718983
<TouchableOpacity
18984+
accessibilityLabel="$0"
1897818985
accessibilityRole="button"
1897918986
accessible={true}
1898018987
hitSlop={
@@ -21403,6 +21410,7 @@ exports[`BuildQuote View renders correctly 2`] = `
2140321410
testID="listitemcolumn"
2140421411
>
2140521412
<TouchableOpacity
21413+
accessibilityLabel="0 ETH"
2140621414
accessibilityRole="button"
2140721415
accessible={true}
2140821416
hitSlop={

app/components/UI/Ramp/Aggregator/components/AmountInput.test.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import AmountInput from './AmountInput';
44
import { TouchableOpacity } from 'react-native';
55
import renderWithProvider from '../../../../../util/test/renderWithProvider';
66
import { backgroundState } from '../../../../../util/test/initial-root-state';
7+
import { BuildQuoteSelectors } from '../Views/BuildQuote/BuildQuote.testIds';
78

89
const defaultState = {
910
engine: {
@@ -80,6 +81,26 @@ describe('AmountInput', () => {
8081
expect(screen.toJSON()).toMatchSnapshot();
8182
});
8283

84+
it('shows live cursor when input is highlighted', () => {
85+
renderWithProvider(<AmountInput {...mockProps} highlighted />, {
86+
state: defaultState,
87+
});
88+
89+
expect(
90+
screen.getByTestId(BuildQuoteSelectors.AMOUNT_INPUT_CURSOR),
91+
).toBeOnTheScreen();
92+
});
93+
94+
it('does not show live cursor when input is not highlighted', () => {
95+
renderWithProvider(<AmountInput {...mockProps} />, {
96+
state: defaultState,
97+
});
98+
99+
expect(
100+
screen.queryByTestId(BuildQuoteSelectors.AMOUNT_INPUT_CURSOR),
101+
).not.toBeOnTheScreen();
102+
});
103+
83104
it('does not call onPress when loading', () => {
84105
const mockOnPress = jest.fn();
85106
renderWithProvider(
Lines changed: 131 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from 'react';
2-
import { StyleSheet, TouchableOpacity } from 'react-native';
2+
import { Animated, StyleSheet, TouchableOpacity, View } from 'react-native';
33
import Box from './Box';
44
import SkeletonText from './SkeletonText';
55
import DownChevronText from './DownChevronText';
@@ -12,12 +12,23 @@ import Text, {
1212
TextColor,
1313
} from '../../../../../component-library/components/Texts/Text';
1414
import { BuildQuoteSelectors } from '../Views/BuildQuote/BuildQuote.testIds';
15+
import { useTheme } from '../../../../../util/theme';
16+
import { useBlinkingCursor } from '../../hooks/useBlinkingCursor';
1517

1618
const styles = StyleSheet.create({
1719
amount: {
1820
fontSize: 24,
1921
lineHeight: 32,
2022
},
23+
amountWithCursor: {
24+
alignItems: 'center',
25+
flexDirection: 'row',
26+
},
27+
cursor: {
28+
height: 24,
29+
marginHorizontal: 1,
30+
width: 1,
31+
},
2132
chevron: {
2233
flex: 0,
2334
marginLeft: 8,
@@ -32,6 +43,7 @@ export interface Props {
3243
highlighted?: boolean;
3344
loading?: boolean;
3445
highlightedError?: boolean;
46+
tokenSymbol?: string;
3547
// TODO: Replace "any" with type
3648
// eslint-disable-next-line @typescript-eslint/no-explicit-any
3749
onPress?: () => any;
@@ -48,56 +60,134 @@ const AmountInput: React.FC<Props> = ({
4860
highlighted,
4961
loading,
5062
highlightedError,
63+
tokenSymbol,
5164
onPress,
5265
onCurrencyPress,
53-
}: Props) => (
54-
<Box label={label} highlighted={highlighted} compact>
55-
<ListItem>
56-
<ListItemColumn widthType={WidthType.Fill}>
57-
<TouchableOpacity
58-
accessible
59-
accessibilityRole="button"
60-
onPress={onPress}
61-
hitSlop={{ top: 20, left: 20, right: 20, bottom: 20 }}
62-
testID={BuildQuoteSelectors.AMOUNT_INPUT}
63-
>
64-
{loading ? (
65-
<SkeletonText medium />
66-
) : (
66+
}: Props) => {
67+
const { colors } = useTheme();
68+
const cursorOpacity = useBlinkingCursor(highlighted);
69+
70+
const textColor = highlightedError ? TextColor.Error : TextColor.Default;
71+
72+
const renderAmountContent = () => {
73+
if (loading) {
74+
return <SkeletonText medium />;
75+
}
76+
77+
if (highlighted) {
78+
const cursorView = (
79+
<Animated.View
80+
style={[
81+
styles.cursor,
82+
{
83+
backgroundColor: colors.primary.default,
84+
opacity: cursorOpacity,
85+
},
86+
]}
87+
testID={BuildQuoteSelectors.AMOUNT_INPUT_CURSOR}
88+
/>
89+
);
90+
91+
// For sell: show "12.5 | ETH" with cursor before token symbol
92+
if (tokenSymbol) {
93+
const suffix = ` ${tokenSymbol}`;
94+
const amountWithoutSymbol = amount.endsWith(suffix)
95+
? amount.slice(0, -suffix.length)
96+
: amount.replace(tokenSymbol, '').trimEnd();
97+
98+
return (
99+
<View style={styles.amountWithCursor}>
67100
<Text
68101
numberOfLines={1}
69102
adjustsFontSizeToFit
70103
style={styles.amount}
71104
variant={TextVariant.BodyMDMedium}
72-
color={highlightedError ? TextColor.Error : TextColor.Default}
105+
color={textColor}
73106
>
74-
{currencySymbol || ''}
75-
{amount}
107+
{amountWithoutSymbol}
76108
</Text>
77-
)}
78-
</TouchableOpacity>
79-
</ListItemColumn>
80-
81-
{onCurrencyPress ? (
82-
<ListItemColumn style={styles.chevron}>
83-
{loading ? (
84-
<SkeletonText small />
85-
) : (
86-
<TouchableOpacity
87-
accessible
88-
accessibilityRole="button"
89-
disabled={!onCurrencyPress}
90-
onPress={onCurrencyPress}
91-
hitSlop={{ top: 20, left: 20, right: 20, bottom: 20 }}
92-
testID={BuildQuoteSelectors.SELECT_CURRENCY}
109+
{cursorView}
110+
<Text
111+
style={styles.amount}
112+
variant={TextVariant.BodyMDMedium}
113+
color={textColor}
93114
>
94-
<DownChevronText text={currencyCode} />
95-
</TouchableOpacity>
96-
)}
115+
{' '}
116+
{tokenSymbol}
117+
</Text>
118+
</View>
119+
);
120+
}
121+
122+
// For buy: show "$100 |" with cursor after amount
123+
return (
124+
<View style={styles.amountWithCursor}>
125+
<Text
126+
numberOfLines={1}
127+
adjustsFontSizeToFit
128+
style={styles.amount}
129+
variant={TextVariant.BodyMDMedium}
130+
color={textColor}
131+
>
132+
{currencySymbol || ''}
133+
{amount}
134+
</Text>
135+
{cursorView}
136+
</View>
137+
);
138+
}
139+
140+
return (
141+
<Text
142+
numberOfLines={1}
143+
adjustsFontSizeToFit
144+
style={styles.amount}
145+
variant={TextVariant.BodyMDMedium}
146+
color={textColor}
147+
>
148+
{currencySymbol || ''}
149+
{amount}
150+
</Text>
151+
);
152+
};
153+
154+
return (
155+
<Box label={label} highlighted={highlighted} compact>
156+
<ListItem>
157+
<ListItemColumn widthType={WidthType.Fill}>
158+
<TouchableOpacity
159+
accessible
160+
accessibilityRole="button"
161+
accessibilityLabel={`${currencySymbol || ''}${amount}`}
162+
onPress={onPress}
163+
hitSlop={{ top: 20, left: 20, right: 20, bottom: 20 }}
164+
testID={BuildQuoteSelectors.AMOUNT_INPUT}
165+
>
166+
{renderAmountContent()}
167+
</TouchableOpacity>
97168
</ListItemColumn>
98-
) : null}
99-
</ListItem>
100-
</Box>
101-
);
169+
170+
{onCurrencyPress ? (
171+
<ListItemColumn style={styles.chevron}>
172+
{loading ? (
173+
<SkeletonText small />
174+
) : (
175+
<TouchableOpacity
176+
accessible
177+
accessibilityRole="button"
178+
disabled={!onCurrencyPress}
179+
onPress={onCurrencyPress}
180+
hitSlop={{ top: 20, left: 20, right: 20, bottom: 20 }}
181+
testID={BuildQuoteSelectors.SELECT_CURRENCY}
182+
>
183+
<DownChevronText text={currencyCode} />
184+
</TouchableOpacity>
185+
)}
186+
</ListItemColumn>
187+
) : null}
188+
</ListItem>
189+
</Box>
190+
);
191+
};
102192

103193
export default AmountInput;

app/components/UI/Ramp/Aggregator/components/__snapshots__/AmountInput.test.tsx.snap

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ exports[`AmountInput renders correctly 1`] = `
6969
testID="listitemcolumn"
7070
>
7171
<TouchableOpacity
72+
accessibilityLabel="$100.50"
7273
accessibilityRole="button"
7374
accessible={true}
7475
hitSlop={
@@ -176,6 +177,7 @@ exports[`AmountInput renders correctly with currency selector 1`] = `
176177
testID="listitemcolumn"
177178
>
178179
<TouchableOpacity
180+
accessibilityLabel="$100.50"
179181
accessibilityRole="button"
180182
accessible={true}
181183
hitSlop={
@@ -359,6 +361,7 @@ exports[`AmountInput renders loading state correctly 1`] = `
359361
testID="listitemcolumn"
360362
>
361363
<TouchableOpacity
364+
accessibilityLabel="$100.50"
362365
accessibilityRole="button"
363366
accessible={true}
364367
hitSlop={
@@ -476,6 +479,7 @@ exports[`AmountInput renders loading state correctly with currency selector 1`]
476479
testID="listitemcolumn"
477480
>
478481
<TouchableOpacity
482+
accessibilityLabel="$100.50"
479483
accessibilityRole="button"
480484
accessible={true}
481485
hitSlop={

0 commit comments

Comments
 (0)