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.)
This commit is contained in:
HangerThem 2026-10-19 14:23:09 +02:00
parent 64ae30b545
commit 4485f3ef1e
5 changed files with 62 additions and 29 deletions

View file

@ -24,6 +24,8 @@ export function MobileNavbar() {
<Link
key={item.href}
href={item.href}
aria-label={item.label}
aria-current={isActive(item.href) ? 'page' : undefined}
className={cn(
'transition-colors',
isActive(item.href) ? 'text-accent' : 'text-fg-dim hover:text-fg',

View file

@ -34,7 +34,11 @@ export function NavLinks() {
{item.label}
</ExternalLink>
) : (
<Link href={item.href} className="flex items-center gap-2 px-2 py-1">
<Link
href={item.href}
className="flex items-center gap-2 px-2 py-1"
aria-current={isActive(item.href) ? 'page' : undefined}
>
{item.icon}
{item.label}
</Link>

View file

@ -47,8 +47,9 @@ export async function Sidebar() {
</div>
<div className="mt-auto flex gap-4 mx-auto">
{socialLinks.map(({ icon, url }) => (
<ExternalLink key={url} href={url} skipIcon>
<ExternalLink key={url} href={url} skipIcon aria-label={icon.title}>
<svg
aria-hidden="true"
dangerouslySetInnerHTML={{ __html: icon.svg }}
className="inline-block w-6 h-6 mr-1 fill-fg-dim hover:fill-accent transition-colors"
/>

View file

@ -38,8 +38,9 @@ export function ShareLinks({ url, title, description }: ShareLinksProps) {
return (
<div className="flex gap-4 mx-auto">
{networks.map(({ name, icon, href }) => (
<ExternalLink key={name} href={href} skipIcon>
<ExternalLink key={name} href={href} skipIcon aria-label={`Share on ${name}`}>
<svg
aria-hidden="true"
dangerouslySetInnerHTML={{ __html: icon.svg }}
className="inline-block w-6 h-6 mr-1 fill-current hover:text-accent transition-colors"
/>

View file

@ -1,41 +1,66 @@
'use client'
import { ButtonHTMLAttributes, useRef } from 'react'
import { Copy } from 'lucide-react'
import { ButtonHTMLAttributes, useEffect, useState } from 'react'
import { Check, Copy } from 'lucide-react'
import { cn } from '@/utils/cn'
type CopyButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
textToCopy: string
}
export function CopyButton({ textToCopy, className, ...props }: CopyButtonProps) {
const buttonRef = useRef<HTMLButtonElement | null>(null)
type CopyState = 'idle' | 'copied' | 'failed'
const handleCopy = () => {
navigator.clipboard.writeText(textToCopy)
const button = buttonRef.current
if (button) {
const original = button.innerHTML
button.innerHTML = 'Copied!'
button.disabled = true
setTimeout(() => {
button.innerHTML = original
button.disabled = false
}, 2000)
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
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}
ref={buttonRef}
{...props}
>
<Copy className="w-4 h-4" />
</button>
<>
<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>
</>
)
}