Skip to content

Commit

Permalink
feat: wallet address copy (coinbase#730)
Browse files Browse the repository at this point in the history
  • Loading branch information
kyhyco authored Jun 27, 2024
1 parent ff128dd commit 72f287b
Show file tree
Hide file tree
Showing 7 changed files with 208 additions and 12 deletions.
7 changes: 7 additions & 0 deletions .changeset/nasty-knives-rush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@coinbase/onchainkit": patch
---

- **feat**: add copyAddressOnClick to Identity component. By @kyhyco #730
- **feat**: add EthBalance component to identity. By @kyhyco #729
- **feat**: add ConnectWallet component. By @kyhyco #720 #728
3 changes: 1 addition & 2 deletions src/identity/components/EthBalance.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { cn, color, text } from '../../styles/theme';
import type { EthBalanceReact } from '../types';
import { useGetETHBalance } from '../../wallet/core/useGetETHBalance';
import { getRoundedAmount } from '../../utils/getRoundedAmount';

export function EthBalance({ address, className }: EthBalanceReact) {
const { address: contextAddress } = useIdentityContext();
if (!contextAddress && !address) {
Expand All @@ -16,7 +15,7 @@ export function EthBalance({ address, className }: EthBalanceReact) {
contextAddress ?? address,
);

if (balance === undefined || error) {
if (!balance || error) {
return null;
}

Expand Down
22 changes: 21 additions & 1 deletion src/identity/components/Identity.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,36 @@
import type { IdentityReact } from '../types';
import { IdentityProvider } from './IdentityProvider';
import { IdentityLayout } from './IdentityLayout';
import { useCallback } from 'react';

export function Identity({
address,
children,
className,
schemaId,
copyAddressOnClick,
}: IdentityReact) {
// istanbul ignore next
const handleCopy = useCallback(async () => {
if (!address) return false;

try {
await navigator.clipboard.writeText(address);
return true;
} catch (e) {
console.error('Failed to copy: ', e);
return false;
}
}, [address]);

// istanbul ignore next
const onClick = copyAddressOnClick ? handleCopy : undefined;

return (
<IdentityProvider address={address} schemaId={schemaId}>
<IdentityLayout className={className}>{children}</IdentityLayout>
<IdentityLayout className={className} onClick={onClick}>
{children}
</IdentityLayout>
</IdentityProvider>
);
}
76 changes: 76 additions & 0 deletions src/identity/components/IdentityLayout.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* @jest-environment jsdom
*/
import React from 'react';
import '@testing-library/jest-dom';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { IdentityLayout } from './IdentityLayout';
import { Avatar } from './Avatar';
import { Name } from './Name';
import { Address } from './Address';
import { EthBalance } from './EthBalance';

const handleCopy = jest.fn().mockResolvedValue(true);

jest.mock('./Avatar', () => ({
Avatar: jest.fn(() => <div>Avatar</div>),
}));

jest.mock('./Name', () => ({
Name: jest.fn(() => <div>Name</div>),
}));

jest.mock('./Address', () => ({
Address: jest.fn(() => <div>Address</div>),
}));

jest.mock('./EthBalance', () => ({
EthBalance: jest.fn(() => <div>EthBalance</div>),
}));

const renderComponent = () => {
return render(
<IdentityLayout onClick={handleCopy} className="custom-class">
<Avatar />
<Name />
<Address />
<EthBalance />
</IdentityLayout>,
);
};

describe('IdentityLayout', () => {
it('shows popover on hover and hides on mouse leave', async () => {
renderComponent();

const container = screen.getByTestId('ockIdentity_container');
fireEvent.mouseEnter(container);

await waitFor(() => {
expect(screen.getByText('Copy')).toBeInTheDocument();
});

fireEvent.mouseLeave(container);

await waitFor(() => {
expect(screen.queryByText('Copy')).not.toBeInTheDocument();
});
});

it('changes popover text to "Copied" on click', async () => {
renderComponent();

const container = screen.getByTestId('ockIdentity_container');
fireEvent.mouseEnter(container);

await waitFor(() => {
expect(screen.getByText('Copy')).toBeInTheDocument();
});

fireEvent.click(container);

await waitFor(() => {
expect(screen.getByText('Copied')).toBeInTheDocument();
});
});
});
110 changes: 102 additions & 8 deletions src/identity/components/IdentityLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,82 @@
import {
Children,
useMemo,
isValidElement,
type ReactNode,
type ReactElement,
useCallback,
useState,
useEffect,
} from 'react';
import { Avatar } from './Avatar';
import { Name } from './Name';
import { Address } from './Address';
import { background, cn, color } from '../../styles/theme';
import { background, cn, color, pressable } from '../../styles/theme';
import { EthBalance } from './EthBalance';
import { findComponent } from '../../internal/utils/findComponent';

function findComponent<T>(component: React.ComponentType<T>) {
return (child: ReactNode): child is ReactElement<T> => {
return isValidElement(child) && child.type === component;
// istanbul ignore next
const noop = () => {};

export function usePopover(onClick?: () => Promise<boolean>) {
const [popoverText, setPopoverText] = useState('Copy');
const [showPopover, setShowPopover] = useState(false);
const [isHovered, setIsHovered] = useState(false);

const handleMouseEnter = useCallback(() => {
setPopoverText('Copy');
setIsHovered(true);
}, []);

const handleMouseLeave = useCallback(() => {
setIsHovered(false);
setShowPopover(false);
}, []);

const handleClick = useCallback(async () => {
if (onClick) {
const result = await onClick();
if (result) {
setPopoverText('Copied');
// istanbul ignore next
setTimeout(() => {
setShowPopover(false);
}, 1000);
}
}
}, [onClick]);

useEffect(() => {
let timer: NodeJS.Timeout;
if (isHovered) {
timer = setTimeout(() => setShowPopover(true), 200);
} else {
setShowPopover(false);
}

return () => clearTimeout(timer);
}, [isHovered]);

if (!onClick) return {};

return {
handleClick,
handleMouseEnter,
handleMouseLeave,
showPopover,
popoverText,
};
}

type IdentityLayoutReact = {
children: ReactNode;
className?: string;
onClick?: () => Promise<boolean>;
};

export function IdentityLayout({ children, className }: IdentityLayoutReact) {
export function IdentityLayout({
children,
className,
onClick,
}: IdentityLayoutReact) {
const { avatar, name, address, ethBalance } = useMemo(() => {
const childrenArray = Children.toArray(children);
return {
Expand All @@ -33,14 +87,28 @@ export function IdentityLayout({ children, className }: IdentityLayoutReact) {
};
}, [children]);

const {
handleClick,
handleMouseEnter,
handleMouseLeave,
showPopover,
popoverText,
} = usePopover(onClick);

return (
<div
data-testid="ockIdentity_container"
className={cn(
background.default,
'flex items-center space-x-4 px-2 py-1',
onClick && `${pressable.default} relative`,
className,
)}
data-testid="ockIdentity_container"
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onKeyUp={noop}
onKeyDown={noop}
>
{avatar}
<div className="flex flex-col">
Expand All @@ -55,6 +123,32 @@ export function IdentityLayout({ children, className }: IdentityLayoutReact) {
</div>
)}
</div>
{showPopover && (
<div
className={cn(background.inverse, color.foreground, 'absolute z-10')}
style={{
top: 'calc(100% - 5px)',
left: '46px',
boxShadow: '0px 4px 8px rgba(0, 0, 0, 0.1)',
borderRadius: '5px',
padding: '5px 10px',
}}
>
{popoverText}
<div
style={{
position: 'absolute',
top: '-5px',
left: '24px',
width: '0',
height: '0',
borderLeft: '5px solid transparent',
borderRight: '5px solid transparent',
borderBottom: '5px solid var(--bg-inverse)',
}}
/>
</div>
)}
</div>
);
}
1 change: 0 additions & 1 deletion src/identity/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ export { Avatar } from './components/Avatar';
export { Badge } from './components/Badge';
export { EthBalance } from './components/EthBalance';
export { Identity } from './components/Identity';
export { IdentityLayout } from './components/IdentityLayout';
export { Name } from './components/Name';
export { getAttestations } from './getAttestations';
export { getAvatar } from './core/getAvatar';
Expand Down
1 change: 1 addition & 0 deletions src/identity/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export type IdentityReact = {
children: ReactNode;
className?: string; // Optional className override for top div element.
schemaId?: Address | null; // The Ethereum address of the schema to use for EAS attestation.
copyAddressOnClick?: boolean;
};

/**
Expand Down

0 comments on commit 72f287b

Please sign in to comment.