Files
KVideo/components/search/SearchHistoryDropdown.tsx
T
kuekhaoyang 44cae03731 feat: Implement comprehensive keyboard navigation for KVideo components
- Added `useKeyboardNavigation` hook for managing keyboard interactions across lists and grids.
- Integrated keyboard navigation into VideoGrid, TypeBadges, SearchHistoryDropdown, and EpisodeList components.
- Enhanced accessibility by implementing ARIA roles and properties.
- Established visual focus indicators following Liquid Glass design principles.
- Created detailed documentation for keyboard shortcuts and component usage.
- Conducted performance optimizations and ensured compliance with WCAG 2.2 standards.
2025-11-18 15:07:39 +08:00

163 lines
5.4 KiB
TypeScript

/**
* Search History Dropdown Component
* 搜索历史下拉组件
*/
'use client';
import { useEffect, useState, useRef, useCallback } from 'react';
import { useSearchHistoryStore } from '@/lib/store/search-history-store';
import { Icons } from '@/components/ui/Icon';
import { useKeyboardNavigation } from '@/lib/hooks/useKeyboardNavigation';
interface SearchHistoryDropdownProps {
isVisible: boolean;
onSelect: (query: string) => void;
onClose: () => void;
inputRect: DOMRect | null;
}
export function SearchHistoryDropdown({
isVisible,
onSelect,
onClose,
inputRect,
}: SearchHistoryDropdownProps) {
const { searchHistory, removeSearchHistory, clearSearchHistory } = useSearchHistoryStore();
const dropdownRef = useRef<HTMLDivElement>(null);
const [focusedIndex, setFocusedIndex] = useState(-1);
const itemRefs = useRef<(HTMLAnchorElement | null)[]>([]);
// Keyboard navigation
useKeyboardNavigation({
enabled: isVisible,
containerRef: dropdownRef,
currentIndex: focusedIndex,
itemCount: searchHistory.length,
orientation: 'vertical',
onNavigate: useCallback((index: number) => {
setFocusedIndex(index);
itemRefs.current[index]?.focus();
}, []),
onSelect: useCallback((index: number) => {
if (searchHistory[index]) {
onSelect(searchHistory[index].query);
}
}, [searchHistory, onSelect]),
onEscape: useCallback(() => {
onClose();
}, [onClose]),
});
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
onClose();
}
};
if (isVisible) {
document.addEventListener('mousedown', handleClickOutside);
// Reset focus when dropdown opens
setFocusedIndex(-1);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isVisible, onClose]);
if (!isVisible || searchHistory.length === 0) return null;
const style = inputRect
? {
position: 'fixed' as const,
top: `${inputRect.bottom + 8}px`,
left: `${inputRect.left}px`,
width: `${inputRect.width}px`,
}
: {};
const handleItemClick = (query: string, event: React.MouseEvent) => {
// Check if middle mouse button (opens in new tab)
if (event.button === 1 || event.ctrlKey || event.metaKey) {
event.preventDefault();
window.open(`/?q=${encodeURIComponent(query)}`, '_blank');
return;
}
onSelect(query);
};
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]"
>
<div className="flex items-center justify-between px-3 py-2 mb-1">
<span className="text-sm font-medium text-[var(--text-color-secondary)]">
搜索历史
</span>
<button
onClick={clearSearchHistory}
className="text-xs text-[var(--text-color-secondary)] hover:text-[var(--accent-color)] transition-colors"
>
清空
</button>
</div>
<div className="max-h-[300px] overflow-y-auto space-y-1">
{searchHistory.map((item, index) => (
<div
key={item.timestamp}
role="option"
aria-selected={focusedIndex === index}
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 ${
focusedIndex === index ? 'bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)] ring-2 ring-[var(--accent-color)] ring-inset' : ''
}`}
>
<Icons.Clock
size={16}
className="text-[var(--text-color-secondary)] flex-shrink-0"
/>
<a
ref={(el) => { itemRefs.current[index] = el; }}
href={`/?q=${encodeURIComponent(item.query)}`}
onClick={(e) => {
e.preventDefault();
handleItemClick(item.query, e as any);
}}
onAuxClick={(e) => handleItemClick(item.query, e as any)}
onFocus={() => setFocusedIndex(index)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect(item.query);
}
}}
tabIndex={0}
className="flex-1 text-sm text-[var(--text-color)] hover:text-[var(--accent-color)] transition-colors truncate focus:outline-none"
title={item.query}
>
{item.query}
</a>
<button
onClick={(e) => {
e.stopPropagation();
removeSearchHistory(item.query);
}}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-[var(--glass-bg)] rounded-full"
aria-label="删除"
>
<Icons.X size={14} className="text-[var(--text-color-secondary)]" />
</button>
</div>
))}
</div>
</div>
);
}