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-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.
50 changes: 37 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,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 (
<div className='v2-page-medium v2-text-center'>
Expand Down Expand Up @@ -208,32 +229,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
117 changes: 117 additions & 0 deletions src/v2/pages/V2SessionSummary.test.jsx
Original file line number Diff line number Diff line change
@@ -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(
<Router history={history}>
<V2SessionSummary />
</Router>
);
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(() => {});
});
});
Loading