Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .Jules/palette.md
Original file line number Diff line number Diff line change
@@ -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.
110 changes: 71 additions & 39 deletions src/components/Library/GameCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,39 +16,44 @@ interface GameCardProps {

const GameCard: React.FC<GameCardProps> = ({ 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 (
<div className="flex flex-wrap gap-1 mt-2 pointer-events-auto">
{displayedTags.map((tag) => (
<span
<button
key={tag.id}
type="button"
onClick={(e) => {
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}
</span>
</button>
))}

{remainingCount > 0 && (
Expand All @@ -73,19 +78,19 @@ const GameCard: React.FC<GameCardProps> = ({ game, onClick, viewMode, onFilter,
return (
<div className="flex flex-wrap gap-1 mt-1">
{allMetadata.map((item) => (
<span
<button
key={`${item.type}-${item.id}`}
type="button"
onClick={(e) => {
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}
</span>
</button>
))}
</div>
);
Expand Down Expand Up @@ -132,18 +137,18 @@ const GameCard: React.FC<GameCardProps> = ({ game, onClick, viewMode, onFilter,
const label = COMPLETION_STATUS_LABELS[completion_status as keyof typeof COMPLETION_STATUS_LABELS] || completion_status;

return (
<span
<button
type="button"
onClick={(e) => {
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}
</span>
</button>
);
};

Expand All @@ -166,17 +171,20 @@ const GameCard: React.FC<GameCardProps> = ({ game, onClick, viewMode, onFilter,
if (viewMode === "grid") {
return (
<div
className="theme-card theme-border border rounded-lg overflow-hidden cursor-pointer transition-all duration-300 hover:scale-105 hover:shadow-xl hover:border-gray-600"
className="theme-card theme-border border rounded-lg overflow-hidden cursor-pointer transition-all duration-300 hover:scale-105 hover:shadow-xl hover:border-gray-600 focus-within:ring-2 focus-within:ring-indigo-500 outline-none"
onClick={(e) => {
// 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}
>
<div className="relative">
{renderCover()}
Expand All @@ -190,13 +198,15 @@ const GameCard: React.FC<GameCardProps> = ({ game, onClick, viewMode, onFilter,
{platformIcon && <span className="text-sm">{platformIcon}</span>}
{showQuickAssign && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onQuickAssign?.();
}}
className="ml-auto text-xs px-2 py-1 bg-orange-500 hover:bg-orange-600 text-white rounded transition-colors"
title="Quick assign platform"
className="ml-auto text-xs px-2 py-1 bg-orange-500 hover:bg-orange-600 text-white rounded transition-colors focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:outline-none"
title={t('platform') || "Quick assign platform"}
aria-label={t('platform') || "Quick assign platform"}
>
🎮
</button>
Expand Down Expand Up @@ -235,14 +245,19 @@ const GameCard: React.FC<GameCardProps> = ({ game, onClick, viewMode, onFilter,
if (viewMode === "compact") {
return (
<div
className="theme-card theme-border border rounded-lg overflow-hidden cursor-pointer transition-all duration-200 hover:scale-105 hover:shadow-lg hover:border-gray-600 relative"
className="theme-card theme-border border rounded-lg overflow-hidden cursor-pointer transition-all duration-200 hover:scale-105 hover:shadow-lg hover:border-gray-600 relative focus-within:ring-2 focus-within:ring-indigo-500 outline-none"
onClick={(e) => {
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}
>
<div className="relative h-24 bg-gray-800">
{cover_url ? (
Expand All @@ -264,16 +279,24 @@ const GameCard: React.FC<GameCardProps> = ({ game, onClick, viewMode, onFilter,
{/* Status badge - small */}
{completion_status && completion_status !== 'not_started' && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onFilter?.('status', completion_status);
}}
className={`absolute top-1 left-1 px-1.5 py-0.5 rounded text-[10px] text-white z-10 ${
className={`absolute top-1 left-1 px-1.5 py-0.5 rounded text-[10px] text-white z-20 ${
completion_status === 'completed' ? 'bg-green-600' :
completion_status === 'playing' ? 'bg-blue-600' :
completion_status === 'dropped' ? 'bg-red-600' :
completion_status === 'wishlist' ? 'bg-purple-600' : 'bg-gray-600'
} hover:opacity-80 transition-opacity cursor-pointer`}
} hover:opacity-80 transition-opacity cursor-pointer focus-visible:ring-1 focus-visible:ring-white focus-visible:outline-none`}
aria-label={`${t('completionStatus')}: ${
completion_status === 'not_started' ? 'Not Started' :
completion_status === 'playing' ? 'Playing' :
completion_status === 'completed' ? 'Completed' :
completion_status === 'dropped' ? 'Dropped' :
completion_status === 'wishlist' ? 'Wishlist' : completion_status
}`}
>
{completion_status === 'not_started' ? 'Not Started' :
completion_status === 'playing' ? 'Playing' :
Expand All @@ -291,12 +314,14 @@ const GameCard: React.FC<GameCardProps> = ({ game, onClick, viewMode, onFilter,
{/* Quick assign button - bottom left, above platform icon */}
{showQuickAssign && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onQuickAssign?.();
}}
className="absolute bottom-1 left-1 w-5 h-5 flex items-center justify-center text-[10px] bg-orange-500 hover:bg-orange-600 text-white rounded transition-colors z-10"
title="Quick assign platform"
className="absolute bottom-1 left-1 w-5 h-5 flex items-center justify-center text-[10px] bg-orange-500 hover:bg-orange-600 text-white rounded transition-colors z-20 focus-visible:ring-1 focus-visible:ring-white focus-visible:outline-none"
title={t('platform') || "Quick assign platform"}
aria-label={t('platform') || "Quick assign platform"}
>
🎮
</button>
Expand Down Expand Up @@ -339,14 +364,19 @@ const GameCard: React.FC<GameCardProps> = ({ game, onClick, viewMode, onFilter,
// List view mode
return (
<div
className="theme-card theme-border border rounded-lg p-4 flex gap-4 cursor-pointer transition-all duration-300 hover:scale-[1.02] hover:shadow-xl hover:border-gray-600"
className="theme-card theme-border border rounded-lg p-4 flex gap-4 cursor-pointer transition-all duration-300 hover:scale-[1.02] hover:shadow-xl hover:border-gray-600 focus-within:ring-2 focus-within:ring-indigo-500 outline-none"
onClick={(e) => {
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}
>
<div className="w-32 h-32 flex-shrink-0 relative bg-gray-800 rounded-lg overflow-hidden">
{cover_url ? (
Expand Down Expand Up @@ -374,12 +404,14 @@ const GameCard: React.FC<GameCardProps> = ({ game, onClick, viewMode, onFilter,
{platformIcon && <span className="text-lg">{platformIcon}</span>}
{showQuickAssign && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onQuickAssign?.();
}}
className="ml-2 text-xs px-2 py-1 bg-orange-500 hover:bg-orange-600 text-white rounded transition-colors"
title="Quick assign platform"
className="ml-2 text-xs px-2 py-1 bg-orange-500 hover:bg-orange-600 text-white rounded transition-colors focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:outline-none"
title={t('platform') || "Quick assign platform"}
aria-label={t('platform') || "Quick assign platform"}
>
🎮
</button>
Expand Down
2 changes: 0 additions & 2 deletions src/components/Library/GameScreenshotsCarousel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ export function GameScreenshotsCarousel({ gameId }: GameScreenshotsCarouselProps
const [igdbScreenshots, setIgdbScreenshots] = useState<string[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [loading, setLoading] = useState(true);
const [hasIgdbId, setHasIgdbId] = useState(false);

useEffect(() => {
const loadScreenshots = async () => {
Expand All @@ -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) {
Expand Down