diff --git a/STRUCTURE.md b/STRUCTURE.md
index ef4b66d..73acf82 100644
--- a/STRUCTURE.md
+++ b/STRUCTURE.md
@@ -69,12 +69,21 @@ components/
├── BottomNav/ # Navigation component
├── BottomSheet/ # Modal sheet component
├── Chart/ # Price chart
-├── DurationOptions/ # Trade duration
+├── Duration/ # Trade duration selection
+│ ├── components/ # Duration subcomponents
+│ │ ├── DurationTab.tsx
+│ │ ├── DurationTabList.tsx
+│ │ ├── DurationValueList.tsx
+│ │ └── HoursDurationValue.tsx
+│ └── DurationController.tsx
+├── DurationOptions/ # Legacy trade duration (to be deprecated)
├── TradeButton/ # Trade execution
├── TradeFields/ # Trade parameters
└── ui/ # Shared UI components
├── button.tsx
├── card.tsx
+ ├── chip.tsx # Selection chip component
+ ├── primary-button.tsx # Primary action button
├── switch.tsx
└── toggle.tsx
```
@@ -214,6 +223,9 @@ RSBUILD_SSE_PROTECTED_PATH=/sse
- Use composition over inheritance
- Keep components focused and single-responsibility
- Document props and side effects
+ - Implement reusable UI components in ui/ directory
+ - Use TailwindCSS for consistent styling
+ - Support theme customization through design tokens
3. **State Management**
- Use local state for UI-only state
diff --git a/llms.txt b/llms.txt
index 877c06a..e420a91 100644
--- a/llms.txt
+++ b/llms.txt
@@ -11,6 +11,36 @@ The application is structured around modular, self-contained components and uses
- [Configuration Files](STRUCTURE.md#configuration-files): Build, development, and testing configurations
- [Module Dependencies](STRUCTURE.md#module-dependencies): Core and development dependencies
+## Typography System
+
+The application uses a consistent typography system based on IBM Plex Sans:
+
+### Text Styles
+
+#### Caption Regular (Small Text)
+```css
+font-family: IBM Plex Sans;
+font-size: 12px;
+font-weight: 400;
+line-height: 18px;
+text-align: left;
+```
+
+#### Body Regular (Default Text)
+```css
+font-family: IBM Plex Sans;
+font-size: 16px;
+font-weight: 400;
+line-height: 24px;
+text-align: left;
+```
+
+### Layout Principles
+- Components should take full available width where appropriate
+- Text content should be consistently left-aligned
+- Maintain proper spacing and padding for readability
+- Use responsive design patterns for different screen sizes
+
## Architecture
- [Component Structure](src/components/README.md): Comprehensive guide on TDD implementation, Atomic Component Design, and component organization
diff --git a/src/components/BottomSheet/BottomSheet.tsx b/src/components/BottomSheet/BottomSheet.tsx
index a2bf093..2601cc5 100644
--- a/src/components/BottomSheet/BottomSheet.tsx
+++ b/src/components/BottomSheet/BottomSheet.tsx
@@ -70,7 +70,7 @@ export const BottomSheet = () => {
<>
{/* Overlay */}
{
// Only close if clicking the overlay itself, not its children
onDragDown?.();
@@ -92,7 +92,7 @@ export const BottomSheet = () => {
rounded-t-[16px]
animate-in fade-in-0 slide-in-from-bottom
duration-300
- z-50
+ z-[60]
transition-transform
overflow-hidden
`}
@@ -106,7 +106,7 @@ export const BottomSheet = () => {
{/* Content */}
- {
+ onDragDown?.();
+ setBottomSheet(false);
+ }}
+/>
```
### Height Processing
diff --git a/src/components/BottomSheet/__tests__/BottomSheet.test.tsx b/src/components/BottomSheet/__tests__/BottomSheet.test.tsx
index bef29a8..c3ee828 100644
--- a/src/components/BottomSheet/__tests__/BottomSheet.test.tsx
+++ b/src/components/BottomSheet/__tests__/BottomSheet.test.tsx
@@ -65,11 +65,13 @@ describe("BottomSheet", () => {
expect(bottomSheet).toHaveStyle({ height: '50vh' });
});
- it("handles drag to dismiss", () => {
+ it("handles drag to dismiss and calls onDragDown", () => {
+ const mockOnDragDown = jest.fn();
mockUseBottomSheetStore.mockReturnValue({
showBottomSheet: true,
key: 'test-key',
height: '380px',
+ onDragDown: mockOnDragDown,
setBottomSheet: mockSetBottomSheet
});
@@ -83,6 +85,45 @@ describe("BottomSheet", () => {
fireEvent.touchMove(document, { touches: [{ clientY: 150 }] });
fireEvent.touchEnd(document);
+ expect(mockOnDragDown).toHaveBeenCalled();
+ expect(mockSetBottomSheet).toHaveBeenCalledWith(false);
+ });
+
+ it("applies animation classes when shown", () => {
+ mockUseBottomSheetStore.mockReturnValue({
+ showBottomSheet: true,
+ key: 'test-key',
+ height: '380px',
+ setBottomSheet: mockSetBottomSheet
+ });
+
+ const { container } = render(
);
+
+ const overlay = container.querySelector('[class*="fixed inset-0"]');
+ const sheet = container.querySelector('[class*="fixed bottom-0"]');
+
+ expect(overlay?.className).toContain('animate-in fade-in-0');
+ expect(sheet?.className).toContain('animate-in fade-in-0 slide-in-from-bottom');
+ expect(sheet?.className).toContain('duration-300');
+ });
+
+ it("calls onDragDown when clicking overlay", () => {
+ const mockOnDragDown = jest.fn();
+ mockUseBottomSheetStore.mockReturnValue({
+ showBottomSheet: true,
+ key: 'test-key',
+ height: '380px',
+ onDragDown: mockOnDragDown,
+ setBottomSheet: mockSetBottomSheet
+ });
+
+ const { container } = render(
);
+
+ const overlay = container.querySelector('[class*="fixed inset-0"]');
+ expect(overlay).toBeInTheDocument();
+ fireEvent.click(overlay!);
+
+ expect(mockOnDragDown).toHaveBeenCalled();
expect(mockSetBottomSheet).toHaveBeenCalledWith(false);
});
diff --git a/src/components/Duration/DurationController.tsx b/src/components/Duration/DurationController.tsx
new file mode 100644
index 0000000..3e7b749
--- /dev/null
+++ b/src/components/Duration/DurationController.tsx
@@ -0,0 +1,96 @@
+import React from "react";
+import { DurationTabList } from "./components/DurationTabList";
+import { DurationValueList } from "./components/DurationValueList";
+import { HoursDurationValue } from "./components/HoursDurationValue";
+import { useTradeStore } from "@/stores/tradeStore";
+import { useBottomSheetStore } from "@/stores/bottomSheetStore";
+import { PrimaryButton } from "@/components/ui/primary-button";
+
+const getDurationValues = (type: string): number[] => {
+ switch (type) {
+ case "tick":
+ return [1, 2, 3, 4, 5];
+ case "second":
+ return [
+ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20,
+ 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38,
+ 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56,
+ 57, 58, 59, 60,
+ ];
+ case "minute":
+ return [1, 2, 3, 5, 10, 15, 30];
+ case "hour":
+ return [1, 2, 3, 4, 6, 8, 12, 24];
+ case "day":
+ return [1];
+ default:
+ return [];
+ }
+};
+
+export const DurationController: React.FC = () => {
+ const { duration, setDuration } = useTradeStore();
+ const { setBottomSheet } = useBottomSheetStore();
+
+ // Initialize local state with store value
+ const [localDuration, setLocalDuration] = React.useState(duration);
+ const [value, type] = localDuration.split(" ");
+ const selectedType = type;
+ const selectedValue: string | number = type === "hour" ? value : parseInt(value, 10);
+
+ const handleTypeSelect = (type: string) => {
+ if (type === "hour") {
+ setLocalDuration("1:0 hour");
+ } else {
+ const values = getDurationValues(type);
+ const newValue = values[0];
+ setLocalDuration(`${newValue} ${type}`);
+ }
+ };
+
+ const handleValueSelect = (value: number) => {
+ setLocalDuration(`${value} ${selectedType}`);
+ };
+
+ const handleSave = () => {
+ setDuration(localDuration); // Update store with local state
+ setBottomSheet(false);
+ };
+
+ return (
+
+
+
+ Duration
+
+
+
+
+
+ {selectedType === "hour" ? (
+
setLocalDuration(`${value} hour`)}
+ />
+ ) : (
+
+ )}
+
+
+
+ );
+};
diff --git a/src/components/Duration/README.md b/src/components/Duration/README.md
new file mode 100644
index 0000000..422be1e
--- /dev/null
+++ b/src/components/Duration/README.md
@@ -0,0 +1,76 @@
+# Duration Component
+
+## Overview
+The Duration component is a comprehensive solution for handling trade duration selection in the Champion Trader application. It provides an intuitive interface for users to select trade durations across different time units (ticks, seconds, minutes, hours, and days).
+
+## Architecture
+
+### Main Components
+- `DurationController`: The main controller component that orchestrates duration selection
+- `DurationTabList`: Handles the selection of duration types (tick, second, minute, hour, day)
+- `DurationValueList`: Displays and manages the selection of specific duration values
+- `HoursDurationValue`: Special component for handling hour-based durations with minute precision
+
+### State Management
+- Uses Zustand via `useTradeStore` for managing duration state
+- Integrates with `useBottomSheetStore` for modal behavior
+
+## Features
+- Supports multiple duration types:
+ - Ticks (1-5)
+ - Seconds (1-60)
+ - Minutes (1, 2, 3, 5, 10, 15, 30)
+ - Hours (1, 2, 3, 4, 6, 8, 12, 24)
+ - Days (1)
+- Real-time duration updates
+- Responsive and accessible UI
+- Integration with bottom sheet for mobile-friendly interaction
+
+## Usage
+
+```tsx
+import { DurationController } from '@/components/Duration';
+
+// Inside your component
+const YourComponent = () => {
+ return (
+
+ );
+};
+```
+
+## State Format
+The duration state follows the format: `"
"`, for example:
+- "5 tick"
+- "30 second"
+- "15 minute"
+- "2 hour"
+- "1 day"
+
+For hours, the format supports minute precision: "1:30 hour"
+
+## Test Coverage
+The component is thoroughly tested with Jest and React Testing Library, covering:
+- Component rendering
+- Duration type selection
+- Duration value selection
+- State management integration
+- UI interactions
+
+## Dependencies
+- React
+- Zustand (for state management)
+- TailwindCSS (for styling)
+- Primary Button component
+
+## Styling
+The component uses TailwindCSS for styling with a focus on:
+- Mobile-first design
+- Consistent spacing and typography
+- Clear visual hierarchy
+- Accessible color contrast
+
+## Integration Points
+- Integrates with the Trade Page for duration selection
+- Works within the Bottom Sheet component for mobile interactions
+- Connects with the global trade store for state management
diff --git a/src/components/Duration/__tests__/DurationController.test.tsx b/src/components/Duration/__tests__/DurationController.test.tsx
new file mode 100644
index 0000000..ad1d21b
--- /dev/null
+++ b/src/components/Duration/__tests__/DurationController.test.tsx
@@ -0,0 +1,276 @@
+import { render, screen, fireEvent, act } from '@testing-library/react';
+import { DurationController } from '../DurationController';
+import { useTradeStore } from '@/stores/tradeStore';
+import { useBottomSheetStore } from '@/stores/bottomSheetStore';
+import type { TradeState } from '@/stores/tradeStore';
+import type { BottomSheetState } from '@/stores/bottomSheetStore';
+
+// Mock the stores
+jest.mock('@/stores/tradeStore', () => ({
+ useTradeStore: jest.fn(),
+}));
+
+jest.mock('@/stores/bottomSheetStore', () => ({
+ useBottomSheetStore: jest.fn(),
+}));
+
+// Mock scrollIntoView and IntersectionObserver
+window.HTMLElement.prototype.scrollIntoView = jest.fn();
+const mockIntersectionObserver = jest.fn();
+mockIntersectionObserver.mockReturnValue({
+ observe: () => null,
+ unobserve: () => null,
+ disconnect: () => null,
+});
+window.IntersectionObserver = mockIntersectionObserver;
+
+describe('DurationController', () => {
+ const mockSetDuration = jest.fn();
+ const mockSetBottomSheet = jest.fn();
+ const mockSetStake = jest.fn();
+ const mockToggleAllowEquals = jest.fn();
+
+ const setupMocks = (initialDuration = '1 tick') => {
+ // Setup store mocks with proper typing
+ (useTradeStore as jest.MockedFunction).mockReturnValue({
+ stake: '10 USD',
+ duration: initialDuration,
+ allowEquals: false,
+ setStake: mockSetStake,
+ setDuration: mockSetDuration,
+ toggleAllowEquals: mockToggleAllowEquals,
+ } as TradeState);
+
+ (useBottomSheetStore as jest.MockedFunction).mockReturnValue({
+ showBottomSheet: false,
+ key: null,
+ height: '380px',
+ setBottomSheet: mockSetBottomSheet,
+ } as BottomSheetState);
+ };
+
+ beforeEach(() => {
+ setupMocks();
+ // Clear mocks
+ mockSetDuration.mockClear();
+ mockSetBottomSheet.mockClear();
+ mockSetStake.mockClear();
+ mockToggleAllowEquals.mockClear();
+ mockIntersectionObserver.mockClear();
+ });
+
+ describe('Initial Render', () => {
+ it('renders duration types and initial value', () => {
+ render();
+
+ expect(screen.getByText('Duration')).toBeInTheDocument();
+ expect(screen.getByText('Ticks')).toBeInTheDocument();
+ expect(screen.getByText('Seconds')).toBeInTheDocument();
+ expect(screen.getByText('Minutes')).toBeInTheDocument();
+ expect(screen.getByText('Hours')).toBeInTheDocument();
+ expect(screen.getByText('End Time')).toBeInTheDocument();
+ });
+
+ it('syncs with store on mount', () => {
+ setupMocks('5 tick');
+ render();
+
+ const saveButton = screen.getByText('Save');
+ fireEvent.click(saveButton);
+
+ expect(mockSetDuration).toHaveBeenCalledWith('5 tick');
+ });
+ });
+
+ describe('Ticks Duration', () => {
+ it('shows correct tick values', () => {
+ render();
+
+ const valueItems = screen.getAllByRole('radio');
+ const values = valueItems.map(item => item.getAttribute('value'));
+
+ expect(values).toEqual(['1', '2', '3', '4', '5']);
+ });
+
+ it('selects default tick value', () => {
+ render();
+
+ const selectedValue = screen.getByRole('radio', { checked: true });
+ expect(selectedValue.getAttribute('value')).toBe('1');
+ });
+
+ it('updates duration on tick selection', () => {
+ render();
+
+ const valueItems = screen.getAllByRole('radio');
+ const threeTicks = valueItems.find(item => item.getAttribute('value') === '3');
+
+ if (threeTicks) {
+ act(() => {
+ fireEvent.click(threeTicks);
+ });
+ }
+
+ const saveButton = screen.getByText('Save');
+ fireEvent.click(saveButton);
+
+ expect(mockSetDuration).toHaveBeenCalledWith('3 tick');
+ });
+ });
+
+ describe('Minutes Duration', () => {
+ beforeEach(() => {
+ render();
+ act(() => {
+ fireEvent.click(screen.getByText('Minutes'));
+ });
+ });
+
+ it('shows correct minute values', () => {
+ const valueItems = screen.getAllByRole('radio');
+ const values = valueItems.map(item => item.getAttribute('value'));
+
+ expect(values).toEqual(['1', '2', '3', '5', '10', '15', '30']);
+ });
+
+ it('selects default minute value', () => {
+ const selectedValue = screen.getByRole('radio', { checked: true });
+ expect(selectedValue.getAttribute('value')).toBe('1');
+ });
+
+ it('updates duration on minute selection', () => {
+ const valueItems = screen.getAllByRole('radio');
+ const fiveMinutes = valueItems.find(item => item.getAttribute('value') === '5');
+
+ if (fiveMinutes) {
+ act(() => {
+ fireEvent.click(fiveMinutes);
+ });
+ }
+
+ const saveButton = screen.getByText('Save');
+ fireEvent.click(saveButton);
+
+ expect(mockSetDuration).toHaveBeenCalledWith('5 minute');
+ });
+ });
+
+ describe('Hours Duration', () => {
+ beforeEach(() => {
+ render();
+ act(() => {
+ fireEvent.click(screen.getByText('Hours'));
+ });
+ });
+
+ it('handles hour:minute format correctly', () => {
+ const saveButton = screen.getByText('Save');
+ fireEvent.click(saveButton);
+
+ expect(mockSetDuration).toHaveBeenCalledWith('1:0 hour');
+ });
+
+ it('preserves hour selection when switching tabs', () => {
+ // Switch to minutes
+ act(() => {
+ fireEvent.click(screen.getByText('Minutes'));
+ });
+
+ // Switch back to hours
+ act(() => {
+ fireEvent.click(screen.getByText('Hours'));
+ });
+
+ const saveButton = screen.getByText('Save');
+ fireEvent.click(saveButton);
+
+ expect(mockSetDuration).toHaveBeenCalledWith('1:0 hour');
+ });
+ });
+
+ describe('End Time Duration', () => {
+ beforeEach(() => {
+ render();
+ act(() => {
+ fireEvent.click(screen.getByText('End Time'));
+ });
+ });
+
+ it('updates duration for end time selection', () => {
+ const saveButton = screen.getByText('Save');
+ fireEvent.click(saveButton);
+
+ expect(mockSetDuration).toHaveBeenCalledWith('1 day');
+ });
+ });
+
+ describe('State Management', () => {
+ it('preserves local state until save', () => {
+ render();
+
+ // Change to minutes
+ act(() => {
+ fireEvent.click(screen.getByText('Minutes'));
+ });
+
+ // Select 5 minutes
+ const valueItems = screen.getAllByRole('radio');
+ const fiveMinutes = valueItems.find(item => item.getAttribute('value') === '5');
+
+ if (fiveMinutes) {
+ act(() => {
+ fireEvent.click(fiveMinutes);
+ });
+ }
+
+ // Verify store hasn't been updated yet
+ expect(mockSetDuration).not.toHaveBeenCalled();
+
+ // Save changes
+ const saveButton = screen.getByText('Save');
+ fireEvent.click(saveButton);
+
+ // Verify store is updated with new value
+ expect(mockSetDuration).toHaveBeenCalledWith('5 minute');
+ expect(mockSetBottomSheet).toHaveBeenCalledWith(false);
+ });
+
+ it('handles rapid tab switching without losing state', () => {
+ render();
+
+ // Rapid switches between duration types
+ act(() => {
+ fireEvent.click(screen.getByText('Minutes'));
+ fireEvent.click(screen.getByText('Hours'));
+ fireEvent.click(screen.getByText('End Time'));
+ fireEvent.click(screen.getByText('Minutes'));
+ });
+
+ // Select and save a value
+ const valueItems = screen.getAllByRole('radio');
+ const threeMinutes = valueItems.find(item => item.getAttribute('value') === '3');
+
+ if (threeMinutes) {
+ act(() => {
+ fireEvent.click(threeMinutes);
+ });
+ }
+
+ const saveButton = screen.getByText('Save');
+ fireEvent.click(saveButton);
+
+ expect(mockSetDuration).toHaveBeenCalledWith('3 minute');
+ });
+
+ it('handles invalid duration format gracefully', () => {
+ setupMocks('invalid duration');
+ render();
+
+ // Should use the provided invalid duration
+ const saveButton = screen.getByText('Save');
+ fireEvent.click(saveButton);
+
+ expect(mockSetDuration).toHaveBeenCalledWith('invalid duration');
+ });
+ });
+});
diff --git a/src/components/Duration/components/DurationTab.tsx b/src/components/Duration/components/DurationTab.tsx
new file mode 100644
index 0000000..33e0724
--- /dev/null
+++ b/src/components/Duration/components/DurationTab.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import { Chip } from '@/components/ui/chip';
+
+interface DurationTabProps {
+ label: string;
+ isSelected: boolean;
+ onSelect: () => void;
+}
+
+export const DurationTab: React.FC = ({
+ label,
+ isSelected,
+ onSelect
+}) => {
+ return (
+
+ {label}
+
+ );
+};
diff --git a/src/components/Duration/components/DurationTabList.tsx b/src/components/Duration/components/DurationTabList.tsx
new file mode 100644
index 0000000..5e9b017
--- /dev/null
+++ b/src/components/Duration/components/DurationTabList.tsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import { DurationTab } from './DurationTab';
+
+interface DurationTabListProps {
+ selectedType: string;
+ onTypeSelect: (type: string) => void;
+}
+
+const DURATION_TYPES = [
+ { label: 'Ticks', value: 'tick' },
+ { label: 'Seconds', value: 'second' },
+ { label: 'Minutes', value: 'minute' },
+ { label: 'Hours', value: 'hour' },
+ { label: 'End Time', value: 'day' }
+];
+
+export const DurationTabList: React.FC = ({
+ selectedType,
+ onTypeSelect
+}) => {
+ return (
+
+
+ {DURATION_TYPES.map(({ label, value }) => (
+
+ onTypeSelect(value)}
+ />
+
+ ))}
+
+
+ );
+};
diff --git a/src/components/Duration/components/DurationValueList.tsx b/src/components/Duration/components/DurationValueList.tsx
new file mode 100644
index 0000000..5e7f59f
--- /dev/null
+++ b/src/components/Duration/components/DurationValueList.tsx
@@ -0,0 +1,166 @@
+import React, { useEffect, useRef } from "react";
+
+interface DurationValueListProps {
+ selectedValue: number;
+ durationType: string;
+ onValueSelect: (value: number) => void;
+ getDurationValues: (type: string) => number[];
+}
+
+const getUnitLabel = (type: string, value: number): string => {
+ switch (type) {
+ case "tick":
+ return value === 1 ? "tick" : "ticks";
+ case "second":
+ return value === 1 ? "second" : "seconds";
+ case "minute":
+ return value === 1 ? "minute" : "minutes";
+ case "hour":
+ return value === 1 ? "hour" : "hours";
+ case "day":
+ return "day";
+ default:
+ return "";
+ }
+};
+
+const ITEM_HEIGHT = 48;
+const SPACER_HEIGHT = 110;
+
+export const DurationValueList: React.FC = ({
+ selectedValue,
+ durationType,
+ onValueSelect,
+ getDurationValues
+}) => {
+ const containerRef = useRef(null);
+ const values = getDurationValues(durationType);
+ const intersectionObserverRef = useRef();
+ const timeoutRef = useRef();
+
+ const handleClick = (value: number) => {
+ // First call onValueSelect to update the selected value
+ onValueSelect(value);
+
+ // Then scroll the clicked item into view with smooth animation
+ const clickedItem = containerRef.current?.querySelector(`[data-value="${value}"]`);
+ if (clickedItem) {
+ clickedItem.scrollIntoView({
+ block: 'center',
+ behavior: 'smooth'
+ });
+ }
+ };
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+
+ // First scroll to selected value
+ const selectedItem = container.querySelector(`[data-value="${selectedValue}"]`);
+ if (selectedItem) {
+ selectedItem.scrollIntoView({ block: 'center', behavior: 'instant' });
+ }
+
+ // Add a small delay before setting up the observer to ensure scroll completes
+ timeoutRef.current = setTimeout(() => {
+ intersectionObserverRef.current = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting) {
+ const value = parseInt(
+ entry.target.getAttribute("data-value") || "0",
+ 10
+ );
+ onValueSelect(value);
+ }
+ });
+ },
+ {
+ root: container,
+ rootMargin: "-51% 0px -49% 0px",
+ threshold: 0,
+ }
+ );
+
+ const items = container.querySelectorAll(".duration-value-item");
+ items.forEach((item) => intersectionObserverRef.current?.observe(item));
+
+ return () => {
+ if (intersectionObserverRef.current) {
+ intersectionObserverRef.current.disconnect();
+ }
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ };
+ }, 100);
+ }, []);
+
+ return (
+
+ {/* Selection zone with gradient background */}
+
+
+ {/* Scrollable content */}
+
+ {/* Top spacer */}
+
+
+ {values.map((value) => (
+
+ ))}
+
+ {/* Bottom spacer */}
+
+
+
+ );
+};
diff --git a/src/components/Duration/components/HoursDurationValue.tsx b/src/components/Duration/components/HoursDurationValue.tsx
new file mode 100644
index 0000000..cc3ee72
--- /dev/null
+++ b/src/components/Duration/components/HoursDurationValue.tsx
@@ -0,0 +1,67 @@
+import React, { useRef } from "react";
+import { DurationValueList } from "./DurationValueList";
+
+interface HoursDurationValueProps {
+ selectedValue: string; // "2:12" format
+ onValueSelect: (value: string) => void;
+}
+
+const getHoursValues = (): number[] => [1, 2, 3, 4, 6, 8, 12, 24];
+const getMinutesValues = (): number[] => Array.from({ length: 60 }, (_, i) => i);
+
+export const HoursDurationValue: React.FC = ({
+ selectedValue,
+ onValueSelect,
+}) => {
+ // Use refs to store last valid values
+ const lastValidHours = useRef();
+ const lastValidMinutes = useRef();
+
+ // Initialize refs if they're undefined
+ if (!lastValidHours.current || !lastValidMinutes.current) {
+ const [h, m] = selectedValue.split(":").map(Number);
+ lastValidHours.current = h;
+ lastValidMinutes.current = m;
+ }
+
+ const handleHoursSelect = (newHours: number) => {
+ lastValidHours.current = newHours;
+ onValueSelect(`${newHours}:${lastValidMinutes.current}`);
+ };
+
+ const handleMinutesSelect = (newMinutes: number) => {
+ lastValidMinutes.current = newMinutes;
+ onValueSelect(`${lastValidHours.current}:${newMinutes}`);
+ };
+
+ return (
+
+ );
+};
diff --git a/src/components/Duration/components/__tests__/DurationTab.test.tsx b/src/components/Duration/components/__tests__/DurationTab.test.tsx
new file mode 100644
index 0000000..78f5bad
--- /dev/null
+++ b/src/components/Duration/components/__tests__/DurationTab.test.tsx
@@ -0,0 +1,109 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { DurationTab } from '../DurationTab';
+
+describe('DurationTab', () => {
+ const defaultProps = {
+ label: 'Minutes',
+ isSelected: false,
+ onSelect: jest.fn(),
+ };
+
+ beforeEach(() => {
+ defaultProps.onSelect.mockClear();
+ });
+
+ describe('Rendering', () => {
+ it('renders label correctly', () => {
+ render();
+ expect(screen.getByText('Minutes')).toBeInTheDocument();
+ });
+
+ it('renders with different labels', () => {
+ const labels = ['Ticks', 'Seconds', 'Hours', 'End Time'];
+
+ labels.forEach(label => {
+ const { rerender } = render();
+ expect(screen.getByText(label)).toBeInTheDocument();
+ rerender(); // Reset to default
+ });
+ });
+
+ it('updates visual state when isSelected changes', () => {
+ const { rerender } = render();
+ const initialButton = screen.getByRole('button');
+ const initialClassName = initialButton.className;
+
+ rerender();
+ const selectedButton = screen.getByRole('button');
+ const selectedClassName = selectedButton.className;
+
+ expect(initialClassName).not.toBe(selectedClassName);
+ expect(selectedClassName).toContain('bg-black');
+ });
+ });
+
+ describe('Interaction', () => {
+ it('handles click events', () => {
+ render();
+
+ fireEvent.click(screen.getByText('Minutes'));
+ expect(defaultProps.onSelect).toHaveBeenCalledTimes(1);
+ });
+
+ it('maintains interactivity after multiple clicks', () => {
+ render();
+ const button = screen.getByRole('button');
+
+ // Multiple clicks should trigger multiple calls
+ fireEvent.click(button);
+ fireEvent.click(button);
+ fireEvent.click(button);
+
+ expect(defaultProps.onSelect).toHaveBeenCalledTimes(3);
+ });
+ });
+
+ describe('Styling', () => {
+ it('applies selected styles when isSelected is true', () => {
+ render();
+ const button = screen.getByRole('button');
+ expect(button.className).toContain('bg-black');
+ expect(button.className).toContain('text-white');
+ });
+
+ it('applies unselected styles when isSelected is false', () => {
+ render();
+ const button = screen.getByRole('button');
+ expect(button.className).toContain('bg-white');
+ expect(button.className).toContain('text-black/60');
+ });
+
+ it('maintains consistent height', () => {
+ render();
+ const button = screen.getByRole('button');
+ expect(button.className).toContain('h-8');
+ });
+ });
+
+ describe('Error Handling', () => {
+ // Using console.error spy to verify prop type warnings
+ const originalError = console.error;
+ beforeAll(() => {
+ console.error = jest.fn();
+ });
+
+ afterAll(() => {
+ console.error = originalError;
+ });
+
+ it('handles missing props gracefully', () => {
+ // @ts-ignore - Testing JS usage
+ expect(() => render()).not.toThrow();
+ });
+
+ it('handles invalid prop types gracefully', () => {
+ // @ts-ignore - Testing JS usage
+ expect(() => render()).not.toThrow();
+ });
+ });
+});
diff --git a/src/components/Duration/components/__tests__/DurationTabList.test.tsx b/src/components/Duration/components/__tests__/DurationTabList.test.tsx
new file mode 100644
index 0000000..be55a22
--- /dev/null
+++ b/src/components/Duration/components/__tests__/DurationTabList.test.tsx
@@ -0,0 +1,43 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { DurationTabList } from '../DurationTabList';
+
+describe('DurationTabList', () => {
+ const defaultProps = {
+ selectedType: 'tick',
+ onTypeSelect: jest.fn(),
+ };
+
+ beforeEach(() => {
+ defaultProps.onTypeSelect.mockClear();
+ });
+
+ it('renders all expected duration types', () => {
+ render();
+ // Originally expected types are "Ticks", "Minutes", "Hours", and "End Time"
+ expect(screen.getByText('Ticks')).toBeInTheDocument();
+ expect(screen.getByText('Minutes')).toBeInTheDocument();
+ expect(screen.getByText('Hours')).toBeInTheDocument();
+ expect(screen.getByText('End Time')).toBeInTheDocument();
+ });
+
+ it('handles click events', () => {
+ const mockOnTypeSelect = jest.fn();
+ render();
+ fireEvent.click(screen.getByText('Minutes'));
+ expect(mockOnTypeSelect).toHaveBeenCalledWith('minute');
+ });
+
+ it('handles keyboard navigation', () => {
+ const mockOnTypeSelect = jest.fn();
+ render();
+ const minutesTab = screen.getByText('Minutes');
+ minutesTab.focus();
+ // Simulate Enter key press
+ fireEvent.keyDown(minutesTab, { key: 'Enter', code: 'Enter' });
+ expect(mockOnTypeSelect).toHaveBeenCalledWith('minute');
+ mockOnTypeSelect.mockClear();
+ // Simulate Space key press
+ fireEvent.keyDown(minutesTab, { key: ' ', code: 'Space' });
+ expect(mockOnTypeSelect).toHaveBeenCalledWith('minute');
+ });
+});
diff --git a/src/components/Duration/components/__tests__/DurationValueList.test.tsx b/src/components/Duration/components/__tests__/DurationValueList.test.tsx
new file mode 100644
index 0000000..8348c85
--- /dev/null
+++ b/src/components/Duration/components/__tests__/DurationValueList.test.tsx
@@ -0,0 +1,137 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { DurationValueList } from '../DurationValueList';
+
+// Mock IntersectionObserver
+const mockIntersectionObserver = jest.fn();
+const mockObserve = jest.fn();
+const mockDisconnect = jest.fn();
+const mockUnobserve = jest.fn();
+
+mockIntersectionObserver.mockImplementation(() => ({
+ observe: mockObserve,
+ unobserve: mockUnobserve,
+ disconnect: mockDisconnect,
+}));
+window.IntersectionObserver = mockIntersectionObserver;
+
+// Mock scrollIntoView
+window.HTMLElement.prototype.scrollIntoView = jest.fn();
+
+describe('DurationValueList', () => {
+ const defaultProps = {
+ selectedValue: 1,
+ durationType: 'tick',
+ onValueSelect: jest.fn(),
+ getDurationValues: () => [1, 2, 3, 4, 5],
+ };
+
+ beforeEach(() => {
+ defaultProps.onValueSelect.mockClear();
+ (window.HTMLElement.prototype.scrollIntoView as jest.Mock).mockClear();
+ mockIntersectionObserver.mockClear();
+ mockObserve.mockClear();
+ mockDisconnect.mockClear();
+ mockUnobserve.mockClear();
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('renders duration values with correct unit labels', () => {
+ render();
+
+ // Check singular form
+ expect(screen.getByText('1 tick')).toBeInTheDocument();
+ // Check plural form
+ expect(screen.getByText('2 ticks')).toBeInTheDocument();
+ });
+
+ it('handles different duration types correctly', () => {
+ const props = {
+ ...defaultProps,
+ durationType: 'minute',
+ getDurationValues: () => [1, 2, 5],
+ };
+
+ render();
+
+ expect(screen.getByText('1 minute')).toBeInTheDocument();
+ expect(screen.getByText('2 minutes')).toBeInTheDocument();
+ expect(screen.getByText('5 minutes')).toBeInTheDocument();
+ });
+
+ it('marks selected value as checked', () => {
+ render();
+
+ const selectedInput = screen.getByDisplayValue('1') as HTMLInputElement;
+ expect(selectedInput.checked).toBe(true);
+
+ const unselectedInput = screen.getByDisplayValue('2') as HTMLInputElement;
+ expect(unselectedInput.checked).toBe(false);
+ });
+
+ it('calls onValueSelect and scrolls when value is clicked', () => {
+ render();
+
+ fireEvent.click(screen.getByText('3 ticks'));
+
+ expect(defaultProps.onValueSelect).toHaveBeenCalledWith(3);
+ expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalledWith({
+ block: 'center',
+ behavior: 'smooth',
+ });
+ });
+
+ it('applies correct styles to selected and unselected values', () => {
+ render();
+
+ const selectedText = screen.getByText('1 tick');
+ const unselectedText = screen.getByText('2 ticks');
+
+ expect(selectedText.className).toContain('text-black');
+ expect(unselectedText.className).toContain('text-gray-300');
+ });
+
+ it('renders with correct spacing and layout', () => {
+ const { container } = render();
+
+ const wrapper = container.firstChild as HTMLElement;
+ expect(wrapper).toHaveClass('relative h-[268px]');
+
+ const scrollContainer = wrapper.querySelector('div:nth-child(2)');
+ expect(scrollContainer).toHaveClass('h-full overflow-y-auto scroll-smooth snap-y snap-mandatory [&::-webkit-scrollbar]:hidden');
+ });
+
+ it('handles day duration type without plural form', () => {
+ const props = {
+ ...defaultProps,
+ durationType: 'day',
+ getDurationValues: () => [1],
+ };
+
+ render();
+
+ expect(screen.getByText('1 day')).toBeInTheDocument();
+ });
+
+ it('initializes with correct scroll position', () => {
+ render();
+
+ expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalledWith({
+ block: 'center',
+ behavior: 'instant',
+ });
+ });
+
+ it('handles value changes after initial render', () => {
+ const { rerender } = render();
+
+ // Change selected value
+ rerender();
+
+ const newSelectedInput = screen.getByDisplayValue('3') as HTMLInputElement;
+ expect(newSelectedInput.checked).toBe(true);
+ });
+});
diff --git a/src/components/Duration/components/__tests__/HoursDurationValue.test.tsx b/src/components/Duration/components/__tests__/HoursDurationValue.test.tsx
new file mode 100644
index 0000000..451d128
--- /dev/null
+++ b/src/components/Duration/components/__tests__/HoursDurationValue.test.tsx
@@ -0,0 +1,100 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { HoursDurationValue } from '../HoursDurationValue';
+
+// Mock the DurationValueList component since we're testing HoursDurationValue in isolation
+jest.mock('../DurationValueList', () => ({
+ DurationValueList: ({ selectedValue, durationType, onValueSelect }: any) => (
+
+
+ Current value: {selectedValue}
+
+ ),
+}));
+
+describe('HoursDurationValue', () => {
+ const defaultProps = {
+ selectedValue: '2:30',
+ onValueSelect: jest.fn(),
+ };
+
+ beforeEach(() => {
+ defaultProps.onValueSelect.mockClear();
+ });
+
+ it('renders hours and minutes sections with proper ARIA labels', () => {
+ render();
+
+ const container = screen.getByRole('group');
+ expect(container).toHaveAttribute('aria-label', 'Duration in hours and minutes');
+
+ const hoursSection = screen.getByLabelText('Hours');
+ const minutesSection = screen.getByLabelText('Minutes');
+
+ expect(hoursSection).toBeInTheDocument();
+ expect(minutesSection).toBeInTheDocument();
+ });
+
+ it('initializes with correct hour and minute values', () => {
+ render();
+
+ expect(screen.getByTestId('duration-value-list-hour')).toHaveTextContent('Current value: 3');
+ expect(screen.getByTestId('duration-value-list-minute')).toHaveTextContent('Current value: 45');
+ });
+
+ it('handles hour selection', () => {
+ render();
+
+ fireEvent.click(screen.getByTestId('increment-hour'));
+
+ expect(defaultProps.onValueSelect).toHaveBeenCalledWith('3:30');
+ });
+
+ it('handles minute selection', () => {
+ render();
+
+ fireEvent.click(screen.getByTestId('increment-minute'));
+
+ expect(defaultProps.onValueSelect).toHaveBeenCalledWith('2:31');
+ });
+
+ it('maintains last valid values when selecting new values', () => {
+ const { rerender } = render();
+
+ // Change hours
+ fireEvent.click(screen.getByTestId('increment-hour'));
+ expect(defaultProps.onValueSelect).toHaveBeenCalledWith('3:30');
+
+ // Update component with new value
+ rerender();
+
+ // Change minutes
+ fireEvent.click(screen.getByTestId('increment-minute'));
+ expect(defaultProps.onValueSelect).toHaveBeenCalledWith('3:31');
+ });
+
+ it('renders with correct layout', () => {
+ const { container } = render();
+
+ const wrapper = container.firstChild as HTMLElement;
+ expect(wrapper).toHaveClass('flex w-full');
+
+ const [hoursDiv, minutesDiv] = wrapper.childNodes;
+ expect(hoursDiv).toHaveClass('flex-1');
+ expect(minutesDiv).toHaveClass('flex-1');
+ });
+
+ it('passes correct props to DurationValueList components', () => {
+ render();
+
+ const hoursList = screen.getByTestId('duration-value-list-hour');
+ const minutesList = screen.getByTestId('duration-value-list-minute');
+
+ expect(hoursList).toBeInTheDocument();
+ expect(minutesList).toBeInTheDocument();
+ });
+});
diff --git a/src/components/Duration/index.ts b/src/components/Duration/index.ts
new file mode 100644
index 0000000..3fe9f81
--- /dev/null
+++ b/src/components/Duration/index.ts
@@ -0,0 +1,3 @@
+export { DurationController } from './DurationController';
+export { DurationTabList } from './components/DurationTabList';
+export { DurationValueList } from './components/DurationValueList';
diff --git a/src/components/TradeButton/Button.tsx b/src/components/TradeButton/Button.tsx
index 3acbab0..4fd739f 100644
--- a/src/components/TradeButton/Button.tsx
+++ b/src/components/TradeButton/Button.tsx
@@ -6,6 +6,7 @@ interface TradeButtonProps {
label: string;
value: string;
title_position: "left" | "right";
+ onClick?: () => void;
}
export const TradeButton: React.FC = ({
@@ -14,13 +15,23 @@ export const TradeButton: React.FC = ({
label,
value,
title_position,
+ onClick,
}) => {
const isLeft = title_position === "left";
return (
)
+ },
+ 'duration': {
+ body: (
+