mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-15 00:33:44 +08:00
feat: Add ConfirmDialog component and accessibility utilities for enhanced user interaction
This commit is contained in:
+43
-15
@@ -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`,简化导入
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 = '';
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user