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

Merged
merged 51 commits into from
Feb 4, 2025
Merged
Show file tree
Hide file tree
Changes from 46 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
303847a
add mobileContainer animations
brendan-defi Jan 17, 2025
34244d3
add mobile walletisland animations
brendan-defi Jan 17, 2025
6b2269c
implement MobileTray
brendan-defi Jan 17, 2025
d31d65e
implement mobiletray in walletisland
brendan-defi Jan 17, 2025
2d2b5d2
fix lints
brendan-defi Jan 17, 2025
bd34637
update tests
brendan-defi Jan 17, 2025
f3c5cba
add test coverage
brendan-defi Jan 17, 2025
9c214b6
fix lints
brendan-defi Jan 17, 2025
f5ad70a
update animations, mobile widths
brendan-defi Jan 17, 2025
caca2af
refactor to use new primitives
brendan-defi Jan 18, 2025
a6b5abe
fix tests
brendan-defi Jan 19, 2025
6f975f9
fix tests
brendan-defi Jan 19, 2025
407551e
fix lints
brendan-defi Jan 19, 2025
2010cee
add aria-attributes to MobileTray
brendan-defi Jan 19, 2025
24dc445
refactored mobiletray animations
brendan-defi Jan 23, 2025
ce65ac7
fix tests, lints
brendan-defi Jan 23, 2025
e3e4d88
remove white bg, rename to BottomSheet
brendan-defi Jan 23, 2025
2b78039
breakpoints in css
brendan-defi Jan 24, 2025
c95172c
fix: tests
brendan-defi Jan 24, 2025
48e35a8
add reposition on resize
brendan-defi Jan 24, 2025
878ded6
disable drag while bottomsheet is open
brendan-defi Jan 24, 2025
e212f66
update breakpoint handling
brendan-defi Jan 24, 2025
4b9600f
fix tests
brendan-defi Jan 24, 2025
9d5316c
fix lints
brendan-defi Jan 24, 2025
e26e0cb
fix imports and tests
brendan-defi Jan 27, 2025
5da65b6
make portal, fix tests
brendan-defi Jan 27, 2025
fd6f38a
fix lints
brendan-defi Jan 27, 2025
8a03fa1
fix portal click issues
brendan-defi Jan 28, 2025
d2818eb
prevent triggerClicks
brendan-defi Jan 28, 2025
9cdcb75
fix tests, lints
brendan-defi Jan 28, 2025
116ec65
move breakpoint to wallet provider
brendan-defi Jan 28, 2025
fcb5148
fix tests
brendan-defi Jan 28, 2025
5cd4aa9
fix lints
brendan-defi Jan 28, 2025
4812476
update
brendan-defi Jan 28, 2025
0685de3
add disabled param to useOutsideClick, disable in mobile wallet
brendan-defi Jan 31, 2025
0e371f5
add test
brendan-defi Jan 31, 2025
69ca7e2
revert change to useoutsideclick
brendan-defi Jan 31, 2025
2db901c
remove early return
brendan-defi Jan 31, 2025
5355bbf
fix typos in comment
brendan-defi Feb 3, 2025
426cffc
remove TODO comment, linear ticket created
brendan-defi Feb 3, 2025
5200db1
remove unnecessary test
brendan-defi Feb 3, 2025
d7d2d5f
fix zIndex
brendan-defi Feb 4, 2025
349be65
remove zIndex from Draggable
brendan-defi Feb 4, 2025
d6c11d0
fix zIndex
brendan-defi Feb 4, 2025
bf359a3
handle keypresses
brendan-defi Feb 4, 2025
7e80c75
fix lints
brendan-defi Feb 4, 2025
f9890bb
merge conflict avoidance
brendan-defi Feb 4, 2025
98cd192
Merge branch 'main' into feat/mobile-wi
brendan-defi Feb 4, 2025
fce4c60
fix tests
brendan-defi Feb 4, 2025
c16fb4a
add classname overrides to bottomsheet
brendan-defi Feb 4, 2025
06edbaf
add test
brendan-defi Feb 4, 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
3 changes: 2 additions & 1 deletion playground/nextjs-app-router/onchainkit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"repository": "https://github.com/coinbase/onchainkit.git",
"license": "MIT",
"scripts": {
"build": "packemon build --addEngines --addFiles --loadConfigs --declaration && npx packemon validate --no-license --no-people --no-repo && tailwindcss -i ./src/styles/index.css -o ./src/tailwind.css --minify && tailwindcss -i ./src/styles/index-with-tailwind.css -o ./src/styles.css --minify",
"build": "packemon build --addEngines --addFiles --loadConfigs --declaration && tscpaths -p tsconfig.esm.json -s ./src -o ./esm && npx packemon validate --no-license --no-people --no-repo && tailwindcss -i ./src/styles/index.css -o ./src/tailwind.css --minify && tailwindcss -i ./src/styles/index-with-tailwind.css -o ./src/styles.css --minify",
"check": "biome check --write .",
"check:unsafe": "biome check . --fix --unsafe",
"ci:check": "biome ci --formatter-enabled=false --linter-enabled=false",
Expand Down Expand Up @@ -77,6 +77,7 @@
"rimraf": "^5.0.5",
"storybook": "^8.2.9",
"tailwindcss": "^3.4.3",
"tscpaths": "^0.0.9",
"tsup": "^8.3.5",
"typescript": "~5.3.3",
"vite": "^5.3.3",
Expand Down
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');
});
});
65 changes: 65 additions & 0 deletions src/internal/components/BottomSheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { DismissableLayer } from '@/internal/components/primitives/DismissableLayer';
import { FocusTrap } from '@/internal/components/primitives/FocusTrap';
import { useTheme } from '@/internal/hooks/useTheme';
import { zIndex } from '@/styles/constants';
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

Copy link
Contributor

Choose a reason for hiding this comment

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

Move to internal/components/primitives/?

Copy link
Contributor

Choose a reason for hiding this comment

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

Just put it in internal/components, not primitives. I'm going to work on migrating primitives once i get a chance

children,
className,
isOpen,
onClose,
triggerRef,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
'aria-describedby': ariaDescribedby,
}: BottomSheetProps) {
const componentTheme = useTheme();

if (!isOpen) {
return null;
}

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,
zIndex.modal,
'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);
}
12 changes: 10 additions & 2 deletions src/internal/components/CopyButton.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
'use client';

import { copyToClipboard } from '@/internal/utils/copyToClipboard';
import type { ReactNode } from 'react';
import { type ReactNode, useCallback } from 'react';

type CopyButtonProps = {
label: string | ReactNode;
Expand All @@ -18,12 +20,18 @@ export function CopyButton({
className,
'aria-label': ariaLabel,
}: CopyButtonProps) {
const handleCopy = useCallback(
() => copyToClipboard({ copyValue, onSuccess, onError }),
[copyValue, onSuccess, onError],
);

return (
<button
type="button"
data-testid="ockCopyButton"
className={className}
onClick={() => copyToClipboard({ copyValue, onSuccess, onError })}
onClick={handleCopy}
onKeyDown={handleCopy}
aria-label={ariaLabel}
>
{label}
Expand Down
6 changes: 4 additions & 2 deletions src/internal/components/Draggable/Draggable.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
'use client';

import { zIndex } from '@/styles/constants';
// 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,14 +105,15 @@ export function Draggable({
dragStartPosition,
]);

useRespositionOnWindowResize(draggableRef, position, setPosition);

return (
<div
ref={draggableRef}
data-testid="ockDraggable"
className={cn(
'fixed touch-none select-none',
'cursor-grab active:cursor-grabbing',
zIndex.modal,
)}
style={{
left: `${position.x}px`,
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
});
});
Loading