diff --git a/.Jules/palette.md b/.Jules/palette.md index a53889c..062ee69 100644 --- a/.Jules/palette.md +++ b/.Jules/palette.md @@ -127,3 +127,7 @@ ## 2025-06-17 - [Accessible Checkboxes and Consistent Prop Spreading] **Learning:** Enhancing base form components like `CustomCheckbox` with standardized `required` indicators (visual asterisk and `aria-required`) and grid support (`s`, `m`, `l`, `xl`, `offset`) improves both accessibility and developer productivity. Ensuring that `...props` are consistently applied to the inner `input` regardless of the presence of a wrapper `div` maintains a predictable component API. **Action:** Always provide visual and semantic cues for mandatory fields and ensure consistent prop-spreading behavior in multi-layered components. + +## 2025-06-27 - [Keyboard Shortcuts and Discoverability in Session Summaries] +**Learning:** For terminal screens like session summaries, adding keyboard shortcuts (Enter for repetition, 'i' for navigation) significantly streamlines the user loop. Discoverability is key: using bracketed visual hints like `[Enter]` and `[i]` directly in button labels, paired with state-aware shortcuts (e.g., only enabling 'r' for review if errors exist), creates a powerful yet accessible interface. +**Action:** Implement keyboard shortcuts for primary actions on all summary and high-traffic screens, ensuring they are discoverable via visual hints and accessible via ARIA labels. diff --git a/src/v2/pages/V2SessionSummary.jsx b/src/v2/pages/V2SessionSummary.jsx index 9911c46..d7acf6c 100644 --- a/src/v2/pages/V2SessionSummary.jsx +++ b/src/v2/pages/V2SessionSummary.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo, useCallback } from 'react'; import { useHistory, useLocation, Link } from 'react-router-dom'; import LeaderboardService from '../../services/LeaderboardService'; import Auth from '../../modules/Auth'; @@ -87,17 +87,37 @@ const V2SessionSummary = () => { return 'var(--md-sys-color-error)'; }; - const handleNewSession = () => { + const handleNewSession = useCallback(() => { history.push('/practica'); - }; + }, [history]); - const handleGoToDashboard = () => { + const handleGoToDashboard = useCallback(() => { history.push('/dashboard'); - }; + }, [history]); - const handleReviewMistakes = () => { + const handleReviewMistakes = useCallback(() => { history.push('/errores'); - }; + }, [history]); + + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e) => { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + + const key = e.key.toLowerCase(); + + if (key === 'enter') { + handleNewSession(); + } else if (key === 'i') { + handleGoToDashboard(); + } else if (key === 'r' && totalQuestions - correctAnswers > 0) { + handleReviewMistakes(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [handleNewSession, handleGoToDashboard, handleReviewMistakes, totalQuestions, correctAnswers]); return (
@@ -208,10 +228,11 @@ const V2SessionSummary = () => { className='v2-btn-tonal v2-btn-h-56' onClick={handleGoToDashboard} style={{ padding: '0 32px' }} - aria-label='Volver al inicio' + aria-label='Volver al inicio (atajo: i)' + title='Atajo: i' > - Inicio + Inicio [i] {totalQuestions - correctAnswers > 0 && ( @@ -219,10 +240,11 @@ const V2SessionSummary = () => { className='v2-btn-tonal v2-btn-h-56' onClick={handleReviewMistakes} style={{ padding: '0 32px' }} - aria-label='Revisar errores' + aria-label='Revisar errores (atajo: r)' + title='Atajo: r' > - Revisar Errores + Revisar Errores [r] )} @@ -230,10 +252,11 @@ const V2SessionSummary = () => { className='v2-btn-filled v2-btn-h-56' onClick={handleNewSession} style={{ padding: '0 32px' }} - aria-label='Nueva sesión de práctica' + aria-label='Nueva sesión de práctica (atajo: Enter)' + title='Atajo: Enter' > - Nueva Sesión + Nueva Sesión [Enter]
diff --git a/src/v2/pages/V2SessionSummary.test.jsx b/src/v2/pages/V2SessionSummary.test.jsx new file mode 100644 index 0000000..2a2e441 --- /dev/null +++ b/src/v2/pages/V2SessionSummary.test.jsx @@ -0,0 +1,150 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import V2SessionSummary from './V2SessionSummary'; +import LeaderboardService from '../../services/LeaderboardService'; +import Auth from '../../modules/Auth'; + +// Use vi.hoisted for variables used in vi.mock +const { mockHistoryPush } = vi.hoisted(() => ({ + mockHistoryPush: vi.fn() +})); + +// Mock services +vi.mock('../../services/LeaderboardService', () => ({ + default: { + getTopUsers: vi.fn().mockResolvedValue({ data: [] }) + } +})); + +vi.mock('../../modules/Auth', () => ({ + default: { + getUserInfo: vi.fn().mockReturnValue({ id: '123', name: 'Test User' }) + } +})); + +// Mock react-router-dom hooks +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useHistory: () => ({ + push: mockHistoryPush + }), + useLocation: vi.fn().mockReturnValue({ + state: { + totalQuestions: 10, + correctAnswers: 8, + xpEarned: 400, + timeElapsed: 120, + userId: '123' + } + }) + }; +}); + +describe('V2SessionSummary', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders session statistics correctly', async () => { + render( + + + + ); + + // Wait for leaderboard loading to finish to avoid act() warnings + await waitFor(() => { + expect(screen.queryByText('...')).toBeNull(); + }); + + expect(screen.getAllByText('80%')).toHaveLength(2); + expect(screen.getByText('+400')).toBeTruthy(); + expect(screen.getByText('02:00')).toBeTruthy(); + expect(screen.getByText('8/10')).toBeTruthy(); + expect(screen.getByText('2/10')).toBeTruthy(); + }); + + it('shows visual hints for keyboard shortcuts', async () => { + render( + + + + ); + + await waitFor(() => expect(screen.queryByText('...')).toBeNull()); + + expect(screen.getByText('[i]')).toBeTruthy(); + expect(screen.getByText('[r]')).toBeTruthy(); + expect(screen.getByText('[Enter]')).toBeTruthy(); + + expect(screen.getByLabelText(/Volver al inicio \(atajo: i\)/)).toBeTruthy(); + expect(screen.getByLabelText(/Revisar errores \(atajo: r\)/)).toBeTruthy(); + expect(screen.getByLabelText(/Nueva sesión de práctica \(atajo: Enter\)/)).toBeTruthy(); + }); + + it('navigates to dashboard when "i" key is pressed', async () => { + render( + + + + ); + + await waitFor(() => expect(screen.queryByText('...')).toBeNull()); + + fireEvent.keyDown(window, { key: 'i' }); + expect(mockHistoryPush).toHaveBeenCalledWith('/dashboard'); + }); + + it('navigates to practice when "Enter" key is pressed', async () => { + render( + + + + ); + + await waitFor(() => expect(screen.queryByText('...')).toBeNull()); + + fireEvent.keyDown(window, { key: 'Enter' }); + expect(mockHistoryPush).toHaveBeenCalledWith('/practica'); + }); + + it('navigates to errors when "r" key is pressed', async () => { + render( + + + + ); + + await waitFor(() => expect(screen.queryByText('...')).toBeNull()); + + fireEvent.keyDown(window, { key: 'r' }); + expect(mockHistoryPush).toHaveBeenCalledWith('/errores'); + }); + + it('does not navigate to errors when "r" key is pressed and there are no errors', async () => { + const { useLocation } = await import('react-router-dom'); + useLocation.mockReturnValue({ + state: { + totalQuestions: 10, + correctAnswers: 10, + xpEarned: 500, + timeElapsed: 100, + userId: '123' + } + }); + + render( + + + + ); + + await waitFor(() => expect(screen.queryByText('...')).toBeNull()); + + fireEvent.keyDown(window, { key: 'r' }); + expect(mockHistoryPush).not.toHaveBeenCalledWith('/errores'); + }); +});