diff --git a/.changeset/clean-mirrors-sparkle.md b/.changeset/clean-mirrors-sparkle.md deleted file mode 100644 index 81c21d35b9c..00000000000 --- a/.changeset/clean-mirrors-sparkle.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@coinbase/onchainkit": patch ---- - -**feat**: Add SwapSettings sub-component. This allows the user to customize their swaps max slippage percentage. If max slippage is not specified then the component will default to a max slippage of 10%. by @0xAlec @cpcramer #1051 diff --git a/site/docs/pages/swap/types.mdx b/site/docs/pages/swap/types.mdx index d5cb7333f22..73beff276bc 100644 --- a/site/docs/pages/swap/types.mdx +++ b/site/docs/pages/swap/types.mdx @@ -180,16 +180,6 @@ type SwapReact = { }; ``` -## `SwapSettingsReact` - -```ts -type SwapSettingsReact = { - className?: string; // Optional className override for top div element. - icon?: ReactNode; // Optional icon override - text?: string; // Optional text override -}; -``` - ## `SwapToggleButtonReact` ```ts diff --git a/src/swap/components/Swap.tsx b/src/swap/components/Swap.tsx index 9fe8ca07453..f0914690e2c 100644 --- a/src/swap/components/Swap.tsx +++ b/src/swap/components/Swap.tsx @@ -7,7 +7,6 @@ import { SwapAmountInput } from './SwapAmountInput'; import { SwapButton } from './SwapButton'; import { SwapMessage } from './SwapMessage'; import { SwapProvider } from './SwapProvider'; -import { SwapSettings } from './SwapSettings'; import { SwapToggleButton } from './SwapToggleButton'; export function Swap({ @@ -19,17 +18,15 @@ export function Swap({ onSuccess, title = 'Swap', }: SwapReact) { - const { inputs, toggleButton, swapButton, swapMessage, swapSettings } = - useMemo(() => { - const childrenArray = Children.toArray(children); - return { - inputs: childrenArray.filter(findComponent(SwapAmountInput)), - toggleButton: childrenArray.find(findComponent(SwapToggleButton)), - swapButton: childrenArray.find(findComponent(SwapButton)), - swapMessage: childrenArray.find(findComponent(SwapMessage)), - swapSettings: childrenArray.find(findComponent(SwapSettings)), - }; - }, [children]); + const { inputs, toggleButton, swapButton, swapMessage } = useMemo(() => { + const childrenArray = Children.toArray(children); + return { + inputs: childrenArray.filter(findComponent(SwapAmountInput)), + toggleButton: childrenArray.find(findComponent(SwapToggleButton)), + swapButton: childrenArray.find(findComponent(SwapButton)), + swapMessage: childrenArray.find(findComponent(SwapMessage)), + }; + }, [children]); const isMounted = useIsMounted(); @@ -53,14 +50,10 @@ export function Swap({ )} data-testid="ockSwap_Container" > -
-

+
+

{title}

-
{swapSettings}
{inputs[0]}
{toggleButton}
diff --git a/src/swap/components/SwapSettings.test.tsx b/src/swap/components/SwapSettings.test.tsx deleted file mode 100644 index 010c245d759..00000000000 --- a/src/swap/components/SwapSettings.test.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; -import { useBreakpoints } from '../../useBreakpoints'; -import { SwapSettings } from './SwapSettings'; - -vi.mock('../../useBreakpoints', () => ({ - useBreakpoints: vi.fn(), -})); - -const useBreakpointsMock = useBreakpoints as vi.Mock; - -describe('SwapSettings', () => { - it('renders with default title', () => { - useBreakpointsMock.mockReturnValue('md'); - render(); - const settingsContainer = screen.getByTestId('ockSwapSettings_Settings'); - expect(settingsContainer.textContent).toBe(''); - }); - - it('renders with custom title', () => { - useBreakpointsMock.mockReturnValue('md'); - render(); - expect(screen.getByText('Custom')).toBeInTheDocument(); - }); - - it('renders custom icon when provided', () => { - useBreakpointsMock.mockReturnValue('md'); - const CustomIcon = () => ( - - ); - render(} />); - expect(screen.getByTestId('custom-icon')).toBeInTheDocument(); - }); - - it('applies correct classes to the button', () => { - useBreakpointsMock.mockReturnValue('md'); - render(); - const button = screen.getByRole('button', { - name: /toggle swap settings/i, - }); - expect(button).toHaveClass( - 'rounded-full p-2 opacity-50 transition-opacity hover:opacity-100', - ); - }); -}); diff --git a/src/swap/components/SwapSettings.tsx b/src/swap/components/SwapSettings.tsx deleted file mode 100644 index bdff2379033..00000000000 --- a/src/swap/components/SwapSettings.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { useCallback, useState } from 'react'; -import { - background, - cn, - color, - pressable, - text as themeText, -} from '../../styles/theme'; -import { useBreakpoints } from '../../useBreakpoints'; -import { useIcon } from '../../wallet/hooks/useIcon'; -import type { SwapSettingsReact } from '../types'; - -export function SwapSettings({ - className, - icon = 'swapSettings', - text = '', -}: SwapSettingsReact) { - const [isOpen, setIsOpen] = useState(false); - const [slippageMode, setSlippageMode] = useState<'Auto' | 'Custom'>('Auto'); - const [customSlippage, setCustomSlippage] = useState('0.5'); - const breakpoint = useBreakpoints(); - - const handleToggle = useCallback(() => { - setIsOpen(!isOpen); - }, [isOpen]); - - const iconSvg = useIcon({ icon }); - - if (!breakpoint) { - return null; - } - - // Placeholder for SwapSettingsBottomSheet - // Implement mobile version here, similar to WalletBottomSheet - if (breakpoint === 'sm') { - return
Mobile version not implemented
; - } - - return ( -
- {text} -
- - {isOpen && ( -
-
-

- Max. slippage -

-

- Your swap will revert if the prices change by more than the - selected percentage. -

-
-
- - -
-
- setCustomSlippage(e.target.value)} - className={cn( - background.default, - 'w-16 rounded-l-md border-t border-b border-l px-2 py-1 text-left', - )} - disabled={slippageMode === 'Auto'} - /> - - % - -
-
-
-
- )} -
-
- ); -} diff --git a/src/swap/components/SwapSettingsBottomSheet.tsx b/src/swap/components/SwapSettingsBottomSheet.tsx deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/src/swap/components/SwapSettingsSlippageInput.test.tsx b/src/swap/components/SwapSettingsSlippageInput.test.tsx new file mode 100644 index 00000000000..178aaccbf37 --- /dev/null +++ b/src/swap/components/SwapSettingsSlippageInput.test.tsx @@ -0,0 +1,120 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import type React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SwapSettingsSlippageInput } from './SwapSettingsSlippageInput'; + +const mockSetMaxSlippage = vi.fn(); +let mockMaxSlippage = 3; + +vi.mock('./SwapProvider', () => ({ + useSwapContext: () => ({ + get maxSlippage() { + return mockMaxSlippage; + }, + setMaxSlippage: (value: number) => { + mockSetMaxSlippage(value); + mockMaxSlippage = value; + }, + }), +})); + +vi.mock('../../styles/theme', () => ({ + cn: (...args: string[]) => args.join(' '), +})); + +const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => { + return <>{children}; +}; + +describe('SwapSettingsSlippageInput', () => { + beforeEach(() => { + mockMaxSlippage = 3; + mockSetMaxSlippage.mockClear(); + }); + + it('renders with default props', () => { + render(, { wrapper: TestWrapper }); + const input = screen.getByRole('textbox') as HTMLInputElement; + expect(input).toBeInTheDocument(); + expect(input.value).toBe('3'); + expect(screen.getByText('%')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Auto' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Custom' })).toBeInTheDocument(); + }); + + it('applies custom className', () => { + const { container } = render( + , + { + wrapper: TestWrapper, + }, + ); + const elementWithCustomClass = container.querySelector('.custom-class'); + expect(elementWithCustomClass).not.toBeNull(); + }); + + it('uses provided defaultSlippage', () => { + render(, { + wrapper: TestWrapper, + }); + const input = screen.getByRole('textbox') as HTMLInputElement; + expect(input.value).toBe('3'); + fireEvent.click(screen.getByRole('button', { name: 'Auto' })); + expect(input.value).toBe('1'); + }); + + it('allows input changes in Custom mode', () => { + render(, { wrapper: TestWrapper }); + const input = screen.getByRole('textbox') as HTMLInputElement; + fireEvent.click(screen.getByRole('button', { name: 'Custom' })); + fireEvent.change(input, { target: { value: '2.0' } }); + expect(input.value).toBe('2'); + expect(mockSetMaxSlippage).toHaveBeenCalledWith(2); + }); + + it('disables input in Auto mode', () => { + render(, { wrapper: TestWrapper }); + const input = screen.getByRole('textbox') as HTMLInputElement; + expect(input).toBeDisabled(); + }); + + it('switches between Auto and Custom modes', () => { + render(, { + wrapper: TestWrapper, + }); + const input = screen.getByRole('textbox') as HTMLInputElement; + expect(input).toBeDisabled(); + expect(input.value).toBe('3'); + fireEvent.click(screen.getByRole('button', { name: 'Custom' })); + expect(input).not.toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: 'Auto' })); + expect(input).toBeDisabled(); + expect(input.value).toBe('1.5'); + expect(mockSetMaxSlippage).toHaveBeenCalledWith(1.5); + }); + + it('prevents invalid input', () => { + render(, { wrapper: TestWrapper }); + const input = screen.getByRole('textbox') as HTMLInputElement; + fireEvent.click(screen.getByRole('button', { name: 'Custom' })); + fireEvent.change(input, { target: { value: 'abc' } }); + expect(input.value).toBe('abc'); + expect(mockSetMaxSlippage).not.toHaveBeenCalled(); + }); + + it('applies correct styles', () => { + render(, { wrapper: TestWrapper }); + const container = screen.getByText('%').closest('div'); + expect(container).toHaveClass( + 'flex items-center justify-between rounded-lg border border-gray-300', + ); + const input = screen.getByRole('textbox') as HTMLInputElement; + expect(input).toHaveClass('flex-grow bg-transparent'); + }); + + it('applies disabled styles in Auto mode', () => { + render(, { wrapper: TestWrapper }); + const inputContainer = screen.getByRole('textbox').closest('div'); + expect(inputContainer).toHaveClass('opacity-50'); + }); +}); diff --git a/src/swap/components/SwapSettingsSlippageInput.tsx b/src/swap/components/SwapSettingsSlippageInput.tsx new file mode 100644 index 00000000000..714557166ff --- /dev/null +++ b/src/swap/components/SwapSettingsSlippageInput.tsx @@ -0,0 +1,108 @@ +import { type ChangeEvent, useCallback, useEffect, useState } from 'react'; +import { cn } from '../../styles/theme'; +import type { SwapSettingsSlippageInputReact } from '../types'; +import { useSwapContext } from './SwapProvider'; + +export function SwapSettingsSlippageInput({ + className, + defaultSlippage = 3, +}: SwapSettingsSlippageInputReact) { + const { maxSlippage, setMaxSlippage } = useSwapContext(); + const [slippageValue, setSlippageValue] = useState(maxSlippage.toString()); + const [currentMode, setCurrentMode] = useState<'Auto' | 'Custom'>('Auto'); + + useEffect(() => { + setSlippageValue(maxSlippage.toString()); + }, [maxSlippage]); + + // Handles changes in the slippage input field + // Updates the slippage value and sets the max slippage if the input is a valid number + const handleSlippageChange = useCallback( + (e: ChangeEvent) => { + const newValue = e.target.value; + setSlippageValue(newValue); + const numericValue = Number.parseFloat(newValue); + if (!Number.isNaN(numericValue)) { + setMaxSlippage(numericValue); + } + }, + [setMaxSlippage], + ); + + // Handles changes between Auto and Custom modes + // Resets to default slippage when switching to Auto mode + const handleModeChange = useCallback( + (mode: 'Auto' | 'Custom') => { + setCurrentMode(mode); + if (mode === 'Auto') { + setMaxSlippage(defaultSlippage); + setSlippageValue(defaultSlippage.toString()); + } + }, + [defaultSlippage, setMaxSlippage], + ); + + return ( +
+
+
+ {['Auto', 'Custom'].map((mode) => ( + + ))} +
+
+ + + % + +
+
+
+ ); +} diff --git a/src/swap/components/SwapSettingsSlippageLayout.test.tsx b/src/swap/components/SwapSettingsSlippageLayout.test.tsx new file mode 100644 index 00000000000..b1ab9b10fd8 --- /dev/null +++ b/src/swap/components/SwapSettingsSlippageLayout.test.tsx @@ -0,0 +1,100 @@ +import { render, screen } from '@testing-library/react'; +import type React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { SwapSettingsSlippageDescription } from './SwapSettingsSlippageDescription'; +import { SwapSettingsSlippageInput } from './SwapSettingsSlippageInput'; +import { SwapSettingsSlippageLayout } from './SwapSettingsSlippageLayout'; +import { SwapSettingsSlippageTitle } from './SwapSettingsSlippageTitle'; + +vi.mock('./SwapSettingsSlippageTitle', () => ({ + SwapSettingsSlippageTitle: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock('./SwapSettingsSlippageDescription', () => ({ + SwapSettingsSlippageDescription: ({ + children, + }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock('./SwapSettingsSlippageInput', () => ({ + SwapSettingsSlippageInput: () =>
Input
, +})); + +vi.mock('../../styles/theme', () => ({ + cn: (...args: string[]) => args.join(' '), +})); + +describe('SwapSettingsSlippageLayout', () => { + it('renders with all child components', () => { + render( + + Title + + Description + + + , + ); + expect( + screen.getByTestId('ockSwapSettingsLayout_container'), + ).toBeInTheDocument(); + expect(screen.getByTestId('mock-title')).toBeInTheDocument(); + expect(screen.getByTestId('mock-description')).toBeInTheDocument(); + expect(screen.getByTestId('mock-input')).toBeInTheDocument(); + }); + + it('renders with only some child components', () => { + render( + + Title + , + ); + expect( + screen.getByTestId('ockSwapSettingsLayout_container'), + ).toBeInTheDocument(); + expect(screen.getByTestId('mock-title')).toBeInTheDocument(); + expect(screen.queryByTestId('mock-description')).not.toBeInTheDocument(); + expect(screen.queryByTestId('mock-input')).not.toBeInTheDocument(); + }); + + it('applies custom className', () => { + render( + + Title + , + ); + const container = screen.getByTestId('ockSwapSettingsLayout_container'); + expect(container.className).toContain('custom-class'); + }); + + it('renders without any child components', () => { + render(); + expect( + screen.getByTestId('ockSwapSettingsLayout_container'), + ).toBeInTheDocument(); + expect(screen.queryByTestId('mock-title')).not.toBeInTheDocument(); + expect(screen.queryByTestId('mock-description')).not.toBeInTheDocument(); + expect(screen.queryByTestId('mock-toggle')).not.toBeInTheDocument(); + expect(screen.queryByTestId('mock-input')).not.toBeInTheDocument(); + }); + + it('renders with correct layout structure', () => { + render( + + Title + + Description + + + , + ); + const container = screen.getByTestId('ockSwapSettingsLayout_container'); + expect(container.children[0]).toHaveTextContent('Title'); + expect(container.children[1]).toHaveTextContent('Description'); + expect(container.children[2].children[0]).toHaveTextContent('Input'); + }); +}); diff --git a/src/swap/components/SwapSettingsSlippageLayout.tsx b/src/swap/components/SwapSettingsSlippageLayout.tsx new file mode 100644 index 00000000000..fd0f4d31c13 --- /dev/null +++ b/src/swap/components/SwapSettingsSlippageLayout.tsx @@ -0,0 +1,40 @@ +import { Children, useMemo } from 'react'; +import { findComponent } from '../../internal/utils/findComponent'; +import { cn } from '../../styles/theme'; +import type { SwapSettingsSlippageLayoutReact } from '../types'; +import { SwapSettingsSlippageDescription } from './SwapSettingsSlippageDescription'; +import { SwapSettingsSlippageInput } from './SwapSettingsSlippageInput'; +import { SwapSettingsSlippageTitle } from './SwapSettingsSlippageTitle'; + +export function SwapSettingsSlippageLayout({ + children, + className, +}: SwapSettingsSlippageLayoutReact) { + const { title, description, input } = useMemo(() => { + const childrenArray = Children.toArray(children); + return { + title: childrenArray.find(findComponent(SwapSettingsSlippageTitle)), + description: childrenArray.find( + findComponent(SwapSettingsSlippageDescription), + ), + input: childrenArray.find(findComponent(SwapSettingsSlippageInput)), + }; + }, [children]); + + return ( +
+ {title} + {description} +
+ {input &&
{input}
} +
+
+ ); +} diff --git a/src/swap/components/SwapSettingsSlippageLayoutBottomSheet.test.tsx b/src/swap/components/SwapSettingsSlippageLayoutBottomSheet.test.tsx new file mode 100644 index 00000000000..b785774d9eb --- /dev/null +++ b/src/swap/components/SwapSettingsSlippageLayoutBottomSheet.test.tsx @@ -0,0 +1,69 @@ +import { render, screen } from '@testing-library/react'; +import type React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { SwapSettingsSlippageDescription } from './SwapSettingsSlippageDescription'; +import { SwapSettingsSlippageInput } from './SwapSettingsSlippageInput'; +import { SwapSettingsSlippageLayoutBottomSheet } from './SwapSettingsSlippageLayoutBottomSheet'; +import { SwapSettingsSlippageTitle } from './SwapSettingsSlippageTitle'; + +vi.mock('./SwapSettingsSlippageTitle', () => ({ + SwapSettingsSlippageTitle: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock('./SwapSettingsSlippageDescription', () => ({ + SwapSettingsSlippageDescription: ({ + children, + }: { + children: React.ReactNode; + }) =>
{children}
, +})); + +vi.mock('./SwapSettingsSlippageInput', () => ({ + SwapSettingsSlippageInput: () =>
Input
, +})); + +vi.mock('../../styles/theme', () => ({ + cn: (...args: string[]) => args.join(' '), +})); + +describe('SwapSettingsSlippageLayoutBottomSheet', () => { + it('renders with all child components', () => { + render( + + Title + + Description + + + , + ); + expect( + screen.getByTestId('ockSwapSettingsLayout_container'), + ).toBeInTheDocument(); + expect(screen.getByTestId('mock-title')).toBeInTheDocument(); + expect(screen.getByTestId('mock-description')).toBeInTheDocument(); + expect(screen.getByTestId('mock-input')).toBeInTheDocument(); + }); + + it('applies custom className', () => { + render( + + Title + , + ); + const container = screen.getByTestId('ockSwapSettingsLayout_container'); + expect(container.className).toContain('custom-class'); + }); + + it('renders without any child components', () => { + render(); + expect( + screen.getByTestId('ockSwapSettingsLayout_container'), + ).toBeInTheDocument(); + expect(screen.queryByTestId('mock-title')).not.toBeInTheDocument(); + expect(screen.queryByTestId('mock-description')).not.toBeInTheDocument(); + expect(screen.queryByTestId('mock-input')).not.toBeInTheDocument(); + }); +}); diff --git a/src/swap/components/SwapSettingsSlippageLayoutBottomSheet.tsx b/src/swap/components/SwapSettingsSlippageLayoutBottomSheet.tsx new file mode 100644 index 00000000000..03e19b3c0d0 --- /dev/null +++ b/src/swap/components/SwapSettingsSlippageLayoutBottomSheet.tsx @@ -0,0 +1,48 @@ +import { Children, useMemo } from 'react'; +import { findComponent } from '../../internal/utils/findComponent'; +import { cn } from '../../styles/theme'; +import type { SwapSettingsSlippageLayoutReact } from '../types'; +import { SwapSettingsSlippageDescription } from './SwapSettingsSlippageDescription'; +import { SwapSettingsSlippageInput } from './SwapSettingsSlippageInput'; +import { SwapSettingsSlippageTitle } from './SwapSettingsSlippageTitle'; + +export function SwapSettingsSlippageLayoutBottomSheet({ + children, + className, +}: SwapSettingsSlippageLayoutReact) { + const { title, description, input } = useMemo(() => { + const childrenArray = Children.toArray(children); + return { + title: childrenArray.find(findComponent(SwapSettingsSlippageTitle)), + description: childrenArray.find( + findComponent(SwapSettingsSlippageDescription), + ), + input: childrenArray.find(findComponent(SwapSettingsSlippageInput)), + }; + }, [children]); + + return ( +
+
+
+

Settings

+
+ +
+ {title} +
{description}
+ {input &&
{input}
} +
+
+
+
+
+ ); +} diff --git a/src/swap/index.ts b/src/swap/index.ts index 3a430b43e84..33682b15130 100644 --- a/src/swap/index.ts +++ b/src/swap/index.ts @@ -4,8 +4,9 @@ export { SwapAmountInput } from './components/SwapAmountInput'; export { SwapButton } from './components/SwapButton'; export { SwapMessage } from './components/SwapMessage'; export { SwapSettings } from './components/SwapSettings'; -export { SwapSettingsSlippageTitle } from './components/SwapSettingsSlippageTitle'; export { SwapSettingsSlippageDescription } from './components/SwapSettingsSlippageDescription'; +export { SwapSettingsSlippageInput } from './components/SwapSettingsSlippageInput'; +export { SwapSettingsSlippageTitle } from './components/SwapSettingsSlippageTitle'; export { SwapToggleButton } from './components/SwapToggleButton'; export type { BuildSwapTransaction, @@ -20,8 +21,9 @@ export type { SwapQuote, SwapReact, SwapSettingsReact, - SwapSettingsSlippageTitleReact, SwapSettingsSlippageDescriptionReact, + SwapSettingsSlippageInputReact, + SwapSettingsSlippageTitleReact, SwapToggleButtonReact, Transaction, } from './types'; diff --git a/src/swap/types.ts b/src/swap/types.ts index 93aff47c5f5..602685beccd 100644 --- a/src/swap/types.ts +++ b/src/swap/types.ts @@ -154,7 +154,9 @@ export type SwapContextType = { ) => void; handleSubmit: () => void; handleToggle: () => void; + maxSlippage: number; setLifeCycleStatus: (state: LifeCycleStatus) => void; // A function to set the lifecycle status of the component + setMaxSlippage: (maxSlippage: number) => void; // A function to set the maximum slippage to: SwapUnit; }; @@ -231,6 +233,7 @@ export type SwapReact = { * Note: exported as public Type */ export type SwapSettingsReact = { + children: React.ReactNode; className?: string; // Optional className override for top div element. icon?: ReactNode; // Optional icon override text?: string; // Optional text override @@ -252,6 +255,19 @@ export type SwapSettingsSlippageDescriptionReact = { className?: string; // Optional className override for top div element. }; +/** + * Note: exported as public Type + */ +export type SwapSettingsSlippageInputReact = { + className?: string; // Optional className override for top div element. + defaultSlippage?: number; // Optional default slippage value in pecentage. +}; + +export type SwapSettingsSlippageLayoutReact = { + children: ReactNode; + className?: string; // Optional className override for top div element. +}; + /** * Note: exported as public Type */