blog.hangerthem.com/components/ui/CopyButton.tsx
HangerThem 4485f3ef1e fix(a11y): accessible names for icon links and the copy button
- aria-labels on share links, sidebar social links and mobile nav icons
- aria-current on active nav links
- CopyButton: React state instead of innerHTML mutation, clipboard error
  handling, accessible label and a polite live region for feedback

(Related code-block and image-caption a11y fixes landed with the
ImageWrapper/CodeWrapper rewrite in the previous perf commit.)
2026-10-19 14:23:16 +02:00

66 lines
1.8 KiB
TypeScript

'use client'
import { ButtonHTMLAttributes, useEffect, useState } from 'react'
import { Check, Copy } from 'lucide-react'
import { cn } from '@/utils/cn'
type CopyButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
textToCopy: string
}
type CopyState = 'idle' | 'copied' | 'failed'
const labels: Record<CopyState, string> = {
idle: 'Copy code',
copied: 'Copied!',
failed: 'Copy failed',
}
export function CopyButton({ textToCopy, className, ...props }: CopyButtonProps) {
const [state, setState] = useState<CopyState>('idle')
useEffect(() => {
if (state === 'idle') return
const timeout = setTimeout(() => setState('idle'), 2000)
return () => clearTimeout(timeout)
}, [state])
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(textToCopy)
setState('copied')
} catch {
setState('failed')
}
}
return (
<>
<button
type="button"
className={cn(
'text-fg-dim hover:text-fg transition-colors js-only text-xs cursor-pointer disabled:cursor-default disabled:hover:text-fg-dim',
className,
)}
onClick={handleCopy}
disabled={state !== 'idle'}
aria-label={labels.idle}
title={labels.idle}
{...props}
>
{state === 'idle' ? (
<Copy className="w-4 h-4" />
) : (
<span className="flex items-center gap-1">
{state === 'copied' && <Check className="w-4 h-4" />}
{labels[state]}
</span>
)}
</button>
{/* Outside the button: a button's children are presentational, so they aren't announced */}
<span className="sr-only" aria-live="polite">
{state === 'idle' ? '' : labels[state]}
</span>
</>
)
}