-
Notifications
You must be signed in to change notification settings - Fork 232
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
Feat: WalletIsland mobile design #1827
Open
brendan-defi
wants to merge
35
commits into
main
Choose a base branch
from
feat/mobile-wi
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
35 commits
Select commit
Hold shift + click to select a range
50db885
add tray zIndex value
brendan-defi e390948
add mobileContainer animations
brendan-defi b3c87bb
add mobile walletisland animations
brendan-defi 5b97631
implement MobileTray
brendan-defi a297413
implement mobiletray in walletisland
brendan-defi 90f4932
fix lints
brendan-defi 5cad826
update tests
brendan-defi dc535f1
add test coverage
brendan-defi e62f828
fix lints
brendan-defi 763fcdd
update animations, mobile widths
brendan-defi 98dad80
refactor to use new primitives
brendan-defi 1bf6f63
fix tests
brendan-defi 73130f0
fix tests
brendan-defi 477bd8f
fix lints
brendan-defi 7dce141
add aria-attributes to MobileTray
brendan-defi 5374c48
refactored mobiletray animations
brendan-defi 67a6649
fix tests, lints
brendan-defi 361faf5
remove white bg, rename to BottomSheet
brendan-defi 77a8d39
breakpoints in css
brendan-defi b9c0765
fix: tests
brendan-defi fc6353b
add reposition on resize
brendan-defi 97356d5
disable drag while bottomsheet is open
brendan-defi 3304373
update breakpoint handling
brendan-defi 3599b46
fix tests
brendan-defi a43317a
fix lints
brendan-defi c024f75
fix imports and tests
brendan-defi 93a42f5
make portal, fix tests
brendan-defi b28f200
fix lints
brendan-defi d53af83
fix portal click issues
brendan-defi 231c2ae
prevent triggerClicks
brendan-defi 6740ca9
fix tests, lints
brendan-defi b00aa95
move breakpoint to wallet provider
brendan-defi 31610cd
fix tests
brendan-defi 6f85647
fix lints
brendan-defi d73d5ce
update
brendan-defi File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
import { fireEvent, render, screen } from '@testing-library/react'; | ||
import { beforeEach, describe, expect, it, vi } from 'vitest'; | ||
import { BottomSheet } from './BottomSheet'; | ||
|
||
vi.mock('../../internal/hooks/useTheme', () => ({ | ||
useTheme: vi.fn(), | ||
})); | ||
|
||
describe('BottomSheet', () => { | ||
const defaultProps = { | ||
isOpen: true, | ||
onClose: vi.fn(), | ||
children: <div>Test Content</div>, | ||
}; | ||
|
||
beforeEach(() => { | ||
vi.clearAllMocks(); | ||
}); | ||
|
||
it('renders children when open', () => { | ||
render(<BottomSheet {...defaultProps} />); | ||
expect(screen.getByText('Test Content')).toBeInTheDocument(); | ||
}); | ||
|
||
it('does not render children when closed', () => { | ||
render(<BottomSheet {...defaultProps} isOpen={false} />); | ||
expect(screen.queryByText('Test Content')).not.toBeInTheDocument(); | ||
}); | ||
|
||
it('calls onClose when Escape key is pressed on overlay', () => { | ||
render(<BottomSheet {...defaultProps} />); | ||
fireEvent.keyDown(screen.getByTestId('ockDismissableLayer'), { | ||
key: 'Escape', | ||
}); | ||
expect(defaultProps.onClose).toHaveBeenCalled(); | ||
}); | ||
|
||
it('applies custom className when provided', () => { | ||
render(<BottomSheet {...defaultProps} className="custom-class" />); | ||
expect(screen.getByTestId('ockBottomSheet')).toHaveClass('custom-class'); | ||
}); | ||
|
||
it('sets all ARIA attributes correctly', () => { | ||
render( | ||
<BottomSheet | ||
{...defaultProps} | ||
aria-label="Test Dialog" | ||
aria-describedby="desc" | ||
aria-labelledby="title" | ||
> | ||
<div>Content</div> | ||
</BottomSheet>, | ||
); | ||
|
||
const sheet = screen.getByTestId('ockBottomSheet'); | ||
expect(sheet).toHaveAttribute('role', 'dialog'); | ||
expect(sheet).toHaveAttribute('aria-label', 'Test Dialog'); | ||
expect(sheet).toHaveAttribute('aria-describedby', 'desc'); | ||
expect(sheet).toHaveAttribute('aria-labelledby', 'title'); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
import { DismissableLayer } from '@/internal/components/primitives/DismissableLayer'; | ||
import { FocusTrap } from '@/internal/components/primitives/FocusTrap'; | ||
import { useTheme } from '@/internal/hooks/useTheme'; | ||
import { background, cn } from '@/styles/theme'; | ||
import { createPortal } from 'react-dom'; | ||
|
||
type BottomSheetProps = { | ||
children: React.ReactNode; | ||
isOpen: boolean; | ||
onClose: () => void; | ||
triggerRef?: React.RefObject<HTMLElement>; | ||
className?: string; | ||
'aria-label'?: string; | ||
'aria-labelledby'?: string; | ||
'aria-describedby'?: string; | ||
}; | ||
|
||
export function BottomSheet({ | ||
children, | ||
className, | ||
isOpen, | ||
onClose, | ||
triggerRef, | ||
'aria-label': ariaLabel, | ||
'aria-labelledby': ariaLabelledby, | ||
'aria-describedby': ariaDescribedby, | ||
}: BottomSheetProps) { | ||
const componentTheme = useTheme(); | ||
|
||
if (!isOpen) { | ||
return null; | ||
} | ||
|
||
// TODO: add overlay when DismissableLayer can handle overlay/trigger clicks | ||
const bottomSheet = ( | ||
<FocusTrap active={isOpen}> | ||
<DismissableLayer | ||
onDismiss={onClose} | ||
triggerRef={triggerRef} | ||
preventTriggerEvents={!!triggerRef} | ||
> | ||
<div | ||
aria-describedby={ariaDescribedby} | ||
aria-label={ariaLabel} | ||
aria-labelledby={ariaLabelledby} | ||
data-testid="ockBottomSheet" | ||
role="dialog" | ||
className={cn( | ||
componentTheme, | ||
background.default, | ||
'fixed right-0 bottom-0 left-0', | ||
'transform rounded-t-3xl p-2 transition-transform', | ||
'fade-in slide-in-from-bottom-1/2 animate-in', | ||
className, | ||
)} | ||
> | ||
{children} | ||
</div> | ||
</DismissableLayer> | ||
</FocusTrap> | ||
); | ||
|
||
return createPortal(bottomSheet, document.body); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
157 changes: 157 additions & 0 deletions
157
src/internal/components/Draggable/useRepositionOnResize.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,157 @@ | ||
import { renderHook } from '@testing-library/react'; | ||
import { beforeEach, describe, expect, it, vi } from 'vitest'; | ||
import { useRespositionOnWindowResize } from './useRepositionOnResize'; | ||
|
||
describe('useRespositionOnWindowResize', () => { | ||
const mockRef = { current: document.createElement('div') }; | ||
const mockResetPosition = vi.fn(); | ||
|
||
beforeEach(() => { | ||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1024); | ||
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(768); | ||
mockRef.current = document.createElement('div'); | ||
|
||
vi.clearAllMocks(); | ||
}); | ||
|
||
it('should not reposition when element is within viewport', () => { | ||
const initialPosition = { x: 100, y: 100 }; | ||
mockRef.current.getBoundingClientRect = vi.fn().mockReturnValue({ | ||
width: 100, | ||
height: 100, | ||
top: 100, | ||
left: 100, | ||
right: 200, | ||
bottom: 200, | ||
}); | ||
|
||
renderHook(() => | ||
useRespositionOnWindowResize(mockRef, initialPosition, mockResetPosition), | ||
); | ||
|
||
window.dispatchEvent(new Event('resize')); | ||
|
||
// Get the callback function that was passed to resetPosition | ||
const callback = mockResetPosition.mock.calls[0][0]; | ||
// Call the callback with current position and verify it returns same position | ||
expect(callback(initialPosition)).toEqual(initialPosition); | ||
}); | ||
|
||
it('should not reposition when ref.current is falsey', () => { | ||
const initialPosition = { x: 100, y: 100 }; | ||
// @ts-expect-error - we are testing the case where ref.current is falsey | ||
mockRef.current = null; | ||
|
||
renderHook(() => | ||
useRespositionOnWindowResize(mockRef, initialPosition, mockResetPosition), | ||
); | ||
|
||
window.dispatchEvent(new Event('resize')); | ||
expect(mockResetPosition).not.toHaveBeenCalled(); | ||
}); | ||
|
||
it('should reposition when element is outside right viewport boundary', () => { | ||
mockRef.current.getBoundingClientRect = vi.fn().mockReturnValue({ | ||
width: 100, | ||
height: 100, | ||
top: 100, | ||
left: 1000, | ||
right: 1100, | ||
bottom: 200, | ||
}); | ||
|
||
renderHook(() => | ||
useRespositionOnWindowResize( | ||
mockRef, | ||
{ x: 1000, y: 100 }, | ||
mockResetPosition, | ||
), | ||
); | ||
|
||
window.dispatchEvent(new Event('resize')); | ||
const callback = mockResetPosition.mock.calls[0][0]; | ||
const newPosition = callback({ x: 1000, y: 100 }); | ||
expect(newPosition.x).toBe(914); // 1024 - 100 - 10 | ||
expect(newPosition.y).toBe(100); // y shouldn't change | ||
}); | ||
|
||
it('should reposition when element is outside bottom viewport boundary', () => { | ||
mockRef.current.getBoundingClientRect = vi.fn().mockReturnValue({ | ||
width: 100, | ||
height: 100, | ||
top: 700, | ||
left: 100, | ||
right: 200, | ||
bottom: 800, | ||
}); | ||
|
||
renderHook(() => | ||
useRespositionOnWindowResize( | ||
mockRef, | ||
{ x: 100, y: 700 }, | ||
mockResetPosition, | ||
), | ||
); | ||
|
||
window.dispatchEvent(new Event('resize')); | ||
const callback = mockResetPosition.mock.calls[0][0]; | ||
const newPosition = callback({ x: 100, y: 700 }); | ||
expect(newPosition.x).toBe(100); // x shouldn't change | ||
expect(newPosition.y).toBe(658); // 768 - 100 - 10 | ||
}); | ||
|
||
it('should reposition when element is outside left viewport boundary', () => { | ||
mockRef.current.getBoundingClientRect = vi.fn().mockReturnValue({ | ||
width: 100, | ||
height: 100, | ||
top: 100, | ||
left: -100, | ||
right: 0, | ||
bottom: 200, | ||
}); | ||
|
||
renderHook(() => | ||
useRespositionOnWindowResize( | ||
mockRef, | ||
{ x: -100, y: 100 }, | ||
mockResetPosition, | ||
), | ||
); | ||
|
||
window.dispatchEvent(new Event('resize')); | ||
const callback = mockResetPosition.mock.calls[0][0]; | ||
const newPosition = callback({ x: -100, y: 100 }); | ||
expect(newPosition.x).toBe(10); // reset to 10 | ||
expect(newPosition.y).toBe(100); // y shouldn't change | ||
}); | ||
|
||
it('should reposition when element is outside top viewport boundary', () => { | ||
// Mock viewport size | ||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(800); | ||
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(600); | ||
|
||
mockRef.current.getBoundingClientRect = vi.fn().mockReturnValue({ | ||
width: 100, | ||
height: 100, | ||
top: -50, | ||
left: 100, | ||
right: 200, | ||
bottom: 50, | ||
}); | ||
|
||
renderHook(() => | ||
useRespositionOnWindowResize( | ||
mockRef, | ||
{ x: 100, y: -50 }, | ||
mockResetPosition, | ||
), | ||
); | ||
|
||
window.dispatchEvent(new Event('resize')); | ||
|
||
const callback = mockResetPosition.mock.calls[0][0]; | ||
const newPosition = callback({ x: 100, y: -50 }); | ||
expect(newPosition.x).toBe(100); // x shouldn't change | ||
expect(newPosition.y).toBe(10); // reset to 10 | ||
}); | ||
}); |
76 changes: 76 additions & 0 deletions
76
src/internal/components/Draggable/useRepositionOnResize.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,76 @@ | ||
import { type Dispatch, useCallback, useEffect } from 'react'; | ||
|
||
export function useRespositionOnWindowResize( | ||
draggableRef: React.RefObject<HTMLDivElement>, | ||
position: { x: number; y: number }, | ||
resetPosition: Dispatch< | ||
React.SetStateAction<{ | ||
x: number; | ||
y: number; | ||
}> | ||
>, | ||
) { | ||
const isElementInViewport = useCallback((rect: DOMRect) => { | ||
return ( | ||
rect.right <= window.innerWidth && | ||
rect.bottom <= window.innerHeight && | ||
rect.left >= 0 && | ||
rect.top >= 0 | ||
); | ||
}, []); | ||
|
||
const repositionDraggable = useCallback( | ||
(rect: DOMRect, currentPosition: { x: number; y: number }) => { | ||
const viewportWidth = window.innerWidth; | ||
const viewportHeight = window.innerHeight; | ||
|
||
let newX: number; | ||
let newY: number; | ||
|
||
if (rect.right > viewportWidth) { | ||
newX = viewportWidth - rect.width - 10; | ||
} else if (rect.left < 0) { | ||
newX = 10; | ||
} else { | ||
newX = currentPosition.x; | ||
} | ||
|
||
if (rect.bottom > viewportHeight) { | ||
newY = viewportHeight - rect.height - 10; | ||
} else if (rect.top < 0) { | ||
newY = 10; | ||
} else { | ||
newY = currentPosition.y; | ||
} | ||
|
||
return { x: newX, y: newY }; | ||
}, | ||
[], | ||
); | ||
|
||
const handleWindowResize = useCallback(() => { | ||
if (!draggableRef.current) { | ||
return; | ||
} | ||
|
||
const el = draggableRef.current; | ||
const rect = el.getBoundingClientRect(); | ||
|
||
const newPosition = repositionDraggable(rect, position); | ||
|
||
resetPosition((currentPos) => | ||
isElementInViewport(rect) ? currentPos : newPosition, | ||
); | ||
}, [ | ||
draggableRef, | ||
position, | ||
repositionDraggable, | ||
resetPosition, | ||
isElementInViewport, | ||
]); | ||
|
||
useEffect(() => { | ||
window.addEventListener('resize', handleWindowResize); | ||
return () => window.removeEventListener('resize', handleWindowResize); | ||
}, [handleWindowResize]); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,5 +4,6 @@ export const zIndex = { | |
dropdown: 10, | ||
tooltip: 20, | ||
modal: 40, | ||
bottomSheet: 45, | ||
notification: 50, | ||
}; | ||
} as const; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
The BottomSheet should probably be a Portal!
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.
Yeah we were talking about this last week. I did some research and have seen both ways. I don't have a strong opinion, wanted to ask the group at our session today