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 @@
## 2026-05-05 - [Accessibility & Keyboard Navigation in Card-based UIs]
**Learning:** Interactive cards that contain nested buttons (like tags, status badges) require careful event management. Using `tabIndex={0}` on the container makes it keyboard-accessible, but an `onKeyDown` handler must use `if (e.target !== e.currentTarget) return;` to prevent the card's primary action (navigation) from firing when a nested interactive element is activated. Semantic `<button>` elements should always be preferred over `<span role="button">` to ensure consistent screen reader behavior and native keyboard support.
**Action:** Use semantic `<button>` for all interactive elements and implement specific target checks in parent container key handlers to prevent event collision.
90 changes: 64 additions & 26 deletions src/components/Library/GameCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ const GameCard: React.FC<GameCardProps> = ({ game, onClick, viewMode, onFilter,
return (
<div className="flex flex-wrap gap-1 mt-2 pointer-events-auto">
{displayedTags.map((tag) => (
<span
<button
type="button"
key={tag.id}
onClick={(e) => {
console.log('[GameCard] Click handler fired for tag:', tag.name);
Expand All @@ -44,11 +45,11 @@ const GameCard: React.FC<GameCardProps> = ({ game, onClick, viewMode, onFilter,
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/50 outline-none`}
aria-label={`${t('filterByTag')}: ${tag.name}`}
>
{tag.name}
</span>
</button>
))}

{remainingCount > 0 && (
Expand All @@ -73,19 +74,20 @@ const GameCard: React.FC<GameCardProps> = ({ game, onClick, viewMode, onFilter,
return (
<div className="flex flex-wrap gap-1 mt-1">
{allMetadata.map((item) => (
<span
<button
type="button"
key={`${item.type}-${item.id}`}
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/50 outline-none"
aria-label={`${item.type}: ${item.name}`}
>
{item.name}
</span>
</button>
))}
</div>
);
Expand Down Expand Up @@ -132,18 +134,19 @@ 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 z-10 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 focus-visible:ring-2 focus-visible:ring-white/50 outline-none`}
aria-label={`Status: ${label}`}
>
{label}
</span>
</button>
);
};

Expand All @@ -166,11 +169,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-visible:ring-2 focus-visible:ring-[var(--color-accent)] focus-visible:ring-offset-2 focus-visible:outline-none"
tabIndex={0}
aria-label={display_name}
onKeyDown={(e) => {
Comment on lines +173 to +175
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add an interactive role to keyboard-focusable card containers

The card wrappers are now keyboard-focusable (tabIndex={0}) and handle Enter/Space activation, but they remain plain <div> elements without an interactive role, so screen readers can announce them as generic containers instead of actionable controls. This creates an accessibility gap where keyboard users can trigger the card but assistive-technology users may not realize it is clickable; the same pattern is repeated in grid/compact/list variants, so these wrappers should expose button/link semantics (role="button" or native interactive elements).

Useful? React with 👍 / 👎.

if (e.key === 'Enter' || e.key === ' ') {
if (e.target !== e.currentTarget) return;
e.preventDefault();
onClick(game.id);
}
}}
onClick={(e) => {
// Don't navigate if clicking on an interactive element
const target = e.target as HTMLElement;
if (target.closest('[role="button"]') || target.closest('button') || target.closest('a')) {
if (target.closest('button') || target.closest('a')) {
console.log('[GameCard] Grid click blocked - interactive element');
return;
}
Expand All @@ -190,13 +202,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-orange-400 outline-none"
title={t('quickAdd') || "Quick assign platform"}
aria-label={t('quickAdd') || "Quick assign platform"}
>
🎮
</button>
Expand Down Expand Up @@ -235,10 +249,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-visible:ring-2 focus-visible:ring-[var(--color-accent)] focus-visible:ring-offset-2 focus-visible:outline-none"
tabIndex={0}
aria-label={display_name}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
if (e.target !== e.currentTarget) return;
e.preventDefault();
onClick(game.id);
}
}}
onClick={(e) => {
const target = e.target as HTMLElement;
if (target.closest('[role="button"]') || target.closest('button') || target.closest('a')) {
if (target.closest('button') || target.closest('a')) {
return;
}
onClick(game.id);
Expand All @@ -264,6 +287,7 @@ 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);
Expand All @@ -273,7 +297,8 @@ const GameCard: React.FC<GameCardProps> = ({ game, onClick, viewMode, onFilter,
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 outline-none`}
aria-label={`Status: ${completion_status}`}
>
{completion_status === 'not_started' ? 'Not Started' :
completion_status === 'playing' ? 'Playing' :
Expand All @@ -285,18 +310,20 @@ const GameCard: React.FC<GameCardProps> = ({ game, onClick, viewMode, onFilter,

{/* Favorite star */}
{is_favorite && (
<span className="absolute top-1 right-1 text-yellow-400 text-sm z-10">★</span>
<span className="absolute top-1 right-1 text-yellow-400 text-sm z-10" aria-hidden="true">★</span>
)}

{/* 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-10 focus-visible:ring-1 focus-visible:ring-white outline-none"
title={t('quickAdd') || "Quick assign platform"}
aria-label={t('quickAdd') || "Quick assign platform"}
>
🎮
</button>
Expand Down Expand Up @@ -339,10 +366,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-visible:ring-2 focus-visible:ring-[var(--color-accent)] focus-visible:ring-offset-2 focus-visible:outline-none"
tabIndex={0}
aria-label={display_name}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
if (e.target !== e.currentTarget) return;
e.preventDefault();
onClick(game.id);
}
}}
onClick={(e) => {
const target = e.target as HTMLElement;
if (target.closest('[role="button"]') || target.closest('button') || target.closest('a')) {
if (target.closest('button') || target.closest('a')) {
return;
}
onClick(game.id);
Expand Down Expand Up @@ -374,12 +410,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-orange-400 outline-none"
title={t('quickAdd') || "Quick assign platform"}
aria-label={t('quickAdd') || "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