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

Feat: WalletIsland mobile design #1827

Open
wants to merge 35 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
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 Jan 17, 2025
e390948
add mobileContainer animations
brendan-defi Jan 17, 2025
b3c87bb
add mobile walletisland animations
brendan-defi Jan 17, 2025
5b97631
implement MobileTray
brendan-defi Jan 17, 2025
a297413
implement mobiletray in walletisland
brendan-defi Jan 17, 2025
90f4932
fix lints
brendan-defi Jan 17, 2025
5cad826
update tests
brendan-defi Jan 17, 2025
dc535f1
add test coverage
brendan-defi Jan 17, 2025
e62f828
fix lints
brendan-defi Jan 17, 2025
763fcdd
update animations, mobile widths
brendan-defi Jan 17, 2025
98dad80
refactor to use new primitives
brendan-defi Jan 18, 2025
1bf6f63
fix tests
brendan-defi Jan 19, 2025
73130f0
fix tests
brendan-defi Jan 19, 2025
477bd8f
fix lints
brendan-defi Jan 19, 2025
7dce141
add aria-attributes to MobileTray
brendan-defi Jan 19, 2025
5374c48
refactored mobiletray animations
brendan-defi Jan 23, 2025
67a6649
fix tests, lints
brendan-defi Jan 23, 2025
361faf5
remove white bg, rename to BottomSheet
brendan-defi Jan 23, 2025
77a8d39
breakpoints in css
brendan-defi Jan 24, 2025
b9c0765
fix: tests
brendan-defi Jan 24, 2025
fc6353b
add reposition on resize
brendan-defi Jan 24, 2025
97356d5
disable drag while bottomsheet is open
brendan-defi Jan 24, 2025
3304373
update breakpoint handling
brendan-defi Jan 24, 2025
3599b46
fix tests
brendan-defi Jan 24, 2025
a43317a
fix lints
brendan-defi Jan 24, 2025
c024f75
fix imports and tests
brendan-defi Jan 27, 2025
93a42f5
make portal, fix tests
brendan-defi Jan 27, 2025
b28f200
fix lints
brendan-defi Jan 27, 2025
d53af83
fix portal click issues
brendan-defi Jan 28, 2025
231c2ae
prevent triggerClicks
brendan-defi Jan 28, 2025
6740ca9
fix tests, lints
brendan-defi Jan 28, 2025
b00aa95
move breakpoint to wallet provider
brendan-defi Jan 28, 2025
31610cd
fix tests
brendan-defi Jan 28, 2025
6f85647
fix lints
brendan-defi Jan 28, 2025
d73d5ce
update
brendan-defi Jan 28, 2025
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
61 changes: 61 additions & 0 deletions src/internal/components/BottomSheet.test.tsx
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');
});
});
64 changes: 64 additions & 0 deletions src/internal/components/BottomSheet.tsx
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({
Copy link
Contributor

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!

Copy link
Contributor Author

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

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);
}
3 changes: 3 additions & 0 deletions src/internal/components/Draggable/Draggable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { zIndex } from '@/styles/constants';
import { cn } from '@/styles/theme';
import { useCallback, useEffect, useRef, useState } from 'react';
import { getBoundedPosition } from './getBoundedPosition';
import { useRespositionOnWindowResize } from './useRepositionOnResize';

type DraggableProps = {
children: React.ReactNode;
Expand Down Expand Up @@ -104,6 +105,8 @@ export function Draggable({
dragStartPosition,
]);

useRespositionOnWindowResize(draggableRef, position, setPosition);

return (
<div
ref={draggableRef}
Expand Down
157 changes: 157 additions & 0 deletions src/internal/components/Draggable/useRepositionOnResize.test.ts
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 src/internal/components/Draggable/useRepositionOnResize.ts
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]);
}
3 changes: 2 additions & 1 deletion src/styles/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ export const zIndex = {
dropdown: 10,
tooltip: 20,
modal: 40,
bottomSheet: 45,
notification: 50,
};
} as const;
Loading
Loading