Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
72 changes: 72 additions & 0 deletions src/components/common/CopyField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { useState } from 'react';
import { Copy, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAutoSelectOnFocus } from '@/hooks/useAutoSelectOnFocus';
import CopySuccessAnnouncement from '@/components/common/CopySuccessAnnouncement';
import { useCopySuccessAnnouncement } from '@/hooks/useCopySuccessAnnouncement';

interface CopyFieldProps {
value: string;
label: string;
className?: string;
inputClassName?: string;
buttonClassName?: string;
}

const CopyField: React.FC<CopyFieldProps> = ({
value,
label,
className,
inputClassName,
buttonClassName,
}) => {
const [copied, setCopied] = useState(false);
const inputRef = useAutoSelectOnFocus<HTMLInputElement>();
const { announcement, announceCopySuccess } = useCopySuccessAnnouncement();

const handleCopy = async () => {
try {
await navigator.clipboard.writeText(value);
announceCopySuccess(`${label} copied.`);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
setCopied(false);
}
};

return (
<div className={cn('flex items-center gap-2', className)}>
<input
ref={inputRef}
type="text"
readOnly
value={value}
aria-label={label}
title={value}
className={cn(
'min-w-0 flex-1 rounded-md border border-white/10 bg-white/[0.04] px-3 py-2 font-mono text-sm text-white/75 outline-none transition-colors focus:border-amber-400/40 focus:bg-white/[0.06] focus:ring-1 focus:ring-amber-400/20',
inputClassName
)}
/>
<button
type="button"
onClick={handleCopy}
className={cn(
'inline-flex size-8 shrink-0 items-center justify-center rounded-md bg-white/5 text-white/40 transition-colors hover:bg-white/10 hover:text-white',
buttonClassName
)}
aria-label={copied ? `${label} copied` : `Copy ${label}`}
>
{copied ? (
<Check className="size-4 text-emerald-400" aria-hidden="true" />
) : (
<Copy className="size-4" aria-hidden="true" />
)}
</button>
<CopySuccessAnnouncement message={announcement} />
</div>
);
};

export default CopyField;
70 changes: 17 additions & 53 deletions src/components/common/TransactionHashRow.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import React, { useState } from 'react';
import { Copy, Check, ExternalLink } from 'lucide-react';
import type { FC } from 'react';
import { ExternalLink } from 'lucide-react';
import { cn } from '@/lib/utils';
import { shortenAddress } from '@/lib/web3/format';
import CopySuccessAnnouncement from '@/components/common/CopySuccessAnnouncement';
import { useCopySuccessAnnouncement } from '@/hooks/useCopySuccessAnnouncement';
import CopyField from '@/components/common/CopyField';

interface TransactionHashRowProps {
hash: string;
Expand All @@ -12,23 +10,12 @@ interface TransactionHashRowProps {
className?: string;
}

const TransactionHashRow: React.FC<TransactionHashRowProps> = ({
const TransactionHashRow: FC<TransactionHashRowProps> = ({
hash,
label = 'Transaction Hash',
explorerUrl,
className,
}) => {
const [copied, setCopied] = useState(false);
const { announcement, announceCopySuccess } = useCopySuccessAnnouncement();

const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
await navigator.clipboard.writeText(hash);
announceCopySuccess('Transaction hash copied.');
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};

return (
<div
className={cn(
Expand All @@ -40,44 +27,21 @@ const TransactionHashRow: React.FC<TransactionHashRowProps> = ({
{label}
</span>
<div className="flex items-center gap-2 overflow-hidden">
<span
className="font-mono font-medium text-white/75 truncate"
title={hash}
>
{shortenAddress(hash, 8, 8)}
</span>
<div className="flex shrink-0 items-center gap-1">
<button
onClick={handleCopy}
<CopyField
value={hash}
label="transaction hash"
/>
{explorerUrl && (
<a
href={explorerUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex size-6 items-center justify-center rounded-md bg-white/5 text-white/40 transition-colors hover:bg-white/10 hover:text-white"
aria-label={
copied
? 'Transaction hash copied'
: 'Copy transaction hash'
}
aria-label="View on block explorer"
>
{copied ? (
<Check
className="size-3 text-emerald-400"
aria-hidden="true"
/>
) : (
<Copy className="size-3" aria-hidden="true" />
)}
</button>
<CopySuccessAnnouncement message={announcement} />
{explorerUrl && (
<a
href={explorerUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex size-6 items-center justify-center rounded-md bg-white/5 text-white/40 transition-colors hover:bg-white/10 hover:text-white"
aria-label="View on block explorer"
>
<ExternalLink className="size-3" />
</a>
)}
</div>
<ExternalLink className="size-3" />
</a>
)}
</div>
</div>
);
Expand Down
68 changes: 19 additions & 49 deletions src/components/common/TruncatedAddress.tsx
Original file line number Diff line number Diff line change
@@ -1,67 +1,37 @@
import { useState } from 'react';
import { Copy, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
import CopySuccessAnnouncement from '@/components/common/CopySuccessAnnouncement';
import { useCopySuccessAnnouncement } from '@/hooks/useCopySuccessAnnouncement';
import CopyField from '@/components/common/CopyField';

interface TruncatedAddressProps {
address: string;
prefixChars?: number;
suffixChars?: number;
copyable?: boolean;
className?: string;
}

function truncate(address: string, prefix: number, suffix: number): string {
if (address.length <= prefix + suffix + 3) {
return address;
}
return `${address.slice(0, prefix)}...${address.slice(-suffix)}`;
}

const TruncatedAddress: React.FC<TruncatedAddressProps> = ({
address,
prefixChars = 6,
suffixChars = 4,
copyable = false,
className,
}) => {
const [copied, setCopied] = useState(false);
const { announcement, announceCopySuccess } = useCopySuccessAnnouncement();

const handleCopy = async () => {
await navigator.clipboard.writeText(address);
announceCopySuccess('Address copied.');
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
if (!copyable) {
return (
<span
className={cn(
'inline-flex items-center gap-1 font-mono text-sm text-white/70',
className
)}
title={address}
>
<span>{address}</span>
</span>
);
}

return (
<span
className={cn(
'inline-flex items-center gap-1 font-mono text-sm text-white/70',
className
)}
title={address}
>
<span>{truncate(address, prefixChars, suffixChars)}</span>
{copyable && (
<>
<button
onClick={handleCopy}
aria-label={copied ? 'Address copied' : 'Copy address'}
className="text-white/40 transition-colors hover:text-amber-500"
>
{copied ? (
<Check className="size-3" aria-hidden="true" />
) : (
<Copy className="size-3" aria-hidden="true" />
)}
</button>
<CopySuccessAnnouncement message={announcement} />
</>
)}
</span>
<CopyField
value={address}
label="Wallet address"
className={className}
/>
);
};

Expand Down
6 changes: 3 additions & 3 deletions src/components/common/__tests__/TransactionHashRow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,18 @@ describe('TransactionHashRow', () => {

expect(writeText).toHaveBeenCalledWith('0xabcdef1234567890');
expect(
screen.getByRole('button', { name: 'Transaction hash copied' })
screen.getByRole('button', { name: /transaction hash copied/i })
).toBeInTheDocument();

act(() => {
vi.advanceTimersByTime(25);
});

const status = screen.getByRole('status');
expect(status).toHaveTextContent('Transaction hash copied.');
expect(status).toHaveTextContent(/transaction hash copied\./i);
expect(status).toHaveClass('sr-only');
expect(status).not.toHaveTextContent(
'Transaction hash copied to clipboard'
'transaction hash copied to clipboard'
);

vi.useRealTimers();
Expand Down
21 changes: 21 additions & 0 deletions src/hooks/useAutoSelectOnFocus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useRef, useEffect, type RefObject } from 'react';

export function useAutoSelectOnFocus<T extends HTMLInputElement | HTMLTextAreaElement>(): RefObject<T | null> {
const ref = useRef<T | null>(null);

useEffect(() => {
const node = ref.current;
if (!node) return;

const handleFocus = () => {
node.select();
};

node.addEventListener('focus', handleFocus);
return () => {
node.removeEventListener('focus', handleFocus);
};
}, []);

return ref;
}
Loading