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);
+ }
}}
>