Skip to content

Commit

Permalink
feat: Add DropdownMenu UI Primitive
Browse files Browse the repository at this point in the history
  • Loading branch information
cpcramer committed Feb 3, 2025
1 parent cdf6a78 commit 3dd39ee
Show file tree
Hide file tree
Showing 3 changed files with 105 additions and 53 deletions.
5 changes: 5 additions & 0 deletions .changeset/tiny-geckos-visit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@coinbase/onchainkit": patch
---

- **feat**: Implemented `DropdownMenu` primitive into `TokenSelectDropdown`. By @cpcramer #1903
100 changes: 83 additions & 17 deletions src/token/components/TokenSelectDropdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Token } from '../types';
import { TokenSelectDropdown } from './TokenSelectDropdown';

vi.mock('react-dom', () => ({
createPortal: (node: React.ReactNode) => node,
}));

vi.mock('@/internal/hooks/useTheme', () => ({
useTheme: vi.fn(),
}));
Expand All @@ -17,17 +21,15 @@ describe('TokenSelectDropdown', () => {
address: '' as Address,
symbol: 'ETH',
decimals: 18,
image:
'https://wallet-api-production.s3.amazonaws.com/uploads/tokens/eth_288.png',
image: 'https://example.com/eth.png',
chainId: 8453,
},
{
name: 'USDC',
address: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913' as Address,
symbol: 'USDC',
decimals: 6,
image:
'https://d3r81g40ycuhqg.cloudfront.net/wallet/wais/44/2b/442b80bd16af0c0d9b22e03a16753823fe826e5bfd457292b55fa0ba8c1ba213-ZWUzYjJmZGUtMDYxNy00NDcyLTg0NjQtMWI4OGEwYjBiODE2',
image: 'https://example.com/usdc.png',
chainId: 8453,
},
];
Expand All @@ -36,42 +38,106 @@ describe('TokenSelectDropdown', () => {
vi.clearAllMocks();
});

it('renders the TokenSelectDropdown component', async () => {
it('renders the TokenSelectDropdown component', () => {
render(<TokenSelectDropdown setToken={setToken} options={options} />);

const button = screen.getByTestId('ockTokenSelectButton_Button');
expect(button).toBeInTheDocument();
expect(screen.queryByTestId('ockDropdownMenu')).not.toBeInTheDocument();
});

it('opens dropdown menu when clicking the button', async () => {
render(<TokenSelectDropdown setToken={setToken} options={options} />);

const button = screen.getByTestId('ockTokenSelectButton_Button');
fireEvent.click(button);

await waitFor(() => {
const button = screen.getByTestId('ockTokenSelectButton_Button');
const list = screen.queryByTestId('ockTokenSelectDropdown_List');
expect(button).toBeInTheDocument();
expect(list).toBeNull();
expect(screen.getByTestId('ockDropdownMenu')).toBeInTheDocument();
expect(screen.getByText(options[0].name)).toBeInTheDocument();
});
});

it('calls setToken when clicking on a token', async () => {
it('calls setToken and closes dropdown when selecting a token', async () => {
render(<TokenSelectDropdown setToken={setToken} options={options} />);

const button = screen.getByTestId('ockTokenSelectButton_Button');
fireEvent.click(button);
await waitFor(() => {
const tokenOption = screen.getByText(options[0].name);
fireEvent.click(tokenOption);
});

expect(setToken).toHaveBeenCalledWith(options[0]);
expect(screen.queryByTestId('ockDropdownMenu')).not.toBeInTheDocument();
});

it('renders with a selected token', () => {
render(
<TokenSelectDropdown
setToken={setToken}
options={options}
token={options[0]}
/>,
);

const button = screen.getByTestId('ockTokenSelectButton_Button');
expect(button).toBeInTheDocument();
expect(screen.getByText(options[0].symbol)).toBeInTheDocument();
});

it('closes dropdown when clicking outside', async () => {
render(<TokenSelectDropdown setToken={setToken} options={options} />);

const button = screen.getByTestId('ockTokenSelectButton_Button');
fireEvent.click(button);

await waitFor(() => {
fireEvent.click(screen.getByText(options[0].name));
expect(screen.getByTestId('ockDropdownMenu')).toBeInTheDocument();
});

fireEvent.keyDown(document.body, { key: 'Escape' });

expect(setToken).toHaveBeenCalledWith(options[0]);
await waitFor(() => {
expect(screen.queryByTestId('ockDropdownMenu')).not.toBeInTheDocument();
});
});

it('toggles when clicking outside the component', async () => {
it('toggles dropdown state when clicking the button multiple times', async () => {
render(<TokenSelectDropdown setToken={setToken} options={options} />);

const button = screen.getByTestId('ockTokenSelectButton_Button');

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

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

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

expect(screen.queryByTestId('ockTokenSelectDropdown_List')).toBeNull();
it('closes dropdown when clicking escape key', async () => {
render(<TokenSelectDropdown setToken={setToken} options={options} />);

const button = screen.getByTestId('ockTokenSelectButton_Button');
fireEvent.click(button);

await waitFor(() => {
expect(screen.getByTestId('ockDropdownMenu')).toBeInTheDocument();
});

fireEvent.keyDown(document, { key: 'Escape' });

await waitFor(() => {
expect(screen.queryByTestId('ockDropdownMenu')).not.toBeInTheDocument();
});
});
});
53 changes: 17 additions & 36 deletions src/token/components/TokenSelectDropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import { DropdownMenu } from '../../internal/components/primitives/DropdownMenu';
import { useTheme } from '../../internal/hooks/useTheme';
import { background, border, cn } from '../../styles/theme';
import type { TokenSelectDropdownReact } from '../types';
Expand All @@ -13,57 +14,37 @@ export function TokenSelectDropdown({
token,
}: TokenSelectDropdownReact) {
const componentTheme = useTheme();

const [isOpen, setIsOpen] = useState(false);

const handleToggle = useCallback(() => {
setIsOpen(!isOpen);
}, [isOpen]);

const dropdownRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);

/* v8 ignore next 11 */
const handleBlur = useCallback((event: MouseEvent) => {
const isOutsideDropdown =
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node);
const isOutsideButton =
buttonRef.current && !buttonRef.current.contains(event.target as Node);

if (isOutsideDropdown && isOutsideButton) {
setIsOpen(false);
}
const closeDropdown = useCallback(() => {
setIsOpen(false);
}, []);

useEffect(() => {
// NOTE: this ensures that handleBlur doesn't get called on initial mount
// We need to use non-div elements to properly handle onblur events
setTimeout(() => {
document.addEventListener('click', handleBlur);
}, 0);

return () => {
document.removeEventListener('click', handleBlur);
};
}, [handleBlur]);
const toggleDropdown = useCallback(() => {
setIsOpen((prev) => !prev);
}, []);

return (
<div className="relative max-w-fit shrink-0">
<TokenSelectButton
ref={buttonRef}
onClick={handleToggle}
onClick={toggleDropdown}
isOpen={isOpen}
token={token}
/>
{isOpen && (
<DropdownMenu
trigger={buttonRef}
isOpen={isOpen}
onClose={closeDropdown}
align="end"
>
<div
ref={dropdownRef}
data-testid="ockTokenSelectDropdown_List"
className={cn(
componentTheme,
border.radius,
'absolute right-0 z-10 mt-1 flex max-h-80 w-[200px] flex-col overflow-y-hidden',
'flex max-h-80 w-[200px] flex-col overflow-y-hidden',
'ock-scrollbar',
)}
>
Expand All @@ -75,13 +56,13 @@ export function TokenSelectDropdown({
token={token}
onClick={() => {
setToken(token);
handleToggle();
setIsOpen(false);
}}
/>
))}
</div>
</div>
)}
</DropdownMenu>
</div>
);
}

0 comments on commit 3dd39ee

Please sign in to comment.