feat: Enhance accessibility with ARIA attributes and focus trap implementation

This commit is contained in:
kuekhaoyang
2025-11-18 14:58:03 +08:00
parent 3dfcc6565d
commit 251e15a37d
7 changed files with 150 additions and 20 deletions
+16 -16
View File
@@ -600,25 +600,25 @@ export const announceToScreenReader = (message: string) => {
### 🟡 High Priority (高优先级,2 周内完成)
#### 5. **完善 ARIA 属性** (预计 3 小时)
- [ ] **SearchForm.tsx**
- [ ] 添加 `role="combobox"`
- [ ] 添加 `aria-expanded={showHistory}`
- [ ] 添加 `aria-controls="search-history-listbox"`
- [ ] 添加 `aria-autocomplete="list"`
- [] **SearchForm.tsx**
- [] 添加 `role="combobox"`
- [] 添加 `aria-expanded={showHistory}`
- [] 添加 `aria-controls="search-history-listbox"`
- [] 添加 `aria-autocomplete="list"`
- [ ] **WatchHistorySidebar.tsx**
- [ ] 添加 `role="complementary"`
- [ ] 添加 `aria-labelledby="history-sidebar-title"`
- [ ] 添加 `aria-hidden={!isOpen}`
- [ ] 实现焦点陷阱 (focus trap)
- [] **WatchHistorySidebar.tsx**
- [] 添加 `role="complementary"`
- [] 添加 `aria-labelledby="history-sidebar-title"`
- [] 添加 `aria-hidden={!isOpen}`
- [] 实现焦点陷阱 (focus trap)
- [ ] **VideoPlayer.tsx**
- [ ] 错误提示添加 `role="alert"`
- [ ] 添加 `aria-live="assertive"`
- [] **VideoPlayer.tsx**
- [] 错误提示添加 `role="alert"`
- [] 添加 `aria-live="assertive"`
- [ ] **VideoGrid.tsx**
- [ ] 添加 `role="list"` 到网格容器
- [ ] 添加 `role="listitem"` 到每个卡片
- [] **VideoGrid.tsx**
- [] 添加 `role="list"` 到网格容器
- [] 添加 `role="listitem"` 到每个卡片
#### 6. **添加键盘导航支持** (预计 4 小时)
- [ ] **VideoGrid.tsx**
+43 -2
View File
@@ -5,16 +5,50 @@
'use client';
import { useState } from 'react';
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 { HistoryItem } from './HistoryItem';
import { HistoryEmptyState } from './HistoryEmptyState';
import { trapFocus } from '@/lib/accessibility/focus-trap';
export function WatchHistorySidebar() {
const [isOpen, setIsOpen] = useState(false);
const { viewingHistory, removeFromHistory, clearHistory } = useHistoryStore();
const sidebarRef = useRef<HTMLElement>(null);
const cleanupFocusTrapRef = useRef<(() => void) | null>(null);
// Setup focus trap when sidebar opens
useEffect(() => {
if (isOpen && sidebarRef.current) {
cleanupFocusTrapRef.current = trapFocus(sidebarRef.current);
}
return () => {
if (cleanupFocusTrapRef.current) {
cleanupFocusTrapRef.current();
cleanupFocusTrapRef.current = null;
}
};
}, [isOpen]);
// Handle escape key to close sidebar
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
}
return () => {
document.removeEventListener('keydown', handleEscape);
};
}, [isOpen]);
return (
<>
@@ -37,6 +71,10 @@ export function WatchHistorySidebar() {
{/* Sidebar */}
<aside
ref={sidebarRef}
role="complementary"
aria-labelledby="history-sidebar-title"
aria-hidden={!isOpen}
className={`fixed top-0 right-0 bottom-0 w-[90%] max-w-[420px] z-[2000] bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] border-l border-[var(--glass-border)] rounded-tl-[var(--radius-2xl)] rounded-bl-[var(--radius-2xl)] p-6 flex flex-col shadow-[0_8px_32px_rgba(0,0,0,0.2)] transition-transform duration-[400ms] cubic-bezier(0.2,0.8,0.2,1) ${
isOpen ? 'translate-x-0' : 'translate-x-full'
}`}
@@ -45,7 +83,10 @@ export function WatchHistorySidebar() {
<header className="flex items-center justify-between mb-6 pb-4 border-b border-[var(--glass-border)]">
<div className="flex items-center gap-3">
<Icons.History size={24} className="text-[var(--accent-color)]" />
<h2 className="text-xl font-semibold text-[var(--text-color)]">
<h2
id="history-sidebar-title"
className="text-xl font-semibold text-[var(--text-color)]"
>
</h2>
</div>
+6 -1
View File
@@ -74,7 +74,12 @@ export function VideoPlayer({ playUrl, videoId, currentEpisode, onBack }: VideoP
<Card hover={false} className="p-0 overflow-hidden">
{videoError ? (
<div className="aspect-video bg-black rounded-[var(--radius-2xl)] flex items-center justify-center">
<div className="text-center text-white max-w-md px-4">
<div
className="text-center text-white max-w-md px-4"
role="alert"
aria-live="assertive"
aria-atomic="true"
>
<Icons.AlertTriangle size={48} className="mx-auto mb-4 text-red-500" />
<p className="text-lg font-semibold mb-2"></p>
<p className="text-sm text-gray-300 mb-4">{videoError}</p>
+5
View File
@@ -79,6 +79,11 @@ export function SearchForm({
onFocus={handleInputFocus}
placeholder="搜索电影、电视剧、综艺..."
className="text-lg pr-24 md:pr-32 truncate"
role="combobox"
aria-expanded={showHistory && !isLoading}
aria-controls="search-history-listbox"
aria-autocomplete="list"
aria-label="搜索视频内容"
/>
{query && (
<button
@@ -65,6 +65,9 @@ export function SearchHistoryDropdown({
return (
<div
ref={dropdownRef}
id="search-history-listbox"
role="listbox"
aria-label="搜索历史建议"
style={style}
className="z-[9999] bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] rounded-[var(--radius-2xl)] shadow-[var(--shadow-md)] border border-[var(--glass-border)] p-2 opacity-0 animate-[slideIn_0.2s_ease-out_forwards]"
>
@@ -84,6 +87,8 @@ export function SearchHistoryDropdown({
{searchHistory.map((item) => (
<div
key={item.timestamp}
role="option"
aria-selected="false"
className="group flex items-center gap-3 px-3 py-2.5 rounded-[var(--radius-2xl)] hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)] transition-all cursor-pointer"
>
<Icons.Clock
+6 -1
View File
@@ -49,7 +49,11 @@ export function VideoGrid({ videos, className = '' }: VideoGridProps) {
};
return (
<div className={`grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-3 md:gap-4 lg:gap-6 ${className}`}>
<div
className={`grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-3 md:gap-4 lg:gap-6 ${className}`}
role="list"
aria-label="视频搜索结果"
>
{videos.map((video, index) => {
const videoUrl = `/player?${new URLSearchParams({
id: video.vod_id,
@@ -65,6 +69,7 @@ export function VideoGrid({ videos, className = '' }: VideoGridProps) {
key={cardId}
href={videoUrl}
onClick={(e) => handleCardClick(e, cardId, videoUrl)}
role="listitem"
>
<Card
className={`p-0 overflow-hidden group cursor-pointer flex flex-col h-full ${video.isNew ? 'animate-scale-in' : ''}`}
+69
View File
@@ -0,0 +1,69 @@
/**
* Focus Trap Utility
* Traps keyboard focus within a specified container for accessibility
*/
const FOCUSABLE_ELEMENTS = [
'a[href]',
'button:not([disabled])',
'textarea:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');
export function getFocusableElements(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_ELEMENTS)).filter(
(el) => !el.hasAttribute('disabled') && el.offsetParent !== null
);
}
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];
// Store the element that had focus before the trap
const previouslyFocusedElement = document.activeElement as HTMLElement;
// Focus the first element
firstElement.focus();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
// Tab
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
};
container.addEventListener('keydown', handleKeyDown);
// Return cleanup function
return () => {
container.removeEventListener('keydown', handleKeyDown);
// Restore focus to the previously focused element
if (previouslyFocusedElement && typeof previouslyFocusedElement.focus === 'function') {
previouslyFocusedElement.focus();
}
};
}
export function restoreFocus(element: HTMLElement | null) {
if (element && typeof element.focus === 'function') {
element.focus();
}
}