Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .Jules/palette.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
52 changes: 39 additions & 13 deletions src/v2/pages/V2SessionSummary.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 (
<div className='v2-page-medium v2-text-center'>
Expand Down Expand Up @@ -208,32 +231,35 @@ 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'
>
<i className='material-icons' aria-hidden='true'>home</i>
Inicio
Inicio <span className='v2-opacity-50' style={{ fontSize: '0.8em', marginLeft: '4px' }}>[I]</span>
</button>

{totalQuestions - correctAnswers > 0 && (
<button
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'
>
<i className='material-icons' aria-hidden='true'>error_outline</i>
Revisar Errores
Revisar Errores <span className='v2-opacity-50' style={{ fontSize: '0.8em', marginLeft: '4px' }}>[R]</span>
</button>
)}

<button
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'
>
<i className='material-icons' aria-hidden='true'>refresh</i>
Nueva Sesión
Nueva Sesión <span className='v2-opacity-50' style={{ fontSize: '0.8em', marginLeft: '4px' }}>[Enter]</span>
</button>
</div>

Expand Down
147 changes: 147 additions & 0 deletions src/v2/pages/V2SessionSummary.test.jsx
Original file line number Diff line number Diff line change
@@ -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(
<MemoryRouter>
<V2SessionSummary />
</MemoryRouter>
);

// 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(
<MemoryRouter>
<V2SessionSummary />
</MemoryRouter>
);

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(
<MemoryRouter>
<V2SessionSummary />
</MemoryRouter>
);

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(
<MemoryRouter>
<V2SessionSummary />
</MemoryRouter>
);

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(
<MemoryRouter>
<V2SessionSummary />
</MemoryRouter>
);

expect(screen.getByText(/\[I\]/)).toBeDefined();
expect(screen.getByText(/\[R\]/)).toBeDefined();
expect(screen.getByText(/\[Enter\]/)).toBeDefined();
});
});
Loading