diff --git a/src/app/[locale]/app/commitments/CommitmentsClient.tsx b/src/app/[locale]/app/commitments/CommitmentsClient.tsx index b3b2162..e11c32c 100644 --- a/src/app/[locale]/app/commitments/CommitmentsClient.tsx +++ b/src/app/[locale]/app/commitments/CommitmentsClient.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo, useOptimistic, useState, useTransition } from 'react'; +import { useEffect, useMemo, useOptimistic, useState, useTransition } from 'react'; import { Check, Pencil, Plus, Trash2 } from 'lucide-react'; import { useTranslations } from 'next-intl'; @@ -101,6 +101,23 @@ export function CommitmentsClient({ setFormMode('closed'); } + // When the form opens (create OR edit), bring it into view and move focus into + // it. Without this, clicking the pencil on a row far down the list opens the + // form at the TOP of the page — off-screen, with no change for a sighted user + // and no cue for keyboard/screen-reader users (dashboard-ux-auditor P1). + useEffect(() => { + if (formMode === 'closed') return; + // `preventScroll` so focus doesn't jump instantly — the scroll below does + // the moving. `scrollIntoView` is guarded (jsdom doesn't implement it). + document.getElementById('commitment-label')?.focus({ preventScroll: true }); + // Respect prefers-reduced-motion (WCAG 2.3.3): no smooth animation for users + // who asked to reduce motion — jump instantly instead (Codex review). + const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + document + .getElementById('commitments-form') + ?.scrollIntoView?.({ behavior: reduceMotion ? 'auto' : 'smooth', block: 'start' }); + }, [formMode, editingId]); + // Optimistic paid ledger: a Set of `${commitmentId}|${periodKey}`. The reducer // takes an EXPLICIT intent (`{key, paid}`) rather than a blind toggle so a `+` // always adds and a `−` always removes — idempotent even on a stale read, so a diff --git a/src/app/[locale]/app/commitments/__tests__/CommitmentsClient.test.tsx b/src/app/[locale]/app/commitments/__tests__/CommitmentsClient.test.tsx index 805f100..b1ed629 100644 --- a/src/app/[locale]/app/commitments/__tests__/CommitmentsClient.test.tsx +++ b/src/app/[locale]/app/commitments/__tests__/CommitmentsClient.test.tsx @@ -346,6 +346,14 @@ describe('', () => { expect(createMock).not.toHaveBeenCalled(); }); + it('moves focus into the form when the pencil opens edit', async () => { + renderPage([carLoan]); + fireEvent.click(screen.getByTestId('commitment-edit-car')); + // The effect focuses the label field so keyboard/SR users land in the form + // even when it opened off-screen at the top of a long list (ux-auditor P1). + await waitFor(() => expect(screen.getByLabelText('Libellé')).toHaveFocus()); + }); + it('blocks reducing installmentsTotal below the number already ticked', async () => { updateMock.mockResolvedValue({ ok: true }); renderPage([carLoan], { paidKeysByCommitment: { car: ['2026-1', '2026-2', '2026-3'] } }); diff --git a/src/components/commitments/InstallmentStepper.tsx b/src/components/commitments/InstallmentStepper.tsx index ba26335..775813d 100644 --- a/src/components/commitments/InstallmentStepper.tsx +++ b/src/components/commitments/InstallmentStepper.tsx @@ -18,7 +18,7 @@ type Props = { }; const BTN = - 'focus-visible:ring-brand-600 border-border hover:border-brand-600 flex size-11 shrink-0 items-center justify-center rounded-full border-2 transition-colors [-webkit-tap-highlight-color:transparent] focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-40 md:size-9'; + 'focus-visible:ring-brand-600 border-border hover:border-brand-600 flex size-11 shrink-0 items-center justify-center rounded-full border-2 transition-colors [-webkit-tap-highlight-color:transparent] focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:size-9'; /** * Payment stepper for a finite commitment — « X / N payées » with − / +.