feat: Add ConfirmDialog component and accessibility utilities for enhanced user interaction

This commit is contained in:
kuekhaoyang
2025-11-18 15:31:22 +08:00
parent 2bc0be5d95
commit db40601649
10 changed files with 579 additions and 22 deletions
+43 -15
View File
@@ -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
<div
id="aria-live-announcer"
@@ -756,9 +769,24 @@ export const announceToScreenReader = (message: string) => {
/>
```
- [ ] 创建 `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<string, Function>)` - 额外添加
- [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`,简化导入
---
+13
View File
@@ -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,
+9
View File
@@ -32,6 +32,15 @@ export default function RootLayout({
<ThemeProvider>
{children}
</ThemeProvider>
{/* ARIA Live Region for Screen Reader Announcements */}
<div
id="aria-live-announcer"
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
/>
</body>
</html>
);
+48 -3
View File
@@ -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<HTMLElement>(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)}
/>
))}
</div>
@@ -132,7 +161,7 @@ export function WatchHistorySidebar() {
<footer className="mt-4 pt-4 border-t border-[var(--glass-border)]">
<Button
variant="secondary"
onClick={clearHistory}
onClick={handleClearAll}
className="w-full flex items-center justify-center gap-2"
>
<Icons.Trash size={18} />
@@ -141,6 +170,22 @@ export function WatchHistorySidebar() {
</footer>
)}
</aside>
{/* Confirm Dialog */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
title={deleteConfirm.isClearAll ? '清空历史记录' : '删除历史记录'}
message={
deleteConfirm.isClearAll
? '确定要清空所有观看历史吗?此操作无法撤销。'
: '确定要删除这条历史记录吗?'
}
onConfirm={confirmDelete}
onCancel={cancelDelete}
confirmText="删除"
cancelText="取消"
variant="danger"
/>
</>
);
}
+7 -4
View File
@@ -1,16 +1,16 @@
import React from 'react';
import React, { forwardRef } from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary';
children: React.ReactNode;
}
export function Button({
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
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
ref={ref}
className={`${baseStyles} ${variants[variant]} ${className}`}
{...props}
>
{children}
</button>
);
}
});
Button.displayName = 'Button';
+126
View File
@@ -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<HTMLDivElement>(null);
const cancelButtonRef = useRef<HTMLButtonElement>(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 */}
<div
className="fixed inset-0 z-[9998] bg-black/30 backdrop-blur-sm animate-fade-in"
onClick={onCancel}
aria-hidden="true"
/>
{/* Dialog */}
<div
ref={dialogRef}
role="alertdialog"
aria-modal="true"
aria-labelledby="dialog-title"
aria-describedby="dialog-description"
className="fixed top-1/2 left-1/2 z-[9999] w-[90%] max-w-md -translate-x-1/2 -translate-y-1/2 animate-slide-up"
>
<Card className="p-6">
{/* Header */}
<h2
id="dialog-title"
className="text-xl font-semibold text-[var(--text-color)] mb-3"
>
{title}
</h2>
{/* Message */}
<p
id="dialog-description"
className="text-[var(--text-color-secondary)] mb-6 leading-relaxed"
>
{message}
</p>
{/* Actions */}
<div className="flex gap-3 justify-end">
<Button
ref={cancelButtonRef}
variant="secondary"
onClick={onCancel}
className="min-w-[100px]"
>
{cancelText}
</Button>
<Button
onClick={onConfirm}
className={`min-w-[100px] ${variantStyles[variant]}`}
>
{confirmText}
</Button>
</div>
</Card>
</div>
</>
);
}
+82
View File
@@ -0,0 +1,82 @@
/**
* ARIA Live Region Announcer
* ARIA 实时区域播报器 - For screen reader announcements
*/
export type AnnouncementPriority = 'polite' | 'assertive';
/**
* Announce a message to screen readers via ARIA live region
* 通过 ARIA 实时区域向屏幕阅读器播报消息
*
* @param message The message to announce
* @param priority The priority level ('polite' or 'assertive')
* @param clearDelay Optional delay in ms before clearing the message (default: 1000)
*/
export function announceToScreenReader(
message: string,
priority: AnnouncementPriority = 'polite',
clearDelay = 1000
): void {
const announcer = document.getElementById('aria-live-announcer');
if (!announcer) {
console.warn(
'ARIA live announcer element not found. Make sure to add the element to your layout.'
);
return;
}
// Set the priority
announcer.setAttribute('aria-live', priority);
// Clear previous content
announcer.textContent = '';
// Use a small delay to ensure screen readers pick up the change
requestAnimationFrame(() => {
announcer.textContent = message;
// Clear the message after the delay
if (clearDelay > 0) {
setTimeout(() => {
announcer.textContent = '';
}, clearDelay);
}
});
}
/**
* Announce an error message to screen readers with assertive priority
* 以断言优先级向屏幕阅读器播报错误消息
*/
export function announceError(message: string): void {
announceToScreenReader(`错误: ${message}`, 'assertive', 3000);
}
/**
* Announce a success message to screen readers with polite priority
* 以礼貌优先级向屏幕阅读器播报成功消息
*/
export function announceSuccess(message: string): void {
announceToScreenReader(`成功: ${message}`, 'polite', 2000);
}
/**
* Announce a loading state to screen readers
* 向屏幕阅读器播报加载状态
*/
export function announceLoading(message = '正在加载...'): void {
announceToScreenReader(message, 'polite', 0);
}
/**
* Clear the announcer
* 清空播报器
*/
export function clearAnnouncer(): void {
const announcer = document.getElementById('aria-live-announcer');
if (announcer) {
announcer.textContent = '';
}
}
+113
View File
@@ -0,0 +1,113 @@
/**
* Focus Management Utilities
* 焦点管理工具库 - For accessible focus trapping and restoration
*/
/**
* Get all focusable elements within a container
* 获取容器内所有可聚焦的元素
*/
export function getFocusableElements(container: HTMLElement): HTMLElement[] {
const selector = [
'a[href]',
'button:not([disabled])',
'textarea:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
'[contenteditable="true"]',
].join(', ');
const elements = Array.from(
container.querySelectorAll<HTMLElement>(selector)
);
return elements.filter(
(element) =>
element.offsetWidth > 0 &&
element.offsetHeight > 0 &&
!element.hasAttribute('hidden') &&
getComputedStyle(element).visibility !== 'hidden'
);
}
/**
* Trap focus within a container (for modals, dialogs, etc.)
* 在容器内陷阱焦点(用于模态框、对话框等)
*
* @param container The container element to trap focus within
* @returns Cleanup function to remove the focus trap
*/
export function trapFocus(container: HTMLElement): () => void {
const focusableElements = getFocusableElements(container);
if (focusableElements.length === 0) {
return () => {};
}
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
// Focus the first element
firstElement.focus();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
// Shift + Tab
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
}
// Tab
else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
};
container.addEventListener('keydown', handleKeyDown);
// Return cleanup function
return () => {
container.removeEventListener('keydown', handleKeyDown);
};
}
/**
* Restore focus to a previously focused element
* 恢复焦点到之前聚焦的元素
*
* @param element The element to restore focus to
*/
export function restoreFocus(element: HTMLElement | null): void {
if (!element) return;
// Use requestAnimationFrame to ensure the element is ready
requestAnimationFrame(() => {
if (element && typeof element.focus === 'function') {
element.focus();
}
});
}
/**
* Save and restore focus for a component lifecycle
* 保存并恢复组件生命周期的焦点
*
* Usage:
* const focusManager = saveFocus();
* // ... do something that changes focus
* focusManager.restore();
*/
export function saveFocus() {
const previouslyFocused = document.activeElement as HTMLElement | null;
return {
restore: () => restoreFocus(previouslyFocused),
};
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Accessibility Utilities Index
* 可访问性工具库索引 - Centralized exports for all accessibility utilities
*/
// Focus Management
export {
getFocusableElements,
trapFocus,
restoreFocus,
saveFocus,
} from './focus-management';
// ARIA Announcer
export {
announceToScreenReader,
announceError,
announceSuccess,
announceLoading,
clearAnnouncer,
type AnnouncementPriority,
} from './aria-announcer';
// Keyboard Utils
export {
isActivationKey,
handleEscape,
hasModifierKey,
getArrowKeyDirection,
preventDefaultForKeys,
createKeyboardHandler,
} from './keyboard-utils';
+106
View File
@@ -0,0 +1,106 @@
/**
* Keyboard Utilities
* 键盘工具库 - For accessible keyboard interaction handling
*/
/**
* Check if the key pressed is an activation key (Enter or Space)
* 检查按下的键是否为激活键(Enter 或 Space
*
* @param event The keyboard event
* @returns True if Enter or Space was pressed
*/
export function isActivationKey(event: KeyboardEvent): boolean {
return event.key === 'Enter' || event.key === ' ';
}
/**
* Handle Escape key press
* 处理 Escape 键按下
*
* @param callback Function to call when Escape is pressed
* @returns Cleanup function to remove the event listener
*/
export function handleEscape(callback: () => void): () => void {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
callback();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}
/**
* Check if any modifier key is pressed
* 检查是否按下了任何修饰键
*
* @param event The keyboard event
* @returns True if Shift, Ctrl, Alt, or Meta is pressed
*/
export function hasModifierKey(event: KeyboardEvent): boolean {
return event.shiftKey || event.ctrlKey || event.altKey || event.metaKey;
}
/**
* Check if the key is an arrow key
* 检查按键是否为方向键
*
* @param event The keyboard event
* @returns The direction or null if not an arrow key
*/
export function getArrowKeyDirection(
event: KeyboardEvent
): 'up' | 'down' | 'left' | 'right' | null {
switch (event.key) {
case 'ArrowUp':
return 'up';
case 'ArrowDown':
return 'down';
case 'ArrowLeft':
return 'left';
case 'ArrowRight':
return 'right';
default:
return null;
}
}
/**
* Prevent default for specific keys
* 阻止特定键的默认行为
*
* @param event The keyboard event
* @param keys Array of keys to prevent default for
*/
export function preventDefaultForKeys(
event: KeyboardEvent,
keys: string[]
): void {
if (keys.includes(event.key)) {
event.preventDefault();
}
}
/**
* Create a keyboard event handler with common patterns
* 创建具有常见模式的键盘事件处理器
*
* @param handlers Object mapping keys to handler functions
* @returns Event handler function
*/
export function createKeyboardHandler(
handlers: Record<string, (event: KeyboardEvent) => void>
): (event: KeyboardEvent) => void {
return (event: KeyboardEvent) => {
const handler = handlers[event.key];
if (handler) {
handler(event);
}
};
}