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 = () => { )}