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 <Link
key={item.href} key={item.href}
href={item.href} href={item.href}
aria-label={item.label}
aria-current={isActive(item.href) ? 'page' : undefined}
className={cn( className={cn(
'transition-colors', 'transition-colors',
isActive(item.href) ? 'text-accent' : 'text-fg-dim hover:text-fg', isActive(item.href) ? 'text-accent' : 'text-fg-dim hover:text-fg',

View file

@ -34,7 +34,11 @@ export function NavLinks() {
{item.label} {item.label}
</ExternalLink> </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.icon}
{item.label} {item.label}
</Link> </Link>

View file

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

View file

@ -1,41 +1,66 @@
'use client' 'use client'
import { ButtonHTMLAttributes, useRef } from 'react' import { ButtonHTMLAttributes, useEffect, useState } from 'react'
import { Copy } from 'lucide-react' import { Check, Copy } from 'lucide-react'
import { cn } from '@/utils/cn' import { cn } from '@/utils/cn'
type CopyButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & { type CopyButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
textToCopy: string textToCopy: string
} }
export function CopyButton({ textToCopy, className, ...props }: CopyButtonProps) { type CopyState = 'idle' | 'copied' | 'failed'
const buttonRef = useRef<HTMLButtonElement | null>(null)
const handleCopy = () => { const labels: Record<CopyState, string> = {
navigator.clipboard.writeText(textToCopy) idle: 'Copy code',
const button = buttonRef.current copied: 'Copied!',
if (button) { failed: 'Copy failed',
const original = button.innerHTML }
button.innerHTML = 'Copied!'
button.disabled = true export function CopyButton({ textToCopy, className, ...props }: CopyButtonProps) {
setTimeout(() => { const [state, setState] = useState<CopyState>('idle')
button.innerHTML = original
button.disabled = false useEffect(() => {
}, 2000) 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 ( return (
<button <>
className={cn( <button
'text-fg-dim hover:text-fg transition-colors js-only text-xs cursor-pointer disabled:cursor-default disabled:hover:text-fg-dim', type="button"
className, className={cn(
)} 'text-fg-dim hover:text-fg transition-colors js-only text-xs cursor-pointer disabled:cursor-default disabled:hover:text-fg-dim',
onClick={handleCopy} className,
ref={buttonRef} )}
{...props} onClick={handleCopy}
> disabled={state !== 'idle'}
<Copy className="w-4 h-4" /> aria-label={labels.idle}
</button> 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>
</>
) )
} }