From ced507f19dedea0d519cc51b384ca05cdb74c247 Mon Sep 17 00:00:00 2001 From: kimanicodee Date: Wed, 29 Apr 2026 12:47:59 +0300 Subject: [PATCH] fix: improve frontend accessibility roles --- .../__tests__/AiPersonalityModal.test.tsx | 83 +++++++++++++++++++ .../matchmaking/AiPersonalityModal.tsx | 39 ++++++++- frontend/app/layout.tsx | 2 +- frontend/app/page.tsx | 26 +++--- frontend/app/puzzles/page.tsx | 40 ++++----- frontend/components/GameModeButtons.tsx | 18 ++-- frontend/types/react-icons.d.ts | 44 ++++++++++ 7 files changed, 206 insertions(+), 46 deletions(-) create mode 100644 frontend/__tests__/AiPersonalityModal.test.tsx create mode 100644 frontend/types/react-icons.d.ts diff --git a/frontend/__tests__/AiPersonalityModal.test.tsx b/frontend/__tests__/AiPersonalityModal.test.tsx new file mode 100644 index 00000000..b6b36ee8 --- /dev/null +++ b/frontend/__tests__/AiPersonalityModal.test.tsx @@ -0,0 +1,83 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { AiPersonalityModal } from "@/app/components/matchmaking/AiPersonalityModal"; + +const setAiPersonality = vi.fn(); + +vi.mock("@/context/matchmakingContext", () => ({ + useMatchmakingContext: () => ({ + aiPersonality: "defensive", + setAiPersonality, + chessVariant: "standard", + }), +})); + +vi.mock("@/lib/chessVariants", () => ({ + getChessVariantById: () => ({ + label: "Standard", + description: "Classic chess rules.", + averageGameTime: "10 min", + }), +})); + +describe("AiPersonalityModal accessibility", () => { + beforeEach(() => { + setAiPersonality.mockClear(); + }); + + it("renders a labelled modal dialog with descriptive copy", () => { + render( + undefined} + onConfirm={() => undefined} + />, + ); + + const dialog = screen.getByRole("dialog", { name: "Finalize Match Setup" }); + + expect(dialog).toHaveAttribute("aria-modal", "true"); + expect(dialog).toHaveAccessibleDescription( + "Lock in your preferred chess format and AI co-pilot before matchmaking starts.", + ); + }); + + it("exposes personality choices as pressed buttons", () => { + render( + undefined} + onConfirm={() => undefined} + />, + ); + + expect(screen.getByRole("button", { name: /^select defensive/i })).toHaveAttribute( + "aria-pressed", + "true", + ); + expect(screen.getByRole("button", { name: /^select aggressive/i })).toHaveAttribute( + "aria-pressed", + "false", + ); + + fireEvent.click(screen.getByRole("button", { name: /^select sacrificial/i })); + + expect(setAiPersonality).toHaveBeenCalledWith("sacrificial"); + }); + + it("closes with the Escape key", () => { + const onClose = vi.fn(); + + render( + undefined} + />, + ); + + fireEvent.keyDown(window, { key: "Escape" }); + + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/app/components/matchmaking/AiPersonalityModal.tsx b/frontend/app/components/matchmaking/AiPersonalityModal.tsx index 5b994ec7..e240d460 100644 --- a/frontend/app/components/matchmaking/AiPersonalityModal.tsx +++ b/frontend/app/components/matchmaking/AiPersonalityModal.tsx @@ -107,20 +107,46 @@ export function AiPersonalityModal({ useMatchmakingContext(); const selectedVariant = getChessVariantById(chessVariant); + React.useEffect(() => { + if (!isOpen) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [isOpen, onClose]); + if (!isOpen) return null; return ( -
+
{/* Backdrop */} {/* Personality cards */} -
+
{PERSONALITIES.map((option) => { const isSelected = aiPersonality === option.id; return (
{/* Selection indicator */} {/* Hint Display */} {showHint && selectedPuzzle.hint && ( -
+

- πŸ’‘ {selectedPuzzle.hint} + {selectedPuzzle.hint}

)} @@ -390,7 +394,7 @@ export default function PuzzlesPage() { aria-live="assertive" >

- {isCorrect ? 'βœ… Correct! Well done!' : '❌ Not quite right. Try again!'} + {isCorrect ? 'Correct! Well done!' : 'Not quite right. Try again!'}

)} @@ -436,19 +440,17 @@ export default function PuzzlesPage() { {MOCK_PUZZLES.map((puzzle, idx) => { const isCompleted = completedPuzzles.has(puzzle.id); return ( -
handlePuzzleSelect(puzzle)} - onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handlePuzzleSelect(puzzle); } }} - role="button" - tabIndex={0} - aria-label={`${puzzle.title} β€” ${puzzle.difficulty} difficulty${isCompleted ? ', completed' : ''}`} + aria-label={`${puzzle.title}, ${puzzle.difficulty} difficulty${isCompleted ? ', completed' : ''}`} >

{puzzle.title}

@@ -466,7 +468,7 @@ export default function PuzzlesPage() { Puzzle #{puzzle.id} +0.01 XLM
-
+ ); })}
diff --git a/frontend/components/GameModeButtons.tsx b/frontend/components/GameModeButtons.tsx index f281d053..04a5abff 100644 --- a/frontend/components/GameModeButtons.tsx +++ b/frontend/components/GameModeButtons.tsx @@ -34,10 +34,10 @@ const GameModeButtons: React.FC = ({ setGameMode }) => {
@@ -61,10 +61,10 @@ const GameModeButtons: React.FC = ({ setGameMode }) => {
@@ -86,10 +86,10 @@ const GameModeButtons: React.FC = ({ setGameMode }) => {
diff --git a/frontend/types/react-icons.d.ts b/frontend/types/react-icons.d.ts new file mode 100644 index 00000000..43fc403d --- /dev/null +++ b/frontend/types/react-icons.d.ts @@ -0,0 +1,44 @@ +declare module "react-icons/fa" { + import type { ComponentType, SVGProps } from "react"; + + export type IconType = ComponentType>; + + export const FaBolt: IconType; + export const FaArrowLeft: IconType; + export const FaBrain: IconType; + export const FaCheck: IconType; + export const FaChartLine: IconType; + export const FaCheckCircle: IconType; + export const FaChess: IconType; + export const FaChessBoard: IconType; + export const FaClock: IconType; + export const FaCrown: IconType; + export const FaExclamationTriangle: IconType; + export const FaEye: IconType; + export const FaFire: IconType; + export const FaGamepad: IconType; + export const FaHistory: IconType; + export const FaPlay: IconType; + export const FaRedo: IconType; + export const FaRobot: IconType; + export const FaSearch: IconType; + export const FaShieldAlt: IconType; + export const FaSignal: IconType; + export const FaSpinner: IconType; + export const FaStar: IconType; + export const FaTimes: IconType; + export const FaTrophy: IconType; + export const FaUser: IconType; + export const FaUsers: IconType; + export const FaWallet: IconType; +} + +declare module "react-icons/ri" { + import type { ComponentType, SVGProps } from "react"; + + export type IconType = ComponentType>; + + export const RiAiGenerate: IconType; + export const RiAliensFill: IconType; + export const RiRobot2Line: IconType; +}