- 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.)
66 lines
1.8 KiB
TypeScript
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>
|
|
</>
|
|
)
|
|
}
|