Skip to content
Open
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
221 changes: 136 additions & 85 deletions frontend/src/components/v1/CopyButton.tsx
Original file line number Diff line number Diff line change
@@ -1,113 +1,164 @@
import React, { useState, useEffect, useCallback } from 'react';
import { copyToClipboard } from '../../utils/v1/clipboard';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import type { AppError } from '../../types/errors';
import { writeToClipboard } from '../../utils/v1/clipboard';
import { ErrorNotice } from './ErrorNotice';
import { useErrorStore } from '../../store/errorStore';

export interface CopyButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
/** The text to copy to the clipboard when clicked. */
type CopyButtonStatus = 'idle' | 'copying' | 'copied';

export interface CopyButtonProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'> {
text: string;
/** Custom label when the button is in default state. */
children?: React.ReactNode;
/** Custom test ID for testing. */
testId?: string;
/** How long to show the success state before reverting back to default (ms). Default 2000ms. */
idleLabel?: React.ReactNode;
copyingLabel?: React.ReactNode;
copiedLabel?: React.ReactNode;
feedbackDurationMs?: number;
/** Optional callback to notify parent when text is successfully copied */
onCopySuccess?: () => void;
/** Display format: 'icon' strictly, 'text', or 'both'. Default is 'icon' */
variant?: 'icon' | 'text' | 'both';
onCopy?: () => void | Promise<void>;
onCopySuccess?: () => void | Promise<void>;
onCopyError?: (error: AppError) => void | Promise<void>;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
testId?: string;
}

/**
* CopyButton Component - v1
*
* Reusable button to copy text to the clipboard with inline success feedback
* and global error fallback if copy fails unsupported environment.
*/
export const CopyButton: React.FC<CopyButtonProps> = ({
export function CopyButton({
text,
children,
testId = 'copy-button',
idleLabel,
copyingLabel,
copiedLabel,
feedbackDurationMs = 2000,
onCopySuccess,
variant = 'icon',
onCopy,
onCopySuccess,
onCopyError,
onClick,
className = '',
...rest
}) => {
const [copied, setCopied] = useState(false);
const setError = useErrorStore((state) => state.setError);

const handleCopy = useCallback(async (e: React.MouseEvent<HTMLButtonElement>) => {
// Prevent event bubbling if the button is within another interactive element
e.stopPropagation();

// Reset state before copy attempt
setCopied(false);

try {
const result = await copyToClipboard(text);
if (result.success) {
setCopied(true);
onCopySuccess?.();
} else {
setError({
code: 'CLIPBOARD_NOT_SUPPORTED',
domain: 'ui',
severity: 'user_actionable',
message: 'Unable to copy text to clipboard.',
action: 'Please select and copy the text manually.',
});
disabled = false,
type = 'button',
testId = 'copy-button',
...buttonProps
}: CopyButtonProps) {
const [status, setStatus] = useState<CopyButtonStatus>('idle');
const [error, setError] = useState<AppError | null>(null);
const timeoutRef = useRef<number | null>(null);
const setGlobalError = useErrorStore((state) => state.setError);

useEffect(() => {
return () => {
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current);
}
} catch (error) {
setError({
code: 'CLIPBOARD_ERROR',
domain: 'ui',
severity: 'terminal',
message: 'An unexpected error occurred while trying to copy text.',
debug: { originalError: error }
});
};
}, []);

const isBusy = status === 'copying';
const isDisabled = disabled || isBusy;
const showIcon = variant === 'icon' || variant === 'both';
const showText = variant === 'text' || variant === 'both';

const resolvedIdleLabel = idleLabel ?? children ?? 'Copy';
const resolvedCopyingLabel = copyingLabel ?? 'Copying...';
const resolvedCopiedLabel = copiedLabel ?? 'Copied!';

const textLabel = useMemo(() => {
if (status === 'copying') {
return resolvedCopyingLabel;
}
}, [text, onCopySuccess, setError]);

// Handle automatic timeout for success feedback
useEffect(() => {
if (!copied) return;
if (status === 'copied') {
return resolvedCopiedLabel;
}

const timeout = setTimeout(() => {
setCopied(false);
return resolvedIdleLabel;
}, [
resolvedCopiedLabel,
resolvedCopyingLabel,
resolvedIdleLabel,
status,
]);

const statusMessage = status === 'copied' ? 'Copied to clipboard.' : '';
const iconName = status === 'copied' ? 'check-circle' : 'copy';

const scheduleReset = () => {
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current);
}

timeoutRef.current = window.setTimeout(() => {
setStatus('idle');
timeoutRef.current = null;
}, feedbackDurationMs);
};

const handleCopy = async (
event: React.MouseEvent<HTMLButtonElement>,
): Promise<void> => {
onClick?.(event);

if (event.defaultPrevented || isDisabled) {
return;
}

return () => clearTimeout(timeout);
}, [copied, feedbackDurationMs]);
setError(null);
setStatus('copying');

const baseClass = `copy-button copy-button--${variant} ${className}`.trim();
const result = await writeToClipboard(text);

if (result.ok) {
await onCopy?.();
await onCopySuccess?.();
setStatus('copied');
scheduleReset();
return;
}

setStatus('idle');
setError(result.error);
setGlobalError(result.error);
await onCopyError?.(result.error);
};

return (
<button
type="button"
className={baseClass}
onClick={handleCopy}
data-testid={testId}
aria-label={copied ? 'Copied to clipboard' : 'Copy to clipboard'}
aria-live="polite"
{...rest}
>
<span className="copy-button__content">
{(variant === 'icon' || variant === 'both') && (
<span
className={`icon icon--${copied ? 'check-circle' : 'copy'}`}
aria-hidden="true"
<div className={className} data-testid={`${testId}-container`}>
<button
{...buttonProps}
type={type}
disabled={isDisabled}
onClick={handleCopy}
aria-busy={isBusy}
aria-disabled={isDisabled}
aria-label={status === 'copied' ? 'Copied to clipboard' : 'Copy to clipboard'}
data-testid={testId}
>
{showIcon ? (
<span
className={`icon icon--${iconName}`}
aria-hidden="true"
data-testid={`${testId}-icon`}
/>
)}

{(variant === 'text' || variant === 'both') && (
<span className="copy-button__text" data-testid={`${testId}-text`}>
{copied ? 'Copied!' : (children || 'Copy')}
</span>
)}
) : null}

{showText ? (
<span data-testid={`${testId}-text`}>{textLabel}</span>
) : null}
</button>

<span role="status" aria-live="polite" data-testid={`${testId}-status`}>
{statusMessage}
</span>
</button>

{error ? (
<ErrorNotice
error={error}
onDismiss={() => setError(null)}
testId={`${testId}-error`}
/>
) : null}
</div>
);
};
}

export default CopyButton;
7 changes: 5 additions & 2 deletions frontend/src/components/v1/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,11 @@ export type {
export { AsyncStateBoundary } from "./AsyncStateBoundary";
export type { AsyncStateBoundaryProps } from "./AsyncStateBoundary";

export { ContractActionButton } from "./ContractActionButton";
export type { ContractActionButtonProps } from "./ContractActionButton";
export { CopyButton, default as CopyButtonDefault } from './CopyButton';
export type { CopyButtonProps } from './CopyButton';

export { ContractActionButton } from './ContractActionButton';
export type { ContractActionButtonProps } from './ContractActionButton';

export {
SessionTimeoutModal,
Expand Down
1 change: 0 additions & 1 deletion frontend/src/hooks/v1/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import type {
NumericBounds,
} from "../../utils/v1/validation";
import {
ValidationErrorCode,
validateWager,
validateGameId,
validateEnum,
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,6 @@
"validation.recipient.required": "Recipient address is required",
"validation.walletaddress.required": "Wallet is not connected. Connect a wallet before playing.",
"validation.network.invalid_format": "Network mismatch. Please switch to the correct network.",
"validation.contractaddress.required": "Contract address is not configured."
"validation.contractaddress.required": "Contract address is not configured.",
"locale.reset": "Reset to Default"
}
Loading
Loading