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'
>
home
- 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'
>
error_outline
- 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'
>
refresh
- 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();
+ });
+});