feat(a11y): add a11y infrastructure — skip-nav, announcer, touch CSS, eslint-jsx-a11y

This commit is contained in:
DrSmoothl
2026-03-05 21:57:27 +08:00
parent 34bd115fa1
commit c12d1ca42a
11 changed files with 372 additions and 43 deletions

View File

@@ -0,0 +1,83 @@
import type { ReactNode } from 'react'
import { createContext, useCallback, useContext, useRef, useState } from 'react'
type Politeness = 'polite' | 'assertive'
interface AnnouncerContextValue {
announce: (message: string, politeness?: Politeness) => void
}
const AnnouncerContext = createContext<AnnouncerContextValue | null>(null)
/**
* useAnnounce — 向屏幕阅读器播报消息
*
* @example
* const announce = useAnnounce()
* announce('保存成功') // polite默认
* announce('操作失败,请重试', 'assertive') // assertive立即打断
*/
export function useAnnounce(): (message: string, politeness?: Politeness) => void {
const ctx = useContext(AnnouncerContext)
if (!ctx) {
// 未在 AnnouncerProvider 内时静默降级,不抛错
return () => {}
}
return ctx.announce
}
interface AnnouncerState {
polite: string
assertive: string
}
/**
* AnnouncerProvider — 在应用根部挂载两个 aria-live 区域
*
* 将此组件包裹在应用根节点,所有子组件即可通过 useAnnounce() 播报消息。
* aria-live 区域视觉上隐藏sr-only不影响布局。
*/
export function AnnouncerProvider({ children }: { children: ReactNode }) {
const [messages, setMessages] = useState<AnnouncerState>({ polite: '', assertive: '' })
// 用于清空 -> 重新设置,触发屏幕阅读器重新朗读相同消息
const politeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const assertiveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const announce = useCallback((message: string, politeness: Politeness = 'polite') => {
if (politeness === 'assertive') {
// 先清空,再填入,确保屏幕阅读器重新朗读
setMessages((prev: AnnouncerState) => ({ ...prev, assertive: '' }))
if (assertiveTimerRef.current) clearTimeout(assertiveTimerRef.current)
assertiveTimerRef.current = setTimeout(() => {
setMessages((prev: AnnouncerState) => ({ ...prev, assertive: message }))
}, 50)
} else {
setMessages((prev: AnnouncerState) => ({ ...prev, polite: '' }))
if (politeTimerRef.current) clearTimeout(politeTimerRef.current)
politeTimerRef.current = setTimeout(() => {
setMessages((prev: AnnouncerState) => ({ ...prev, polite: message }))
}, 50)
}
}, [])
return (
<AnnouncerContext.Provider value={{ announce }}>
{children}
{/* aria-live 区域:视觉隐藏,屏幕阅读器可读 */}
<div
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{messages.polite}
</div>
<div
aria-live="assertive"
aria-atomic="true"
className="sr-only"
>
{messages.assertive}
</div>
</AnnouncerContext.Provider>
)
}

View File

@@ -0,0 +1,40 @@
import { useTranslation } from 'react-i18next'
/**
* Skip-to-content 无障碍导航链接
*
* 默认视觉上隐藏sr-only当键盘用户 Tab 聚焦时显示,
* 允许屏幕阅读器/键盘用户跳过重复的导航区域直达主内容。
*
* 使用 focus-visible 而非 focus鼠标点击不触发显示。
*/
export function SkipNav() {
const { t } = useTranslation()
return (
<a
href="#main-content"
className={[
'sr-only',
'focus-visible:not-sr-only',
'focus-visible:fixed',
'focus-visible:left-4',
'focus-visible:top-4',
'focus-visible:z-[9999]',
'focus-visible:rounded-md',
'focus-visible:bg-background',
'focus-visible:px-4',
'focus-visible:py-2',
'focus-visible:text-sm',
'focus-visible:font-medium',
'focus-visible:text-foreground',
'focus-visible:shadow-md',
'focus-visible:outline-none',
'focus-visible:ring-2',
'focus-visible:ring-ring',
].join(' ')}
>
{t('a11y.skipToContent')}
</a>
)
}

View File

@@ -0,0 +1,90 @@
/**
* ZoomableChart — 支持 pinch-to-zoom 的图表容器Task 8
*
* 用法:
* <ZoomableChart aria-label="每小时请求量趋势">
* <ChartContainer ...>
* <LineChart ...>...</LineChart>
* </ChartContainer>
* </ZoomableChart>
*
* 特性:
* - 支持 macOS 触控板双指缩放wheel + ctrlKey
* - 支持移动端/触屏双指 pinch-to-zoom
* - 缩放范围 0.5x 4x带 rubberband 效果
* - 动画由 @react-spring/web 处理,不触发 React re-render
* - Must NOT: 不在 handler 内使用 useState
*/
import { useRef } from 'react'
import { animated, useSpring } from '@react-spring/web'
import { usePinch } from '@use-gesture/react'
import { cn } from '@/lib/utils'
interface ZoomableChartProps {
children: React.ReactNode
className?: string
'aria-label': string
minScale?: number
maxScale?: number
}
export function ZoomableChart({
children,
className,
'aria-label': ariaLabel,
minScale = 0.5,
maxScale = 4,
}: ZoomableChartProps) {
const containerRef = useRef<HTMLDivElement>(null)
const [style, api] = useSpring(() => ({
scale: 1,
config: { tension: 300, friction: 40 },
}))
usePinch(
({ offset: [scale], first, last }) => {
// Rubberband: 超出范围时有弹性阻力
const clamped = Math.min(Math.max(scale, minScale * 0.85), maxScale * 1.15)
const rubberband = clamped < minScale
? minScale + (clamped - minScale) * 0.3
: clamped > maxScale
? maxScale + (clamped - maxScale) * 0.3
: clamped
api.start({ scale: rubberband, immediate: first })
// 松手后弹回范围内
if (last && (scale < minScale || scale > maxScale)) {
api.start({
scale: Math.min(Math.max(scale, minScale), maxScale),
config: { tension: 200, friction: 30 },
})
}
},
{
target: containerRef,
scaleBounds: { min: minScale * 0.85, max: maxScale * 1.15 },
rubberband: true,
// 阻止浏览器默认的页面缩放
preventDefault: true,
eventOptions: { passive: false },
}
)
return (
<div
ref={containerRef}
role="img"
aria-label={ariaLabel}
className={cn('overflow-hidden touch-none select-none', className)}
style={{ touchAction: 'none' }}
>
<animated.div style={style} className="w-full h-full origin-center">
{children}
</animated.div>
</div>
)
}