diff --git a/app/components/UI/Perps/components/PerpsLeverageBottomSheet/PerpsLeverageBottomSheet.test.tsx b/app/components/UI/Perps/components/PerpsLeverageBottomSheet/PerpsLeverageBottomSheet.test.tsx index c0b0f6186f23..53b2f4306b25 100644 --- a/app/components/UI/Perps/components/PerpsLeverageBottomSheet/PerpsLeverageBottomSheet.test.tsx +++ b/app/components/UI/Perps/components/PerpsLeverageBottomSheet/PerpsLeverageBottomSheet.test.tsx @@ -1,5 +1,6 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react-native'; +import { fireGestureHandler } from 'react-native-gesture-handler/jest-utils'; import PerpsLeverageBottomSheet from './PerpsLeverageBottomSheet'; // Mock dependencies - only what's absolutely necessary @@ -22,6 +23,9 @@ jest.mock('react-native-gesture-handler', () => ({ Tap: jest.fn().mockReturnValue({ onEnd: jest.fn().mockReturnThis(), }), + LongPress: jest.fn().mockReturnValue({ + onEnd: jest.fn().mockReturnThis(), + }), Simultaneous: jest.fn(), }, })); @@ -110,6 +114,16 @@ jest.mock('../../hooks/usePerpsEventTracking', () => ({ })), })); +// Mock expo-haptics +jest.mock('expo-haptics', () => ({ + impactAsync: jest.fn(() => Promise.resolve()), + ImpactFeedbackStyle: { + Light: 'Light', + Medium: 'Medium', + Heavy: 'Heavy', + }, +})); + // usePerpsScreenTracking removed - migrated to usePerpsMeasurement // Mock BottomSheet components from component library @@ -381,113 +395,6 @@ describe('PerpsLeverageBottomSheet', () => { expect(screen.getByText(/100\.0%/)).toBeOnTheScreen(); }); - it('uses theoretical calculation when liquidation price is invalid', () => { - // Arrange - Mock hook to return invalid liquidation price - const mockUsePerpsLiquidationPrice = jest.requireMock( - '../../hooks/usePerpsLiquidationPrice', - ); - mockUsePerpsLiquidationPrice.usePerpsLiquidationPrice.mockReturnValueOnce( - { - liquidationPrice: '0.00', // Invalid liquidation price - isCalculating: false, - error: null, - }, - ); - - const props = { - ...defaultProps, - leverage: 5, // Should give theoretical: 1/5 * 100 = 20% - currentPrice: 3000, - }; - - // Act - render(); - - // Assert - Should use theoretical calculation: 20% - expect(screen.getByText(/20\.0%/)).toBeOnTheScreen(); - }); - - it('uses theoretical calculation when liquidation price is NaN', () => { - // Arrange - Mock hook to return NaN liquidation price - const mockUsePerpsLiquidationPrice = jest.requireMock( - '../../hooks/usePerpsLiquidationPrice', - ); - mockUsePerpsLiquidationPrice.usePerpsLiquidationPrice.mockReturnValueOnce( - { - liquidationPrice: 'invalid', // Will become NaN when parsed - isCalculating: false, - error: null, - }, - ); - - const props = { - ...defaultProps, - leverage: 10, // Should give theoretical: 1/10 * 100 = 10% - currentPrice: 3000, - }; - - // Act - render(); - - // Assert - Should use theoretical calculation: 10% - expect(screen.getByText(/10\.0%/)).toBeOnTheScreen(); - }); - - it('shows correct theoretical percentage for high leverage', () => { - // Arrange - Mock hook to return invalid liquidation price - const mockUsePerpsLiquidationPrice = jest.requireMock( - '../../hooks/usePerpsLiquidationPrice', - ); - mockUsePerpsLiquidationPrice.usePerpsLiquidationPrice.mockReturnValueOnce( - { - liquidationPrice: '0.00', // Invalid, will use theoretical - isCalculating: false, - error: null, - }, - ); - - const props = { - ...defaultProps, - leverage: 50, // Theoretical: 1/50 * 100 = 2.0% - maxLeverage: 50, - currentPrice: 3000, - }; - - // Act - render(); - - // Assert - Should show 2.0% - expect(screen.getByText(/2\.0%/)).toBeOnTheScreen(); - }); - - it('uses theoretical calculation correctly for short position', () => { - // Arrange - Mock hook to return invalid liquidation price for short position - const mockUsePerpsLiquidationPrice = jest.requireMock( - '../../hooks/usePerpsLiquidationPrice', - ); - mockUsePerpsLiquidationPrice.usePerpsLiquidationPrice.mockReturnValueOnce( - { - liquidationPrice: '0.00', // Invalid, will use theoretical - isCalculating: false, - error: null, - }, - ); - - const props = { - ...defaultProps, - direction: 'short' as const, - leverage: 5, // Theoretical: 1/5 * 100 = 20% (same for both directions) - currentPrice: 3000, - }; - - // Act - render(); - - // Assert - Should use theoretical calculation: 20% (same as long) - expect(screen.getByText(/20\.0%/)).toBeOnTheScreen(); - expect(screen.getByText(/rises/)).toBeOnTheScreen(); // Direction text for short - }); - it('caps actual liquidation percentage at 100% for very high values', () => { // Arrange - Mock hook to return liquidation price very far from current price (>99.9%) const mockUsePerpsLiquidationPrice = jest.requireMock( @@ -651,6 +558,45 @@ describe('PerpsLeverageBottomSheet', () => { expect(screen.queryByText('40x')).toBeNull(); // Should not show 40x }); + it('updates leverage when quick select button is pressed', () => { + // Arrange + const mockOnConfirm = jest.fn(); + render( + , + ); + + // Act - Press 10x button (multiple instances exist, get quick select button) + const buttons10x = screen.getAllByText('10x'); + const quickSelectButton = buttons10x[0]; // First one is quick select button + fireEvent.press(quickSelectButton); + + // Wait for state update, then confirm + const confirmButton = screen.getByText(/Set \d+x/); + fireEvent.press(confirmButton); + + // Assert - onConfirm is called with the leverage value + expect(mockOnConfirm).toHaveBeenCalled(); + }); + + it('shows all available quick select options for maxLeverage 40', () => { + // Arrange + const props = { ...defaultProps, maxLeverage: 40 }; + + // Act + render(); + + // Assert - Should show all options: 2, 5, 10, 20, 40 + expect(screen.getByText('2x')).toBeOnTheScreen(); + expect(screen.getAllByText('5x').length).toBeGreaterThan(0); + expect(screen.getAllByText('10x').length).toBeGreaterThan(0); + expect(screen.getAllByText('20x').length).toBeGreaterThan(0); + expect(screen.getAllByText('40x').length).toBeGreaterThan(0); + }); + it('shows both 2x and 3x buttons when maxLeverage is 3', () => { // Arrange - maxLeverage: 3, should show [2, 3] to give users more choice const props = { ...defaultProps, maxLeverage: 3, leverage: 2 }; @@ -666,6 +612,18 @@ describe('PerpsLeverageBottomSheet', () => { // 5x should not appear in slider labels or buttons (initial leverage is now 2x) expect(screen.queryByText('5x')).toBeNull(); }); + + it('does not set skeleton state when pressing already active quick select button', () => { + // Arrange + render(); + + // Act - Press the currently active 5x button + const buttons5x = screen.getAllByText('5x'); + fireEvent.press(buttons5x[0]); + + // Assert - Component continues to render without errors + expect(screen.getByText('Set 5x')).toBeOnTheScreen(); + }); }); describe('Leverage Risk Styling', () => { @@ -680,6 +638,17 @@ describe('PerpsLeverageBottomSheet', () => { expect(screen.getByText('Set 2x')).toBeOnTheScreen(); }); + it('displays safe risk styling for very low leverage', () => { + // Arrange - leverage 1 with maxLeverage 20 = 0% = safe risk + const props = { ...defaultProps, leverage: 1, maxLeverage: 20 }; + + // Act + render(); + + // Assert - Component renders with safe styling + expect(screen.getByText('Set 1x')).toBeOnTheScreen(); + }); + it('displays medium risk styling for medium leverage', () => { // Arrange - leverage 10 with maxLeverage 20 = 50% = medium risk const props = { ...defaultProps, leverage: 10, maxLeverage: 20 }; @@ -702,6 +671,17 @@ describe('PerpsLeverageBottomSheet', () => { expect(screen.getByText('18x')).toBeOnTheScreen(); expect(screen.getByText('Set 18x')).toBeOnTheScreen(); }); + + it('displays high risk styling for max leverage', () => { + // Arrange - leverage 20 with maxLeverage 20 = 100% = high risk + const props = { ...defaultProps, leverage: 20, maxLeverage: 20 }; + + // Act + render(); + + // Assert - Component renders with high risk styling + expect(screen.getAllByText('20x').length).toBeGreaterThan(0); + }); }); describe('Slider Component', () => { @@ -895,9 +875,444 @@ describe('PerpsLeverageBottomSheet', () => { }); }); - describe('Accessibility', () => { - it('has accessibility features', () => { - expect(true).toBe(true); + describe('Gesture Handlers', () => { + describe('Pan Gesture Behavior', () => { + it('initializes pan gesture with optional handlers', () => { + // Arrange + const { Gesture } = jest.requireMock('react-native-gesture-handler'); + const mockPanGesture = { + onBegin: jest.fn().mockReturnThis(), + onUpdate: jest.fn().mockReturnThis(), + onEnd: jest.fn().mockReturnThis(), + onFinalize: jest.fn().mockReturnThis(), + }; + Gesture.Pan.mockReturnValue(mockPanGesture); + + // Act + render(); + + // Assert + expect(mockPanGesture.onFinalize).toHaveBeenCalledWith( + expect.any(Function), + ); + expect(mockPanGesture.onEnd).toHaveBeenCalledWith(expect.any(Function)); + expect(mockPanGesture.onUpdate).toHaveBeenCalledWith( + expect.any(Function), + ); + expect(mockPanGesture.onBegin).toHaveBeenCalledWith( + expect.any(Function), + ); + }); + + it('triggers haptic feedback and onDragStart callback when pan begins', async () => { + // Arrange + const { Gesture } = jest.requireMock('react-native-gesture-handler'); + let capturedOnBegin: (() => void) | null = null; + + const mockPanGesture: { + onBegin: jest.Mock; + onUpdate: jest.Mock; + onEnd: jest.Mock; + onFinalize: jest.Mock; + } = { + onBegin: jest.fn(), + onUpdate: jest.fn().mockReturnThis(), + onEnd: jest.fn().mockReturnThis(), + onFinalize: jest.fn().mockReturnThis(), + }; + + mockPanGesture.onBegin.mockImplementation((handler: () => void) => { + capturedOnBegin = handler; + return mockPanGesture; + }); + + Gesture.Pan.mockReturnValue(mockPanGesture); + + const { act } = jest.requireActual('@testing-library/react-native'); + + render(); + + // Act - Invoke the onBegin handler and verify no crash + await act(async () => { + expect(() => capturedOnBegin?.()).not.toThrow(); + }); + + // Assert - Handler was captured and can be invoked + expect(capturedOnBegin).toBeDefined(); + expect(typeof capturedOnBegin).toBe('function'); + // Component still renders after handler invocation + expect( + screen.getByText('perps.order.leverage_modal.title'), + ).toBeOnTheScreen(); + }); + + it('updates position and value when pan gesture moves', async () => { + // Arrange + const { Gesture } = jest.requireMock('react-native-gesture-handler'); + let capturedOnUpdate: ((event: { x: number }) => void) | null = null; + + const mockPanGesture: { + onBegin: jest.Mock; + onUpdate: jest.Mock; + onEnd: jest.Mock; + onFinalize: jest.Mock; + } = { + onBegin: jest.fn().mockReturnThis(), + onUpdate: jest.fn(), + onEnd: jest.fn().mockReturnThis(), + onFinalize: jest.fn().mockReturnThis(), + }; + + mockPanGesture.onUpdate.mockImplementation( + (handler: (event: { x: number }) => void) => { + capturedOnUpdate = handler; + return mockPanGesture; + }, + ); + + Gesture.Pan.mockReturnValue(mockPanGesture); + + const { act } = jest.requireActual('@testing-library/react-native'); + + render(); + + // Trigger layout to set slider width + const slider = screen.getByTestId('leverage-slider-track'); + fireEvent(slider, 'layout', { + nativeEvent: { layout: { width: 300, height: 8 } }, + }); + + // Act - Invoke handler with mid-range position + await act(async () => { + expect(() => capturedOnUpdate?.({ x: 150 })).not.toThrow(); + }); + + // Assert - Handler was captured and can be invoked + expect(capturedOnUpdate).toBeDefined(); + expect(typeof capturedOnUpdate).toBe('function'); + // Component still renders after handler invocation + expect( + screen.getByText('perps.order.leverage_modal.title'), + ).toBeOnTheScreen(); + }); + + it('triggers haptic feedback and updates value when pan ends', async () => { + // Arrange + const { Gesture } = jest.requireMock('react-native-gesture-handler'); + let capturedOnEnd: (() => void) | null = null; + + const mockPanGesture: { + onBegin: jest.Mock; + onUpdate: jest.Mock; + onEnd: jest.Mock; + onFinalize: jest.Mock; + } = { + onBegin: jest.fn().mockReturnThis(), + onUpdate: jest.fn().mockReturnThis(), + onEnd: jest.fn(), + onFinalize: jest.fn().mockReturnThis(), + }; + + mockPanGesture.onEnd.mockImplementation((handler: () => void) => { + capturedOnEnd = handler; + return mockPanGesture; + }); + + Gesture.Pan.mockReturnValue(mockPanGesture); + + const { act } = jest.requireActual('@testing-library/react-native'); + + render(); + + // Trigger layout to set slider width + const slider = screen.getByTestId('leverage-slider-track'); + fireEvent(slider, 'layout', { + nativeEvent: { layout: { width: 300, height: 8 } }, + }); + + // Act - Invoke the onEnd handler and verify no crash + await act(async () => { + expect(() => capturedOnEnd?.()).not.toThrow(); + }); + + // Assert - Handler was captured and can be invoked + expect(capturedOnEnd).toBeDefined(); + expect(typeof capturedOnEnd).toBe('function'); + // Component still renders after handler invocation + expect( + screen.getByText('perps.order.leverage_modal.title'), + ).toBeOnTheScreen(); + }); + }); + + describe('Gesture Integration', () => { + it('renders component with all gesture handlers configured', () => { + // Arrange + const { Gesture } = jest.requireMock('react-native-gesture-handler'); + + // Act + render(); + + // Assert - All three gesture types are initialized + expect(Gesture.Tap).toHaveBeenCalled(); + expect(Gesture.LongPress).toHaveBeenCalled(); + expect(Gesture.Pan).toHaveBeenCalled(); + expect(Gesture.Simultaneous).toHaveBeenCalled(); + }); + }); + + describe('handleHoldEnd Behavior', () => { + it('updates state when drag ends via slider interaction', () => { + // Arrange + render(); + expect(screen.getByText('Set 5x')).toBeOnTheScreen(); + + // Act - Simulate drag end by using quick select (similar state update) + const button2x = screen.getByText('2x'); + fireEvent.press(button2x); + + // Assert - State is updated + expect(screen.getByText('Set 2x')).toBeOnTheScreen(); + }); + + it('handles tap gesture end event', () => { + // Arrange + const { Gesture } = jest.requireMock('react-native-gesture-handler'); + let tapEndHandler: ((event: { x: number }) => void) | null = null; + + Gesture.Tap.mockImplementation(() => ({ + onEnd: (handler: (event: { x: number }) => void) => { + tapEndHandler = handler; + return { onEnd: jest.fn().mockReturnThis() }; + }, + })); + + // Act + render(); + + // Assert - Tap gesture was configured with end handler + expect(tapEndHandler).not.toBeNull(); + }); + + it('handles long press gesture end event', () => { + // Arrange + const { Gesture } = jest.requireMock('react-native-gesture-handler'); + let longPressEndHandler: ((event: { x: number }) => void) | null = null; + + Gesture.LongPress.mockImplementation(() => ({ + onEnd: (handler: (event: { x: number }) => void) => { + longPressEndHandler = handler; + return { onEnd: jest.fn().mockReturnThis() }; + }, + })); + + // Act + render(); + + // Assert - Long press gesture was configured with end handler + expect(longPressEndHandler).not.toBeNull(); + }); + + it('handles gesture tap without crashing', async () => { + // Arrange + const { Gesture } = jest.requireMock('react-native-gesture-handler'); + let capturedHandler: ((event: { x: number }) => void) | null = null; + + Gesture.Tap.mockImplementation(() => ({ + onEnd: (handler: (event: { x: number }) => void) => { + capturedHandler = handler; + return { onEnd: jest.fn().mockReturnThis() }; + }, + })); + + const { act } = jest.requireActual('@testing-library/react-native'); + + render(); + + const slider = screen.getByTestId('leverage-slider-track'); + fireEvent(slider, 'layout', { + nativeEvent: { layout: { width: 300, height: 8 } }, + }); + + // Act - Call handler and verify no crash + await act(async () => { + expect(() => capturedHandler?.({ x: 150 })).not.toThrow(); + }); + + // Assert - Component still renders after handler invocation + expect( + screen.getByText('perps.order.leverage_modal.title'), + ).toBeOnTheScreen(); + // Verify button text matches pattern "Set Nx" + expect(screen.getByText(/Set \d+x/)).toBeOnTheScreen(); + }); + }); + }); + + describe('Skeleton Loading State', () => { + it('displays skeleton when liquidation price is calculating', () => { + // Arrange + const mockUsePerpsLiquidationPrice = jest.requireMock( + '../../hooks/usePerpsLiquidationPrice', + ); + mockUsePerpsLiquidationPrice.usePerpsLiquidationPrice.mockReturnValueOnce( + { + liquidationPrice: '0.00', + isCalculating: true, // Loading state + error: null, + }, + ); + + // Act + render(); + + // Assert - Component renders in loading state + expect(screen.getByText('Set 5x')).toBeOnTheScreen(); + }); + + it('hides liquidation price when calculating', () => { + // Arrange + const mockUsePerpsLiquidationPrice = jest.requireMock( + '../../hooks/usePerpsLiquidationPrice', + ); + mockUsePerpsLiquidationPrice.usePerpsLiquidationPrice.mockReturnValueOnce( + { + liquidationPrice: '', + isCalculating: true, + error: null, + }, + ); + + // Act + render(); + + // Assert - Component still renders + expect( + screen.getByText('perps.order.leverage_modal.title'), + ).toBeOnTheScreen(); + }); + + it('sets skeleton state to true when isCalculating is true', () => { + // Arrange + const mockUsePerpsLiquidationPrice = jest.requireMock( + '../../hooks/usePerpsLiquidationPrice', + ); + mockUsePerpsLiquidationPrice.usePerpsLiquidationPrice.mockReturnValueOnce( + { + liquidationPrice: '2400.00', + isCalculating: true, + error: null, + }, + ); + + // Act + render(); + + // Assert - Title still renders, skeleton is shown + expect( + screen.getByText('perps.order.leverage_modal.title'), + ).toBeOnTheScreen(); + }); + + it('sets skeleton state to false when isCalculating is false', () => { + // Arrange + const mockUsePerpsLiquidationPrice = jest.requireMock( + '../../hooks/usePerpsLiquidationPrice', + ); + mockUsePerpsLiquidationPrice.usePerpsLiquidationPrice.mockReturnValueOnce( + { + liquidationPrice: '2400.00', + isCalculating: false, + error: null, + }, + ); + + // Act + render(); + + // Assert - Liquidation price displays (format may vary but price is shown) + expect(screen.getByText(/\$2,400/)).toBeOnTheScreen(); + }); + }); + + describe('Gesture Handler Integration', () => { + it('handles pan gesture start event', () => { + // Arrange + const { getByTestId } = render( + , + ); + + const slider = getByTestId('leverage-slider-track'); + + // Act - Simulate pan gesture beginning + fireGestureHandler(slider, [ + { state: 2 }, // BEGAN state + ]); + + // Assert - Component still renders (gesture initiated) + expect(slider).toBeOnTheScreen(); + }); + + it('handles pan gesture update with position changes', () => { + // Arrange + const { getByTestId } = render( + , + ); + + const slider = getByTestId('leverage-slider-track'); + + // Act - Simulate pan gesture with updates + fireGestureHandler(slider, [ + { state: 2 }, // BEGAN + { state: 4, x: 50 }, // ACTIVE with position + { state: 4, x: 100 }, // ACTIVE with new position + ]); + + // Assert - Slider processes gesture updates + expect(slider).toBeOnTheScreen(); + }); + + it('handles pan gesture end event', () => { + // Arrange + const { getByTestId } = render( + , + ); + + const slider = getByTestId('leverage-slider-track'); + + // Act - Complete pan gesture + fireGestureHandler(slider, [ + { state: 2 }, // BEGAN + { state: 4, x: 100 }, // ACTIVE + { state: 5 }, // END + ]); + + // Assert - Gesture completes successfully + expect(slider).toBeOnTheScreen(); + }); + + it('processes layout and gesture sequence together', () => { + // Arrange + const { getByTestId } = render( + , + ); + + const slider = getByTestId('leverage-slider-track'); + + // Trigger layout + fireEvent(slider, 'layout', { + nativeEvent: { layout: { width: 300, height: 8 } }, + }); + + // Act - Now perform gesture with known width + fireGestureHandler(slider, [ + { state: 2 }, // BEGAN + { state: 4, x: 150 }, // ACTIVE at midpoint + { state: 5 }, // END + ]); + + // Assert - Layout and gesture work together + expect(slider).toBeOnTheScreen(); }); }); }); diff --git a/app/components/UI/Perps/components/PerpsLeverageBottomSheet/PerpsLeverageBottomSheet.tsx b/app/components/UI/Perps/components/PerpsLeverageBottomSheet/PerpsLeverageBottomSheet.tsx index 7397fb23d7dc..43f8891a037a 100644 --- a/app/components/UI/Perps/components/PerpsLeverageBottomSheet/PerpsLeverageBottomSheet.tsx +++ b/app/components/UI/Perps/components/PerpsLeverageBottomSheet/PerpsLeverageBottomSheet.tsx @@ -98,8 +98,8 @@ interface PerpsLeverageBottomSheetProps { const LeverageSlider: React.FC<{ value: number; onValueChange: (value: number) => void; - onDragStart?: () => void; - onDragEnd?: (value: number) => void; + onDragStart: () => void; + onDragEnd: (value: number) => void; minValue: number; maxValue: number; colors: Theme['colors']; @@ -215,9 +215,7 @@ const LeverageSlider: React.FC<{ .onBegin(() => { isPressed.value = true; runOnJS(triggerHapticFeedback)(ImpactFeedbackStyle.Medium); - if (onDragStart) { - runOnJS(onDragStart)(); - } + runOnJS(onDragStart)(); }) .onUpdate((event) => { const newPosition = Math.max(0, Math.min(event.x, sliderWidth.value)); @@ -233,25 +231,28 @@ const LeverageSlider: React.FC<{ const currentValue = positionToValue(translateX.value, sliderWidth.value); runOnJS(updateValue)(currentValue); runOnJS(triggerHapticFeedback)(ImpactFeedbackStyle.Medium); - if (onDragEnd) { - runOnJS(onDragEnd)(currentValue); - } + runOnJS(onDragEnd)(currentValue); }) .onFinalize(() => { isPressed.value = false; thumbScale.value = 1; // Direct assignment, no spring }); - const tapGesture = Gesture.Tap().onEnd((event) => { + const handleHoldEnd = (event: { x: number }) => { const newPosition = Math.max(0, Math.min(event.x, sliderWidth.value)); translateX.value = newPosition; // Direct assignment for instant response const newValue = positionToValue(newPosition, sliderWidth.value); runOnJS(updateValue)(newValue); runOnJS(checkThresholdCrossing)(newValue); runOnJS(triggerHapticFeedback)(ImpactFeedbackStyle.Light); - }); + runOnJS(onDragEnd)(newValue); + }; - const composed = Gesture.Simultaneous(tapGesture, panGesture); + const tapGesture = Gesture.Tap().onEnd(handleHoldEnd); + + const holdGesture = Gesture.LongPress().onEnd(handleHoldEnd); + + const composed = Gesture.Simultaneous(tapGesture, panGesture, holdGesture); // Generate tick marks based on max leverage using configuration constants const tickMarks = useMemo(() => { @@ -281,7 +282,11 @@ const LeverageSlider: React.FC<{ return ( - + {/* Progress bar with clipped gradient */} {/* Using leverage risk colors - will be replaced with design tokens */} @@ -339,13 +344,14 @@ const PerpsLeverageBottomSheet: React.FC = ({ const [draggingLeverage, setDraggingLeverage] = useState(initialLeverage); const [isDragging, setIsDragging] = useState(false); const [inputMethod, setInputMethod] = useState<'slider' | 'preset'>('slider'); + const [shouldShowSkeleton, setShouldShowSkeleton] = useState(false); // Dynamically calculate liquidation price based on tempLeverage // Use limit price for limit orders, market price for market orders const entryPrice = useMemo( () => orderType === 'limit' && limitPrice - ? parseFloat(limitPrice) + ? Number.parseFloat(limitPrice) : currentPrice, [orderType, limitPrice, currentPrice], ); @@ -364,27 +370,13 @@ const PerpsLeverageBottomSheet: React.FC = ({ }, ); - // Calculate theoretical liquidation price for immediate drag feedback - const theoreticalLiquidationPrice = useMemo(() => { - const leverageToUse = isDragging ? draggingLeverage : tempLeverage; - - if (!entryPrice || leverageToUse <= 0) return 0; - - // Standard isolated margin liquidation price calculation for immediate feedback - // This provides accurate theoretical values during drag, API provides precise values after - const liquidationMultiplier = - direction === 'long' - ? 1 - 1 / leverageToUse // Long: liquidation when price drops by 1/leverage - : 1 + 1 / leverageToUse; // Short: liquidation when price rises by 1/leverage - - return entryPrice * liquidationMultiplier; - }, [entryPrice, direction, isDragging, draggingLeverage, tempLeverage]); + // Instead of using isDragging || isCalculating, we use shouldShowSkeleton to show the skeleton + // Otherwise the skeleton would flicker for a split second when the user stops dragging and the liquidation price is not yet being calculated + useEffect(() => { + setShouldShowSkeleton(isCalculating); + }, [isCalculating]); - // Use theoretical price during drag for immediate feedback, API price when settled - // Show skeleton while API is calculating (not dragging and calculating) - const dynamicLiquidationPrice = isDragging - ? theoreticalLiquidationPrice - : parseFloat(apiLiquidationPrice) || theoreticalLiquidationPrice; + const dynamicLiquidationPrice = Number.parseFloat(apiLiquidationPrice); useEffect(() => { if (isVisible) { @@ -394,6 +386,7 @@ const PerpsLeverageBottomSheet: React.FC = ({ setTempLeverage(initialLeverage); setDraggingLeverage(initialLeverage); setIsDragging(false); + setShouldShowSkeleton(false); } }, [isVisible, initialLeverage]); @@ -617,7 +610,7 @@ const PerpsLeverageBottomSheet: React.FC = ({ variant={TextVariant.BodySM} style={[warningStyles.textStyle, styles.warningText]} > - {!isDragging && isCalculating ? ( + {shouldShowSkeleton || Number.isNaN(dynamicLiquidationPrice) ? ( ) : ( strings('perps.order.leverage_modal.liquidation_warning', { @@ -649,7 +642,7 @@ const PerpsLeverageBottomSheet: React.FC = ({ color={warningStyles.priceColor} style={styles.priceIcon} /> - {!isDragging && isCalculating ? ( + {shouldShowSkeleton || Number.isNaN(dynamicLiquidationPrice) ? ( ) : ( = ({ { + setShouldShowSkeleton(true); + if (isDragging) { setDraggingLeverage(newValue); } else { setTempLeverage(newValue); } + setShouldShowSkeleton(true); }} onDragStart={() => { setIsDragging(true); @@ -705,6 +701,9 @@ const PerpsLeverageBottomSheet: React.FC = ({ setIsDragging(false); setTempLeverage(finalValue); setInputMethod('slider'); + if (tempLeverage === finalValue) { + setShouldShowSkeleton(false); + } }} minValue={minLeverage} maxValue={maxLeverage} @@ -739,6 +738,9 @@ const PerpsLeverageBottomSheet: React.FC = ({ setInputMethod('preset'); // Add haptic feedback for quick select buttons impactAsync(ImpactFeedbackStyle.Light); + if (value !== tempLeverage) { + setShouldShowSkeleton(true); + } }} >