diff --git a/.Jules/palette.md b/.Jules/palette.md index a53889c..2a643b5 100644 --- a/.Jules/palette.md +++ b/.Jules/palette.md @@ -124,6 +124,10 @@ **Learning:** For progress indicators to be truly accessible, they must include `role="progressbar"` along with `aria-valuenow`, `aria-valuemin`, and `aria-valuemax` attributes, allowing screen readers to accurately report status. In side-navigation rails, ensuring decorative icons are marked with `aria-hidden="true"` and the active link has `aria-current="page"` significantly improves screen reader navigation. Additionally, maintaining localization consistency (e.g., 'Inicio' vs 'Home') in new UI versions (V2) is essential for a cohesive user experience. **Action:** Always implement full ARIA attributes for progress components and audit new navigation structures for both accessibility markers and language consistency. +## 2025-02-27 - [Discoverable Shortcuts and Contextual Actions in Session Summaries] +**Learning:** Adding keyboard shortcuts (`Enter`, `I`, `R`) to session summary pages significantly streamlines the transition back to core study loops. To ensure these shortcuts are discoverable, visual hints in brackets (e.g., `[Enter]`) should be integrated into button labels, complemented by descriptive `aria-label` and `title` attributes. Furthermore, contextual actions (like "Revisar Errores") should only be keyboard-accessible when the underlying condition (presence of errors) is met, preventing non-functional shortcut triggers. +**Action:** Implement bracketed shortcut hints for primary post-session actions and ensure keyboard listeners respect the same conditional logic as the UI. + ## 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. diff --git a/src/v2/pages/V2SessionSummary.jsx b/src/v2/pages/V2SessionSummary.jsx index 9911c46..f203770 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,40 @@ 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; + if (loading) return; + + const key = e.key.toLowerCase(); + const hasErrors = totalQuestions - correctAnswers > 0; + + if (key === 'enter') { + e.preventDefault(); + handleNewSession(); + } else if (key === 'i') { + handleGoToDashboard(); + } else if (key === 'r' && hasErrors) { + handleReviewMistakes(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [loading, totalQuestions, correctAnswers, handleNewSession, handleGoToDashboard, handleReviewMistakes]); return (
@@ -208,10 +231,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 +243,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 +255,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..eb09def --- /dev/null +++ b/src/v2/pages/V2SessionSummary.test.jsx @@ -0,0 +1,147 @@ +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 '../pages/V2SessionSummary'; +import LeaderboardService from '../../services/LeaderboardService'; + +// Standard mock for useLocation +const mockLocation = { + state: { + totalQuestions: 10, + correctAnswers: 8, + xpEarned: 400, + timeElapsed: 600 + } +}; + +const mockPush = vi.fn(); + +// Mock react-router-dom +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useHistory: () => ({ + push: mockPush + }), + useLocation: () => mockLocation + }; +}); + +// Mock services +vi.mock('../../services/LeaderboardService', () => ({ + default: { + getTopUsers: vi.fn() + } +})); + +vi.mock('../../modules/Auth', () => ({ + default: { + getUserInfo: vi.fn(() => ({ name: 'García', id: 1 })) + } +})); + +describe('V2SessionSummary Shortcuts', () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Reset location state to default with errors + mockLocation.state = { + totalQuestions: 10, + correctAnswers: 8, + xpEarned: 400, + timeElapsed: 600 + }; + }); + + it('navigates to practice on Enter key', async () => { + LeaderboardService.getTopUsers.mockResolvedValue({ data: [] }); + render( + + + + ); + + // Wait for initial load to finish (loading=false) + await waitFor(() => { + expect(screen.queryByText('...')).toBeNull(); + }); + + fireEvent.keyDown(window, { key: 'Enter' }); + + expect(mockPush).toHaveBeenCalledWith('/practica'); + }); + + it('navigates to dashboard on "i" key', async () => { + LeaderboardService.getTopUsers.mockResolvedValue({ data: [] }); + render( + + + + ); + + await waitFor(() => { + expect(screen.queryByText('...')).toBeNull(); + }); + + fireEvent.keyDown(window, { key: 'i' }); + + expect(mockPush).toHaveBeenCalledWith('/dashboard'); + }); + + it('navigates to mistakes on "r" key when there are errors', async () => { + LeaderboardService.getTopUsers.mockResolvedValue({ data: [] }); + render( + + + + ); + + await waitFor(() => { + expect(screen.queryByText('...')).toBeNull(); + }); + + fireEvent.keyDown(window, { key: 'r' }); + + expect(mockPush).toHaveBeenCalledWith('/errores'); + }); + + it('does not navigate to mistakes on "r" key when there are NO errors', async () => { + LeaderboardService.getTopUsers.mockResolvedValue({ data: [] }); + mockLocation.state = { + totalQuestions: 10, + correctAnswers: 10, + xpEarned: 500, + timeElapsed: 600 + }; + + render( + + + + ); + + await waitFor(() => { + expect(screen.queryByText('...')).toBeNull(); + }); + + fireEvent.keyDown(window, { key: 'r' }); + + expect(mockPush).not.toHaveBeenCalledWith('/errores'); + }); + + it('displays shortcut hints in button labels', async () => { + // Avoid state update after test by using a never-resolving promise + LeaderboardService.getTopUsers.mockReturnValue(new Promise(() => {})); + + render( + + + + ); + + expect(screen.getByText(/\[I\]/)).toBeDefined(); + expect(screen.getByText(/\[R\]/)).toBeDefined(); + expect(screen.getByText(/\[Enter\]/)).toBeDefined(); + }); +});