diff --git a/.Jules/palette.md b/.Jules/palette.md new file mode 100644 index 0000000..f757b8d --- /dev/null +++ b/.Jules/palette.md @@ -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. diff --git a/src/components/Library/GameCard.tsx b/src/components/Library/GameCard.tsx index f628ab4..9b2497e 100644 --- a/src/components/Library/GameCard.tsx +++ b/src/components/Library/GameCard.tsx @@ -18,7 +18,25 @@ const GameCard: React.FC = ({ 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 = { + 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 ? ( + + {platform.startsWith('ps') ? '๐ŸŽฎ' : platform.startsWith('xbox') ? '๐ŸŽฏ' : platform.startsWith('nintendo') ? '๐Ÿ•น๏ธ' : platform === 'pc' ? '๐Ÿ’ป' : platform === 'mobile' ? '๐Ÿ“ฑ' : '๐Ÿ“Ÿ'} + + ) : null; const renderTags = () => { if (tags.length === 0) return null; @@ -26,26 +44,29 @@ const GameCard: React.FC = ({ game, onClick, viewMode, onFilter, 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`} + 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} @@ -78,11 +99,19 @@ const GameCard: React.FC = ({ 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} @@ -136,11 +165,19 @@ const GameCard: React.FC = ({ 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} @@ -163,18 +200,26 @@ const GameCard: React.FC = ({ 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 (
{ // 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); }} > @@ -235,7 +280,9 @@ 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')) { @@ -339,7 +386,9 @@ 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')) { diff --git a/src/components/Library/GameDetail.tsx b/src/components/Library/GameDetail.tsx index 6e12f43..e7e78d6 100644 --- a/src/components/Library/GameDetail.tsx +++ b/src/components/Library/GameDetail.tsx @@ -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)) { diff --git a/src/components/Library/GameList.tsx b/src/components/Library/GameList.tsx index 3944f9c..c3b2490 100644 --- a/src/components/Library/GameList.tsx +++ b/src/components/Library/GameList.tsx @@ -232,22 +232,25 @@ export function GameList({ onSelectGame, searchQuery, activeFilters: externalFil
@@ -267,7 +270,9 @@ export function GameList({ onSelectGame, searchQuery, activeFilters: externalFil 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) { diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index 1664377..669d1d6 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -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', @@ -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',