Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add mobile drawer and useBreakpoints hook #1045

Merged
merged 21 commits into from
Aug 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/useBreakpoints.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
46 changes: 46 additions & 0 deletions src/useBreakpoints.ts
Original file line number Diff line number Diff line change
@@ -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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Loooooove the comments

// 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;
122 changes: 122 additions & 0 deletions src/wallet/components/WalletBottomSheet.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<IdentityProvider address={address}>{children}</IdentityProvider>
)),
}));

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(<WalletBottomSheet>Test Children</WalletBottomSheet>);

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(<WalletBottomSheet>Test Children</WalletBottomSheet>);

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 }) => (
<WalletBottomSheet>
<Identity>{children}</Identity>
</WalletBottomSheet>
),
});

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(<WalletBottomSheet>Content</WalletBottomSheet>);

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(<WalletBottomSheet>Content</WalletBottomSheet>);

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(<WalletBottomSheet>Content</WalletBottomSheet>);

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(<WalletBottomSheet>Content</WalletBottomSheet>);

fireEvent.keyDown(screen.getByRole('button'), {
key: 'Escape',
code: 'Escape',
});

expect(setIsOpenMock).toHaveBeenCalledWith(false);
});
});
73 changes: 73 additions & 0 deletions src/wallet/components/WalletBottomSheet.tsx
Original file line number Diff line number Diff line change
@@ -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({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is BottomSheet a common term for those kind of components? just a curiosity.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i feel like i've seen Drawer or Tray used more frequently but WalletBottomSheet was in the designs

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mmmm, it's not a public API so it's fine for now, but in general feel free to triple check those names and see what's more common across other Design Systems.

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<HTMLDivElement>) => {
if (event.key === 'Escape') {
setIsOpen(false);
}
},
[setIsOpen],
);

if (!address) {
return null;
}

return (
<>
{isOpen && (
<div
className="fixed inset-0 z-40 bg-black bg-opacity-20"
onClick={handleOverlayClick}
onKeyDown={handleEscKeyPress}
role="button"
tabIndex={0}
/>
)}
<div
className={cn(
background.default,
'fixed right-0 bottom-0 left-0 z-50',
'transform rounded-[20px_20px_0_0] p-4 transition-transform',
`${isOpen ? 'translate-y-0' : 'translate-y-full'}`,
className,
)}
data-testid="ockWalletBottomSheet"
>
{childrenArray}
</div>
</>
);
}
47 changes: 40 additions & 7 deletions src/wallet/components/WalletDropdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
IdentityProvider,
useIdentityContext,
} from '../../identity/components/IdentityProvider';
import useBreakpoints from '../../useBreakpoints';
import { WalletDropdown } from './WalletDropdown';
import { useWalletContext } from './WalletProvider';

Expand All @@ -17,32 +18,64 @@ vi.mock('./WalletProvider', () => ({
useWalletContext: vi.fn(),
}));

vi.mock('../../useBreakpoints', () => ({
default: vi.fn(),
}));

vi.mock('../../identity/components/Identity', () => ({
Identity: vi.fn(({ address, children }) => (
<IdentityProvider address={address}>{children}</IdentityProvider>
)),
}));

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(<WalletDropdown>Test Children</WalletDropdown>);

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(<WalletDropdown>Test Children</WalletDropdown>);
render(<WalletDropdown>Content</WalletDropdown>);

expect(screen.queryByText('Content')).not.toBeInTheDocument();
});

it('renders WalletBottomSheet when breakpoint is "sm"', () => {
useAccountMock.mockReturnValue({ address: '0x123' });
useBreakpointsMock.mockReturnValue('sm');

render(<WalletDropdown className="bottom-sheet">Content</WalletDropdown>);

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(<WalletDropdown className="dropdown">Content</WalletDropdown>);

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 () => {
Expand Down
Loading