diff --git a/.Jules/palette.md b/.Jules/palette.md new file mode 100644 index 0000000..9a88019 --- /dev/null +++ b/.Jules/palette.md @@ -0,0 +1,3 @@ +## 2025-05-14 - [GameCard Accessibility & Interaction] +**Learning:** When adding `role="button"` to a container that uses `target.closest('[role="button"]')` to prevent navigation when clicking interactive children, the check must be updated to exclude the container itself (e.g., `interactiveChild && interactiveChild !== e.currentTarget`). +**Action:** Always verify that adding accessibility roles to containers doesn't break existing event delegation or "click-blocking" logic. diff --git a/src/components/Library/GameCard.tsx b/src/components/Library/GameCard.tsx index f628ab4..7fe7ffc 100644 --- a/src/components/Library/GameCard.tsx +++ b/src/components/Library/GameCard.tsx @@ -16,39 +16,44 @@ interface GameCardProps { const GameCard: React.FC = ({ game, onClick, viewMode, onFilter, showQuickAssign, onQuickAssign }) => { const { t } = useI18n(); - const { display_name, cover_url, personal_rating, igdb_rating, tags, release_date, completion_status, play_time, is_favorite, genres, game_modes, player_perspectives, themes, platform } = game; + const { id, display_name, cover_url, personal_rating, igdb_rating, tags, release_date, completion_status, play_time, is_favorite, genres, game_modes, player_perspectives, themes, platform } = game; const platformIcon = platform ? (platform.startsWith('ps') ? '🎮' : platform.startsWith('xbox') ? 'đŸŽ¯' : platform.startsWith('nintendo') ? 'đŸ•šī¸' : platform === 'pc' ? 'đŸ’ģ' : platform === 'mobile' ? '📱' : '📟') : null; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.target !== e.currentTarget) return; + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(id); + } + }; + const renderTags = () => { if (tags.length === 0) return null; const displayedTags = tags.slice(0, 3); const remainingCount = tags.length - 3; - console.log('[GameCard] Rendering tags:', displayedTags.map(t => t.name), 'onFilter exists:', !!onFilter); - return (
{displayedTags.map((tag) => ( - { - console.log('[GameCard] Click handler fired for tag:', tag.name); e.stopPropagation(); e.preventDefault(); - console.log('[GameCard] Calling onFilter with:', 'tag', tag.name); if (onFilter) { onFilter('tag', tag.name); } else { console.warn('[GameCard] onFilter is undefined!'); } }} - className={`relative z-20 px-2 py-0.5 text-xs rounded-full text-white ${getCategoryColor(tag.category)} hover:opacity-80 hover:scale-110 hover:shadow-md transition-all cursor-pointer select-none border border-transparent hover:border-white/30 pointer-events-auto`} - role="button" + className={`relative z-20 px-2 py-0.5 text-xs rounded-full text-white ${getCategoryColor(tag.category)} hover:opacity-80 hover:scale-110 hover:shadow-md transition-all cursor-pointer select-none border border-transparent hover:border-white/30 pointer-events-auto focus-visible:ring-2 focus-visible:ring-white focus-visible:outline-none`} + aria-label={`${t('tags')}: ${tag.name}`} > {tag.name} - + ))} {remainingCount > 0 && ( @@ -73,19 +78,19 @@ const GameCard: React.FC = ({ game, onClick, viewMode, onFilter, return (
{allMetadata.map((item) => ( - { e.stopPropagation(); e.preventDefault(); - console.log('[GameCard] Metadata tag clicked:', item.type, item.name); onFilter?.(item.type, item.name); }} - className="px-1.5 py-0.5 text-xs rounded bg-gray-700/50 theme-text-muted hover:bg-indigo-600/30 transition-colors cursor-pointer select-none" - role="button" + className="px-1.5 py-0.5 text-xs rounded bg-gray-700/50 theme-text-muted hover:bg-indigo-600/30 transition-colors cursor-pointer select-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:outline-none" + aria-label={`${t(item.type as any)}: ${item.name}`} > {item.name} - + ))}
); @@ -132,18 +137,18 @@ const GameCard: React.FC = ({ game, onClick, viewMode, onFilter, const label = COMPLETION_STATUS_LABELS[completion_status as keyof typeof COMPLETION_STATUS_LABELS] || completion_status; return ( - { e.stopPropagation(); e.preventDefault(); - console.log('[GameCard] Status badge clicked:', completion_status); onFilter?.('status', completion_status); }} - className={`absolute top-2 left-2 px-2 py-1 rounded-full text-xs text-white ${statusColors[completion_status] || 'bg-gray-600'} hover:opacity-80 transition-opacity cursor-pointer select-none`} - role="button" + className={`absolute top-2 left-2 px-2 py-1 rounded-full text-xs text-white ${statusColors[completion_status] || 'bg-gray-600'} hover:opacity-80 transition-opacity cursor-pointer select-none z-20 focus-visible:ring-2 focus-visible:ring-white focus-visible:outline-none`} + aria-label={`${t('completionStatus')}: ${label}`} > {label} - + ); }; @@ -166,17 +171,20 @@ const GameCard: React.FC = ({ game, onClick, viewMode, onFilter, if (viewMode === "grid") { return (
{ - // Don't navigate if clicking on an interactive element + // Don't navigate if clicking on an interactive element (excluding the card itself) const target = e.target as HTMLElement; - if (target.closest('[role="button"]') || target.closest('button') || target.closest('a')) { - console.log('[GameCard] Grid click blocked - interactive element'); + const interactiveChild = target.closest('button, a, [role="button"]'); + if (interactiveChild && interactiveChild !== e.currentTarget) { return; } - console.log('[GameCard] Grid click - navigating to game:', game.id); - onClick(game.id); + onClick(id); }} + onKeyDown={handleKeyDown} + role="button" + tabIndex={0} + aria-label={display_name} >
{renderCover()} @@ -190,13 +198,15 @@ const GameCard: React.FC = ({ game, onClick, viewMode, onFilter, {platformIcon && {platformIcon}} {showQuickAssign && ( @@ -235,14 +245,19 @@ const GameCard: React.FC = ({ game, onClick, viewMode, onFilter, if (viewMode === "compact") { return (
{ const target = e.target as HTMLElement; - if (target.closest('[role="button"]') || target.closest('button') || target.closest('a')) { + const interactiveChild = target.closest('button, a, [role="button"]'); + if (interactiveChild && interactiveChild !== e.currentTarget) { return; } - onClick(game.id); + onClick(id); }} + onKeyDown={handleKeyDown} + role="button" + tabIndex={0} + aria-label={display_name} >
{cover_url ? ( @@ -264,16 +279,24 @@ const GameCard: React.FC = ({ game, onClick, viewMode, onFilter, {/* Status badge - small */} {completion_status && completion_status !== 'not_started' && ( @@ -339,14 +364,19 @@ const GameCard: React.FC = ({ game, onClick, viewMode, onFilter, // List view mode return (
{ const target = e.target as HTMLElement; - if (target.closest('[role="button"]') || target.closest('button') || target.closest('a')) { + const interactiveChild = target.closest('button, a, [role="button"]'); + if (interactiveChild && interactiveChild !== e.currentTarget) { return; } - onClick(game.id); + onClick(id); }} + onKeyDown={handleKeyDown} + role="button" + tabIndex={0} + aria-label={display_name} >
{cover_url ? ( @@ -374,12 +404,14 @@ const GameCard: React.FC = ({ game, onClick, viewMode, onFilter, {platformIcon && {platformIcon}} {showQuickAssign && ( diff --git a/src/components/Library/GameScreenshotsCarousel.tsx b/src/components/Library/GameScreenshotsCarousel.tsx index 6a4e37c..bfae3b8 100644 --- a/src/components/Library/GameScreenshotsCarousel.tsx +++ b/src/components/Library/GameScreenshotsCarousel.tsx @@ -20,7 +20,6 @@ export function GameScreenshotsCarousel({ gameId }: GameScreenshotsCarouselProps const [igdbScreenshots, setIgdbScreenshots] = useState([]); const [currentIndex, setCurrentIndex] = useState(0); const [loading, setLoading] = useState(true); - const [hasIgdbId, setHasIgdbId] = useState(false); useEffect(() => { const loadScreenshots = async () => { @@ -29,7 +28,6 @@ export function GameScreenshotsCarousel({ gameId }: GameScreenshotsCarouselProps // Load game data to check IGDB ID try { const game = await invoke<{ igdb_id: number | null }>("get_game_by_id", { id: gameId }); - setHasIgdbId(!!game?.igdb_id); // Load IGDB screenshots if available if (game?.igdb_id) {