diff --git a/src/useBreakpoints.test.ts b/src/useBreakpoints.test.ts new file mode 100644 index 0000000000..b8a3731b6e --- /dev/null +++ b/src/useBreakpoints.test.ts @@ -0,0 +1,55 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { useBreakpoints } from './useBreakpoints'; + +const createMatchMediaMock = (query: string) => ({ + matches: query === '(min-width: 769px) and (max-width: 1023px)', + addEventListener: vi.fn(), + removeEventListener: vi.fn(), +}); + +describe('useBreakpoints', () => { + it('should set the breakpoint based on the window size', () => { + (window.matchMedia as jest.Mock) = createMatchMediaMock; + + const { result } = renderHook(() => useBreakpoints()); + + act(() => { + window.dispatchEvent(new Event('resize')); + }); + + expect(result.current).toBe('lg'); + }); + + it('should update the breakpoint on resize', () => { + (window.matchMedia as jest.Mock) = createMatchMediaMock; + + const { result } = renderHook(() => useBreakpoints()); + + (window.matchMedia as jest.Mock) = (query: string) => + ({ + matches: query === '(max-width: 640px)', + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }) as unknown as MediaQueryList; + + act(() => { + window.dispatchEvent(new Event('resize')); + }); + + expect(result.current).toBe('sm'); + }); + + it('should return md when no breakpoints match', () => { + (window.matchMedia as jest.Mock) = (_query: string) => + ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }) as unknown as MediaQueryList; + + const { result } = renderHook(() => useBreakpoints()); + + expect(result.current).toBe('md'); + }); +}); diff --git a/src/useBreakpoints.ts b/src/useBreakpoints.ts new file mode 100644 index 0000000000..daaab84522 --- /dev/null +++ b/src/useBreakpoints.ts @@ -0,0 +1,46 @@ +import { useEffect, useState } from 'react'; + +// tailwind breakpoints +const BREAKPOINTS = { + sm: '(max-width: 640px)', + md: '(min-width: 641px) and (max-width: 768px)', + lg: '(min-width: 769px) and (max-width: 1023px)', + xl: '(min-width: 1024px) and (max-width: 1279px)', + '2xl': '(min-width: 1280px)', +}; + +export function useBreakpoints() { + const [currentBreakpoint, setCurrentBreakpoint] = useState< + string | undefined + >(undefined); + + // handles SSR case where window would be undefined, + // once component mounts on client, hook sets correct breakpoint + useEffect(() => { + // get the current breakpoint based on media queries + const getCurrentBreakpoint = () => { + const entries = Object.entries(BREAKPOINTS) as [string, string][]; + for (const [key, query] of entries) { + if (window.matchMedia(query).matches) { + return key; + } + } + return 'md'; + }; + + // set initial breakpoint + setCurrentBreakpoint(getCurrentBreakpoint()); + + // listen changes in the window size + const handleResize = () => { + setCurrentBreakpoint(getCurrentBreakpoint()); + }; + + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + return currentBreakpoint; +} + +export default useBreakpoints; diff --git a/src/wallet/components/WalletBottomSheet.test.tsx b/src/wallet/components/WalletBottomSheet.test.tsx new file mode 100644 index 0000000000..75d0eb8145 --- /dev/null +++ b/src/wallet/components/WalletBottomSheet.test.tsx @@ -0,0 +1,122 @@ +import '@testing-library/jest-dom'; +import { + fireEvent, + render, + renderHook, + screen, + waitFor, +} from '@testing-library/react'; +import { useAccount } from 'wagmi'; +import { Identity } from '../../identity/components/Identity'; +import { + IdentityProvider, + useIdentityContext, +} from '../../identity/components/IdentityProvider'; +import { WalletBottomSheet } from './WalletBottomSheet'; +import { useWalletContext } from './WalletProvider'; + +vi.mock('wagmi', () => ({ + useAccount: vi.fn(), +})); + +vi.mock('./WalletProvider', () => ({ + useWalletContext: vi.fn(), +})); + +vi.mock('../../identity/components/Identity', () => ({ + Identity: vi.fn(({ address, children }) => ( + {children} + )), +})); + +const useWalletContextMock = useWalletContext as vi.Mock; +const useAccountMock = useAccount as vi.Mock; + +describe('WalletBottomSheet', () => { + it('renders null when address is not provided', () => { + useWalletContextMock.mockReturnValue({ isOpen: true }); + useAccountMock.mockReturnValue({ address: null }); + + render(Test Children); + + expect(screen.queryByText('Test Children')).not.toBeInTheDocument(); + }); + + it('renders children when isOpen is true and address is provided', () => { + useWalletContextMock.mockReturnValue({ isOpen: true }); + useAccountMock.mockReturnValue({ address: '0x123' }); + + render(Test Children); + + expect(screen.getByText('Test Children')).toBeInTheDocument(); + }); + + it('injects address prop to Identity component', async () => { + const address = '0x123'; + useWalletContextMock.mockReturnValue({ isOpen: true }); + useAccountMock.mockReturnValue({ address }); + + const { result } = renderHook(() => useIdentityContext(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + await waitFor(() => { + expect(result.current.address).toEqual(address); + }); + }); + + it('does not render overlay when isOpen is false', () => { + useAccountMock.mockReturnValue({ address: '0x123' }); + useWalletContextMock.mockReturnValue({ isOpen: false, setIsOpen: vi.fn() }); + + render(Content); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('renders overlay when isOpen is true', () => { + useAccountMock.mockReturnValue({ address: '0x123' }); + useWalletContextMock.mockReturnValue({ isOpen: true, setIsOpen: vi.fn() }); + + render(Content); + + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + it('closes the bottom sheet when the overlay is clicked', () => { + const setIsOpenMock = vi.fn(); + useAccountMock.mockReturnValue({ address: '0x123' }); + useWalletContextMock.mockReturnValue({ + isOpen: true, + setIsOpen: setIsOpenMock, + }); + + render(Content); + + fireEvent.click(screen.getByRole('button')); + + expect(setIsOpenMock).toHaveBeenCalledWith(false); + }); + + it('closes the bottom sheet when Escape key is pressed', () => { + const setIsOpenMock = vi.fn(); + useAccountMock.mockReturnValue({ address: '0x123' }); + useWalletContextMock.mockReturnValue({ + isOpen: true, + setIsOpen: setIsOpenMock, + }); + + render(Content); + + fireEvent.keyDown(screen.getByRole('button'), { + key: 'Escape', + code: 'Escape', + }); + + expect(setIsOpenMock).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/wallet/components/WalletBottomSheet.tsx b/src/wallet/components/WalletBottomSheet.tsx new file mode 100644 index 0000000000..f200f134f4 --- /dev/null +++ b/src/wallet/components/WalletBottomSheet.tsx @@ -0,0 +1,73 @@ +import { + Children, + cloneElement, + isValidElement, + useCallback, + useMemo, +} from 'react'; +import { useAccount } from 'wagmi'; +import { Identity } from '../../identity/components/Identity'; +import { background, cn } from '../../styles/theme'; +import type { WalletBottomSheetReact } from '../types'; +import { useWalletContext } from './WalletProvider'; + +export function WalletBottomSheet({ + children, + className, +}: WalletBottomSheetReact) { + const { isOpen, setIsOpen } = useWalletContext(); + const { address } = useAccount(); + + const childrenArray = useMemo(() => { + return Children.toArray(children).map((child) => { + if (isValidElement(child) && child.type === Identity) { + // @ts-ignore + return cloneElement(child, { address }); + } + return child; + }); + }, [children, address]); + + const handleOverlayClick = useCallback(() => { + setIsOpen(false); + }, [setIsOpen]); + + const handleEscKeyPress = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setIsOpen(false); + } + }, + [setIsOpen], + ); + + if (!address) { + return null; + } + + return ( + <> + {isOpen && ( +
+ )} +
+ {childrenArray} +
+ + ); +} diff --git a/src/wallet/components/WalletDropdown.test.tsx b/src/wallet/components/WalletDropdown.test.tsx index cbc9030db8..a0fbf495bf 100644 --- a/src/wallet/components/WalletDropdown.test.tsx +++ b/src/wallet/components/WalletDropdown.test.tsx @@ -6,6 +6,7 @@ import { IdentityProvider, useIdentityContext, } from '../../identity/components/IdentityProvider'; +import useBreakpoints from '../../useBreakpoints'; import { WalletDropdown } from './WalletDropdown'; import { useWalletContext } from './WalletProvider'; @@ -17,6 +18,10 @@ vi.mock('./WalletProvider', () => ({ useWalletContext: vi.fn(), })); +vi.mock('../../useBreakpoints', () => ({ + default: vi.fn(), +})); + vi.mock('../../identity/components/Identity', () => ({ Identity: vi.fn(({ address, children }) => ( {children} @@ -24,25 +29,53 @@ vi.mock('../../identity/components/Identity', () => ({ })); const useWalletContextMock = useWalletContext as vi.Mock; + const useAccountMock = useAccount as vi.Mock; +const useBreakpointsMock = useBreakpoints as vi.Mock; describe('WalletDropdown', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('renders null when address is not provided', () => { + useAccountMock.mockReturnValue({ address: undefined }); useWalletContextMock.mockReturnValue({ isOpen: true }); - useAccountMock.mockReturnValue({ address: null }); - render(Test Children); - expect(screen.queryByText('Test Children')).not.toBeInTheDocument(); }); - it('renders children when isOpen is true and address is provided', () => { - useWalletContextMock.mockReturnValue({ isOpen: true }); + it('does not render anything if breakpoint is not defined', () => { useAccountMock.mockReturnValue({ address: '0x123' }); + useBreakpointsMock.mockReturnValue(null); - render(Test Children); + render(Content); + + expect(screen.queryByText('Content')).not.toBeInTheDocument(); + }); + + it('renders WalletBottomSheet when breakpoint is "sm"', () => { + useAccountMock.mockReturnValue({ address: '0x123' }); + useBreakpointsMock.mockReturnValue('sm'); + + render(Content); + + const bottomSheet = screen.getByTestId('ockWalletBottomSheet'); + + expect(bottomSheet).toBeInTheDocument(); + expect(bottomSheet).toHaveClass('bottom-sheet'); + }); + + it('renders WalletDropdown when breakpoint is not "sm"', () => { + useAccountMock.mockReturnValue({ address: '0x123' }); + useBreakpointsMock.mockReturnValue('md'); + + render(Content); + + const dropdown = screen.getByTestId('ockWalletDropdown'); - expect(screen.getByText('Test Children')).toBeInTheDocument(); + expect(dropdown).toBeInTheDocument(); + expect(dropdown).toHaveClass('dropdown'); }); it('injects address prop to Identity component', async () => { diff --git a/src/wallet/components/WalletDropdown.tsx b/src/wallet/components/WalletDropdown.tsx index cb7910d594..f89ee3b71c 100644 --- a/src/wallet/components/WalletDropdown.tsx +++ b/src/wallet/components/WalletDropdown.tsx @@ -2,9 +2,12 @@ import { Children, cloneElement, isValidElement, useMemo } from 'react'; import { useAccount } from 'wagmi'; import { Identity } from '../../identity/components/Identity'; import { background, cn } from '../../styles/theme'; +import useBreakpoints from '../../useBreakpoints'; import type { WalletDropdownReact } from '../types'; +import { WalletBottomSheet } from './WalletBottomSheet'; export function WalletDropdown({ children, className }: WalletDropdownReact) { + const breakpoint = useBreakpoints(); const { address } = useAccount(); const childrenArray = useMemo(() => { @@ -21,6 +24,16 @@ export function WalletDropdown({ children, className }: WalletDropdownReact) { return null; } + if (!breakpoint) { + return null; + } + + if (breakpoint === 'sm') { + return ( + {children} + ); + } + return (
{childrenArray}
diff --git a/src/wallet/types.ts b/src/wallet/types.ts index 7782411331..76ef8764a3 100644 --- a/src/wallet/types.ts +++ b/src/wallet/types.ts @@ -73,6 +73,14 @@ export type WalletReact = { children: React.ReactNode; }; +/** + * Note: exported as public Type + */ +export type WalletBottomSheetReact = { + children: React.ReactNode; + className?: string; // Optional className override for top div element +}; + /** * Note: exported as public Type */