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 - [Semantic Buttons and Card Accessibility]
**Learning:** Using semantic `<button type="button">` instead of `<span>` with `role="button"` provides native keyboard support (Enter/Space) and focusability without manual implementation. For card containers that should be focusable, adding `tabIndex={0}`, `role="button"`, and a targeted `onKeyDown` handler (checking `e.target === e.currentTarget`) ensures the entire card is accessible while preventing nested interactive elements from double-triggering actions.
**Action:** Always prefer semantic buttons for interactive elements. When making containers focusable, use `e.target === e.currentTarget` in keyboard handlers to avoid event bubbling issues from nested buttons or links.
60 changes: 45 additions & 15 deletions src/components/Library/GameCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ 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
key={tag.id}
type="button"
onClick={(e) => {
console.log('[GameCard] Click handler fired for tag:', tag.name);
e.stopPropagation();
Expand All @@ -44,11 +45,10 @@ 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-offset-1 focus-visible:ring-indigo-500 outline-none`}
>
{tag.name}
</span>
</button>
))}

{remainingCount > 0 && (
Expand All @@ -73,19 +73,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-offset-1 focus-visible:ring-indigo-500 outline-none"
>
{item.name}
</span>
</button>
))}
</div>
);
Expand Down Expand Up @@ -132,18 +132,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 focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-indigo-500 outline-none`}
>
{label}
</span>
</button>
);
};

Expand All @@ -166,7 +166,10 @@ 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-indigo-500 outline-none"
tabIndex={0}
role="button"
aria-label={`${display_name}`}
onClick={(e) => {
// Don't navigate if clicking on an interactive element
const target = e.target as HTMLElement;
Expand All @@ -177,6 +180,13 @@ const GameCard: React.FC<GameCardProps> = ({ game, onClick, viewMode, onFilter,
console.log('[GameCard] Grid click - navigating to game:', game.id);
onClick(game.id);
}}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick(game.id);
}
}}
>
<div className="relative">
{renderCover()}
Expand Down Expand Up @@ -235,14 +245,24 @@ 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-indigo-500 outline-none"
tabIndex={0}
role="button"
aria-label={`${display_name}`}
onClick={(e) => {
const target = e.target as HTMLElement;
if (target.closest('[role="button"]') || target.closest('button') || target.closest('a')) {
return;
}
onClick(game.id);
}}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick(game.id);
}
}}
>
<div className="relative h-24 bg-gray-800">
{cover_url ? (
Expand Down Expand Up @@ -339,14 +359,24 @@ 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-indigo-500 outline-none"
tabIndex={0}
role="button"
aria-label={`${display_name}`}
onClick={(e) => {
const target = e.target as HTMLElement;
if (target.closest('[role="button"]') || target.closest('button') || target.closest('a')) {
return;
}
onClick(game.id);
}}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick(game.id);
}
}}
>
<div className="w-32 h-32 flex-shrink-0 relative bg-gray-800 rounded-lg overflow-hidden">
{cover_url ? (
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