Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
6bb62bc
feat: Keyboard-first order entry UX: auto-focus, auto-select, Tab nav…
abretonc7s Apr 20, 2026
fb95106
feat: keyboard-first order entry UX (TAT-2802)
abretonc7s Apr 21, 2026
0850807
Merge remote-tracking branch 'origin/main' into feat/tat-2802-keyboar…
abretonc7s Apr 21, 2026
7f629d3
feat(perps): leverage input select-on-focus + ArrowUp/ArrowDown step
abretonc7s Apr 21, 2026
e3f8e6e
refactor(perps): remove redundant 'min $10' placeholder from order si…
abretonc7s Apr 22, 2026
3cc0923
fix(perps): set type="button" on ButtonBase children inside order-ent…
abretonc7s Apr 22, 2026
a5f0261
Merge remote-tracking branch 'origin/main' into feat/tat-2802-keyboar…
abretonc7s Apr 22, 2026
5e3cd69
fix(perps): prevent stuck 'Submitting your trade' toast on order submit
abretonc7s Apr 22, 2026
58a4710
fix(perps): friendlier min-order copy, leverage stale-closure + navig…
abretonc7s Apr 22, 2026
c4cfa6f
fix(perps): reword min-order-size copy to match < comparison
abretonc7s Apr 22, 2026
2469be6
chore: align order entry branch with main
abretonc7s Apr 23, 2026
2321f59
fix: address review comments on PR #41949
abretonc7s Apr 24, 2026
9bad87c
Merge remote-tracking branch 'origin/main' into feat/tat-2802-keyboar…
abretonc7s Apr 24, 2026
ceea518
Merge remote-tracking branch 'origin/main' into feat/tat-2802-keyboar…
abretonc7s Apr 25, 2026
8b1e058
chore: drop accidental perps-controller resolutions bump
abretonc7s Apr 25, 2026
d9c2fec
test(perps): cover stuck-toast root-cause + RPC empty-message guard
abretonc7s Apr 25, 2026
1bffa5a
fix(perps): close two market-order edge cases
abretonc7s Apr 25, 2026
bf92866
Merge branch 'main' into feat/tat-2802-keyboard-order-entry-ux
abretonc7s Apr 27, 2026
3ff63ef
fix(perps): pop history on order-entry back button
abretonc7s Apr 28, 2026
16aefed
Merge remote-tracking branch 'origin/main' into feat/tat-2802-keyboar…
abretonc7s Apr 28, 2026
ba57802
Merge remote-tracking branch 'origin/main' into feat/tat-2802-keyboar…
abretonc7s Apr 29, 2026
2edb227
fix: address review comments on PR #41949
abretonc7s Apr 29, 2026
536cd7e
Merge remote-tracking branch 'origin/feat/tat-2802-keyboard-order-ent…
abretonc7s Apr 29, 2026
d862730
fix: address CI feedback
abretonc7s Apr 29, 2026
426f037
fix(perps): restore pending-deposit guard on submit-in-progress toast
abretonc7s Apr 29, 2026
7a7bdb4
Merge remote-tracking branch 'origin/main' into feat/tat-2802-keyboar…
abretonc7s Apr 30, 2026
ce2cb1d
fix: address review comments on PR #41949
abretonc7s Apr 30, 2026
560621c
fix: address review comments on PR #41949
abretonc7s Apr 30, 2026
bcce271
Merge remote-tracking branch 'origin/main' into feat/tat-2802-keyboar…
abretonc7s Apr 30, 2026
a4b6baa
fix: address CI feedback
abretonc7s Apr 30, 2026
d70bc9b
fix: remove unrelated image changes
abretonc7s Apr 30, 2026
22a599f
Resolve PR branch conflicts with current main
abretonc7s Apr 30, 2026
3ffa336
chore: revert changes to metaRPCClientFactory file
gambinish Apr 30, 2026
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
4 changes: 4 additions & 0 deletions app/_locales/en/messages.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions app/_locales/en_GB/messages.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,26 @@ describe('ClosePositionModal', () => {
mockSubmitRequestToBackground.mockResolvedValue({ success: true });
});

describe('auto-focus', () => {
it('auto-focuses the Close Position submit button on mount', async () => {
renderWithProvider(
<ClosePositionModal
isOpen
onClose={jest.fn()}
position={basePosition}
currentPrice={2900}
/>,
mockStore,
);

await waitFor(() => {
expect(
screen.getByTestId('perps-close-position-modal-submit'),
).toHaveFocus();
});
});
});

describe('ORDER_SIZE_MIN from background', () => {
it('shows localized min-notional message when close rejects with ORDER_SIZE_MIN', async () => {
const user = userEvent.setup();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,7 @@ export const ClosePositionModal: React.FC<ClosePositionModalProps> = ({
'data-testid': 'perps-close-position-modal-submit',
children: t('perpsClosePosition'),
disabled: isSubmitDisabled,
autoFocus: true,
}}
/>
</ModalContent>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,74 @@ describe('EditMarginModalContent', () => {
expect(screen.getByText(/available/iu)).toBeInTheDocument();
});

describe('auto-focus and select-all', () => {
it('auto-focuses the margin amount input on mount', () => {
renderWithProvider(
<EditMarginModalContent {...defaultProps} />,
mockStore,
);

const container = screen.getByTestId('perps-edit-margin-amount-input');
const input = container.querySelector('input') as HTMLInputElement;
expect(input).toHaveFocus();
});

it('selects existing margin amount on focus', () => {
renderWithProvider(
<EditMarginModalContent {...defaultProps} />,
mockStore,
);

const container = screen.getByTestId('perps-edit-margin-amount-input');
const input = container.querySelector('input') as HTMLInputElement;
fireEvent.change(input, { target: { value: '42' } });
const selectSpy = jest.spyOn(input, 'select');
fireEvent.focus(input);
expect(selectSpy).toHaveBeenCalled();
});
});

describe('keyboard submission', () => {
it('submits margin update when Enter is pressed with a valid amount', async () => {
renderWithProvider(
<EditMarginModalContent {...defaultProps} />,
mockStore,
);

const container = screen.getByTestId('perps-edit-margin-amount-input');
const input = container.querySelector('input') as HTMLInputElement;
fireEvent.change(input, { target: { value: '100' } });

fireEvent.keyDown(input, { key: 'Enter' });

await waitFor(() => {
expect(mockSubmitRequestToBackground).toHaveBeenCalledWith(
'perpsUpdateMargin',
[
expect.objectContaining({
symbol: basePosition.symbol,
amount: '100',
}),
],
);
});
});

it('does not submit when Enter is pressed with an empty amount', () => {
renderWithProvider(
<EditMarginModalContent {...defaultProps} />,
mockStore,
);

const container = screen.getByTestId('perps-edit-margin-amount-input');
const input = container.querySelector('input') as HTMLInputElement;

fireEvent.keyDown(input, { key: 'Enter' });

expect(mockSubmitRequestToBackground).not.toHaveBeenCalled();
});
});

describe('geo-blocking', () => {
it('shows geo-block modal instead of saving when user is not eligible', async () => {
mockUsePerpsEligibility.mockReturnValue({ isEligible: false });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -447,12 +447,34 @@ export const EditMarginModalContent: React.FC<EditMarginModalContentProps> = ({
size={TextFieldSize.Md}
value={marginAmount}
onChange={handleAmountChange}
onFocus={(event: React.FocusEvent<HTMLInputElement>) =>
event.target.select()
}
placeholder="0.00"
borderRadius={BorderRadius.MD}
borderWidth={0}
backgroundColor={BackgroundColor.backgroundMuted}
disabled={isSaving}
inputProps={{ inputMode: 'decimal', size: 10 }}
autoFocus
data-testid="perps-edit-margin-amount-input"
inputProps={{
inputMode: 'decimal',
size: 10,
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => {
if (
event.key !== 'Enter' ||
event.shiftKey ||
event.nativeEvent.isComposing ||
confirmDisabled
) {
return;
}
event.preventDefault();
handleSaveMargin().catch(() => {
// Errors are surfaced via the perps toast system.
});
},
}}
startAccessory={
<Text
variant={TextVariant.BodyMd}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -522,4 +522,91 @@ describe('AmountInput', () => {
expect(screen.getByTestId('amount-slider')).toBeInTheDocument();
});
});

describe('auto-focus and select-all', () => {
it('auto-focuses the USD input when autoFocus is true', () => {
renderWithProvider(
<AmountInput {...defaultProps} autoFocus />,
mockStore,
);

const container = screen.getByTestId('amount-input-field');
const input = container.querySelector('input');
expect(input).toHaveFocus();
});

it('does not auto-focus the USD input when autoFocus is false', () => {
renderWithProvider(
<AmountInput {...defaultProps} autoFocus={false} />,
mockStore,
);

const container = screen.getByTestId('amount-input-field');
const input = container.querySelector('input');
expect(input).not.toHaveFocus();
});

it('selects existing USD value on focus', () => {
renderWithProvider(
<AmountInput {...defaultProps} amount="123.45" />,
mockStore,
);

const container = screen.getByTestId('amount-input-field');
const input = container.querySelector('input') as HTMLInputElement;
const selectSpy = jest.spyOn(input, 'select');
fireEvent.focus(input);
expect(selectSpy).toHaveBeenCalled();
});

it('selects existing token value after focus switches to editing mode', () => {
renderWithProvider(
<AmountInput {...defaultProps} amount="9000" currentPrice={45000} />,
mockStore,
);

const container = screen.getByTestId('amount-input-token-field');
const input = container.querySelector('input') as HTMLInputElement;
const selectSpy = jest.spyOn(input, 'select');
fireEvent.focus(input);

expect(input).toHaveValue('0.2');
expect(selectSpy).toHaveBeenCalled();
});

it('selects existing percent value on focus', () => {
renderWithProvider(
<AmountInput {...defaultProps} balancePercent={42} />,
mockStore,
);

const container = screen.getByTestId('balance-percent-input');
const input = container.querySelector('input') as HTMLInputElement;
const selectSpy = jest.spyOn(input, 'select');
fireEvent.focus(input);
expect(selectSpy).toHaveBeenCalled();
});

it('uses custom usdPlaceholder when provided', () => {
renderWithProvider(
<AmountInput {...defaultProps} usdPlaceholder="min $10" />,
mockStore,
);

const container = screen.getByTestId('amount-input-field');
const input = container.querySelector('input');
expect(input).toHaveAttribute('placeholder', 'min $10');
});

it('exposes the USD input through usdInputRef', () => {
const ref: { current: HTMLInputElement | null } = { current: null };
renderWithProvider(
<AmountInput {...defaultProps} usdInputRef={ref} />,
mockStore,
);

expect(ref.current).not.toBeNull();
expect(ref.current?.tagName).toBe('INPUT');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@ import {
IconSize,
IconColor,
} from '@metamask/design-system-react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';

import {
BorderRadius,
Expand All @@ -29,6 +35,12 @@ import {
isUnsignedDecimalInput,
} from '../../utils';

const handleNumericFocusSelectAll = (
event: React.FocusEvent<HTMLInputElement>,
) => {
event.target.select();
};

/**
* AmountInput - Size section with dual USD/token inputs and percentage slider
*
Expand All @@ -45,6 +57,9 @@ import {
* @param options0.currentPrice
* @param options0.onAddFunds
* @param options0.szDecimals
* @param options0.autoFocus
* @param options0.usdPlaceholder
* @param options0.usdInputRef
*/
export const AmountInput: React.FC<AmountInputProps> = ({
amount,
Expand All @@ -57,12 +72,17 @@ export const AmountInput: React.FC<AmountInputProps> = ({
currentPrice,
szDecimals,
onAddFunds,
autoFocus = false,
usdPlaceholder = '0.00',
usdInputRef,
}) => {
const t = useI18nContext();
const { formatCurrencyWithMinThreshold, formatNumber } = useFormatters();
const [percentInputValue, setPercentInputValue] = useState<string>(
String(balancePercent),
);
const tokenInputRef = useRef<HTMLInputElement | null>(null);
const shouldSelectTokenOnEditRef = useRef(false);

useEffect(() => {
setPercentInputValue(String(balancePercent));
Expand Down Expand Up @@ -93,6 +113,14 @@ export const AmountInput: React.FC<AmountInputProps> = ({
const [isEditingToken, setIsEditingToken] = useState(false);
const [tokenInputValue, setTokenInputValue] = useState(unGroupedTokenDisplay);

useEffect(() => {
if (!isEditingToken || !shouldSelectTokenOnEditRef.current) {
return;
}
shouldSelectTokenOnEditRef.current = false;
tokenInputRef.current?.select();
}, [isEditingToken, tokenInputValue]);

// When not editing, derive the displayed token value from the current amount
// rather than syncing via an effect — avoids a stale intermediate render.
const displayedTokenValue = isEditingToken
Expand Down Expand Up @@ -193,10 +221,14 @@ export const AmountInput: React.FC<AmountInputProps> = ({
],
);

const handleTokenFocus = useCallback(() => {
setTokenInputValue(unGroupedTokenDisplay);
setIsEditingToken(true);
}, [unGroupedTokenDisplay]);
const handleTokenFocus = useCallback(
(_event: React.FocusEvent<HTMLInputElement>) => {
shouldSelectTokenOnEditRef.current = true;
setTokenInputValue(unGroupedTokenDisplay);
setIsEditingToken(true);
},
[unGroupedTokenDisplay],
);
Comment thread
abretonc7s marked this conversation as resolved.

const handleTokenBlur = useCallback(() => {
setIsEditingToken(false);
Expand Down Expand Up @@ -317,13 +349,16 @@ export const AmountInput: React.FC<AmountInputProps> = ({
size={TextFieldSize.Md}
value={amount}
onChange={handleAmountChange}
onFocus={handleNumericFocusSelectAll}
onBlur={handleAmountBlur}
placeholder="0.00"
placeholder={usdPlaceholder}
borderRadius={BorderRadius.MD}
borderWidth={0}
backgroundColor={BackgroundColor.backgroundMuted}
className="w-full"
data-testid="amount-input-field"
autoFocus={autoFocus}
inputRef={usdInputRef}
inputProps={{ inputMode: 'decimal' }}
startAccessory={
<Text
Expand All @@ -343,6 +378,7 @@ export const AmountInput: React.FC<AmountInputProps> = ({
onFocus={handleTokenFocus}
onBlur={handleTokenBlur}
placeholder="0"
inputRef={tokenInputRef}
borderRadius={BorderRadius.MD}
borderWidth={0}
backgroundColor={BackgroundColor.backgroundMuted}
Expand Down Expand Up @@ -380,6 +416,7 @@ export const AmountInput: React.FC<AmountInputProps> = ({
size={TextFieldSize.Sm}
value={percentInputValue}
onChange={handlePercentInputChange}
onFocus={handleNumericFocusSelectAll}
onBlur={handlePercentInputBlur}
borderRadius={BorderRadius.MD}
borderWidth={0}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export const DirectionTabs: React.FC<DirectionTabsProps> = ({
data-testid="direction-tabs"
>
<ButtonBase
type="button"
className={longTabStyles}
onClick={() => handleDirectionClick('long')}
data-testid="direction-tab-long"
Expand All @@ -75,6 +76,7 @@ export const DirectionTabs: React.FC<DirectionTabsProps> = ({
</ButtonBase>

<ButtonBase
type="button"
className={shortTabStyles}
onClick={() => handleDirectionClick('short')}
data-testid="direction-tab-short"
Expand Down
Loading
Loading