Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add SwapSettings #1051

Closed
wants to merge 9 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/chilly-rice-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@coinbase/onchainkit": patch
---

- **feat**: Introducing the SwapSettings component, a dropdown that allows you to easily customize slippage during swaps. It features subcomponents for slippage options and includes a toggle icon. by @0xAlec @cpcramer #1051
16 changes: 15 additions & 1 deletion playground/nextjs-app-router/components/demo/Swap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import {
SwapAmountInput,
SwapButton,
SwapMessage,
SwapSettings,
SwapSettingsSlippageDescription,
SwapSettingsSlippageInput,
SwapSettingsSlippageTitle,
SwapSettingsSlippageToggle,
SwapToggleButton,
} from '@coinbase/onchainkit/swap';
import type { Token } from '@coinbase/onchainkit/token';
Expand Down Expand Up @@ -86,7 +91,16 @@ function SwapComponent() {
</div>
)}
{address ? (
<Swap className="border bg-[#ffffff]" onStatus={handleOnStatus}>
<Swap className="border" onStatus={handleOnStatus}>
<SwapSettings>
<SwapSettingsSlippageTitle>Max. slippage</SwapSettingsSlippageTitle>
<SwapSettingsSlippageDescription>
Your swap will revert if the prices change by more than the
selected percentage.
</SwapSettingsSlippageDescription>
<SwapSettingsSlippageToggle />
<SwapSettingsSlippageInput />
</SwapSettings>
<SwapAmountInput
label="Sell"
swappableTokens={swappableTokens}
Expand Down
21 changes: 21 additions & 0 deletions src/internal/svg/swapSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { fill } from '../../styles/theme';

export const swapSettingsSvg = (
<svg
role="img"
aria-label="ock-swapSettingsSvg"
width="19"
height="20"
viewBox="0 0 19 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8.92071 5.89742e-08C8.00371 5.89742e-08 7.22171 0.663 7.07071 1.567L6.89271 2.639C6.87271 2.759 6.77771 2.899 6.59571 2.987C6.25306 3.15171 5.92344 3.34226 5.60971 3.557C5.44371 3.672 5.27571 3.683 5.15971 3.64L4.14271 3.258C3.72695 3.10224 3.26941 3.09906 2.85152 3.24904C2.43364 3.39901 2.08254 3.69241 1.86071 4.077L0.938708 5.674C0.716797 6.05836 0.638423 6.50897 0.717525 6.94569C0.796628 7.3824 1.02808 7.7769 1.37071 8.059L2.21071 8.751C2.30571 8.829 2.38071 8.98 2.36471 9.181C2.33621 9.56013 2.33621 9.94087 2.36471 10.32C2.37971 10.52 2.30571 10.672 2.21171 10.75L1.37071 11.442C1.02808 11.7241 0.796628 12.1186 0.717525 12.5553C0.638423 12.992 0.716797 13.4426 0.938708 13.827L1.86071 15.424C2.08269 15.8084 2.43387 16.1016 2.85173 16.2514C3.2696 16.4012 3.72706 16.3978 4.14271 16.242L5.16171 15.86C5.27671 15.817 5.44471 15.829 5.61171 15.942C5.92371 16.156 6.25271 16.347 6.59671 16.512C6.77871 16.6 6.87371 16.74 6.89371 16.862L7.07171 17.933C7.22271 18.837 8.00471 19.5 8.92171 19.5H10.7657C11.6817 19.5 12.4647 18.837 12.6157 17.933L12.7937 16.861C12.8137 16.741 12.9077 16.601 13.0907 16.512C13.4347 16.347 13.7637 16.156 14.0757 15.942C14.2427 15.828 14.4107 15.817 14.5257 15.86L15.5457 16.242C15.9612 16.3972 16.4183 16.4001 16.8357 16.2502C17.2532 16.1002 17.6039 15.8071 17.8257 15.423L18.7487 13.826C18.9706 13.4416 19.049 12.991 18.9699 12.5543C18.8908 12.1176 18.6593 11.7231 18.3167 11.441L17.4767 10.749C17.3817 10.671 17.3067 10.52 17.3227 10.319C17.3511 9.93987 17.3511 9.55913 17.3227 9.18C17.3067 8.98 17.3817 8.828 17.4757 8.75L18.3157 8.058C19.0237 7.476 19.2067 6.468 18.7487 5.673L17.8267 4.076C17.6047 3.69159 17.2535 3.3984 16.8357 3.24861C16.4178 3.09883 15.9604 3.10215 15.5447 3.258L14.5247 3.64C14.4107 3.683 14.2427 3.671 14.0757 3.557C13.7623 3.3423 13.433 3.15175 13.0907 2.987C12.9077 2.9 12.8137 2.76 12.7937 2.639L12.6147 1.567C12.5418 1.12906 12.3158 0.731216 11.977 0.444267C11.6383 0.157318 11.2087 -0.00011124 10.7647 5.89742e-08H8.92171H8.92071ZM9.84271 13.5C10.8373 13.5 11.7911 13.1049 12.4944 12.4017C13.1976 11.6984 13.5927 10.7446 13.5927 9.75C13.5927 8.75544 13.1976 7.80161 12.4944 7.09835C11.7911 6.39509 10.8373 6 9.84271 6C8.84815 6 7.89432 6.39509 7.19106 7.09835C6.4878 7.80161 6.09271 8.75544 6.09271 9.75C6.09271 10.7446 6.4878 11.6984 7.19106 12.4017C7.89432 13.1049 8.84815 13.5 9.84271 13.5Z"
fill="#6B7280"
className={fill.defaultReverse}
/>
</svg>
);
29 changes: 18 additions & 11 deletions src/swap/components/Swap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ 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({
Expand All @@ -18,15 +19,17 @@ export function Swap({
onSuccess,
title = 'Swap',
}: SwapReact) {
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 { 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 isMounted = useIsMounted();

Expand All @@ -50,10 +53,14 @@ export function Swap({
)}
data-testid="ockSwap_Container"
>
<div className="mb-4">
<h3 className={text.title3} data-testid="ockSwap_Title">
<div className="mb-4 flex items-center justify-between">
<h3
className={cn(text.title3, 'text-inherit')}
data-testid="ockSwap_Title"
>
{title}
</h3>
{swapSettings}
</div>
{inputs[0]}
<div className="relative h-1">{toggleButton}</div>
Expand Down
191 changes: 191 additions & 0 deletions src/swap/components/SwapSettings.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, vi } from 'vitest';
import { useBreakpoints } from '../../useBreakpoints';
import { useIcon } from '../../wallet/hooks/useIcon';
import { SwapSettings } from './SwapSettings';
import { SwapSettingsSlippageDescription } from './SwapSettingsSlippageDescription';
import { SwapSettingsSlippageInput } from './SwapSettingsSlippageInput';
import { SwapSettingsSlippageTitle } from './SwapSettingsSlippageTitle';
import { SwapSettingsSlippageToggle } from './SwapSettingsSlippageToggle';

vi.mock('../../wallet/hooks/useIcon', () => ({
useIcon: vi.fn(() => <svg data-testid="mock-icon" />),
}));

vi.mock('./SwapSettingsSlippageLayout', () => ({
SwapSettingsSlippageLayout: vi.fn(({ children }) => (
<div data-testid="mock-layout">{children}</div>
)),
}));

vi.mock('./SwapSettingsSlippageTitle', () => ({
SwapSettingsSlippageTitle: vi.fn(() => <div>Title</div>),
}));

vi.mock('./SwapSettingsSlippageDescription', () => ({
SwapSettingsSlippageDescription: vi.fn(() => <div>Description</div>),
}));

vi.mock('./SwapSettingsSlippageToggle', () => ({
SwapSettingsSlippageToggle: vi.fn(() => <div>Toggle</div>),
}));

vi.mock('./SwapSettingsSlippageInput', () => ({
SwapSettingsSlippageInput: vi.fn(() => <div>Input</div>),
}));

vi.mock('../../useBreakpoints', () => ({
useBreakpoints: vi.fn(),
}));

const useBreakpointsMock = useBreakpoints as vi.Mock;

const renderComponent = (props = {}) => {
return render(
<SwapSettings {...props}>
<SwapSettingsSlippageTitle />
<SwapSettingsSlippageDescription />
<SwapSettingsSlippageToggle />
<SwapSettingsSlippageInput />
</SwapSettings>,
);
};

describe('SwapSettings', () => {
beforeEach(() => {
vi.clearAllMocks();
useBreakpointsMock.mockReturnValue('md'); // Default to 'md' breakpoint
});

it('should render with default props', () => {
renderComponent();
expect(screen.getByTestId('ockSwapSettings_Settings')).toBeInTheDocument();
expect(screen.getByTestId('mock-icon')).toBeInTheDocument();
});

it('should render with custom text and className', () => {
renderComponent({ text: 'Custom Text', className: 'custom-class' });
expect(screen.getByText('Custom Text')).toBeInTheDocument();
expect(screen.getByTestId('ockSwapSettings_Settings')).toHaveClass(
'custom-class',
);
});

it('should toggle dropdown on button click', async () => {
renderComponent();
const button = screen.getByRole('button', {
name: /toggle swap settings/i,
});

fireEvent.click(button);
await waitFor(() => {
expect(screen.getByTestId('ockSwapSettingsDropdown')).toBeInTheDocument();
});

fireEvent.click(button);
await waitFor(() => {
expect(
screen.queryByTestId('ockSwapSettingsDropdown'),
).not.toBeInTheDocument();
});
});

it('should close dropdown when clicking outside', async () => {
render(
<div>
<SwapSettings />
<div data-testid="outside">Outside</div>
</div>,
);
const button = screen.getByRole('button', {
name: /toggle swap settings/i,
});
fireEvent.click(button);
await waitFor(() => {
expect(screen.getByTestId('ockSwapSettingsDropdown')).toBeInTheDocument();
});
fireEvent.mouseDown(screen.getByTestId('outside'));
await waitFor(() => {
expect(
screen.queryByTestId('ockSwapSettingsDropdown'),
).not.toBeInTheDocument();
});
});

it('should render children components when dropdown is open', async () => {
renderComponent();
const button = screen.getByRole('button', {
name: /toggle swap settings/i,
});
fireEvent.click(button);
await waitFor(() => {
expect(screen.getByTestId('mock-layout')).toBeInTheDocument();
expect(screen.getByText('Title')).toBeInTheDocument();
expect(screen.getByText('Description')).toBeInTheDocument();
expect(screen.getByText('Toggle')).toBeInTheDocument();
expect(screen.getByText('Input')).toBeInTheDocument();
});
});

it('should use custom icon when provided', () => {
renderComponent({ icon: 'custom-icon' });
expect(useIcon).toHaveBeenCalledWith({ icon: 'custom-icon' });
});

it('should keep dropdown open when clicking inside', async () => {
renderComponent();
const button = screen.getByRole('button', {
name: /toggle swap settings/i,
});
fireEvent.click(button);
await waitFor(() => {
expect(screen.getByTestId('ockSwapSettingsDropdown')).toBeInTheDocument();
});
fireEvent.mouseDown(screen.getByTestId('ockSwapSettingsDropdown'));
expect(screen.getByTestId('ockSwapSettingsDropdown')).toBeInTheDocument();
});

it('should remove event listener on unmount', () => {
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
const { unmount } = renderComponent();
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'mousedown',
expect.any(Function),
);
removeEventListenerSpy.mockRestore();
});

it('should handle non-valid React elements as children', async () => {
render(
<SwapSettings>
<SwapSettingsSlippageTitle />
Plain text child
<SwapSettingsSlippageInput />
</SwapSettings>,
);
const button = screen.getByRole('button', {
name: /toggle swap settings/i,
});
fireEvent.click(button);
await waitFor(() => {
expect(screen.getByTestId('ockSwapSettingsDropdown')).toBeInTheDocument();
expect(screen.getByText('Title')).toBeInTheDocument();
expect(screen.getByText('Plain text child')).toBeInTheDocument();
expect(screen.getByText('Input')).toBeInTheDocument();
});
});

it('renders SwapSettingsSlippageLayoutBottomSheet when breakpoint is "sm"', () => {
useBreakpointsMock.mockReturnValue('sm');
renderComponent();
const button = screen.getByRole('button', {
name: /toggle swap settings/i,
});
fireEvent.click(button);
expect(
screen.getByTestId('ockSwapSettingsSlippageLayoutBottomSheet_container'),
).toBeInTheDocument();
});
});
Loading
Loading