feat: implement SourceBadgeList and SourceBadgeItem components for improved video source filtering

This commit is contained in:
kuekhaoyang
2025-11-19 11:17:54 +08:00
parent 057df7434c
commit f7c32485b9
4 changed files with 205 additions and 41 deletions
+73
View File
@@ -0,0 +1,73 @@
/**
* SourceBadgeItem - Individual source badge component
* Displays a single video source badge with count, supports selection state
*/
interface SourceBadgeItemProps {
id: string;
name: string;
count: number;
isSelected: boolean;
onToggle: () => void;
isFocused?: boolean;
onFocus?: () => void;
innerRef?: (el: HTMLButtonElement | null) => void;
}
export function SourceBadgeItem({
id,
name,
count,
isSelected,
onToggle,
isFocused = false,
onFocus,
innerRef,
}: SourceBadgeItemProps) {
const handleClick = () => {
// Immediate visual feedback before state update
onToggle();
};
return (
<button
ref={innerRef}
onClick={handleClick}
onFocus={onFocus}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
}}
tabIndex={0}
aria-pressed={isSelected}
aria-label={`${name} 视频源,${count} 个视频${isSelected ? ',已选中' : ''}`}
className={`
inline-flex items-center gap-1.5 px-4 py-2
rounded-full
border-2
text-sm font-medium whitespace-nowrap
transition-all duration-200 ease-out
hover:scale-105 hover:shadow-[var(--shadow-sm)]
active:scale-95 snap-start
focus:outline-none
${isSelected
? 'bg-[var(--accent-color)] text-white border-[var(--accent-color)] shadow-md'
: 'bg-[var(--glass-bg)] text-[var(--text-color)] border-[var(--glass-border)] hover:border-[var(--accent-color)]'
}
`}
>
<span>{name}</span>
<span className={`
px-2 py-0.5 rounded-full text-xs font-semibold
${isSelected
? 'bg-white/20 text-white'
: 'bg-[var(--accent-color)]/10 text-[var(--accent-color)]'
}
`}>
{count}
</span>
</button>
);
}
+123
View File
@@ -0,0 +1,123 @@
/**
* SourceBadgeList - Badge list container with responsive layout
* Desktop: Expandable grid with show more/less
* Mobile: Horizontal scroll with snap
*/
'use client';
import { useState, useRef, useCallback } from 'react';
import { Icons } from '@/components/ui/Icon';
import { SourceBadgeItem } from './SourceBadgeItem';
import { useKeyboardNavigation } from '@/lib/hooks/useKeyboardNavigation';
interface Source {
id: string;
name: string;
count: number;
}
interface SourceBadgeListProps {
sources: Source[];
selectedSources: Set<string>;
onToggleSource: (sourceId: string) => void;
}
export function SourceBadgeList({ sources, selectedSources, onToggleSource }: SourceBadgeListProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [focusedIndex, setFocusedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const badgeRefs = useRef<(HTMLButtonElement | null)[]>([]);
// Keyboard navigation
useKeyboardNavigation({
enabled: true,
containerRef: containerRef,
currentIndex: focusedIndex,
itemCount: sources.length,
orientation: 'horizontal',
onNavigate: useCallback((index: number) => {
setFocusedIndex(index);
badgeRefs.current[index]?.focus();
// Scroll into view for mobile
badgeRefs.current[index]?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center',
});
}, []),
onSelect: useCallback((index: number) => {
onToggleSource(sources[index].id);
}, [sources, onToggleSource]),
});
return (
<>
{/* Desktop: Expandable Grid */}
<div
ref={containerRef}
className="hidden md:flex md:flex-col md:flex-1 -mx-1 px-1"
role="group"
aria-label="视频源筛选"
>
<div className={`flex items-center gap-2 flex-wrap transition-all duration-300 ${
!isExpanded ? 'max-h-[3rem] overflow-hidden' : ''
}`}>
{sources.map((source, index) => (
<SourceBadgeItem
key={source.id}
id={source.id}
name={source.name}
count={source.count}
isSelected={selectedSources.has(source.id)}
onToggle={() => onToggleSource(source.id)}
isFocused={focusedIndex === index}
onFocus={() => setFocusedIndex(index)}
innerRef={(el: HTMLButtonElement | null) => { badgeRefs.current[index] = el; }}
/>
))}
</div>
{sources.length > 5 && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="mt-2 text-xs text-[var(--text-color-secondary)] hover:text-[var(--accent-color)]
flex items-center gap-1 transition-colors self-start"
>
<span>{isExpanded ? '收起' : '展开更多'}</span>
<Icons.ChevronDown
size={14}
className={`transition-transform duration-300 ${isExpanded ? 'rotate-180' : ''}`}
/>
</button>
)}
</div>
{/* Mobile & Tablet: Horizontal Scroll */}
<div
className="flex md:hidden flex-1 -mx-1 px-1 overflow-hidden"
role="group"
aria-label="视频源筛选"
>
<div
ref={containerRef}
className="flex items-center gap-2 overflow-x-auto pb-2 scrollbar-hide snap-x snap-mandatory"
>
{sources.map((source, index) => (
<SourceBadgeItem
key={source.id}
id={source.id}
name={source.name}
count={source.count}
isSelected={selectedSources.has(source.id)}
onToggle={() => onToggleSource(source.id)}
isFocused={focusedIndex === index}
onFocus={() => setFocusedIndex(index)}
innerRef={(el: HTMLButtonElement | null) => { badgeRefs.current[index] = el; }}
/>
))}
</div>
</div>
</>
);
}
+7 -39
View File
@@ -2,15 +2,15 @@
* SourceBadges - Clickable video source badges with filtering
* Shows available video sources with counts
* Click to filter videos by source
* Similar functionality to TypeBadges
* Responsive: Desktop shows expand/collapse, Mobile shows horizontal scroll
*/
'use client';
import { memo } from 'react';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Icons } from '@/components/ui/Icon';
import { SourceBadgeList } from './SourceBadgeList';
interface Source {
id: string;
@@ -52,43 +52,11 @@ export const SourceBadges = memo(function SourceBadges({
</span>
</div>
<div className="flex-1 flex items-center gap-2 flex-wrap">
{sources.map((source) => {
const isSelected = selectedSources.has(source.id);
return (
<button
key={source.id}
onClick={() => onToggleSource(source.id)}
className={`
inline-flex items-center gap-1.5 px-4 py-2
rounded-full
border-2
text-sm font-medium whitespace-nowrap
transition-all duration-200 ease-out
hover:scale-105 hover:shadow-[var(--shadow-sm)]
active:scale-95
focus:outline-none
${isSelected
? 'bg-[var(--accent-color)] text-white border-[var(--accent-color)] shadow-md'
: 'bg-[var(--glass-bg)] text-[var(--text-color)] border-[var(--glass-border)] hover:border-[var(--accent-color)]'
}
`}
>
<span>{source.name}</span>
<span className={`
px-2 py-0.5 rounded-full text-xs font-semibold
${isSelected
? 'bg-white/20 text-white'
: 'bg-[var(--accent-color)]/10 text-[var(--accent-color)]'
}
`}>
{source.count}
</span>
</button>
);
})}
</div>
<SourceBadgeList
sources={sources}
selectedSources={selectedSources}
onToggleSource={onToggleSource}
/>
</div>
{selectedSources.size > 0 && (
+2 -2
View File
@@ -93,13 +93,13 @@ export function TypeBadgeList({ badges, selectedTypes, onToggleType }: TypeBadge
{/* Mobile & Tablet: Horizontal Scroll */}
<div
className="flex md:hidden flex-1 -mx-1 px-1"
className="flex md:hidden flex-1 -mx-1 px-1 overflow-hidden"
role="group"
aria-label="类型筛选"
>
<div
ref={containerRef}
className="flex items-center gap-2 overflow-x-auto overflow-y-visible pb-2 scrollbar-hide snap-x snap-mandatory"
className="flex items-center gap-2 overflow-x-auto pb-2 scrollbar-hide snap-x snap-mandatory"
>
{badges.map((badge, index) => (
<TypeBadgeItem