From 576b33ff6dcce0aede34a6059673bb60198ab441 Mon Sep 17 00:00:00 2001 From: barry01-hash Date: Sun, 29 Mar 2026 16:15:35 +0100 Subject: [PATCH 1/2] Add reusable clipboard utility and copy button --- frontend/src/components/v1/CopyButton.tsx | 141 ++++++++++++++ frontend/src/components/v1/index.ts | 3 + frontend/src/utils/v1/clipboard.ts | 179 ++++++++++++++++++ frontend/src/utils/v1/index.ts | 1 + .../tests/components/v1/CopyButton.test.tsx | 105 ++++++++++ frontend/tests/utils/v1/clipboard.test.ts | 148 +++++++++++++++ 6 files changed, 577 insertions(+) create mode 100644 frontend/src/components/v1/CopyButton.tsx create mode 100644 frontend/src/utils/v1/clipboard.ts create mode 100644 frontend/tests/components/v1/CopyButton.test.tsx create mode 100644 frontend/tests/utils/v1/clipboard.test.ts diff --git a/frontend/src/components/v1/CopyButton.tsx b/frontend/src/components/v1/CopyButton.tsx new file mode 100644 index 00000000..68244972 --- /dev/null +++ b/frontend/src/components/v1/CopyButton.tsx @@ -0,0 +1,141 @@ +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'; + +type CopyButtonStatus = 'idle' | 'copying' | 'copied'; + +export interface CopyButtonProps + extends Omit< + React.ButtonHTMLAttributes, + 'children' | 'onClick' + > { + text: string; + idleLabel?: React.ReactNode; + copyingLabel?: React.ReactNode; + copiedLabel?: React.ReactNode; + feedbackDurationMs?: number; + onCopy?: () => void | Promise; + onCopyError?: (error: AppError) => void | Promise; + onClick?: React.MouseEventHandler; + testId?: string; +} + +export function CopyButton({ + text, + idleLabel = 'Copy', + copyingLabel = 'Copying...', + copiedLabel = 'Copied', + feedbackDurationMs = 2000, + onCopy, + onCopyError, + onClick, + className = '', + disabled = false, + type = 'button', + testId = 'copy-button', + ...buttonProps +}: CopyButtonProps) { + const [status, setStatus] = useState('idle'); + const [error, setError] = useState(null); + const timeoutRef = useRef(null); + const setGlobalError = useErrorStore((state) => state.setError); + + useEffect(() => { + return () => { + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + } + }; + }, []); + + const isBusy = status === 'copying'; + const isDisabled = disabled || isBusy; + + const buttonLabel = useMemo(() => { + if (status === 'copying') { + return copyingLabel; + } + + if (status === 'copied') { + return copiedLabel; + } + + return idleLabel; + }, [copiedLabel, copyingLabel, idleLabel, status]); + + const statusMessage = status === 'copied' ? 'Copied to clipboard.' : ''; + + 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, + ): Promise => { + onClick?.(event); + + if (event.defaultPrevented || isDisabled) { + return; + } + + setError(null); + setStatus('copying'); + + const result = await writeToClipboard(text); + + if (result.ok) { + await onCopy?.(); + setStatus('copied'); + scheduleReset(); + return; + } + + setStatus('idle'); + setError(result.error); + setGlobalError(result.error); + await onCopyError?.(result.error); + }; + + return ( +
+ + + + {statusMessage} + + + {error ? ( + setError(null)} + testId={`${testId}-error`} + /> + ) : null} +
+ ); +} + +export default CopyButton; diff --git a/frontend/src/components/v1/index.ts b/frontend/src/components/v1/index.ts index f77a0ebf..955fa0da 100644 --- a/frontend/src/components/v1/index.ts +++ b/frontend/src/components/v1/index.ts @@ -49,6 +49,9 @@ export type { export { AsyncStateBoundary } from './AsyncStateBoundary'; export type { AsyncStateBoundaryProps } from './AsyncStateBoundary'; +export { CopyButton, default as CopyButtonDefault } from './CopyButton'; +export type { CopyButtonProps } from './CopyButton'; + export { ContractActionButton } from './ContractActionButton'; export type { ContractActionButtonProps } from './ContractActionButton'; diff --git a/frontend/src/utils/v1/clipboard.ts b/frontend/src/utils/v1/clipboard.ts new file mode 100644 index 00000000..24a8f3e8 --- /dev/null +++ b/frontend/src/utils/v1/clipboard.ts @@ -0,0 +1,179 @@ +import { type AppError, ErrorDomain, ErrorSeverity } from '../../types/errors'; + +export type ClipboardWriteMethod = 'clipboard-api' | 'exec-command'; +export type ClipboardWriteFailureReason = 'unsupported' | 'write-failed'; + +export interface ClipboardWriteSuccess { + ok: true; + method: ClipboardWriteMethod; +} + +export interface ClipboardWriteFailure { + ok: false; + reason: ClipboardWriteFailureReason; + error: AppError; +} + +export type ClipboardWriteResult = ClipboardWriteSuccess | ClipboardWriteFailure; + +export interface ClipboardWriteOptions { + navigator?: Navigator; + document?: Document; +} + +export function isClipboardWriteSupported( + options: ClipboardWriteOptions = {}, +): boolean { + return hasClipboardApi(options.navigator) || hasExecCommand(options.document); +} + +export async function writeToClipboard( + value: string, + options: ClipboardWriteOptions = {}, +): Promise { + const navigatorRef = options.navigator ?? getNavigator(); + const documentRef = options.document ?? getDocument(); + const methodsTried: ClipboardWriteMethod[] = []; + + if (hasClipboardApi(navigatorRef)) { + methodsTried.push('clipboard-api'); + + try { + await navigatorRef.clipboard.writeText(value); + return { + ok: true, + method: 'clipboard-api', + }; + } catch (error) { + if (!hasExecCommand(documentRef)) { + return { + ok: false, + reason: 'write-failed', + error: createClipboardWriteFailedError(error, methodsTried), + }; + } + } + } + + if (hasExecCommand(documentRef)) { + methodsTried.push('exec-command'); + + try { + const copied = copyWithExecCommand(value, documentRef); + if (copied) { + return { + ok: true, + method: 'exec-command', + }; + } + + return { + ok: false, + reason: 'write-failed', + error: createClipboardWriteFailedError( + new Error('document.execCommand("copy") returned false.'), + methodsTried, + ), + }; + } catch (error) { + return { + ok: false, + reason: 'write-failed', + error: createClipboardWriteFailedError(error, methodsTried), + }; + } + } + + return { + ok: false, + reason: 'unsupported', + error: createClipboardUnsupportedError(methodsTried), + }; +} + +function copyWithExecCommand(value: string, documentRef: Document): boolean { + if (!documentRef.body) { + throw new Error('Document body is not available for clipboard fallback.'); + } + + const textarea = documentRef.createElement('textarea'); + textarea.value = value; + textarea.setAttribute('readonly', 'true'); + textarea.setAttribute('aria-hidden', 'true'); + textarea.style.position = 'fixed'; + textarea.style.top = '0'; + textarea.style.left = '-9999px'; + textarea.style.opacity = '0'; + + documentRef.body.appendChild(textarea); + + const activeElement = + documentRef.activeElement instanceof HTMLElement + ? documentRef.activeElement + : null; + + textarea.focus(); + textarea.select(); + textarea.setSelectionRange(0, value.length); + + try { + return documentRef.execCommand('copy'); + } finally { + documentRef.body.removeChild(textarea); + activeElement?.focus(); + } +} + +function hasClipboardApi(navigatorRef?: Navigator): navigatorRef is Navigator & { + clipboard: Clipboard; +} { + return typeof navigatorRef?.clipboard?.writeText === 'function'; +} + +function hasExecCommand(documentRef?: Document): documentRef is Document { + return ( + typeof documentRef?.execCommand === 'function' && + typeof documentRef?.createElement === 'function' + ); +} + +function createClipboardUnsupportedError( + methodsTried: ClipboardWriteMethod[], +): AppError { + return { + code: 'UNKNOWN', + domain: ErrorDomain.UNKNOWN, + severity: ErrorSeverity.USER_ACTIONABLE, + message: 'Copy is not supported in this environment.', + context: { + feature: 'clipboard', + methodsTried, + }, + }; +} + +function createClipboardWriteFailedError( + raw: unknown, + methodsTried: ClipboardWriteMethod[], +): AppError { + return { + code: 'UNKNOWN', + domain: ErrorDomain.UNKNOWN, + severity: ErrorSeverity.RETRYABLE, + message: 'Unable to copy to clipboard. Please try again.', + originalError: raw, + context: { + feature: 'clipboard', + methodsTried, + }, + retryAfterMs: 1000, + }; +} + +function getNavigator(): Navigator | undefined { + return typeof globalThis.navigator === 'undefined' ? undefined : globalThis.navigator; +} + +function getDocument(): Document | undefined { + return typeof globalThis.document === 'undefined' ? undefined : globalThis.document; +} diff --git a/frontend/src/utils/v1/index.ts b/frontend/src/utils/v1/index.ts index 78ce935b..bf65445d 100644 --- a/frontend/src/utils/v1/index.ts +++ b/frontend/src/utils/v1/index.ts @@ -4,6 +4,7 @@ * @module utils/v1 */ +export * from './clipboard'; export * from './errorMapper'; export * from './formatters'; export * from './idempotency'; diff --git a/frontend/tests/components/v1/CopyButton.test.tsx b/frontend/tests/components/v1/CopyButton.test.tsx new file mode 100644 index 00000000..d288d328 --- /dev/null +++ b/frontend/tests/components/v1/CopyButton.test.tsx @@ -0,0 +1,105 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { CopyButton } from '@/components/v1/CopyButton'; +import { useErrorStore } from '@/store/errorStore'; + +describe('CopyButton', () => { + const originalClipboardDescriptor = Object.getOwnPropertyDescriptor( + Navigator.prototype, + 'clipboard', + ); + const originalExecCommandDescriptor = Object.getOwnPropertyDescriptor( + Document.prototype, + 'execCommand', + ); + + afterEach(() => { + useErrorStore.getState().clearError(); + useErrorStore.getState().clearHistory(); + vi.useRealTimers(); + + if (originalClipboardDescriptor) { + Object.defineProperty( + Navigator.prototype, + 'clipboard', + originalClipboardDescriptor, + ); + } else { + Reflect.deleteProperty(Navigator.prototype, 'clipboard'); + } + + if (originalExecCommandDescriptor) { + Object.defineProperty( + Document.prototype, + 'execCommand', + originalExecCommandDescriptor, + ); + } else { + Reflect.deleteProperty(Document.prototype, 'execCommand'); + } + }); + + it('shows copied feedback after a successful copy and resets after the timeout', async () => { + vi.useFakeTimers(); + const writeText = vi.fn().mockResolvedValue(undefined); + const onCopy = vi.fn(); + + Object.defineProperty(Navigator.prototype, 'clipboard', { + configurable: true, + value: { + writeText, + }, + }); + + render( + , + ); + + const button = screen.getByTestId('copy-button'); + fireEvent.click(button); + + await waitFor(() => expect(writeText).toHaveBeenCalledWith('stellar address')); + await waitFor(() => expect(onCopy).toHaveBeenCalledTimes(1)); + expect(button).toHaveTextContent('Address copied'); + expect(screen.getByTestId('copy-button-status')).toHaveTextContent( + 'Copied to clipboard.', + ); + + act(() => { + vi.advanceTimersByTime(1500); + }); + + await waitFor(() => expect(button).toHaveTextContent('Copy address')); + }); + + it('shows failure feedback and records the error in the global store', async () => { + const onCopyError = vi.fn(); + + Reflect.deleteProperty(Navigator.prototype, 'clipboard'); + Reflect.deleteProperty(Document.prototype, 'execCommand'); + + render( + , + ); + + fireEvent.click(screen.getByTestId('copy-button')); + + await waitFor(() => expect(onCopyError).toHaveBeenCalledTimes(1)); + expect(screen.getByTestId('copy-button-error')).toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent( + 'Copy is not supported in this environment.', + ); + expect(useErrorStore.getState().current?.message).toBe( + 'Copy is not supported in this environment.', + ); + }); +}); diff --git a/frontend/tests/utils/v1/clipboard.test.ts b/frontend/tests/utils/v1/clipboard.test.ts new file mode 100644 index 00000000..ea9bd3f6 --- /dev/null +++ b/frontend/tests/utils/v1/clipboard.test.ts @@ -0,0 +1,148 @@ +import { + isClipboardWriteSupported, + writeToClipboard, +} from '@/utils/v1/clipboard'; + +describe('clipboard utility', () => { + const originalExecCommandDescriptor = Object.getOwnPropertyDescriptor( + document, + 'execCommand', + ); + + afterEach(() => { + if (originalExecCommandDescriptor) { + Object.defineProperty(document, 'execCommand', originalExecCommandDescriptor); + } else { + Reflect.deleteProperty(document, 'execCommand'); + } + }); + + it('writes with the Clipboard API when available', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + + const result = await writeToClipboard('stellar', { + navigator: { + clipboard: { + writeText, + }, + } as unknown as Navigator, + document: {} as Document, + }); + + expect(writeText).toHaveBeenCalledWith('stellar'); + expect(result).toEqual({ + ok: true, + method: 'clipboard-api', + }); + }); + + it('falls back to execCommand when the Clipboard API is unavailable', async () => { + const execCommand = vi.fn().mockReturnValue(true); + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: execCommand, + }); + + const result = await writeToClipboard('fallback copy', { + navigator: {} as unknown as Navigator, + document, + }); + + expect(execCommand).toHaveBeenCalledWith('copy'); + expect(result).toEqual({ + ok: true, + method: 'exec-command', + }); + }); + + it('falls back to execCommand when the Clipboard API write fails', async () => { + const writeText = vi.fn().mockRejectedValue(new Error('permission denied')); + const execCommand = vi.fn().mockReturnValue(true); + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: execCommand, + }); + + const result = await writeToClipboard('fallback after rejection', { + navigator: { + clipboard: { + writeText, + }, + } as unknown as Navigator, + document, + }); + + expect(writeText).toHaveBeenCalledWith('fallback after rejection'); + expect(execCommand).toHaveBeenCalledWith('copy'); + expect(result).toEqual({ + ok: true, + method: 'exec-command', + }); + }); + + it('returns an unsupported result when no copy method exists', async () => { + const result = await writeToClipboard('no support', { + navigator: {} as unknown as Navigator, + document: {} as Document, + }); + + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error('Expected clipboard write to fail.'); + } + + expect(result.reason).toBe('unsupported'); + expect(result.error.message).toBe('Copy is not supported in this environment.'); + }); + + it('returns a failure result when execCommand fallback cannot copy', async () => { + const execCommand = vi.fn().mockReturnValue(false); + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: execCommand, + }); + + const result = await writeToClipboard('copy failure', { + navigator: {} as unknown as Navigator, + document, + }); + + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error('Expected clipboard write to fail.'); + } + + expect(result.reason).toBe('write-failed'); + expect(result.error.message).toBe('Unable to copy to clipboard. Please try again.'); + }); + + it('detects supported and unsupported environments', () => { + expect( + isClipboardWriteSupported({ + navigator: { + clipboard: { + writeText: vi.fn(), + }, + } as unknown as Navigator, + }), + ).toBe(true); + + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: vi.fn(), + }); + + expect( + isClipboardWriteSupported({ + document, + }), + ).toBe(true); + + expect( + isClipboardWriteSupported({ + navigator: {} as unknown as Navigator, + document: {} as Document, + }), + ).toBe(false); + }); +}); From 6c5ee0dfd9fcf9cf2268c482cc75ad5f39305684 Mon Sep 17 00:00:00 2001 From: barry01-hash Date: Sun, 29 Mar 2026 16:38:09 +0100 Subject: [PATCH 2/2] Fix frontend clipboard merge regressions --- frontend/src/components/v1/CopyButton.tsx | 170 +++++------------- frontend/src/components/v1/index.ts | 2 - frontend/src/hooks/v1/validation.ts | 1 - frontend/src/i18n/messages/en.json | 2 +- frontend/src/utils/v1/clipboard.ts | 108 +++-------- .../tests/components/v1/CopyButton.test.tsx | 162 +++-------------- frontend/tests/utils/v1/clipboard.test.ts | 92 +--------- 7 files changed, 100 insertions(+), 437 deletions(-) diff --git a/frontend/src/components/v1/CopyButton.tsx b/frontend/src/components/v1/CopyButton.tsx index eaabc96b..4e27e10e 100644 --- a/frontend/src/components/v1/CopyButton.tsx +++ b/frontend/src/components/v1/CopyButton.tsx @@ -7,16 +7,16 @@ import { useErrorStore } from '../../store/errorStore'; type CopyButtonStatus = 'idle' | 'copying' | 'copied'; export interface CopyButtonProps - extends Omit< - React.ButtonHTMLAttributes, - 'children' | 'onClick' - > { + extends Omit, 'onClick'> { text: string; + children?: React.ReactNode; idleLabel?: React.ReactNode; copyingLabel?: React.ReactNode; copiedLabel?: React.ReactNode; feedbackDurationMs?: number; + variant?: 'icon' | 'text' | 'both'; onCopy?: () => void | Promise; + onCopySuccess?: () => void | Promise; onCopyError?: (error: AppError) => void | Promise; onClick?: React.MouseEventHandler; testId?: string; @@ -24,11 +24,14 @@ export interface CopyButtonProps export function CopyButton({ text, - idleLabel = 'Copy', - copyingLabel = 'Copying...', - copiedLabel = 'Copied', + children, + idleLabel, + copyingLabel, + copiedLabel, feedbackDurationMs = 2000, + variant = 'icon', onCopy, + onCopySuccess, onCopyError, onClick, className = '', @@ -52,20 +55,32 @@ export function CopyButton({ const isBusy = status === 'copying'; const isDisabled = disabled || isBusy; + const showIcon = variant === 'icon' || variant === 'both'; + const showText = variant === 'text' || variant === 'both'; - const buttonLabel = useMemo(() => { + const resolvedIdleLabel = idleLabel ?? children ?? 'Copy'; + const resolvedCopyingLabel = copyingLabel ?? 'Copying...'; + const resolvedCopiedLabel = copiedLabel ?? 'Copied!'; + + const textLabel = useMemo(() => { if (status === 'copying') { - return copyingLabel; + return resolvedCopyingLabel; } if (status === 'copied') { - return copiedLabel; + return resolvedCopiedLabel; } - return idleLabel; - }, [copiedLabel, copyingLabel, idleLabel, status]); + 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) { @@ -94,6 +109,7 @@ export function CopyButton({ if (result.ok) { await onCopy?.(); + await onCopySuccess?.(); setStatus('copied'); scheduleReset(); return; @@ -114,16 +130,23 @@ export function CopyButton({ onClick={handleCopy} aria-busy={isBusy} aria-disabled={isDisabled} + aria-label={status === 'copied' ? 'Copied to clipboard' : 'Copy to clipboard'} data-testid={testId} > - {buttonLabel} + {showIcon ? ( +