From 259422bcb8f80b712eb639402a14d0b32a55920e Mon Sep 17 00:00:00 2001 From: Okeke Chinedu Emmanuel Date: Thu, 28 May 2026 16:20:25 +0100 Subject: [PATCH 1/2] Add creator avatar lightbox escape handling --- .../common/CreatorProfileHeader.tsx | 81 ++++++++++++++++--- .../__tests__/CreatorInitialsAvatar.test.tsx | 5 +- .../__tests__/CreatorProfileHeader.test.tsx | 34 ++++++++ 3 files changed, 108 insertions(+), 12 deletions(-) create mode 100644 src/components/common/__tests__/CreatorProfileHeader.test.tsx diff --git a/src/components/common/CreatorProfileHeader.tsx b/src/components/common/CreatorProfileHeader.tsx index 880de2e..103178b 100644 --- a/src/components/common/CreatorProfileHeader.tsx +++ b/src/components/common/CreatorProfileHeader.tsx @@ -1,7 +1,14 @@ -import React, { useState } from 'react'; +import React, { useRef, useState } from 'react'; import { Copy, Check, Share2 } from 'lucide-react'; import showToast from '@/utils/toast.util'; import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; import { cn } from '@/lib/utils'; import VerifiedBadge from '@/components/common/VerifiedBadge'; import CreatorInitialsAvatar from '@/components/common/CreatorInitialsAvatar'; @@ -31,6 +38,8 @@ const CreatorProfileHeader: React.FC = ({ className, }) => { const [copied, setCopied] = useState(false); + const [avatarLightboxOpen, setAvatarLightboxOpen] = useState(false); + const avatarTriggerRef = useRef(null); // Display-normalised handle; raw `handle` is preserved for any equality / // URL construction the caller might do via the prop. @@ -67,6 +76,10 @@ const CreatorProfileHeader: React.FC = ({ const canNativeShare = typeof navigator !== 'undefined' && !!navigator.share; + const avatar = ( + + ); + return (
= ({ )} >
-
- -
+ {avatarUrl ? ( + + + + + { + event.preventDefault(); + avatarTriggerRef.current?.focus(); + }} + onEscapeKeyDown={() => { + setAvatarLightboxOpen(false); + }} + > + + {name} profile image + + + Expanded creator profile image. Press Escape or the close button to + dismiss it. + + {`${name} + + + ) : ( +
+ {avatar} +
+ )}

= ({ )} - {copied ? 'Copied!' : canNativeShare ? 'Share Profile' : 'Copy Profile Link'} + {copied + ? 'Copied!' + : canNativeShare + ? 'Share Profile' + : 'Copy Profile Link'} {copied ? 'Copied' : canNativeShare ? 'Share' : 'Copy'} diff --git a/src/components/common/__tests__/CreatorInitialsAvatar.test.tsx b/src/components/common/__tests__/CreatorInitialsAvatar.test.tsx index 5157bd8..98fedf7 100644 --- a/src/components/common/__tests__/CreatorInitialsAvatar.test.tsx +++ b/src/components/common/__tests__/CreatorInitialsAvatar.test.tsx @@ -17,11 +17,10 @@ describe('CreatorInitialsAvatar', () => { it('renders initials fallback with hashed background when image is missing', () => { render(); - const initials = screen.getByLabelText('Alex Rivers initials avatar'); - const avatar = initials.parentElement; + const avatar = screen.getByRole('img', { name: 'Alex Rivers avatar' }); const colors = getFallbackAvatarColors('creator-123'); - expect(initials).toHaveTextContent('AR'); + expect(avatar).toHaveTextContent('AR'); expect(avatar).toHaveStyle({ background: colors.background, color: colors.textColor, diff --git a/src/components/common/__tests__/CreatorProfileHeader.test.tsx b/src/components/common/__tests__/CreatorProfileHeader.test.tsx new file mode 100644 index 0000000..186cd75 --- /dev/null +++ b/src/components/common/__tests__/CreatorProfileHeader.test.tsx @@ -0,0 +1,34 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import CreatorProfileHeader from '@/components/common/CreatorProfileHeader'; + +describe('CreatorProfileHeader', () => { + it('closes the profile image lightbox with Escape and returns focus to the trigger', async () => { + render( + + ); + + const avatarTrigger = screen.getByRole('button', { + name: 'Open Alex Rivers profile image', + }); + + fireEvent.click(avatarTrigger); + + expect( + screen.getByRole('dialog', { name: 'Alex Rivers profile image' }) + ).toBeInTheDocument(); + + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + expect(avatarTrigger).toHaveFocus(); + }); +}); From 963411e61c204433b808688daf50fad2979d5ade Mon Sep 17 00:00:00 2001 From: Okeke Chinedu Emmanuel Date: Thu, 28 May 2026 16:46:04 +0100 Subject: [PATCH 2/2] Add copy success screen reader announcements --- .../common/CopySuccessAnnouncement.tsx | 18 ++++++ .../common/CreatorProfileHeader.tsx | 29 +++++++-- .../common/EmptyTransactionTimelineState.tsx | 42 ++++++++----- .../common/TransactionFailureDrawer.tsx | 37 +++++------ src/components/common/TransactionHashRow.tsx | 24 ++++--- src/components/common/TruncatedAddress.tsx | 13 ++-- .../__tests__/TransactionHashRow.test.tsx | 41 ++++++++++++ .../useCopySuccessAnnouncement.test.tsx | 45 +++++++++++++ src/hooks/useCopySuccessAnnouncement.ts | 63 +++++++++++++++++++ src/utils/toast.util.tsx | 17 ++--- 10 files changed, 261 insertions(+), 68 deletions(-) create mode 100644 src/components/common/CopySuccessAnnouncement.tsx create mode 100644 src/components/common/__tests__/TransactionHashRow.test.tsx create mode 100644 src/hooks/__tests__/useCopySuccessAnnouncement.test.tsx create mode 100644 src/hooks/useCopySuccessAnnouncement.ts diff --git a/src/components/common/CopySuccessAnnouncement.tsx b/src/components/common/CopySuccessAnnouncement.tsx new file mode 100644 index 0000000..ada4ba2 --- /dev/null +++ b/src/components/common/CopySuccessAnnouncement.tsx @@ -0,0 +1,18 @@ +interface CopySuccessAnnouncementProps { + message: string; +} + +function CopySuccessAnnouncement({ message }: CopySuccessAnnouncementProps) { + return ( + + {message} + + ); +} + +export default CopySuccessAnnouncement; diff --git a/src/components/common/CreatorProfileHeader.tsx b/src/components/common/CreatorProfileHeader.tsx index 103178b..5d2d4f1 100644 --- a/src/components/common/CreatorProfileHeader.tsx +++ b/src/components/common/CreatorProfileHeader.tsx @@ -14,6 +14,11 @@ import VerifiedBadge from '@/components/common/VerifiedBadge'; import CreatorInitialsAvatar from '@/components/common/CreatorInitialsAvatar'; import CreatorBio from '@/components/common/CreatorBio'; import { formatCreatorHandle } from '@/utils/handleDisplay.utils'; +import CopySuccessAnnouncement from '@/components/common/CopySuccessAnnouncement'; +import { + COPY_SUCCESS_TOAST_ARIA_PROPS, + useCopySuccessAnnouncement, +} from '@/hooks/useCopySuccessAnnouncement'; interface CreatorProfileHeaderProps { name: string; @@ -38,6 +43,7 @@ const CreatorProfileHeader: React.FC = ({ className, }) => { const [copied, setCopied] = useState(false); + const { announcement, announceCopySuccess } = useCopySuccessAnnouncement(); const [avatarLightboxOpen, setAvatarLightboxOpen] = useState(false); const avatarTriggerRef = useRef(null); @@ -66,8 +72,11 @@ const CreatorProfileHeader: React.FC = ({ // Fallback: copy to clipboard try { await navigator.clipboard.writeText(url); + announceCopySuccess('Profile link copied.'); setCopied(true); - showToast.success('Profile link copied to clipboard!'); + showToast.success('Profile link copied to clipboard!', { + ariaProps: COPY_SUCCESS_TOAST_ARIA_PROPS, + }); setTimeout(() => setCopied(false), 2000); } catch { showToast.error('Failed to copy link'); @@ -77,7 +86,11 @@ const CreatorProfileHeader: React.FC = ({ const canNativeShare = typeof navigator !== 'undefined' && !!navigator.share; const avatar = ( - + ); return ( @@ -89,7 +102,10 @@ const CreatorProfileHeader: React.FC = ({ >
{avatarUrl ? ( - + +

); diff --git a/src/components/common/EmptyTransactionTimelineState.tsx b/src/components/common/EmptyTransactionTimelineState.tsx index 5d7e635..88f4c36 100644 --- a/src/components/common/EmptyTransactionTimelineState.tsx +++ b/src/components/common/EmptyTransactionTimelineState.tsx @@ -2,6 +2,8 @@ import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Check, Clock3, Copy, XCircle } from 'lucide-react'; import { formatRecentActivityCompactTimestamp } from '@/utils/recentActivityTimestamp.utils'; +import CopySuccessAnnouncement from '@/components/common/CopySuccessAnnouncement'; +import { useCopySuccessAnnouncement } from '@/hooks/useCopySuccessAnnouncement'; type CopyState = 'idle' | 'success' | 'error'; @@ -19,7 +21,8 @@ const DEFAULT_TIMELINE_ENTRIES: TimelineEntry[] = [ id: 'entry-1', action: 'Buy', amount: '+2 keys', - txHash: '0x2a43bcfdef77ca4c50ef7d38148dd5d7f0149a6e2e20f70f04ce1f4b66fe55dd', + txHash: + '0x2a43bcfdef77ca4c50ef7d38148dd5d7f0149a6e2e20f70f04ce1f4b66fe55dd', compactTimestamp: '2m ago', status: 'confirmed', }, @@ -27,7 +30,8 @@ const DEFAULT_TIMELINE_ENTRIES: TimelineEntry[] = [ id: 'entry-2', action: 'Sell', amount: '-1 key', - txHash: '0x90c82ac01478b42fcbf9db73a26ed32bd8e50a8917e2408c31c95e9f6a59fc19', + txHash: + '0x90c82ac01478b42fcbf9db73a26ed32bd8e50a8917e2408c31c95e9f6a59fc19', compactTimestamp: '18m ago', status: 'pending', }, @@ -35,23 +39,28 @@ const DEFAULT_TIMELINE_ENTRIES: TimelineEntry[] = [ id: 'entry-3', action: 'Buy', amount: '+3 keys', - txHash: '0x16d2ffbc4297a8c2c3086e07c16e66f47287df0d5a1ce1aef9e448e2f0f3ab51', + txHash: + '0x16d2ffbc4297a8c2c3086e07c16e66f47287df0d5a1ce1aef9e448e2f0f3ab51', compactTimestamp: '51m ago', status: 'failed', }, ]; -const shortenTxHash = (hash: string) => `${hash.slice(0, 8)}...${hash.slice(-6)}`; +const shortenTxHash = (hash: string) => + `${hash.slice(0, 8)}...${hash.slice(-6)}`; interface EmptyTransactionTimelineStateProps { /** Optional transaction data. If provided and empty, the component returns null. */ data?: TimelineEntry[]; } -const EmptyTransactionTimelineState: React.FC = ({ - data = DEFAULT_TIMELINE_ENTRIES, -}) => { - const [copyStateById, setCopyStateById] = useState>({}); +const EmptyTransactionTimelineState: React.FC< + EmptyTransactionTimelineStateProps +> = ({ data = DEFAULT_TIMELINE_ENTRIES }) => { + const [copyStateById, setCopyStateById] = useState< + Record + >({}); + const { announcement, announceCopySuccess } = useCopySuccessAnnouncement(); if (!data || data.length === 0) { return null; @@ -60,6 +69,7 @@ const EmptyTransactionTimelineState: React.FC { try { await navigator.clipboard.writeText(txHash); + announceCopySuccess('Transaction hash copied.'); setCopyStateById(current => ({ ...current, [entryId]: 'success' })); } catch { setCopyStateById(current => ({ ...current, [entryId]: 'error' })); @@ -105,7 +115,9 @@ const EmptyTransactionTimelineState: React.FC {entry.action} - {entry.amount} + + {entry.amount} +
@@ -161,6 +171,8 @@ const EmptyTransactionTimelineState: React.FC + +
diff --git a/src/components/common/TransactionFailureDrawer.tsx b/src/components/common/TransactionFailureDrawer.tsx index 21c63ec..6ccce35 100644 --- a/src/components/common/TransactionFailureDrawer.tsx +++ b/src/components/common/TransactionFailureDrawer.tsx @@ -11,6 +11,11 @@ import { Button } from '@/components/ui/button'; import { AlertCircle, Copy, Check } from 'lucide-react'; import showToast from '@/utils/toast.util'; import { formatTimestampTooltip } from '@/utils/time.utils'; +import CopySuccessAnnouncement from '@/components/common/CopySuccessAnnouncement'; +import { + COPY_SUCCESS_TOAST_ARIA_PROPS, + useCopySuccessAnnouncement, +} from '@/hooks/useCopySuccessAnnouncement'; export interface TransactionFailureDetails { txHash?: string; @@ -38,6 +43,7 @@ const TransactionFailureDrawer: React.FC = ({ const [copiedField, setCopiedField] = useState< 'errorCode' | 'txHash' | null >(null); + const { announcement, announceCopySuccess } = useCopySuccessAnnouncement(); const copyToClipboard = async ( text: string, @@ -45,7 +51,14 @@ const TransactionFailureDrawer: React.FC = ({ ) => { try { await navigator.clipboard.writeText(text); - showToast.success('Copied to clipboard'); + showToast.success('Copied to clipboard', { + ariaProps: COPY_SUCCESS_TOAST_ARIA_PROPS, + }); + announceCopySuccess( + field === 'errorCode' + ? 'Error code copied.' + : 'Transaction hash copied.' + ); setCopiedField(field); window.setTimeout(() => setCopiedField(null), 2000); } catch { @@ -148,16 +161,6 @@ const TransactionFailureDrawer: React.FC = ({ )}
- - {copiedField === 'errorCode' - ? 'Error code copied to clipboard' - : ''} -
)} @@ -193,19 +196,11 @@ const TransactionFailureDrawer: React.FC = ({ )}
- - {copiedField === 'txHash' - ? 'Transaction hash copied to clipboard' - : ''} -
)} + + {failureDetails.developerDetails && Object.keys(failureDetails.developerDetails).length > 0 && (
diff --git a/src/components/common/TransactionHashRow.tsx b/src/components/common/TransactionHashRow.tsx index c4d67c0..275dad7 100644 --- a/src/components/common/TransactionHashRow.tsx +++ b/src/components/common/TransactionHashRow.tsx @@ -2,6 +2,8 @@ import React, { useState } from 'react'; import { Copy, Check, 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'; interface TransactionHashRowProps { hash: string; @@ -17,10 +19,12 @@ const TransactionHashRow: React.FC = ({ 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); }; @@ -46,22 +50,22 @@ const TransactionHashRow: React.FC = ({ - - {copied ? 'Transaction hash copied to clipboard' : ''} - + {explorerUrl && ( = ({ 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); }; @@ -54,14 +58,7 @@ const TruncatedAddress: React.FC = ({