diff --git a/site/docs/pages/token/token-select-dropdown.mdx b/site/docs/pages/token/token-select-dropdown.mdx new file mode 100644 index 0000000000..83bdf78fcf --- /dev/null +++ b/site/docs/pages/token/token-select-dropdown.mdx @@ -0,0 +1,156 @@ +import { TokenSelectDropdown } from '../../../../src/token'; +import TokenSelectorContainer from '../../components/TokenSelectorContainer.tsx'; +import App from '../App'; + +# `` + +The `TokenSelectDropdown` component is a dropdown component that selects a token in a given list of tokens. + +## Usage + +:::code-group + +```tsx [code] + // [!code focus] +``` + +```html [return html] +
+ +
+
+ + + +
+
+
+``` + +::: + +## Props + +[`TokenSelectDropdownReact`](/token/types#tokenselectdropdownreact) + +## CSS + +```css +.ock-scrollbar { + scrollbar-width: thin; + scrollbar-color: #d1d5db #ffffff; +} +``` diff --git a/site/docs/pages/token/types.mdx b/site/docs/pages/token/types.mdx index 47eac5abad..51988d18c5 100644 --- a/site/docs/pages/token/types.mdx +++ b/site/docs/pages/token/types.mdx @@ -89,12 +89,12 @@ export type TokenSearchReact = { }; ``` -## `TokenSelectorDropdownReact` +## `TokenSearchReact` ```ts -export type TokenSelectorDropdownReact = { - onToggle: () => void; // Injected by TokenSelector. To be removed. +export type TokenSelectDropdownReact = { options: Token[]; // List of tokens setToken: (token: Token) => void; // Token setter + token?: Token; // Selected token }; ``` diff --git a/site/sidebar.ts b/site/sidebar.ts index e63fee2e98..caae3a3642 100644 --- a/site/sidebar.ts +++ b/site/sidebar.ts @@ -172,7 +172,7 @@ export const sidebar = [ { text: 'TokenSelectDropdown', link: '/token/token-select-dropdown', - } + }, ], }, { diff --git a/src/token/components/TokenSelectDropdown.css b/src/token/components/TokenSelectDropdown.css new file mode 100644 index 0000000000..f1f8a88275 --- /dev/null +++ b/src/token/components/TokenSelectDropdown.css @@ -0,0 +1,4 @@ +.ock-scrollbar { + scrollbar-width: thin; + scrollbar-color: #d1d5db #ffffff; +} diff --git a/src/token/components/TokenSelectDropdown.test.tsx b/src/token/components/TokenSelectDropdown.test.tsx new file mode 100644 index 0000000000..252d6f3e38 --- /dev/null +++ b/src/token/components/TokenSelectDropdown.test.tsx @@ -0,0 +1,71 @@ +/** + * @jest-environment jsdom + */ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { Address } from 'viem'; +import { TokenSelectDropdown } from './TokenSelectDropdown'; +import { Token } from '../types'; + +describe('TokenSelectDropdown', () => { + const setToken = jest.fn(); + const options: Token[] = [ + { + name: 'Ethereum', + address: '' as Address, + symbol: 'ETH', + decimals: 18, + image: 'https://wallet-api-production.s3.amazonaws.com/uploads/tokens/eth_288.png', + chainId: 8453, + }, + { + name: 'USDC', + address: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913' as Address, + symbol: 'USDC', + decimals: 6, + image: + 'https://d3r81g40ycuhqg.cloudfront.net/wallet/wais/44/2b/442b80bd16af0c0d9b22e03a16753823fe826e5bfd457292b55fa0ba8c1ba213-ZWUzYjJmZGUtMDYxNy00NDcyLTg0NjQtMWI4OGEwYjBiODE2', + chainId: 8453, + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the TokenSelectDropdown component', async () => { + render(); + + await waitFor(() => { + const button = screen.getByTestId('ockTokenSelectButton_Button'); + const list = screen.queryByTestId('ockTokenSelectDropdown_List'); + expect(button).toBeInTheDocument(); + expect(list).toBeNull(); + }); + }); + + it('calls setToken when clicking on a token', async () => { + render(); + + const button = screen.getByTestId('ockTokenSelectButton_Button'); + fireEvent.click(button); + + await waitFor(() => { + fireEvent.click(screen.getByText(options[0].name)); + + expect(setToken).toHaveBeenCalledWith(options[0]); + }); + }); + + it('calls onToggle when clicking outside the component', async () => { + render(); + + const button = screen.getByTestId('ockTokenSelectButton_Button'); + fireEvent.click(button); + + const result = screen.getByTestId('ockTokenSelectDropdown_List'); + console.log(result); + // expect(screen.getByTestId('ockTokenSelectDropdown_List')).toBeInTheDocument(); + }); +}); diff --git a/src/token/components/TokenSelectDropdown.tsx b/src/token/components/TokenSelectDropdown.tsx new file mode 100644 index 0000000000..bcde1f5b2f --- /dev/null +++ b/src/token/components/TokenSelectDropdown.tsx @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { TokenSelectDropdownReact } from '../types'; +import { TokenRow } from './TokenRow'; +import { TokenSelectButton } from './TokenSelectButton'; +import { cn } from '../../lib/utils'; + +export function TokenSelectDropdown({ options, setToken, token }: TokenSelectDropdownReact) { + const [isOpen, setIsOpen] = useState(false); + + const handleToggle = useCallback(() => { + setIsOpen(!isOpen); + }, [isOpen]); + + const dropdownRef = useRef(null); + const buttonRef = useRef(null); + + /* istanbul ignore next */ + 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); + } + }, []); + + 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); + }; + }, []); + + return ( +
+ + {isOpen && ( +
+
+ {options.map((token) => ( + { + setToken(token); + handleToggle(); + }} + /> + ))} +
+
+ )} +
+ ); +} diff --git a/src/token/index.ts b/src/token/index.ts index 692ed0ba84..29a460084a 100644 --- a/src/token/index.ts +++ b/src/token/index.ts @@ -3,6 +3,7 @@ export { TokenChip } from './components/TokenChip'; export { TokenImage } from './components/TokenImage'; export { TokenRow } from './components/TokenRow'; export { TokenSearch } from './components/TokenSearch'; +export { TokenSelectDropdown } from './components/TokenSelectDropdown'; export { TokenSelectorDropdown } from './components/TokenSelectorDropdown'; export { formatAmount } from './core/formatAmount'; export { getTokens } from './core/getTokens'; @@ -13,4 +14,5 @@ export type { Token, TokenChipReact, TokenRowReact, + TokenSelectDropdownReact, } from './types'; diff --git a/src/token/types.ts b/src/token/types.ts index d0449b4fca..7827d63dbf 100644 --- a/src/token/types.ts +++ b/src/token/types.ts @@ -94,6 +94,15 @@ export type TokenSelectButtonReact = { token?: Token; // Selected token }; +/** + * Note: exported as public Type + */ +export type TokenSelectDropdownReact = { + options: Token[]; // List of tokens + setToken: (token: Token) => void; // Token setter + token?: Token; // Selected token +}; + /** * Note: exported as public Type */