Skip to content

Commit 1e4813d

Browse files
authored
Feat: WalletIsland mobile design (#1827)
1 parent 773ccbf commit 1e4813d

25 files changed

+731
-166
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { fireEvent, render, screen } from '@testing-library/react';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
import { BottomSheet } from './BottomSheet';
4+
5+
vi.mock('../../internal/hooks/useTheme', () => ({
6+
useTheme: vi.fn(),
7+
}));
8+
9+
describe('BottomSheet', () => {
10+
const defaultProps = {
11+
isOpen: true,
12+
onClose: vi.fn(),
13+
children: <div>Test Content</div>,
14+
};
15+
16+
beforeEach(() => {
17+
vi.clearAllMocks();
18+
});
19+
20+
it('renders children when open', () => {
21+
render(<BottomSheet {...defaultProps} />);
22+
expect(screen.getByText('Test Content')).toBeInTheDocument();
23+
});
24+
25+
it('does not render children when closed', () => {
26+
render(<BottomSheet {...defaultProps} isOpen={false} />);
27+
expect(screen.queryByText('Test Content')).not.toBeInTheDocument();
28+
});
29+
30+
it('calls onClose when Escape key is pressed on overlay', () => {
31+
render(<BottomSheet {...defaultProps} />);
32+
fireEvent.keyDown(screen.getByTestId('ockDismissableLayer'), {
33+
key: 'Escape',
34+
});
35+
expect(defaultProps.onClose).toHaveBeenCalled();
36+
});
37+
38+
it('applies custom className when provided', () => {
39+
render(<BottomSheet {...defaultProps} className="custom-class" />);
40+
expect(screen.getByTestId('ockBottomSheet')).toHaveClass('custom-class');
41+
});
42+
43+
it('sets all ARIA attributes correctly', () => {
44+
render(
45+
<BottomSheet
46+
{...defaultProps}
47+
aria-label="Test Dialog"
48+
aria-describedby="desc"
49+
aria-labelledby="title"
50+
>
51+
<div>Content</div>
52+
</BottomSheet>,
53+
);
54+
55+
const sheet = screen.getByTestId('ockBottomSheet');
56+
expect(sheet).toHaveAttribute('role', 'dialog');
57+
expect(sheet).toHaveAttribute('aria-label', 'Test Dialog');
58+
expect(sheet).toHaveAttribute('aria-describedby', 'desc');
59+
expect(sheet).toHaveAttribute('aria-labelledby', 'title');
60+
});
61+
});
+65
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { DismissableLayer } from '@/internal/components/primitives/DismissableLayer';
2+
import { FocusTrap } from '@/internal/components/primitives/FocusTrap';
3+
import { useTheme } from '@/internal/hooks/useTheme';
4+
import { zIndex } from '@/styles/constants';
5+
import { background, cn } from '@/styles/theme';
6+
import { createPortal } from 'react-dom';
7+
8+
type BottomSheetProps = {
9+
children: React.ReactNode;
10+
isOpen: boolean;
11+
onClose: () => void;
12+
triggerRef?: React.RefObject<HTMLElement>;
13+
className?: string;
14+
'aria-label'?: string;
15+
'aria-labelledby'?: string;
16+
'aria-describedby'?: string;
17+
};
18+
19+
export function BottomSheet({
20+
children,
21+
className,
22+
isOpen,
23+
onClose,
24+
triggerRef,
25+
'aria-label': ariaLabel,
26+
'aria-labelledby': ariaLabelledby,
27+
'aria-describedby': ariaDescribedby,
28+
}: BottomSheetProps) {
29+
const componentTheme = useTheme();
30+
31+
if (!isOpen) {
32+
return null;
33+
}
34+
35+
const bottomSheet = (
36+
<FocusTrap active={isOpen}>
37+
<DismissableLayer
38+
onDismiss={onClose}
39+
triggerRef={triggerRef}
40+
preventTriggerEvents={!!triggerRef}
41+
>
42+
<div
43+
aria-describedby={ariaDescribedby}
44+
aria-label={ariaLabel}
45+
aria-labelledby={ariaLabelledby}
46+
data-testid="ockBottomSheet"
47+
role="dialog"
48+
className={cn(
49+
componentTheme,
50+
background.default,
51+
zIndex.modal,
52+
'fixed right-0 bottom-0 left-0',
53+
'transform rounded-t-3xl p-2 transition-transform',
54+
'fade-in slide-in-from-bottom-1/2 animate-in',
55+
className,
56+
)}
57+
>
58+
{children}
59+
</div>
60+
</DismissableLayer>
61+
</FocusTrap>
62+
);
63+
64+
return createPortal(bottomSheet, document.body);
65+
}

src/internal/components/CopyButton.tsx

+10-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
'use client';
2+
13
import { copyToClipboard } from '@/internal/utils/copyToClipboard';
2-
import type { ReactNode } from 'react';
4+
import { type ReactNode, useCallback } from 'react';
35

46
type CopyButtonProps = {
57
label: string | ReactNode;
@@ -18,12 +20,18 @@ export function CopyButton({
1820
className,
1921
'aria-label': ariaLabel,
2022
}: CopyButtonProps) {
23+
const handleCopy = useCallback(
24+
() => copyToClipboard({ copyValue, onSuccess, onError }),
25+
[copyValue, onSuccess, onError],
26+
);
27+
2128
return (
2229
<button
2330
type="button"
2431
data-testid="ockCopyButton"
2532
className={className}
26-
onClick={() => copyToClipboard({ copyValue, onSuccess, onError })}
33+
onClick={handleCopy}
34+
onKeyDown={handleCopy}
2735
aria-label={ariaLabel}
2836
>
2937
{label}

src/internal/components/Draggable/Draggable.tsx

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
'use client';
22

3-
import { zIndex } from '@/styles/constants';
3+
// import { zIndex } from '@/styles/constants';
44
import { cn } from '@/styles/theme';
55
import { useCallback, useEffect, useRef, useState } from 'react';
66
import { getBoundedPosition } from './getBoundedPosition';
7+
import { useRespositionOnWindowResize } from './useRepositionOnResize';
78

89
type DraggableProps = {
910
children: React.ReactNode;
@@ -104,14 +105,15 @@ export function Draggable({
104105
dragStartPosition,
105106
]);
106107

108+
useRespositionOnWindowResize(draggableRef, position, setPosition);
109+
107110
return (
108111
<div
109112
ref={draggableRef}
110113
data-testid="ockDraggable"
111114
className={cn(
112115
'fixed touch-none select-none',
113116
'cursor-grab active:cursor-grabbing',
114-
zIndex.modal,
115117
)}
116118
style={{
117119
left: `${position.x}px`,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { renderHook } from '@testing-library/react';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
import { useRespositionOnWindowResize } from './useRepositionOnResize';
4+
5+
describe('useRespositionOnWindowResize', () => {
6+
const mockRef = { current: document.createElement('div') };
7+
const mockResetPosition = vi.fn();
8+
9+
beforeEach(() => {
10+
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1024);
11+
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(768);
12+
mockRef.current = document.createElement('div');
13+
14+
vi.clearAllMocks();
15+
});
16+
17+
it('should not reposition when element is within viewport', () => {
18+
const initialPosition = { x: 100, y: 100 };
19+
mockRef.current.getBoundingClientRect = vi.fn().mockReturnValue({
20+
width: 100,
21+
height: 100,
22+
top: 100,
23+
left: 100,
24+
right: 200,
25+
bottom: 200,
26+
});
27+
28+
renderHook(() =>
29+
useRespositionOnWindowResize(mockRef, initialPosition, mockResetPosition),
30+
);
31+
32+
window.dispatchEvent(new Event('resize'));
33+
34+
// Get the callback function that was passed to resetPosition
35+
const callback = mockResetPosition.mock.calls[0][0];
36+
// Call the callback with current position and verify it returns same position
37+
expect(callback(initialPosition)).toEqual(initialPosition);
38+
});
39+
40+
it('should not reposition when ref.current is falsey', () => {
41+
const initialPosition = { x: 100, y: 100 };
42+
// @ts-expect-error - we are testing the case where ref.current is falsey
43+
mockRef.current = null;
44+
45+
renderHook(() =>
46+
useRespositionOnWindowResize(mockRef, initialPosition, mockResetPosition),
47+
);
48+
49+
window.dispatchEvent(new Event('resize'));
50+
expect(mockResetPosition).not.toHaveBeenCalled();
51+
});
52+
53+
it('should reposition when element is outside right viewport boundary', () => {
54+
mockRef.current.getBoundingClientRect = vi.fn().mockReturnValue({
55+
width: 100,
56+
height: 100,
57+
top: 100,
58+
left: 1000,
59+
right: 1100,
60+
bottom: 200,
61+
});
62+
63+
renderHook(() =>
64+
useRespositionOnWindowResize(
65+
mockRef,
66+
{ x: 1000, y: 100 },
67+
mockResetPosition,
68+
),
69+
);
70+
71+
window.dispatchEvent(new Event('resize'));
72+
const callback = mockResetPosition.mock.calls[0][0];
73+
const newPosition = callback({ x: 1000, y: 100 });
74+
expect(newPosition.x).toBe(914); // 1024 - 100 - 10
75+
expect(newPosition.y).toBe(100); // y shouldn't change
76+
});
77+
78+
it('should reposition when element is outside bottom viewport boundary', () => {
79+
mockRef.current.getBoundingClientRect = vi.fn().mockReturnValue({
80+
width: 100,
81+
height: 100,
82+
top: 700,
83+
left: 100,
84+
right: 200,
85+
bottom: 800,
86+
});
87+
88+
renderHook(() =>
89+
useRespositionOnWindowResize(
90+
mockRef,
91+
{ x: 100, y: 700 },
92+
mockResetPosition,
93+
),
94+
);
95+
96+
window.dispatchEvent(new Event('resize'));
97+
const callback = mockResetPosition.mock.calls[0][0];
98+
const newPosition = callback({ x: 100, y: 700 });
99+
expect(newPosition.x).toBe(100); // x shouldn't change
100+
expect(newPosition.y).toBe(658); // 768 - 100 - 10
101+
});
102+
103+
it('should reposition when element is outside left viewport boundary', () => {
104+
mockRef.current.getBoundingClientRect = vi.fn().mockReturnValue({
105+
width: 100,
106+
height: 100,
107+
top: 100,
108+
left: -100,
109+
right: 0,
110+
bottom: 200,
111+
});
112+
113+
renderHook(() =>
114+
useRespositionOnWindowResize(
115+
mockRef,
116+
{ x: -100, y: 100 },
117+
mockResetPosition,
118+
),
119+
);
120+
121+
window.dispatchEvent(new Event('resize'));
122+
const callback = mockResetPosition.mock.calls[0][0];
123+
const newPosition = callback({ x: -100, y: 100 });
124+
expect(newPosition.x).toBe(10); // reset to 10
125+
expect(newPosition.y).toBe(100); // y shouldn't change
126+
});
127+
128+
it('should reposition when element is outside top viewport boundary', () => {
129+
// Mock viewport size
130+
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(800);
131+
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(600);
132+
133+
mockRef.current.getBoundingClientRect = vi.fn().mockReturnValue({
134+
width: 100,
135+
height: 100,
136+
top: -50,
137+
left: 100,
138+
right: 200,
139+
bottom: 50,
140+
});
141+
142+
renderHook(() =>
143+
useRespositionOnWindowResize(
144+
mockRef,
145+
{ x: 100, y: -50 },
146+
mockResetPosition,
147+
),
148+
);
149+
150+
window.dispatchEvent(new Event('resize'));
151+
152+
const callback = mockResetPosition.mock.calls[0][0];
153+
const newPosition = callback({ x: 100, y: -50 });
154+
expect(newPosition.x).toBe(100); // x shouldn't change
155+
expect(newPosition.y).toBe(10); // reset to 10
156+
});
157+
});

0 commit comments

Comments
 (0)