From 16c2f760f2c07c69260c4f7cdca6b817136eb618 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:35:23 +0000 Subject: [PATCH] feat(v2): add keyboard shortcuts to session summary - Implemented Enter (New Session), i (Dashboard), and r (Review Errors) shortcuts. - Added visual hints [Enter], [i], [r] to button labels. - Enhanced ARIA labels and titles with shortcut information. - Added conflict prevention for Enter key when buttons are focused. - Added unit tests and updated Palette journal. Co-authored-by: godie <227743+godie@users.noreply.github.com> --- .Jules/palette.md | 4 + src/v2/pages/V2SessionSummary.jsx | 50 ++++++++--- src/v2/pages/V2SessionSummary.test.jsx | 117 +++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 13 deletions(-) create mode 100644 src/v2/pages/V2SessionSummary.test.jsx diff --git a/.Jules/palette.md b/.Jules/palette.md index a53889c..7e0aa0d 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-04 - [Shortcut Discoverability and Conflict Prevention in Session Summaries] +**Learning:** Implementing keyboard shortcuts (`Enter`, `i`, `r`) for post-session actions significantly enhances efficiency for power users. Adding visual hints in brackets (e.g., `[Enter]`) directly to labels ensures these shortcuts are discoverable. Crucially, when mapping `Enter` to a primary action like "New Session", checking `e.target.tagName !== 'BUTTON'` prevents conflicts with standard browser button activation, ensuring a predictable experience during keyboard navigation. +**Action:** Always provide visual hints for keyboard shortcuts and implement target checks to avoid conflicts with native browser interactions. + ## 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..c7bdaca 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,38 @@ 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) => { + // Don't trigger if user is typing in an input or textarea + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + + const key = e.key.toLowerCase(); + + if (key === 'enter' && e.target.tagName !== 'BUTTON') { + 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 +229,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 +241,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 +253,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..a5e4a7c --- /dev/null +++ b/src/v2/pages/V2SessionSummary.test.jsx @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'; +import { Router } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; +import V2SessionSummary from './V2SessionSummary'; +import LeaderboardService from '../../services/LeaderboardService'; +import Auth from '../../modules/Auth'; + +vi.mock('../../services/LeaderboardService'); +vi.mock('../../modules/Auth'); + +describe('V2SessionSummary', () => { + const mockLocation = { + state: { + totalQuestions: 10, + correctAnswers: 8, + xpEarned: 450, + timeElapsed: 120 + } + }; + + beforeEach(() => { + vi.clearAllMocks(); + Auth.getUserInfo.mockReturnValue({ id: '1', name: 'García' }); + // Return a never-resolving promise for leaderboard to avoid async state updates after render + LeaderboardService.getTopUsers.mockReturnValue(new Promise(() => {})); + }); + + const renderWithRouter = (state = mockLocation.state) => { + const history = createMemoryHistory(); + history.push({ + pathname: '/simulacro/resumen', + state + }); + const pushSpy = vi.spyOn(history, 'push'); + + render( + + + + ); + return { history, pushSpy }; + }; + + it('renders session statistics correctly', async () => { + renderWithRouter(); + + expect(screen.getAllByText('80%')).toBeDefined(); + expect(screen.getByText('+450')).toBeDefined(); + expect(screen.getByText('02:00')).toBeDefined(); + expect(screen.getByText('8/10')).toBeDefined(); + expect(screen.getByText('2/10')).toBeDefined(); + await waitFor(() => {}); + }); + + it('navigates to dashboard on "Inicio" click', async () => { + const { pushSpy } = renderWithRouter(); + + const dashboardBtn = screen.getByLabelText('Volver al inicio (atajo: i)'); + fireEvent.click(dashboardBtn); + + expect(pushSpy).toHaveBeenCalledWith('/dashboard'); + await waitFor(() => {}); + }); + + it('navigates to dashboard on "i" key press', async () => { + const { pushSpy } = renderWithRouter(); + + fireEvent.keyDown(window, { key: 'i' }); + + expect(pushSpy).toHaveBeenCalledWith('/dashboard'); + await waitFor(() => {}); + }); + + it('navigates to practice on "Nueva Sesión" click', async () => { + const { pushSpy } = renderWithRouter(); + + const newSessionBtn = screen.getByLabelText('Nueva sesión de práctica (atajo: Enter)'); + fireEvent.click(newSessionBtn); + + expect(pushSpy).toHaveBeenCalledWith('/practica'); + await waitFor(() => {}); + }); + + it('navigates to practice on "Enter" key press', async () => { + const { pushSpy } = renderWithRouter(); + + fireEvent.keyDown(window, { key: 'Enter' }); + + expect(pushSpy).toHaveBeenCalledWith('/practica'); + await waitFor(() => {}); + }); + + it('navigates to error review on "r" key press when there are mistakes', async () => { + const { pushSpy } = renderWithRouter({ + totalQuestions: 10, + correctAnswers: 8 + }); + + fireEvent.keyDown(window, { key: 'r' }); + + expect(pushSpy).toHaveBeenCalledWith('/errores'); + await waitFor(() => {}); + }); + + it('does not navigate to error review on "r" key press when there are no mistakes', async () => { + const { pushSpy } = renderWithRouter({ + totalQuestions: 10, + correctAnswers: 10 + }); + + fireEvent.keyDown(window, { key: 'r' }); + + expect(pushSpy).not.toHaveBeenCalledWith('/errores'); + await waitFor(() => {}); + }); +});