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
*/