- 移除不存在的 @typescript-eslint/no-deprecated 規則 - 修復 npm ENOTEMPTY 錯誤 (先清理舊目錄) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
156 lines
3.9 KiB
TypeScript
156 lines
3.9 KiB
TypeScript
/**
|
|
* useReducedMotion - 無障礙動畫偵測 Hook
|
|
* ========================================
|
|
* Phase 19.A - Accessibility Motion Preference
|
|
*
|
|
* 遵循 WCAG 2.1 Level AA 標準:
|
|
* - 偵測使用者的 prefers-reduced-motion 設定
|
|
* - 動態監聽變化
|
|
* - 提供 SSR 安全的預設值
|
|
*
|
|
* 使用方式:
|
|
* ```tsx
|
|
* const prefersReducedMotion = useReducedMotion()
|
|
*
|
|
* return (
|
|
* <div className={prefersReducedMotion ? '' : 'animate-terminal-open'}>
|
|
* ...
|
|
* </div>
|
|
* )
|
|
* ```
|
|
*
|
|
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion
|
|
* @see ADR-031 Omni-Terminal SSE Architecture
|
|
*
|
|
* @author Claude Code (首席架構師)
|
|
* @version 1.0.0
|
|
* @date 2026-03-28 (台北時間)
|
|
*/
|
|
|
|
import { useState, useEffect } from 'react'
|
|
|
|
/** Media Query 字串 */
|
|
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'
|
|
|
|
/**
|
|
* 偵測使用者是否偏好減少動畫
|
|
*
|
|
* @returns {boolean} true = 使用者偏好減少動畫,應該禁用或簡化動畫
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* function TerminalMessage({ children }) {
|
|
* const prefersReducedMotion = useReducedMotion()
|
|
*
|
|
* return (
|
|
* <div
|
|
* className={cn(
|
|
* 'transition-opacity',
|
|
* prefersReducedMotion
|
|
* ? 'opacity-100' // 直接顯示,無動畫
|
|
* : 'animate-message-slide-in' // 有動畫
|
|
* )}
|
|
* >
|
|
* {children}
|
|
* </div>
|
|
* )
|
|
* }
|
|
* ```
|
|
*/
|
|
export function useReducedMotion(): boolean {
|
|
// SSR 安全預設值: false (預設有動畫)
|
|
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false)
|
|
|
|
useEffect(() => {
|
|
// 檢查瀏覽器支援
|
|
if (typeof window === 'undefined' || !window.matchMedia) {
|
|
return
|
|
}
|
|
|
|
const mediaQuery = window.matchMedia(REDUCED_MOTION_QUERY)
|
|
|
|
// 設定初始值
|
|
setPrefersReducedMotion(mediaQuery.matches)
|
|
|
|
// 監聽變化 (使用者可能在系統設定中切換)
|
|
const handleChange = (event: MediaQueryListEvent) => {
|
|
setPrefersReducedMotion(event.matches)
|
|
}
|
|
|
|
// 現代瀏覽器使用 addEventListener
|
|
if (mediaQuery.addEventListener) {
|
|
mediaQuery.addEventListener('change', handleChange)
|
|
return () => mediaQuery.removeEventListener('change', handleChange)
|
|
}
|
|
|
|
// 舊版瀏覽器 fallback (Safari < 14)
|
|
// Note: addListener/removeListener 在新瀏覽器已棄用,但為相容 Safari < 14 保留
|
|
mediaQuery.addListener(handleChange)
|
|
return () => mediaQuery.removeListener(handleChange)
|
|
}, [])
|
|
|
|
return prefersReducedMotion
|
|
}
|
|
|
|
/**
|
|
* 取得動畫類名 (自動考慮 reduced motion)
|
|
*
|
|
* @param animationClass - 動畫類名 (e.g., 'animate-terminal-open')
|
|
* @param fallbackClass - reduced motion 時的替代類名 (預設無動畫)
|
|
* @returns 適當的類名
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* function Terminal() {
|
|
* const getMotionClass = useMotionClass()
|
|
*
|
|
* return (
|
|
* <div className={getMotionClass('animate-terminal-open', 'opacity-100')}>
|
|
* ...
|
|
* </div>
|
|
* )
|
|
* }
|
|
* ```
|
|
*/
|
|
export function useMotionClass(): (
|
|
animationClass: string,
|
|
fallbackClass?: string
|
|
) => string {
|
|
const prefersReducedMotion = useReducedMotion()
|
|
|
|
return (animationClass: string, fallbackClass: string = '') => {
|
|
return prefersReducedMotion ? fallbackClass : animationClass
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 取得動畫時長 (reduced motion 時返回 0)
|
|
*
|
|
* @param durationMs - 正常動畫時長 (毫秒)
|
|
* @returns 適當的時長
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* function AnimatedComponent() {
|
|
* const getDuration = useMotionDuration()
|
|
*
|
|
* useEffect(() => {
|
|
* const timer = setTimeout(() => {
|
|
* // 動畫完成後的操作
|
|
* }, getDuration(300))
|
|
*
|
|
* return () => clearTimeout(timer)
|
|
* }, [getDuration])
|
|
* }
|
|
* ```
|
|
*/
|
|
export function useMotionDuration(): (durationMs: number) => number {
|
|
const prefersReducedMotion = useReducedMotion()
|
|
|
|
return (durationMs: number) => {
|
|
return prefersReducedMotion ? 0 : durationMs
|
|
}
|
|
}
|
|
|
|
export default useReducedMotion
|