diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json
index b51531e6d824..6f4eb23e3585 100644
--- a/app/_locales/en/messages.json
+++ b/app/_locales/en/messages.json
@@ -5931,6 +5931,10 @@
"perpsMid": {
"message": "Mid"
},
+ "perpsMinOrderSize": {
+ "message": "Order size must be at least $1",
+ "description": "$1 is the minimum order size with currency symbol (e.g., '$10'). Shown as submit-button copy when the size input is empty or below the minimum."
+ },
"perpsModify": {
"message": "Modify"
},
diff --git a/app/_locales/en_GB/messages.json b/app/_locales/en_GB/messages.json
index b51531e6d824..6f4eb23e3585 100644
--- a/app/_locales/en_GB/messages.json
+++ b/app/_locales/en_GB/messages.json
@@ -5931,6 +5931,10 @@
"perpsMid": {
"message": "Mid"
},
+ "perpsMinOrderSize": {
+ "message": "Order size must be at least $1",
+ "description": "$1 is the minimum order size with currency symbol (e.g., '$10'). Shown as submit-button copy when the size input is empty or below the minimum."
+ },
"perpsModify": {
"message": "Modify"
},
diff --git a/ui/components/app/perps/close-position/close-position-modal.test.tsx b/ui/components/app/perps/close-position/close-position-modal.test.tsx
index 8f4be37815ea..9c0b4a34d148 100644
--- a/ui/components/app/perps/close-position/close-position-modal.test.tsx
+++ b/ui/components/app/perps/close-position/close-position-modal.test.tsx
@@ -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(
+ ,
+ 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();
diff --git a/ui/components/app/perps/close-position/close-position-modal.tsx b/ui/components/app/perps/close-position/close-position-modal.tsx
index 829916faa6ab..9dc6f6b1418a 100644
--- a/ui/components/app/perps/close-position/close-position-modal.tsx
+++ b/ui/components/app/perps/close-position/close-position-modal.tsx
@@ -671,6 +671,7 @@ export const ClosePositionModal: React.FC = ({
'data-testid': 'perps-close-position-modal-submit',
children: t('perpsClosePosition'),
disabled: isSubmitDisabled,
+ autoFocus: true,
}}
/>
diff --git a/ui/components/app/perps/edit-margin/edit-margin-modal-content.test.tsx b/ui/components/app/perps/edit-margin/edit-margin-modal-content.test.tsx
index 4288f8631ce1..6530239fa44f 100644
--- a/ui/components/app/perps/edit-margin/edit-margin-modal-content.test.tsx
+++ b/ui/components/app/perps/edit-margin/edit-margin-modal-content.test.tsx
@@ -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(
+ ,
+ 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(
+ ,
+ 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(
+ ,
+ 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(
+ ,
+ 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 });
diff --git a/ui/components/app/perps/edit-margin/edit-margin-modal-content.tsx b/ui/components/app/perps/edit-margin/edit-margin-modal-content.tsx
index cae819a93d26..24a4531989a9 100644
--- a/ui/components/app/perps/edit-margin/edit-margin-modal-content.tsx
+++ b/ui/components/app/perps/edit-margin/edit-margin-modal-content.tsx
@@ -447,12 +447,34 @@ export const EditMarginModalContent: React.FC = ({
size={TextFieldSize.Md}
value={marginAmount}
onChange={handleAmountChange}
+ onFocus={(event: React.FocusEvent) =>
+ 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) => {
+ if (
+ event.key !== 'Enter' ||
+ event.shiftKey ||
+ event.nativeEvent.isComposing ||
+ confirmDisabled
+ ) {
+ return;
+ }
+ event.preventDefault();
+ handleSaveMargin().catch(() => {
+ // Errors are surfaced via the perps toast system.
+ });
+ },
+ }}
startAccessory={
{
expect(screen.getByTestId('amount-slider')).toBeInTheDocument();
});
});
+
+ describe('auto-focus and select-all', () => {
+ it('auto-focuses the USD input when autoFocus is true', () => {
+ renderWithProvider(
+ ,
+ 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(
+ ,
+ 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(
+ ,
+ 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(
+ ,
+ 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(
+ ,
+ 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(
+ ,
+ 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(
+ ,
+ mockStore,
+ );
+
+ expect(ref.current).not.toBeNull();
+ expect(ref.current?.tagName).toBe('INPUT');
+ });
+ });
});
diff --git a/ui/components/app/perps/order-entry/components/amount-input/amount-input.tsx b/ui/components/app/perps/order-entry/components/amount-input/amount-input.tsx
index 0656b892da93..bfc7dbc8a862 100644
--- a/ui/components/app/perps/order-entry/components/amount-input/amount-input.tsx
+++ b/ui/components/app/perps/order-entry/components/amount-input/amount-input.tsx
@@ -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,
@@ -29,6 +35,12 @@ import {
isUnsignedDecimalInput,
} from '../../utils';
+const handleNumericFocusSelectAll = (
+ event: React.FocusEvent,
+) => {
+ event.target.select();
+};
+
/**
* AmountInput - Size section with dual USD/token inputs and percentage slider
*
@@ -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 = ({
amount,
@@ -57,12 +72,17 @@ export const AmountInput: React.FC = ({
currentPrice,
szDecimals,
onAddFunds,
+ autoFocus = false,
+ usdPlaceholder = '0.00',
+ usdInputRef,
}) => {
const t = useI18nContext();
const { formatCurrencyWithMinThreshold, formatNumber } = useFormatters();
const [percentInputValue, setPercentInputValue] = useState(
String(balancePercent),
);
+ const tokenInputRef = useRef(null);
+ const shouldSelectTokenOnEditRef = useRef(false);
useEffect(() => {
setPercentInputValue(String(balancePercent));
@@ -93,6 +113,14 @@ export const AmountInput: React.FC = ({
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
@@ -193,10 +221,14 @@ export const AmountInput: React.FC = ({
],
);
- const handleTokenFocus = useCallback(() => {
- setTokenInputValue(unGroupedTokenDisplay);
- setIsEditingToken(true);
- }, [unGroupedTokenDisplay]);
+ const handleTokenFocus = useCallback(
+ (_event: React.FocusEvent) => {
+ shouldSelectTokenOnEditRef.current = true;
+ setTokenInputValue(unGroupedTokenDisplay);
+ setIsEditingToken(true);
+ },
+ [unGroupedTokenDisplay],
+ );
const handleTokenBlur = useCallback(() => {
setIsEditingToken(false);
@@ -317,13 +349,16 @@ export const AmountInput: React.FC = ({
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={
= ({
onFocus={handleTokenFocus}
onBlur={handleTokenBlur}
placeholder="0"
+ inputRef={tokenInputRef}
borderRadius={BorderRadius.MD}
borderWidth={0}
backgroundColor={BackgroundColor.backgroundMuted}
@@ -380,6 +416,7 @@ export const AmountInput: React.FC = ({
size={TextFieldSize.Sm}
value={percentInputValue}
onChange={handlePercentInputChange}
+ onFocus={handleNumericFocusSelectAll}
onBlur={handlePercentInputBlur}
borderRadius={BorderRadius.MD}
borderWidth={0}
diff --git a/ui/components/app/perps/order-entry/components/direction-tabs/direction-tabs.tsx b/ui/components/app/perps/order-entry/components/direction-tabs/direction-tabs.tsx
index d9129680ae42..6520846b5239 100644
--- a/ui/components/app/perps/order-entry/components/direction-tabs/direction-tabs.tsx
+++ b/ui/components/app/perps/order-entry/components/direction-tabs/direction-tabs.tsx
@@ -58,6 +58,7 @@ export const DirectionTabs: React.FC = ({
data-testid="direction-tabs"
>
handleDirectionClick('long')}
data-testid="direction-tab-long"
@@ -75,6 +76,7 @@ export const DirectionTabs: React.FC = ({
handleDirectionClick('short')}
data-testid="direction-tab-short"
diff --git a/ui/components/app/perps/order-entry/components/leverage-slider/leverage-slider.test.tsx b/ui/components/app/perps/order-entry/components/leverage-slider/leverage-slider.test.tsx
index 47109a4143ec..4ad286cfdbdc 100644
--- a/ui/components/app/perps/order-entry/components/leverage-slider/leverage-slider.test.tsx
+++ b/ui/components/app/perps/order-entry/components/leverage-slider/leverage-slider.test.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { screen } from '@testing-library/react';
+import { createEvent, fireEvent, screen } from '@testing-library/react';
import { renderWithProvider } from '../../../../../../../test/lib/render-helpers-navigate';
import configureStore from '../../../../../../store/store';
import mockState from '../../../../../../../test/data/mock-state.json';
@@ -50,4 +50,129 @@ describe('LeverageSlider', () => {
expect(screen.getByTestId('leverage-slider')).toBeInTheDocument();
});
});
+
+ describe('keyboard interaction', () => {
+ const getInput = () =>
+ screen
+ .getByTestId('leverage-input')
+ .querySelector('input') as HTMLInputElement;
+
+ it('selects the existing value when the input is focused', () => {
+ renderWithProvider(
+ ,
+ mockStore,
+ );
+ const input = getInput();
+ input.focus();
+ expect(input.selectionStart).toBe(0);
+ expect(input.selectionEnd).toBe(String(7).length);
+ });
+
+ it('increments leverage by 1 on ArrowUp and clamps at maxLeverage', () => {
+ const onLeverageChange = jest.fn();
+ renderWithProvider(
+ ,
+ mockStore,
+ );
+ const input = getInput();
+ fireEvent.keyDown(input, { key: 'ArrowUp' });
+ expect(onLeverageChange).toHaveBeenLastCalledWith(4);
+ fireEvent.keyDown(input, { key: 'ArrowUp' });
+ expect(onLeverageChange).toHaveBeenLastCalledWith(5);
+ fireEvent.keyDown(input, { key: 'ArrowUp' });
+ expect(onLeverageChange).toHaveBeenLastCalledWith(5);
+ });
+
+ it('decrements leverage by 1 on ArrowDown and clamps at minLeverage', () => {
+ const onLeverageChange = jest.fn();
+ renderWithProvider(
+ ,
+ mockStore,
+ );
+ const input = getInput();
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
+ expect(onLeverageChange).toHaveBeenLastCalledWith(1);
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
+ expect(onLeverageChange).toHaveBeenLastCalledWith(1);
+ });
+
+ it('ignores non-arrow keys', () => {
+ const onLeverageChange = jest.fn();
+ renderWithProvider(
+ ,
+ mockStore,
+ );
+ const input = getInput();
+ fireEvent.keyDown(input, { key: 'Enter' });
+ fireEvent.keyDown(input, { key: 'a' });
+ expect(onLeverageChange).not.toHaveBeenCalled();
+ });
+
+ it('swallows Enter so it does not bubble to an outer form', () => {
+ const onLeverageChange = jest.fn();
+ renderWithProvider(
+ ,
+ mockStore,
+ );
+ const input = getInput();
+ const event = createEvent.keyDown(input, { key: 'Enter' });
+ fireEvent(input, event);
+ expect(event.defaultPrevented).toBe(true);
+ expect(onLeverageChange).not.toHaveBeenCalled();
+ });
+
+ it('commits typed digits within range via onChange', () => {
+ const onLeverageChange = jest.fn();
+ renderWithProvider(
+ ,
+ mockStore,
+ );
+ const input = getInput();
+ fireEvent.change(input, { target: { value: '5' } });
+ expect(onLeverageChange).toHaveBeenLastCalledWith(5);
+ });
+
+ it('clamps to minLeverage when blurred with empty/invalid value', () => {
+ const onLeverageChange = jest.fn();
+ renderWithProvider(
+ ,
+ mockStore,
+ );
+ const input = getInput();
+ fireEvent.change(input, { target: { value: '' } });
+ fireEvent.blur(input);
+ expect(onLeverageChange).toHaveBeenLastCalledWith(1);
+ expect(input).toHaveValue('1');
+ });
+ });
});
diff --git a/ui/components/app/perps/order-entry/components/leverage-slider/leverage-slider.tsx b/ui/components/app/perps/order-entry/components/leverage-slider/leverage-slider.tsx
index 7099e6b52c48..e1edc6ec31f5 100644
--- a/ui/components/app/perps/order-entry/components/leverage-slider/leverage-slider.tsx
+++ b/ui/components/app/perps/order-entry/components/leverage-slider/leverage-slider.tsx
@@ -74,8 +74,8 @@ export const LeverageSlider: React.FC = ({
const { value } = event.target;
if (value === '' || isDigitsOnlyInput(value)) {
setInputValue(value);
- const num = parseInt(value, 10);
- if (!isNaN(num) && num >= minLeverage && num <= maxLeverage) {
+ const num = Number.parseInt(value, 10);
+ if (!Number.isNaN(num) && num >= minLeverage && num <= maxLeverage) {
onLeverageChange(num);
}
}
@@ -84,8 +84,8 @@ export const LeverageSlider: React.FC = ({
);
const handleInputBlur = useCallback(() => {
- const num = parseInt(inputValue, 10);
- if (isNaN(num) || num < minLeverage) {
+ const num = Number.parseInt(inputValue, 10);
+ if (Number.isNaN(num) || num < minLeverage) {
onLeverageChange(minLeverage);
setInputValue(String(minLeverage));
} else if (num > maxLeverage) {
@@ -97,6 +97,40 @@ export const LeverageSlider: React.FC = ({
}
}, [inputValue, onLeverageChange, minLeverage, maxLeverage]);
+ const handleInputFocus = useCallback(
+ (event: React.FocusEvent) => {
+ event.target.select();
+ },
+ [],
+ );
+
+ const handleInputKeyDown = useCallback(
+ (event: React.KeyboardEvent) => {
+ // Leverage is a secondary control — Enter must not submit the outer
+ // order-entry form. Swallow it so the native form-submit path only
+ // fires from primary inputs (size/limit price).
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ return;
+ }
+ if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') {
+ return;
+ }
+ event.preventDefault();
+ const step = event.key === 'ArrowUp' ? 1 : -1;
+ // Read the current value straight from the DOM input so rapid consecutive
+ // presses cannot batch against a stale local-state capture in this
+ // closure. Falls back to `leverage` (the parent-owned source of truth)
+ // when the input is empty or non-numeric.
+ const rawCurrent = Number.parseInt(event.currentTarget.value, 10);
+ const base = Number.isNaN(rawCurrent) ? leverage : rawCurrent;
+ const next = Math.min(maxLeverage, Math.max(minLeverage, base + step));
+ setInputValue(String(next));
+ onLeverageChange(next);
+ },
+ [leverage, onLeverageChange, minLeverage, maxLeverage],
+ );
+
return (
{t('perpsLeverage')}
@@ -123,6 +157,7 @@ export const LeverageSlider: React.FC = ({
value={inputValue}
onChange={handleInputChange}
onBlur={handleInputBlur}
+ onFocus={handleInputFocus}
borderRadius={BorderRadius.MD}
borderWidth={0}
backgroundColor={BackgroundColor.backgroundMuted}
@@ -131,6 +166,7 @@ export const LeverageSlider: React.FC = ({
inputProps={{
inputMode: 'numeric',
style: { textAlign: 'right' },
+ onKeyDown: handleInputKeyDown,
}}
endAccessory={
{
).not.toBeInTheDocument();
});
});
+
+ describe('auto-focus and select-all', () => {
+ it('auto-focuses the limit price input when autoFocus is true', () => {
+ renderWithProvider(
+ ,
+ mockStore,
+ );
+
+ const container = screen.getByTestId('limit-price-input');
+ const input = container.querySelector('input');
+ expect(input).toHaveFocus();
+ });
+
+ it('does not auto-focus the limit price input when autoFocus is false', () => {
+ renderWithProvider(
+ ,
+ mockStore,
+ );
+
+ const container = screen.getByTestId('limit-price-input');
+ const input = container.querySelector('input');
+ expect(input).not.toHaveFocus();
+ });
+
+ it('selects existing limit price value on focus', () => {
+ renderWithProvider(
+ ,
+ mockStore,
+ );
+
+ const container = screen.getByTestId('limit-price-input');
+ const input = container.querySelector('input') as HTMLInputElement;
+ const selectSpy = jest.spyOn(input, 'select');
+ fireEvent.focus(input);
+ expect(selectSpy).toHaveBeenCalled();
+ });
+ });
});
diff --git a/ui/components/app/perps/order-entry/components/limit-price-input/limit-price-input.tsx b/ui/components/app/perps/order-entry/components/limit-price-input/limit-price-input.tsx
index 2e0e6330dde2..f8fd061943ca 100644
--- a/ui/components/app/perps/order-entry/components/limit-price-input/limit-price-input.tsx
+++ b/ui/components/app/perps/order-entry/components/limit-price-input/limit-price-input.tsx
@@ -45,6 +45,8 @@ export type LimitPriceInputProps = {
direction: OrderDirection;
/** Raw estimated liquidation price (for proximity warning) */
liquidationPrice?: number | null;
+ /** Auto-focus the input on mount (used for keyboard-first order entry) */
+ autoFocus?: boolean;
};
/**
@@ -56,6 +58,7 @@ export type LimitPriceInputProps = {
* @param options0.midPrice
* @param options0.direction
* @param options0.liquidationPrice
+ * @param options0.autoFocus
*/
export const LimitPriceInput: React.FC = ({
limitPrice,
@@ -64,6 +67,7 @@ export const LimitPriceInput: React.FC = ({
midPrice: midPriceProp,
direction,
liquidationPrice,
+ autoFocus = false,
}) => {
const t = useI18nContext();
const midPrice = midPriceProp ?? currentPrice;
@@ -133,6 +137,9 @@ export const LimitPriceInput: React.FC = ({
size={TextFieldSize.Md}
value={limitPrice}
onChange={handlePriceChange}
+ onFocus={(event: React.FocusEvent) =>
+ event.target.select()
+ }
onBlur={handlePriceBlur}
placeholder="0.00"
borderRadius={BorderRadius.MD}
@@ -140,6 +147,7 @@ export const LimitPriceInput: React.FC = ({
backgroundColor={BackgroundColor.backgroundMuted}
className="w-full"
data-testid="limit-price-input"
+ autoFocus={autoFocus}
inputProps={{ inputMode: 'decimal' }}
startAccessory={
@@ -148,6 +156,7 @@ export const LimitPriceInput: React.FC = ({
}
endAccessory={
{
expect(screen.queryByTestId('limit-price-input')).not.toBeInTheDocument();
});
+ it('passes the USD placeholder override to market order amount input', () => {
+ renderWithProvider(
+ ,
+ mockStore,
+ );
+
+ const container = screen.getByTestId('amount-input-field');
+ const input = container.querySelector('input');
+ expect(input).toHaveAttribute('placeholder', 'min $10');
+ });
+
+ it('keeps the default USD placeholder for limit orders when no override is provided', () => {
+ renderWithProvider(
+ ,
+ mockStore,
+ );
+
+ const container = screen.getByTestId('amount-input-field');
+ const input = container.querySelector('input');
+ expect(input).toHaveAttribute('placeholder', '0.00');
+ });
+
it('hides limit price input in close mode even when orderType is limit', () => {
const existingPosition = {
size: '2.5',
diff --git a/ui/components/app/perps/order-entry/order-entry.tsx b/ui/components/app/perps/order-entry/order-entry.tsx
index 76b66570ff29..0ab029ce6ca3 100644
--- a/ui/components/app/perps/order-entry/order-entry.tsx
+++ b/ui/components/app/perps/order-entry/order-entry.tsx
@@ -61,6 +61,9 @@ import { CloseAmountSection } from './components/close-amount-section';
* @param props.initialLeverage
* @param props.sizeDecimals
* @param props.markPrice
+ * @param props.autoFocusUsd
+ * @param props.autoFocusLimitPrice
+ * @param props.usdPlaceholder
*/
export const OrderEntry: React.FC = ({
asset,
@@ -82,6 +85,9 @@ export const OrderEntry: React.FC = ({
initialLeverage,
sizeDecimals,
markPrice,
+ autoFocusUsd = false,
+ autoFocusLimitPrice = false,
+ usdPlaceholder,
}) => {
const t = useI18nContext();
@@ -163,6 +169,20 @@ export const OrderEntry: React.FC = ({
onOrderTypeChange?.(type);
};
+ // Refocus the USD size input whenever the user switches back to market mode,
+ // so the keyboard-first flow stays consistent across order-type toggles.
+ const usdInputRef = useRef(null);
+ useEffect(() => {
+ if (
+ autoFocusUsd &&
+ mode !== 'close' &&
+ formState.type === 'market' &&
+ usdInputRef.current
+ ) {
+ usdInputRef.current.focus();
+ }
+ }, [autoFocusUsd, mode, formState.type]);
+
// Determine submit button text based on mode
const submitButtonText = useMemo(() => {
switch (mode) {
@@ -313,6 +333,7 @@ export const OrderEntry: React.FC = ({
midPrice={midPrice}
direction={formState.direction}
liquidationPrice={calculations.liquidationPriceRaw}
+ autoFocus={autoFocusLimitPrice}
/>
)}
@@ -329,6 +350,9 @@ export const OrderEntry: React.FC = ({
currentPrice={currentPrice}
szDecimals={marketInfo?.szDecimals}
onAddFunds={onAddFunds}
+ autoFocus={autoFocusUsd && formState.type === 'market'}
+ usdPlaceholder={usdPlaceholder}
+ usdInputRef={usdInputRef}
/>
)}
diff --git a/ui/components/app/perps/order-entry/order-entry.types.ts b/ui/components/app/perps/order-entry/order-entry.types.ts
index b2fc4bf3820f..586b99cd1880 100644
--- a/ui/components/app/perps/order-entry/order-entry.types.ts
+++ b/ui/components/app/perps/order-entry/order-entry.types.ts
@@ -135,6 +135,12 @@ export type OrderEntryProps = {
* Falls back to currentPrice when not yet available.
*/
markPrice?: number;
+ /** Auto-focus the USD size input on mount / when market order type is active */
+ autoFocusUsd?: boolean;
+ /** Auto-focus the limit price input on mount / when switching to limit order type */
+ autoFocusLimitPrice?: boolean;
+ /** Placeholder override for the USD input. Defaults to AmountInput's '0.00'. */
+ usdPlaceholder?: string;
};
/**
@@ -176,6 +182,14 @@ export type AmountInputProps = {
szDecimals?: number;
/** Callback when add-funds icon is pressed */
onAddFunds?: () => void;
+ /** Auto-focus the USD input on mount (used for keyboard-first order entry) */
+ autoFocus?: boolean;
+ /** Placeholder override for the USD input. Defaults to '0.00'. */
+ usdPlaceholder?: string;
+ /** Ref to the USD input element so parents can imperatively refocus it on order-type changes */
+ usdInputRef?:
+ | React.MutableRefObject
+ | ((instance: HTMLInputElement | null) => void);
};
/**
diff --git a/ui/components/app/perps/update-tpsl/update-tpsl-modal-content.test.tsx b/ui/components/app/perps/update-tpsl/update-tpsl-modal-content.test.tsx
index faed3fd04bde..26864a8b88a3 100644
--- a/ui/components/app/perps/update-tpsl/update-tpsl-modal-content.test.tsx
+++ b/ui/components/app/perps/update-tpsl/update-tpsl-modal-content.test.tsx
@@ -157,6 +157,37 @@ describe('UpdateTPSLModalContent', () => {
).toBeInTheDocument();
});
+ it('auto-focuses the TP trigger price input on mount', () => {
+ renderTpslModalContent();
+
+ const input = screen.getByTestId(
+ 'perps-update-tpsl-tp-price-input',
+ ) as HTMLInputElement;
+ expect(input).toHaveFocus();
+ });
+
+ it('selects existing TP price value on focus', () => {
+ renderTpslModalContent();
+
+ const input = screen.getByTestId(
+ 'perps-update-tpsl-tp-price-input',
+ ) as HTMLInputElement;
+ const selectSpy = jest.spyOn(input, 'select');
+ fireEvent.focus(input);
+ expect(selectSpy).toHaveBeenCalled();
+ });
+
+ it('selects existing SL price value on focus', () => {
+ renderTpslModalContent();
+
+ const input = screen.getByTestId(
+ 'perps-update-tpsl-sl-price-input',
+ ) as HTMLInputElement;
+ const selectSpy = jest.spyOn(input, 'select');
+ fireEvent.focus(input);
+ expect(selectSpy).toHaveBeenCalled();
+ });
+
it('renders four text inputs (TP price, TP %, SL price, SL %)', () => {
renderTpslModalContent();
@@ -165,6 +196,36 @@ describe('UpdateTPSLModalContent', () => {
expect(priceInputs).toHaveLength(2);
expect(percentInputs).toHaveLength(2);
});
+
+ it('keeps the TP percent value selected after focus switches to the raw value', async () => {
+ renderTpslModalContent();
+
+ const tpPercentInput = screen.getAllByPlaceholderText(
+ '0',
+ )[0] as HTMLInputElement;
+ fireEvent.focus(tpPercentInput);
+
+ await waitFor(() => {
+ expect(tpPercentInput.value.length).toBeGreaterThan(0);
+ expect(tpPercentInput.selectionStart).toBe(0);
+ expect(tpPercentInput.selectionEnd).toBe(tpPercentInput.value.length);
+ });
+ });
+
+ it('keeps the SL percent value selected after focus switches to the raw value', async () => {
+ renderTpslModalContent();
+
+ const slPercentInput = screen.getAllByPlaceholderText(
+ '0',
+ )[1] as HTMLInputElement;
+ fireEvent.focus(slPercentInput);
+
+ await waitFor(() => {
+ expect(slPercentInput.value.length).toBeGreaterThan(0);
+ expect(slPercentInput.selectionStart).toBe(0);
+ expect(slPercentInput.selectionEnd).toBe(slPercentInput.value.length);
+ });
+ });
});
describe('initialization', () => {
@@ -789,6 +850,52 @@ describe('UpdateTPSLModalContent', () => {
});
});
+ describe('keyboard submission', () => {
+ it('submits TP/SL update when Enter is pressed on the TP price input', async () => {
+ renderTpslModalContent();
+
+ const input = screen.getByTestId(
+ 'perps-update-tpsl-tp-price-input',
+ ) as HTMLInputElement;
+
+ fireEvent.keyDown(input, { key: 'Enter' });
+
+ await waitFor(() => {
+ expect(mockSubmitRequestToBackground).toHaveBeenCalledWith(
+ 'perpsUpdatePositionTPSL',
+ [
+ {
+ symbol: positionWithTPSL.symbol,
+ takeProfitPrice: '3200.00',
+ stopLossPrice: '2600.00',
+ },
+ ],
+ );
+ });
+ });
+
+ it('does not submit when Enter is pressed with invalid TP/SL', async () => {
+ renderTpslModalContent();
+
+ const tpInput = screen.getByTestId(
+ 'perps-update-tpsl-tp-price-input',
+ ) as HTMLInputElement;
+ // For a long position, TP must be above entry (2850); set it below.
+ fireEvent.change(tpInput, { target: { value: '100' } });
+
+ fireEvent.keyDown(tpInput, { key: 'Enter' });
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(mockSubmitRequestToBackground).not.toHaveBeenCalledWith(
+ 'perpsUpdatePositionTPSL',
+ expect.anything(),
+ );
+ });
+ });
+
describe('error handling', () => {
it('shows toast error when perpsUpdatePositionTPSL fails', async () => {
mockSubmitRequestToBackground.mockImplementation((method: string) => {
diff --git a/ui/components/app/perps/update-tpsl/update-tpsl-modal-content.tsx b/ui/components/app/perps/update-tpsl/update-tpsl-modal-content.tsx
index 29808e380b22..dad92f07959b 100644
--- a/ui/components/app/perps/update-tpsl/update-tpsl-modal-content.tsx
+++ b/ui/components/app/perps/update-tpsl/update-tpsl-modal-content.tsx
@@ -131,6 +131,9 @@ export const UpdateTPSLModalContent: React.FC = ({
// Exact preset percent values, kept until the user manually edits a field
const [tpPresetPercent, setTpPresetPercent] = useState(null);
const [slPresetPercent, setSlPresetPercent] = useState(null);
+ const tpPercentInputRef = useRef(null);
+ const slPercentInputRef = useRef(null);
+ const pendingPercentSelectRef = useRef<'tp' | 'sl' | null>(null);
const entryPriceForEdit = useMemo(() => {
if (position?.entryPrice) {
@@ -314,6 +317,20 @@ export const UpdateTPSLModalContent: React.FC = ({
const hasInvalidTPSL = isTpInvalid || isSlInvalid || isSlLiquidationInvalid;
+ useLayoutEffect(() => {
+ const pendingInput = pendingPercentSelectRef.current;
+ if (!pendingInput) {
+ return;
+ }
+
+ pendingPercentSelectRef.current = null;
+ const input =
+ pendingInput === 'tp'
+ ? tpPercentInputRef.current
+ : slPercentInputRef.current;
+ input?.select();
+ });
+
useEffect(() => {
return () => {
isMountedRef.current = false;
@@ -366,6 +383,7 @@ export const UpdateTPSLModalContent: React.FC = ({
);
const handleTpPercentFocus = useCallback(() => {
+ pendingPercentSelectRef.current = 'tp';
setRawTpPercent(tpPresetPercent ?? editingTpPercent);
setIsTpPercentFocused(true);
}, [tpPresetPercent, editingTpPercent]);
@@ -395,6 +413,7 @@ export const UpdateTPSLModalContent: React.FC = ({
);
const handleSlPercentFocus = useCallback(() => {
+ pendingPercentSelectRef.current = 'sl';
setRawSlPercent(slPresetPercent ?? editingSlPercent);
setIsSlPercentFocused(true);
}, [slPresetPercent, editingSlPercent]);
@@ -544,14 +563,34 @@ export const UpdateTPSLModalContent: React.FC = ({
track,
]);
+ const isSubmitDisabled = isSaving || hasInvalidTPSL;
+
useLayoutEffect(() => {
onSubmitStateChange?.({
onSubmit: handleSave,
isSaving,
- submitDisabled: isSaving || hasInvalidTPSL,
+ submitDisabled: isSubmitDisabled,
submitButtonTitle: undefined,
});
- }, [onSubmitStateChange, handleSave, isSaving, hasInvalidTPSL, t]);
+ }, [onSubmitStateChange, handleSave, isSaving, isSubmitDisabled, t]);
+
+ const handleInputEnterKey = useCallback(
+ (event: React.KeyboardEvent) => {
+ if (
+ event.key !== 'Enter' ||
+ event.shiftKey ||
+ event.nativeEvent.isComposing ||
+ isSubmitDisabled
+ ) {
+ return;
+ }
+ event.preventDefault();
+ handleSave().catch(() => {
+ // Errors are surfaced via the perps toast system.
+ });
+ },
+ [handleSave, isSubmitDisabled],
+ );
return (
@@ -600,6 +639,9 @@ export const UpdateTPSLModalContent: React.FC = ({
setTpPresetPercent(null);
}
}}
+ onFocus={(event: React.FocusEvent) =>
+ event.target.select()
+ }
onBlur={handleTpPriceBlur}
placeholder="0.00"
borderRadius={BorderRadius.MD}
@@ -607,7 +649,9 @@ export const UpdateTPSLModalContent: React.FC = ({
backgroundColor={BackgroundColor.backgroundMuted}
className="w-full"
disabled={isSaving}
+ autoFocus
testId="perps-update-tpsl-tp-price-input"
+ inputProps={{ onKeyDown: handleInputEnterKey }}
startAccessory={
= ({
className="w-full"
disabled={isSaving}
testId="perps-update-tpsl-tp-percent-input"
+ inputRef={tpPercentInputRef}
+ inputProps={{ onKeyDown: handleInputEnterKey }}
endAccessory={
= ({
setSlPresetPercent(null);
}
}}
+ onFocus={(event: React.FocusEvent) =>
+ event.target.select()
+ }
onBlur={handleSlPriceBlur}
placeholder="0.00"
borderRadius={BorderRadius.MD}
@@ -739,6 +788,7 @@ export const UpdateTPSLModalContent: React.FC = ({
className="w-full"
disabled={isSaving}
testId="perps-update-tpsl-sl-price-input"
+ inputProps={{ onKeyDown: handleInputEnterKey }}
startAccessory={
= ({
className="w-full"
disabled={isSaving}
testId="perps-update-tpsl-sl-percent-input"
+ inputRef={slPercentInputRef}
+ inputProps={{ onKeyDown: handleInputEnterKey }}
endAccessory={
undefined);
+const enterAmount = (value: string) => {
+ const amountContainer = screen.getByTestId('amount-input-field');
+ const amountInput = amountContainer.querySelector(
+ 'input',
+ ) as HTMLInputElement;
+ fireEvent.change(amountInput, { target: { value } });
+};
+
jest.mock('@metamask/perps-controller', () => ({
PERPS_ERROR_CODES: {
CLIENT_NOT_INITIALIZED: 'CLIENT_NOT_INITIALIZED',
@@ -352,6 +360,7 @@ describe('PerpsOrderEntryPage', () => {
it('renders the submit button with Open Long text by default', () => {
const store = mockStore(createMockState());
renderWithProvider(, store);
+ enterAmount('100');
expect(screen.getByTestId('submit-order-button')).toHaveTextContent(
'Open long ETH',
@@ -451,6 +460,7 @@ describe('PerpsOrderEntryPage', () => {
it('defaults to long direction', () => {
const store = mockStore(createMockState());
renderWithProvider(, store);
+ enterAmount('100');
expect(screen.getByTestId('submit-order-button')).toHaveTextContent(
'Open long',
@@ -461,6 +471,7 @@ describe('PerpsOrderEntryPage', () => {
mockSearchParams.set('direction', 'short');
const store = mockStore(createMockState());
renderWithProvider(, store);
+ enterAmount('100');
expect(screen.getByTestId('submit-order-button')).toHaveTextContent(
'Open short',
@@ -510,7 +521,9 @@ describe('PerpsOrderEntryPage', () => {
renderWithProvider(, store);
fireEvent.click(screen.getByTestId('perps-order-entry-back-button'));
- expect(mockUseNavigate).toHaveBeenCalledWith(-1);
+ expect(mockUseNavigate).toHaveBeenCalledWith('/perps/market/ETH', {
+ replace: true,
+ });
});
it('navigates back in history for encoded symbol markets', () => {
@@ -519,7 +532,9 @@ describe('PerpsOrderEntryPage', () => {
renderWithProvider(, store);
fireEvent.click(screen.getByTestId('perps-order-entry-back-button'));
- expect(mockUseNavigate).toHaveBeenCalledWith(-1);
+ expect(mockUseNavigate).toHaveBeenCalledWith('/perps/market/xyz%3ATSLA', {
+ replace: true,
+ });
});
});
@@ -970,7 +985,9 @@ describe('PerpsOrderEntryPage', () => {
}),
],
);
- expect(mockUseNavigate).toHaveBeenCalledWith(-1);
+ expect(mockUseNavigate).toHaveBeenCalledWith('/perps/market/ETH', {
+ replace: true,
+ });
expect(mockReplacePerpsToastByKey).toHaveBeenCalledWith(
expect.objectContaining({
key: 'perpsToastSubmitInProgress',
@@ -1007,7 +1024,9 @@ describe('PerpsOrderEntryPage', () => {
fireEvent.click(screen.getByTestId('submit-order-button'));
});
- expect(mockUseNavigate).toHaveBeenCalledWith(-1);
+ expect(mockUseNavigate).toHaveBeenCalledWith('/perps/market/xyz%3ATSLA', {
+ replace: true,
+ });
expect(mockReplacePerpsToastByKey).toHaveBeenCalledWith(
expect.objectContaining({
key: 'perpsToastSubmitInProgress',
@@ -1099,7 +1118,9 @@ describe('PerpsOrderEntryPage', () => {
},
],
);
- expect(mockUseNavigate).toHaveBeenCalledWith(-1);
+ expect(mockUseNavigate).toHaveBeenCalledWith('/perps/market/ETH', {
+ replace: true,
+ });
expect(mockReplacePerpsToastByKey).toHaveBeenCalledWith(
expect.objectContaining({
key: 'perpsToastCloseInProgress',
@@ -1143,7 +1164,9 @@ describe('PerpsOrderEntryPage', () => {
}),
],
);
- expect(mockUseNavigate).toHaveBeenCalledWith(-1);
+ expect(mockUseNavigate).toHaveBeenCalledWith('/perps/market/ETH', {
+ replace: true,
+ });
expect(mockReplacePerpsToastByKey).toHaveBeenCalledWith(
expect.objectContaining({
key: 'perpsToastPartialCloseInProgress',
@@ -1178,7 +1201,9 @@ describe('PerpsOrderEntryPage', () => {
fireEvent.click(screen.getByTestId('submit-order-button'));
});
- expect(mockUseNavigate).toHaveBeenCalledWith(-1);
+ expect(mockUseNavigate).toHaveBeenCalledWith('/perps/market/ETH', {
+ replace: true,
+ });
expect(mockReplacePerpsToastByKey).toHaveBeenCalledWith(
expect.objectContaining({
key: 'perpsToastCloseInProgress',
@@ -1213,7 +1238,9 @@ describe('PerpsOrderEntryPage', () => {
fireEvent.click(screen.getByTestId('submit-order-button'));
});
- expect(mockUseNavigate).toHaveBeenCalledWith(-1);
+ expect(mockUseNavigate).toHaveBeenCalledWith('/perps/market/ETH', {
+ replace: true,
+ });
expect(mockReplacePerpsToastByKey).toHaveBeenCalledWith(
expect.objectContaining({
key: 'perpsToastCloseInProgress',
@@ -1247,7 +1274,9 @@ describe('PerpsOrderEntryPage', () => {
fireEvent.click(screen.getByTestId('submit-order-button'));
});
- expect(mockUseNavigate).toHaveBeenCalledWith(-1);
+ expect(mockUseNavigate).toHaveBeenCalledWith('/perps/market/ETH', {
+ replace: true,
+ });
expect(mockReplacePerpsToastByKey).toHaveBeenCalledWith(
expect.objectContaining({
key: 'perpsToastCloseInProgress',
@@ -1281,7 +1310,9 @@ describe('PerpsOrderEntryPage', () => {
}),
],
);
- expect(mockUseNavigate).toHaveBeenCalledWith(-1);
+ expect(mockUseNavigate).toHaveBeenCalledWith('/perps/market/ETH', {
+ replace: true,
+ });
expect(mockReplacePerpsToastByKey).toHaveBeenCalledWith(
expect.objectContaining({
key: 'perpsToastUpdateInProgress',
@@ -1321,7 +1352,9 @@ describe('PerpsOrderEntryPage', () => {
}),
]),
);
- expect(mockUseNavigate).toHaveBeenCalledWith(-1);
+ expect(mockUseNavigate).toHaveBeenCalledWith('/perps/market/ETH', {
+ replace: true,
+ });
expect(mockReplacePerpsToastByKey).toHaveBeenCalledWith(
expect.objectContaining({
key: 'perpsToastSubmitInProgress',
@@ -1451,7 +1484,9 @@ describe('PerpsOrderEntryPage', () => {
renderWithProvider(, store);
fireEvent.click(screen.getByTestId('perps-order-entry-back-button'));
- expect(mockUseNavigate).toHaveBeenCalledWith(-1);
+ expect(mockUseNavigate).toHaveBeenCalledWith('/perps/market/UNKNOWN', {
+ replace: true,
+ });
});
});
@@ -1760,7 +1795,9 @@ describe('PerpsOrderEntryPage', () => {
}),
],
);
- expect(mockUseNavigate).toHaveBeenCalledWith(-1);
+ expect(mockUseNavigate).toHaveBeenCalledWith('/perps/market/ETH', {
+ replace: true,
+ });
expect(mockReplacePerpsToastByKey).toHaveBeenCalledWith(
expect.objectContaining({
key: 'perpsToastSubmitInProgress',
@@ -1829,7 +1866,9 @@ describe('PerpsOrderEntryPage', () => {
fireEvent.click(screen.getByTestId('submit-order-button'));
});
- expect(mockUseNavigate).toHaveBeenCalledWith(-1);
+ expect(mockUseNavigate).toHaveBeenCalledWith('/perps/market/ETH', {
+ replace: true,
+ });
expect(mockReplacePerpsToastByKey).toHaveBeenCalledWith(
expect.objectContaining({
key: 'perpsToastSubmitInProgress',
diff --git a/ui/pages/perps/perps-order-entry-page.tsx b/ui/pages/perps/perps-order-entry-page.tsx
index 2a9ca821156a..46dd556cb89b 100644
--- a/ui/pages/perps/perps-order-entry-page.tsx
+++ b/ui/pages/perps/perps-order-entry-page.tsx
@@ -49,7 +49,10 @@ import { MetaMetricsEventName } from '../../../shared/constants/metametrics';
import { getIsPerpsExperienceAvailable } from '../../selectors/perps/feature-flags';
import { getSelectedInternalAccount } from '../../selectors/accounts';
import { useI18nContext } from '../../hooks/useI18nContext';
-import { DEFAULT_ROUTE } from '../../helpers/constants/routes';
+import {
+ DEFAULT_ROUTE,
+ PERPS_MARKET_DETAIL_ROUTE,
+} from '../../helpers/constants/routes';
import {
usePerpsLivePositions,
usePerpsLiveAccount,
@@ -100,6 +103,7 @@ import {
isStopLossSafeFromLiquidation,
} from '../../components/app/perps/utils/tpslValidation';
import { PerpsDetailPageSkeleton } from '../../components/app/perps/perps-skeletons';
+import { PERPS_MIN_MARKET_ORDER_USD } from '../../components/app/perps/constants';
import {
OrderEntry,
DirectionTabs,
@@ -570,6 +574,26 @@ const PerpsOrderEntryPage: React.FC = () => {
return marginRequired > availableBalance;
}, [orderFormState, orderMode, availableBalance]);
+ // For new market orders and modify-with-amount paths, require an amount
+ // meeting the $10 market-order minimum so submit stays disabled (and the
+ // button advertises the minimum) while the user has not entered a valid
+ // size. Modify with empty amount is the TP/SL-only update path and is
+ // intentionally exempt — it does not call perpsPlaceOrder.
+ const isBelowMinOrderSize = useMemo(() => {
+ if (!orderFormState || orderType !== 'market') {
+ return false;
+ }
+ if (orderMode !== 'new' && orderMode !== 'modify') {
+ return false;
+ }
+ const rawAmount = orderFormState.amount.replace(/,/gu, '').trim();
+ if (orderMode === 'modify' && rawAmount === '') {
+ return false;
+ }
+ const amount = Number.parseFloat(rawAmount) || 0;
+ return amount < PERPS_MIN_MARKET_ORDER_USD;
+ }, [orderFormState, orderMode, orderType]);
+
const isSubmitDisabled =
!selectedAddress ||
isDepositLoading ||
@@ -582,6 +606,7 @@ const PerpsOrderEntryPage: React.FC = () => {
isNearLiquidation ||
hasInvalidTPSL ||
isInsufficientFunds ||
+ isBelowMinOrderSize ||
currentPrice <= 0 ||
(orderMode === 'close' &&
(orderFormState?.closePercent ?? FULL_CLOSE_PERCENT) <= 0)));
@@ -648,30 +673,51 @@ const PerpsOrderEntryPage: React.FC = () => {
);
const handleBackClick = useCallback(
- (
- perpsToastKey?: PerpsToastKey,
- perpsToastDescription?: string,
- extraState?: Partial,
- ) => {
- if (perpsToastKey) {
- replacePerpsToastByKey({
- key: perpsToastKey,
- ...(perpsToastDescription
- ? { description: perpsToastDescription }
- : {}),
+ (extraState?: Partial) => {
+ if (extraState?.pendingOrderSymbol) {
+ setPendingOrder({
+ symbol: extraState.pendingOrderSymbol,
+ filledDescription: extraState.pendingOrderFilledDescription,
});
- if (extraState?.pendingOrderSymbol) {
- setPendingOrder({
- symbol: extraState.pendingOrderSymbol,
- filledDescription: extraState.pendingOrderFilledDescription,
- });
- }
}
- navigate(-1);
+
+ if (!decodedSymbol) {
+ return;
+ }
+
+ const marketDetailPath = `${PERPS_MARKET_DETAIL_ROUTE}/${encodeURIComponent(
+ decodedSymbol,
+ )}`;
+
+ // Pending-order data is delivered imperatively via setPendingOrder above.
+ // Avoid route state so browser back/forward cannot replay filled toasts.
+ // Replace (not push) so the just-submitted order-entry page does not
+ // remain in history — otherwise the market-detail back button (which
+ // uses navigate(-1)) would return to a stale post-submit form.
+ navigate(marketDetailPath, { replace: true });
},
- [navigate, replacePerpsToastByKey, setPendingOrder],
+ [decodedSymbol, navigate, setPendingOrder],
);
+ // Visible header back button: pop the history stack so the user returns to
+ // wherever they came from. Pushing marketDetailPath instead would create a
+ // market-detail -> order-entry -> market-detail loop, since the
+ // market-detail back button uses navigate(-1).
+ const handleBackButtonClick = useCallback(() => {
+ if (typeof window !== 'undefined' && window.history.length > 1) {
+ navigate(-1);
+ return;
+ }
+ if (decodedSymbol) {
+ navigate(
+ `${PERPS_MARKET_DETAIL_ROUTE}/${encodeURIComponent(decodedSymbol)}`,
+ { replace: true },
+ );
+ return;
+ }
+ navigate(DEFAULT_ROUTE, { replace: true });
+ }, [decodedSymbol, navigate]);
+
const getTradeActionToastDescription = useCallback(() => {
if (orderMode === 'modify' || !orderFormState) {
return undefined;
@@ -827,6 +873,8 @@ const PerpsOrderEntryPage: React.FC = () => {
const isPartialClose =
orderMode === 'close' && closePercent < FULL_CLOSE_PERCENT;
+ const shouldShowOrderSubmissionToasts =
+ shouldShowPerpsOrderSubmissionToasts(hasPendingPerpsDeposit);
let inProgressToastKey: PerpsToastKey | undefined;
let inProgressToastDescription: string | undefined;
if (orderMode === 'close') {
@@ -837,7 +885,12 @@ const PerpsOrderEntryPage: React.FC = () => {
? closePartialToastDescription
: tradeActionToastDescription;
} else {
- inProgressToastKey = ORDER_MODE_TOAST_KEYS[orderMode].inProgress;
+ inProgressToastKey =
+ orderMode === 'new' && !shouldShowOrderSubmissionToasts
+ ? undefined
+ : ORDER_MODE_TOAST_KEYS[orderMode].inProgress;
+ inProgressToastDescription =
+ orderMode === 'new' ? tradeActionToastDescription : undefined;
}
if (inProgressToastKey) {
replacePerpsToastByKey({
@@ -909,15 +962,6 @@ const PerpsOrderEntryPage: React.FC = () => {
position.size,
marketInfo?.szDecimals,
);
- handleBackClick(
- isPartialClose
- ? PERPS_TOAST_KEYS.PARTIAL_CLOSE_IN_PROGRESS
- : PERPS_TOAST_KEYS.CLOSE_IN_PROGRESS,
- isPartialClose
- ? closePartialToastDescription
- : tradeActionToastDescription,
- );
-
const result = await submitRequestToBackground(
'perpsClosePosition',
[closeParams],
@@ -935,6 +979,10 @@ const PerpsOrderEntryPage: React.FC = () => {
);
throw new Error(result.error ?? 'Failed to close position');
}
+ // Navigate only on success. Staying on the form on failure lets the
+ // catch block surface the inline error (setSubmitError for partial
+ // close) and the failure toast renders on the current page.
+ handleBackClick();
track(MetaMetricsEventName.PerpsPositionCloseTransaction, {
[PERPS_EVENT_PROPERTY.ASSET]: orderFormState.asset,
[PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.SUCCESS,
@@ -960,10 +1008,13 @@ const PerpsOrderEntryPage: React.FC = () => {
orderMode,
position?.size,
);
- handleBackClick(
- PERPS_TOAST_KEYS.SUBMIT_IN_PROGRESS,
- tradeActionToastDescription,
- );
+ // Emit the submit-in-progress toast here (not via route state).
+ replacePerpsToastByKey({
+ key: PERPS_TOAST_KEYS.SUBMIT_IN_PROGRESS,
+ ...(tradeActionToastDescription
+ ? { description: tradeActionToastDescription }
+ : {}),
+ });
const result = await submitRequestToBackground<{
success: boolean;
error?: string;
@@ -976,6 +1027,9 @@ const PerpsOrderEntryPage: React.FC = () => {
);
throw new Error(result.error ?? 'Failed to add to position');
}
+ // Navigate only on success. On failure, stay on the form so the
+ // catch block's failure toast renders on the current page.
+ handleBackClick();
track(MetaMetricsEventName.PerpsTradeTransaction, {
[PERPS_EVENT_PROPERTY.ASSET]: orderFormState.asset,
@@ -1008,7 +1062,8 @@ const PerpsOrderEntryPage: React.FC = () => {
? orderFormState.stopLossPrice
: undefined,
});
- handleBackClick(PERPS_TOAST_KEYS.UPDATE_IN_PROGRESS);
+ // Emit the update-in-progress toast here (not via route state).
+ replacePerpsToastByKey({ key: PERPS_TOAST_KEYS.UPDATE_IN_PROGRESS });
const result = await submitRequestToBackground(
'perpsUpdatePositionTPSL',
[{ symbol: orderFormState.asset, takeProfitPrice, stopLossPrice }],
@@ -1042,6 +1097,7 @@ const PerpsOrderEntryPage: React.FC = () => {
replacePerpsToastByKey({
key: PERPS_TOAST_KEYS.UPDATE_SUCCESS,
});
+ handleBackClick();
return;
}
@@ -1051,22 +1107,10 @@ const PerpsOrderEntryPage: React.FC = () => {
orderMode,
position?.size,
);
- const shouldShowOrderSubmissionToasts =
- shouldShowPerpsOrderSubmissionToasts(hasPendingPerpsDeposit);
- handleBackClick(
- shouldShowOrderSubmissionToasts
- ? PERPS_TOAST_KEYS.SUBMIT_IN_PROGRESS
- : undefined,
- shouldShowOrderSubmissionToasts
- ? tradeActionToastDescription
- : undefined,
- shouldShowOrderSubmissionToasts && orderFormState.type === 'market'
- ? {
- pendingOrderSymbol: orderFormState.asset,
- pendingOrderFilledDescription: tradeActionToastDescription,
- }
- : undefined,
- );
+ // Do not re-emit SUBMIT_IN_PROGRESS via route state — it was already
+ // emitted above by replacePerpsToastByKey. Re-emitting from the
+ // market-detail useEffect races with the ORDER_SUBMITTED replace below
+ // and can leave the toast stuck at "Submitting your trade".
const result = await submitRequestToBackground(
'perpsPlaceOrder',
[orderParams],
@@ -1079,6 +1123,18 @@ const PerpsOrderEntryPage: React.FC = () => {
);
throw new Error(result.error ?? 'Failed to place order');
}
+ // Navigate only on success. On failure, stay on the form so the catch
+ // block's failure toast renders on the current page. Navigating before
+ // the await previously unmounted this page and orphaned the Promise
+ // response, leaving the "Submitting your trade" toast stuck forever.
+ handleBackClick(
+ orderFormState.type === 'market'
+ ? {
+ pendingOrderSymbol: orderFormState.asset,
+ pendingOrderFilledDescription: tradeActionToastDescription,
+ }
+ : undefined,
+ );
track(MetaMetricsEventName.PerpsTradeTransaction, {
[PERPS_EVENT_PROPERTY.ASSET]: orderFormState.asset,
@@ -1194,6 +1250,19 @@ const PerpsOrderEntryPage: React.FC = () => {
triggerDeposit,
]);
+ const handleFormSubmit = useCallback(
+ (event: React.FormEvent) => {
+ event.preventDefault();
+ if (isSubmitDisabled) {
+ return;
+ }
+ handlePrimaryAction().catch(() => {
+ // Errors are surfaced via the page's submit-error state / toast system.
+ });
+ },
+ [handlePrimaryAction, isSubmitDisabled],
+ );
+
if (!isPerpsExperienceAvailable) {
return ;
}
@@ -1209,7 +1278,7 @@ const PerpsOrderEntryPage: React.FC = () => {
handleBackClick()}
+ onClick={handleBackButtonClick}
aria-label={t('back')}
className="p-2 cursor-pointer"
>
@@ -1253,15 +1322,22 @@ const PerpsOrderEntryPage: React.FC = () => {
}
})();
- const resolvedButtonText =
- isPrimaryTradeAction && isInsufficientFunds
- ? t('insufficientFundsSend')
- : submitButtonText;
+ let resolvedButtonText = submitButtonText;
+ if (isPrimaryTradeAction) {
+ if (isBelowMinOrderSize) {
+ resolvedButtonText = t('perpsMinOrderSize', [
+ `$${PERPS_MIN_MARKET_ORDER_USD}`,
+ ]);
+ } else if (isInsufficientFunds) {
+ resolvedButtonText = t('insufficientFundsSend');
+ }
+ }
return (
-
{/* Header: Back (left) + Asset symbol, price, % gain (centered) + spacer (right) */}
{
>
handleBackClick()}
+ onClick={handleBackButtonClick}
aria-label={t('back')}
className="w-9 shrink-0 cursor-pointer"
>
@@ -1360,6 +1436,13 @@ const PerpsOrderEntryPage: React.FC = () => {
onOrderTypeChange={setOrderType}
onAddFunds={triggerDeposit}
initialLeverage={initialLeverage}
+ autoFocusUsd={orderMode !== 'close'}
+ autoFocusLimitPrice={orderMode !== 'close'}
+ usdPlaceholder={
+ orderType === 'market'
+ ? `min $${PERPS_MIN_MARKET_ORDER_USD}`
+ : undefined
+ }
sizeDecimals={marketInfo?.szDecimals}
/>
@@ -1391,9 +1474,9 @@ const PerpsOrderEntryPage: React.FC = () => {
)}
+
);
};