From d557f53d781db31475c758538ec5c5b3c861e010 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:28:21 +0000 Subject: [PATCH] feat: add keyboard shortcuts to V2SessionSummary Implemented Enter, 'i', and 'r' shortcuts for common actions on the session summary page. Added visual hints and enhanced ARIA labels for better discoverability and accessibility. Co-authored-by: godie <227743+godie@users.noreply.github.com> --- .Jules/palette.md | 4 + src/v2/pages/V2SessionSummary.jsx | 49 +++++--- src/v2/pages/V2SessionSummary.test.jsx | 150 +++++++++++++++++++++++++ 3 files changed, 190 insertions(+), 13 deletions(-) create mode 100644 src/v2/pages/V2SessionSummary.test.jsx 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'); + }); +});