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-15 - [Keyboard Accessibility & Safety Improvements]
**Learning:** Adding `tabIndex={0}` and `onKeyDown` handlers to custom interactive elements (like spans with `role="button"`) is critical for keyboard accessibility in this app. Also, the build system (`pnpm build`) is very strict about unused variables, so they must be removed to avoid deployment failures.
**Action:** Always include `tabIndex` and `onKeyDown` when using `role="button"`. Ensure every destructive action has a `window.confirm` with an i18n key. Check for unused state/variables before finalizing.
83 changes: 66 additions & 17 deletions src/components/Library/GameCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,34 +18,55 @@ const GameCard: React.FC<GameCardProps> = ({ game, onClick, viewMode, onFilter,
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 platformIcon = platform ? (platform.startsWith('ps') ? '🎮' : platform.startsWith('xbox') ? '🎯' : platform.startsWith('nintendo') ? '🕹️' : platform === 'pc' ? '💻' : platform === 'mobile' ? '📱' : '📟') : null;
const platformNames: Record<string, string> = {
pc: 'PC',
ps5: 'PlayStation 5',
ps4: 'PlayStation 4',
ps3: 'PlayStation 3',
ps2: 'PlayStation 2',
ps1: 'PlayStation',
xbox_series: 'Xbox Series X|S',
xbox_one: 'Xbox One',
xbox_360: 'Xbox 360',
nintendo_switch: 'Nintendo Switch',
mobile: 'Mobile',
};

const platformIcon = platform ? (
<span role="img" aria-label={platformNames[platform] || platform}>
{platform.startsWith('ps') ? '🎮' : platform.startsWith('xbox') ? '🎯' : platform.startsWith('nintendo') ? '🕹️' : platform === 'pc' ? '💻' : platform === 'mobile' ? '📱' : '📟'}
</span>
) : null;

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
key={tag.id}
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`}
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`}
role="button"
tabIndex={0}
aria-label={`${t('filterByTag')}: ${tag.name}`}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onFilter?.('tag', tag.name);
}
}}
>
{tag.name}
</span>
Expand Down Expand Up @@ -78,11 +99,19 @@ const GameCard: React.FC<GameCardProps> = ({ game, onClick, viewMode, onFilter,
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"
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"
role="button"
tabIndex={0}
aria-label={`${t('filterByTag')}: ${item.name}`}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onFilter?.(item.type, item.name);
}
}}
>
{item.name}
</span>
Expand Down Expand Up @@ -136,11 +165,19 @@ const GameCard: React.FC<GameCardProps> = ({ game, onClick, viewMode, onFilter,
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`}
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-10 focus-visible:ring-2 focus-visible:ring-white`}
role="button"
tabIndex={0}
aria-label={`${t('completionStatus')}: ${label}`}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onFilter?.('status', completion_status);
}
}}
>
{label}
</span>
Expand All @@ -163,18 +200,26 @@ const GameCard: React.FC<GameCardProps> = ({ game, onClick, viewMode, onFilter,
);
};

const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.target !== e.currentTarget) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick(game.id);
}
};

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}
onKeyDown={handleKeyDown}
Comment on lines 213 to +216
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 Give focusable game cards button semantics

The card containers are now keyboard-focusable and activate on Enter/Space, but they are still plain <div> elements without an interactive role or name. In screen readers this is typically announced as generic content, so users can tab to it without being told it is actionable, making the new keyboard navigation difficult to discover across grid/list/compact cards. Use a semantic <button> (preferred) or add role="button" plus an accessible name.

Useful? React with 👍 / 👎.

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')) {
console.log('[GameCard] Grid click blocked - interactive element');
return;
}
console.log('[GameCard] Grid click - navigating to game:', game.id);
onClick(game.id);
}}
>
Expand Down Expand Up @@ -235,7 +280,9 @@ 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}
onKeyDown={handleKeyDown}
onClick={(e) => {
const target = e.target as HTMLElement;
if (target.closest('[role="button"]') || target.closest('button') || target.closest('a')) {
Expand Down Expand Up @@ -339,7 +386,9 @@ 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}
onKeyDown={handleKeyDown}
onClick={(e) => {
const target = e.target as HTMLElement;
if (target.closest('[role="button"]') || target.closest('button') || target.closest('a')) {
Expand Down
6 changes: 5 additions & 1 deletion src/components/Library/GameDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,11 @@ export function GameDetail({ gameId, onBack, onFilter }: GameDetailProps) {
setGame({ ...game, notes: notesValue });
};

const handleDelete = async () => { if (await deleteGame(game.id)) onBack(); };
const handleDelete = async () => {
if (window.confirm(t('confirmDelete'))) {
if (await deleteGame(game.id)) onBack();
}
};

const handlePlayTimeChange = async (hours: number) => {
if (await updatePlayTime(game.id, hours)) {
Expand Down
19 changes: 12 additions & 7 deletions src/components/Library/GameList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -232,22 +232,25 @@ export function GameList({ onSelectGame, searchQuery, activeFilters: externalFil
<div className="flex items-center gap-1 p-1 rounded-lg theme-bg-tertiary border theme-border">
<button
onClick={() => setViewMode("grid")}
className={`p-1.5 rounded ${viewMode === "grid" ? 'theme-accent text-white' : 'theme-text-secondary hover:theme-text-primary'}`}
title="Grid view"
className={`p-1.5 rounded ${viewMode === "grid" ? 'theme-accent text-white' : 'theme-text-secondary hover:theme-text-primary'} focus-visible:ring-2 focus-visible:ring-indigo-500`}
title={t('grid') || "Grid view"}
aria-label={t('grid') || "Grid view"}
>
</button>
<button
onClick={() => setViewMode("list")}
className={`p-1.5 rounded ${viewMode === "list" ? 'theme-accent text-white' : 'theme-text-secondary hover:theme-text-primary'}`}
title="List view"
className={`p-1.5 rounded ${viewMode === "list" ? 'theme-accent text-white' : 'theme-text-secondary hover:theme-text-primary'} focus-visible:ring-2 focus-visible:ring-indigo-500`}
title={t('list') || "List view"}
aria-label={t('list') || "List view"}
>
</button>
<button
onClick={() => setViewMode("compact")}
className={`p-1.5 rounded ${viewMode === "compact" ? 'theme-accent text-white' : 'theme-text-secondary hover:theme-text-primary'}`}
title="Compact view"
className={`p-1.5 rounded ${viewMode === "compact" ? 'theme-accent text-white' : 'theme-text-secondary hover:theme-text-primary'} focus-visible:ring-2 focus-visible:ring-indigo-500`}
title={t('compact') || "Compact view"}
aria-label={t('compact') || "Compact view"}
>
</button>
Expand All @@ -267,7 +270,9 @@ export function GameList({ onSelectGame, searchQuery, activeFilters: externalFil
</select>
<button
onClick={() => setSortOrder(sortOrder === "asc" ? "desc" : "asc")}
className="theme-bg-tertiary theme-text-primary px-3 py-2 rounded-lg border theme-border hover:theme-bg-secondary"
className="theme-bg-tertiary theme-text-primary px-3 py-2 rounded-lg border theme-border hover:theme-bg-secondary focus-visible:ring-2 focus-visible:ring-indigo-500"
aria-label={sortOrder === "asc" ? t('sortAscending') : t('sortDescending')}
title={sortOrder === "asc" ? t('sortAscending') : t('sortDescending')}
>
{sortOrder === "asc" ? "↑" : "↓"}
</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
6 changes: 6 additions & 0 deletions src/i18n/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ export const translations = {
import: 'Import',
grid: 'Grid',
list: 'List',
compact: 'Compact',
sortAscending: 'Sort Ascending',
sortDescending: 'Sort Descending',
name: 'Name',
rating: 'Rating',
dateAdded: 'Date Added',
Expand Down Expand Up @@ -429,6 +432,9 @@ export const translations = {
import: 'Importer',
grid: 'Grille',
list: 'Liste',
compact: 'Compact',
sortAscending: 'Trier par ordre croissant',
sortDescending: 'Trier par ordre décroissant',
name: 'Nom',
rating: 'Note',
dateAdded: 'Date d\'ajout',
Expand Down