mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-15 08:43:44 +08:00
feat: Enhance accessibility with ARIA attributes and focus trap implementation
This commit is contained in:
+16
-16
@@ -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**
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' : ''}`}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user