From e18f007da9903c5f42539e0dc1f17a8f70d4bae3 Mon Sep 17 00:00:00 2001 From: Paul Cramer Date: Wed, 29 Jan 2025 15:56:41 -0800 Subject: [PATCH 1/2] feat: Add DropdownMenu UI Primitive --- .changeset/tiny-geckos-visit.md | 5 + .../components/TokenSelectDropdown.test.tsx | 100 +++++++++++++++--- src/token/components/TokenSelectDropdown.tsx | 61 ++++------- 3 files changed, 108 insertions(+), 58 deletions(-) create mode 100644 .changeset/tiny-geckos-visit.md diff --git a/.changeset/tiny-geckos-visit.md b/.changeset/tiny-geckos-visit.md new file mode 100644 index 0000000000..a5b661f258 --- /dev/null +++ b/.changeset/tiny-geckos-visit.md @@ -0,0 +1,5 @@ +--- +"@coinbase/onchainkit": patch +--- + +- **feat**: Implemented `DropdownMenu` primitive into `TokenSelectDropdown`. By @cpcramer #1903 diff --git a/src/token/components/TokenSelectDropdown.test.tsx b/src/token/components/TokenSelectDropdown.test.tsx index 8f6cb4ff4d..21f6802ade 100644 --- a/src/token/components/TokenSelectDropdown.test.tsx +++ b/src/token/components/TokenSelectDropdown.test.tsx @@ -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(), })); @@ -17,8 +21,7 @@ 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, }, { @@ -26,8 +29,7 @@ describe('TokenSelectDropdown', () => { 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, }, ]; @@ -36,42 +38,106 @@ describe('TokenSelectDropdown', () => { vi.clearAllMocks(); }); - it('renders the TokenSelectDropdown component', async () => { + it('renders the TokenSelectDropdown component', () => { + render(); + + 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(); + 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(); + + 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( + , + ); + + const button = screen.getByTestId('ockTokenSelectButton_Button'); + expect(button).toBeInTheDocument(); + expect(screen.getByText(options[0].symbol)).toBeInTheDocument(); + }); + + it('closes dropdown when clicking outside', async () => { render(); 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(); 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(); + + 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(); + }); }); }); diff --git a/src/token/components/TokenSelectDropdown.tsx b/src/token/components/TokenSelectDropdown.tsx index 991c88bb36..8647b086a7 100644 --- a/src/token/components/TokenSelectDropdown.tsx +++ b/src/token/components/TokenSelectDropdown.tsx @@ -1,8 +1,8 @@ 'use client'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import { useTheme } from '../../internal/hooks/useTheme'; -import { background, border, cn } from '../../styles/theme'; +import { useCallback, useRef, useState } from 'react'; +import { DropdownMenu } from '../../internal/components/primitives/DropdownMenu'; +import { background, border, cn, color } from '../../styles/theme'; import type { TokenSelectDropdownReact } from '../types'; import { TokenRow } from './TokenRow'; import { TokenSelectButton } from './TokenSelectButton'; @@ -12,62 +12,41 @@ export function TokenSelectDropdown({ setToken, token, }: TokenSelectDropdownReact) { - const componentTheme = useTheme(); - const [isOpen, setIsOpen] = useState(false); - - const handleToggle = useCallback(() => { - setIsOpen(!isOpen); - }, [isOpen]); - - const dropdownRef = useRef(null); const buttonRef = useRef(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 (
- {isOpen && ( +
-
+
{options.map((token) => ( { setToken(token); - handleToggle(); + setIsOpen(false); }} /> ))}
- )} +
); } From ba0620b12ebfaf4e53935081673358dbc9791a01 Mon Sep 17 00:00:00 2001 From: Paul Cramer Date: Mon, 3 Feb 2025 14:39:48 -0800 Subject: [PATCH 2/2] Remove CN and hardcoded background color --- src/token/components/TokenSelectDropdown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/token/components/TokenSelectDropdown.tsx b/src/token/components/TokenSelectDropdown.tsx index 8647b086a7..b2e0be0d7e 100644 --- a/src/token/components/TokenSelectDropdown.tsx +++ b/src/token/components/TokenSelectDropdown.tsx @@ -46,7 +46,7 @@ export function TokenSelectDropdown({ 'ock-scrollbar', )} > -
+
{options.map((token) => (