Skip to content

Commit a1e8bfb

Browse files
authored
a11y(customizer): add aria-live feedback for copy action (JhaSourav07#441)
1 parent 2a20044 commit a1e8bfb

3 files changed

Lines changed: 93 additions & 6 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { fireEvent, render, screen } from '@testing-library/react';
2+
import { describe, expect, it, vi } from 'vitest';
3+
import { ExportPanel } from './ExportPanel';
4+
5+
describe('ExportPanel', () => {
6+
it('announces copy success through a polite live region', () => {
7+
const onCopy = vi.fn();
8+
9+
render(
10+
<ExportPanel
11+
format="markdown"
12+
snippet="![CommitPulse](https://commitpulse.vercel.app/api/streak?user=octocat)"
13+
copied
14+
copyStatusMessage="Markdown snippet copied to clipboard."
15+
hasUsername
16+
onFormatChange={vi.fn()}
17+
onCopy={onCopy}
18+
/>
19+
);
20+
21+
const copyButton = screen.getByRole('button', {
22+
name: /copy markdown export snippet to clipboard/i,
23+
});
24+
25+
fireEvent.click(copyButton);
26+
27+
expect(onCopy).toHaveBeenCalledTimes(1);
28+
expect(copyButton.getAttribute('aria-describedby')).toBe('export-copy-status');
29+
expect(screen.getByRole('status').textContent).toBe('Markdown snippet copied to clipboard.');
30+
expect(screen.getByText('Copied!')).toBeDefined();
31+
});
32+
});

app/customize/components/ExportPanel.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,24 @@ export function ExportPanel({
1111
format,
1212
snippet,
1313
copied,
14+
copyStatusMessage,
1415
hasUsername,
1516
onFormatChange,
1617
onCopy,
1718
}: {
1819
format: ExportFormat;
1920
snippet: string;
2021
copied: boolean;
22+
copyStatusMessage: string;
2123
hasUsername: boolean;
2224
onFormatChange: (format: ExportFormat) => void;
23-
onCopy: () => void;
25+
onCopy: () => void | Promise<void>;
2426
}): ReactElement {
2527
const activeSnippet = hasUsername ? snippet : getPlaceholderSnippet(format);
2628
const formatLabel = format === 'markdown' ? 'Markdown' : 'HTML';
29+
const copyButtonLabel = hasUsername
30+
? `Copy ${formatLabel} export snippet to clipboard`
31+
: `Add a GitHub username to enable copying the ${formatLabel} export snippet`;
2732

2833
return (
2934
<div className="bg-[#0a0a0a] border border-white/5 rounded-[1.75rem] p-6">
@@ -63,6 +68,8 @@ export function ExportPanel({
6368
id="copy-markdown-btn"
6469
onClick={onCopy}
6570
disabled={!hasUsername}
71+
aria-label={copyButtonLabel}
72+
aria-describedby="export-copy-status"
6673
className={`relative inline-flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all duration-200 ${
6774
!hasUsername
6875
? 'bg-white/[0.04] border border-white/8 text-white/30'
@@ -111,6 +118,16 @@ export function ExportPanel({
111118
</div>
112119
</div>
113120

121+
<p
122+
id="export-copy-status"
123+
role="status"
124+
aria-live="polite"
125+
aria-atomic="true"
126+
className="sr-only"
127+
>
128+
{copyStatusMessage}
129+
</p>
130+
114131
<div className="bg-black/60 border border-white/8 rounded-xl px-5 py-4 overflow-x-auto">
115132
<code className="text-emerald-300 text-xs font-mono leading-relaxed break-all whitespace-pre-wrap">
116133
{activeSnippet}

app/customize/page.tsx

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import { useCallback, useState, type ReactElement } from 'react';
3+
import { useCallback, useEffect, useRef, useState, type ReactElement } from 'react';
44
import Link from 'next/link';
55
import { motion } from 'framer-motion';
66
import { ControlsPanel } from './components/ControlsPanel';
@@ -23,12 +23,24 @@ export default function CustomizePage(): ReactElement {
2323
const [size, setSize] = useState<BadgeSize>('medium');
2424
const [exportFormat, setExportFormat] = useState<ExportFormat>('markdown');
2525
const [copied, setCopied] = useState(false);
26+
const [copyStatusMessage, setCopyStatusMessage] = useState('');
27+
const copyResetTimeoutRef = useRef<number | null>(null);
2628
const trimmedUsername = username.trim();
2729
const hasUsername = trimmedUsername.length > 0;
2830
const isAutoTheme = theme === 'auto';
2931
const isRandomTheme = theme === 'random';
3032
const skipsCustomColors = isAutoTheme || isRandomTheme;
3133

34+
useEffect(() => {
35+
return () => {
36+
if (copyResetTimeoutRef.current !== null) {
37+
window.clearTimeout(copyResetTimeoutRef.current);
38+
}
39+
};
40+
}, []);
41+
42+
// Clear custom hex overrides when switching to auto — fixed colors
43+
// conflict with the dual-palette prefers-color-scheme switching.
3244
// Clear custom hex overrides when switching to virtual themes because
3345
// fixed colors conflict with their palette-selection behavior.
3446
const handleThemeChange = useCallback((newTheme: string): void => {
@@ -89,12 +101,37 @@ export default function CustomizePage(): ReactElement {
89101
const previewSrc = `/api/streak?${queryString}`;
90102
const exportSnippet = getExportSnippet(exportFormat, queryString);
91103

92-
const copyExportSnippet = (): void => {
104+
const announceCopyStatus = useCallback((message: string): void => {
105+
setCopyStatusMessage('');
106+
window.setTimeout(() => {
107+
setCopyStatusMessage(message);
108+
}, 0);
109+
}, []);
110+
111+
const copyExportSnippet = async (): Promise<void> => {
93112
if (!hasUsername) return;
94113

95-
navigator.clipboard.writeText(exportSnippet);
96-
setCopied(true);
97-
setTimeout(() => setCopied(false), 3000);
114+
try {
115+
await navigator.clipboard.writeText(exportSnippet);
116+
setCopied(true);
117+
announceCopyStatus(
118+
`${exportFormat === 'markdown' ? 'Markdown' : 'HTML'} snippet copied to clipboard.`
119+
);
120+
121+
if (copyResetTimeoutRef.current !== null) {
122+
window.clearTimeout(copyResetTimeoutRef.current);
123+
}
124+
125+
copyResetTimeoutRef.current = window.setTimeout(() => {
126+
setCopied(false);
127+
setCopyStatusMessage('');
128+
}, 3000);
129+
} catch {
130+
setCopied(false);
131+
announceCopyStatus(
132+
`Unable to copy the ${exportFormat === 'markdown' ? 'Markdown' : 'HTML'} snippet.`
133+
);
134+
}
98135
};
99136

100137
return (
@@ -273,6 +310,7 @@ export default function CustomizePage(): ReactElement {
273310
format={exportFormat}
274311
snippet={exportSnippet}
275312
copied={copied}
313+
copyStatusMessage={copyStatusMessage}
276314
hasUsername={hasUsername}
277315
onFormatChange={setExportFormat}
278316
onCopy={copyExportSnippet}

0 commit comments

Comments
 (0)