-
Notifications
You must be signed in to change notification settings - Fork 203
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
Changes from all commits
2bf7733
c3ca3ba
8a20acf
082b700
b1b0e85
9aeeb64
dbe6823
3d0d6ba
1241876
e239e0e
b85b9e6
cd54cf7
edb7821
d6bd7cf
4d8a3f1
4a87cd5
e2fe05f
0d7a532
ae451ce
f4da508
1cff3b8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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'); | ||
}); | ||
}); |
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, | ||
// 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; |
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); | ||
}); | ||
}); |
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({ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i feel like i've seen There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> | ||
</> | ||
); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Loooooove the comments