diff --git a/UI_AUDIT_REPORT.md b/UI_AUDIT_REPORT.md index 7e5b78d..7ca88fb 100644 --- a/UI_AUDIT_REPORT.md +++ b/UI_AUDIT_REPORT.md @@ -723,8 +723,8 @@ export const announceToScreenReader = (message: string) => { } ``` -#### 11. **添加确认对话框组件** (预计 2 小时) -- [ ] 创建 `components/ui/ConfirmDialog.tsx` +#### 11. **添加确认对话框组件** (预计 2 小时) ✅ **已完成** +- [x] 创建 `components/ui/ConfirmDialog.tsx` ```tsx interface ConfirmDialogProps { isOpen: boolean; @@ -734,18 +734,31 @@ export const announceToScreenReader = (message: string) => { onCancel: () => void; } ``` -- [ ] 在 `WatchHistorySidebar` 中使用 -- [ ] 在删除历史时显示确认对话框 +- [x] 在 `WatchHistorySidebar` 中使用 +- [x] 在删除历史时显示确认对话框 -#### 12. **创建可访问性工具库** (预计 3 小时) -- [ ] 创建 `lib/accessibility/focus-management.ts` - - [ ] `trapFocus(container: HTMLElement)` - - [ ] `restoreFocus(element: HTMLElement)` - - [ ] `getFocusableElements(container: HTMLElement)` +**实现细节**: +- ✅ 创建了完整的 `ConfirmDialog` 组件,遵循 Liquid Glass 设计系统 +- ✅ 支持 danger/warning/info 三种变体 +- ✅ 完整的 ARIA 属性支持 (`alertdialog`, `aria-modal`, `aria-labelledby`, `aria-describedby`) +- ✅ 键盘支持:Escape 键关闭,焦点管理 +- ✅ 在 `WatchHistorySidebar` 中集成,用于删除单个历史和清空全部历史 +- ✅ 更新 `Button` 组件支持 `forwardRef`,增强可访问性 -- [ ] 创建 `lib/accessibility/aria-announcer.ts` - - [ ] `announceToScreenReader(message: string)` - - [ ] 在 `layout.tsx` 中添加 live region +#### 12. **创建可访问性工具库** (预计 3 小时) ✅ **已完成** +- [x] 创建 `lib/accessibility/focus-management.ts` + - [x] `trapFocus(container: HTMLElement)` + - [x] `restoreFocus(element: HTMLElement)` + - [x] `getFocusableElements(container: HTMLElement)` + - [x] `saveFocus()` - 额外添加的实用函数 + +- [x] 创建 `lib/accessibility/aria-announcer.ts` + - [x] `announceToScreenReader(message: string)` + - [x] `announceError(message: string)` - 额外添加 + - [x] `announceSuccess(message: string)` - 额外添加 + - [x] `announceLoading(message: string)` - 额外添加 + - [x] `clearAnnouncer()` - 额外添加 + - [x] 在 `layout.tsx` 中添加 live region ```tsx
{ /> ``` -- [ ] 创建 `lib/accessibility/keyboard-utils.ts` - - [ ] `isActivationKey(event: KeyboardEvent)` - - [ ] `handleEscape(callback: () => void)` +- [x] 创建 `lib/accessibility/keyboard-utils.ts` + - [x] `isActivationKey(event: KeyboardEvent)` + - [x] `handleEscape(callback: () => void)` + - [x] `hasModifierKey(event: KeyboardEvent)` - 额外添加 + - [x] `getArrowKeyDirection(event: KeyboardEvent)` - 额外添加 + - [x] `preventDefaultForKeys(event: KeyboardEvent, keys: string[])` - 额外添加 + - [x] `createKeyboardHandler(handlers: Record)` - 额外添加 + +- [x] 创建 `lib/accessibility/index.ts` - 统一导出所有工具 + +**实现细节**: +- ✅ **focus-management.ts**: 完整的焦点管理工具,包括焦点陷阱、焦点恢复、获取可聚焦元素 +- ✅ **aria-announcer.ts**: 屏幕阅读器播报工具,支持不同优先级(polite/assertive) +- ✅ **keyboard-utils.ts**: 键盘交互工具,涵盖激活键、Escape 键、方向键等 +- ✅ 在 `app/layout.tsx` 中添加了 ARIA live region +- ✅ 在 `globals.css` 中添加了 `.sr-only` 工具类 +- ✅ 所有工具都有完整的 TypeScript 类型定义和中英文注释 +- ✅ 创建了统一的导出文件 `lib/accessibility/index.ts`,简化导入 --- diff --git a/app/globals.css b/app/globals.css index 9d6e8f1..46f5a1a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -99,6 +99,19 @@ html { border-radius: var(--radius-2xl); } +/* Screen reader only utility */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + button:focus-visible, a:focus-visible, input:focus-visible, diff --git a/app/layout.tsx b/app/layout.tsx index 8cf5639..c667d2f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -32,6 +32,15 @@ export default function RootLayout({ {children} + + {/* ARIA Live Region for Screen Reader Announcements */} +
); diff --git a/components/history/WatchHistorySidebar.tsx b/components/history/WatchHistorySidebar.tsx index f9f16fb..a1cac80 100644 --- a/components/history/WatchHistorySidebar.tsx +++ b/components/history/WatchHistorySidebar.tsx @@ -9,12 +9,19 @@ import { useState, useEffect, useRef } from 'react'; import { useHistoryStore } from '@/lib/store/history-store'; import { Icons } from '@/components/ui/Icon'; import { Button } from '@/components/ui/Button'; +import { ConfirmDialog } from '@/components/ui/ConfirmDialog'; import { HistoryItem } from './HistoryItem'; import { HistoryEmptyState } from './HistoryEmptyState'; -import { trapFocus } from '@/lib/accessibility/focus-trap'; +import { trapFocus } from '@/lib/accessibility/focus-management'; export function WatchHistorySidebar() { const [isOpen, setIsOpen] = useState(false); + const [deleteConfirm, setDeleteConfirm] = useState<{ + isOpen: boolean; + videoId?: string; + source?: string; + isClearAll?: boolean; + }>({ isOpen: false }); const { viewingHistory, removeFromHistory, clearHistory } = useHistoryStore(); const sidebarRef = useRef(null); const cleanupFocusTrapRef = useRef<(() => void) | null>(null); @@ -50,6 +57,28 @@ export function WatchHistorySidebar() { }; }, [isOpen]); + // Handle delete confirmation + const handleDeleteItem = (videoId: string | number, source: string) => { + setDeleteConfirm({ isOpen: true, videoId: String(videoId), source }); + }; + + const handleClearAll = () => { + setDeleteConfirm({ isOpen: true, isClearAll: true }); + }; + + const confirmDelete = () => { + if (deleteConfirm.isClearAll) { + clearHistory(); + } else if (deleteConfirm.videoId && deleteConfirm.source) { + removeFromHistory(deleteConfirm.videoId, deleteConfirm.source); + } + setDeleteConfirm({ isOpen: false }); + }; + + const cancelDelete = () => { + setDeleteConfirm({ isOpen: false }); + }; + return ( <> {/* Toggle Button */} @@ -120,7 +149,7 @@ export function WatchHistorySidebar() { playbackPosition={item.playbackPosition} duration={item.duration} timestamp={item.timestamp} - onRemove={() => removeFromHistory(item.videoId, item.source)} + onRemove={() => handleDeleteItem(item.videoId, item.source)} /> ))}
@@ -132,7 +161,7 @@ export function WatchHistorySidebar() {
)} + + {/* Confirm Dialog */} + ); } diff --git a/components/ui/Button.tsx b/components/ui/Button.tsx index a025325..98b0d5f 100644 --- a/components/ui/Button.tsx +++ b/components/ui/Button.tsx @@ -1,16 +1,16 @@ -import React from 'react'; +import React, { forwardRef } from 'react'; interface ButtonProps extends React.ButtonHTMLAttributes { variant?: 'primary' | 'secondary'; children: React.ReactNode; } -export function Button({ +export const Button = forwardRef(({ variant = 'primary', children, className = '', ...props -}: ButtonProps) { +}, ref) => { const baseStyles = "inline-flex items-center justify-center px-4 py-2.5 md:px-6 md:py-3 font-semibold text-sm md:text-base transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed min-h-[44px] touch-manipulation"; const variants = { @@ -43,12 +43,15 @@ export function Button({ return ( ); -} +}); + +Button.displayName = 'Button'; diff --git a/components/ui/ConfirmDialog.tsx b/components/ui/ConfirmDialog.tsx new file mode 100644 index 0000000..2cef467 --- /dev/null +++ b/components/ui/ConfirmDialog.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { Button } from './Button'; +import { Card } from './Card'; + +interface ConfirmDialogProps { + isOpen: boolean; + title: string; + message: string; + onConfirm: () => void; + onCancel: () => void; + confirmText?: string; + cancelText?: string; + variant?: 'danger' | 'warning' | 'info'; +} + +export function ConfirmDialog({ + isOpen, + title, + message, + onConfirm, + onCancel, + confirmText = '确认', + cancelText = '取消', + variant = 'warning', +}: ConfirmDialogProps) { + const dialogRef = useRef(null); + const cancelButtonRef = useRef(null); + + useEffect(() => { + if (isOpen) { + // Focus the cancel button when dialog opens + cancelButtonRef.current?.focus(); + + // Prevent body scroll + document.body.style.overflow = 'hidden'; + } else { + // Restore body scroll + document.body.style.overflow = ''; + } + + return () => { + document.body.style.overflow = ''; + }; + }, [isOpen]); + + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onCancel(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onCancel]); + + if (!isOpen) return null; + + const variantStyles = { + danger: 'bg-red-500 hover:bg-red-600', + warning: 'bg-[var(--accent-color)] hover:brightness-110', + info: 'bg-blue-500 hover:bg-blue-600', + }; + + return ( + <> + {/* Backdrop */} +