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.) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fd96e4b7fc
commit
42b3888a76
5 changed files with 62 additions and 29 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue