diff --git a/docs/components/MilestoneCreationForm.md b/docs/components/MilestoneCreationForm.md index 4d2cc6c0..738fffa8 100644 --- a/docs/components/MilestoneCreationForm.md +++ b/docs/components/MilestoneCreationForm.md @@ -52,7 +52,8 @@ For example, `Frontend Development - Sprint 1` becomes an id shaped like `fronte - The modal root uses `role="dialog"`, `aria-modal="true"`, and `aria-labelledby="create-milestone-title"`. - `ErrorSummary` exposes validation failures through `role="alert"` and links each error back to its field id. - `FormField` applies required markers, `aria-invalid`, `aria-describedby`, and error styling for invalid fields. -- All controls are native form inputs, selects, or buttons, so keyboard operation follows browser defaults. +- All controls are native form inputs, selects, or buttons, so keyboard operation follows browser defaults (Tab, Enter, Space). +- Cancel and Submit expose a visible `focus-visible` outline so keyboard users can track focus without layout changes. - The Cancel button is `type="button"` so it never submits the form. ## Parent Contract diff --git a/docs/components/MilestonesList.md b/docs/components/MilestonesList.md index a3f34e6f..5cc0bd4a 100644 --- a/docs/components/MilestonesList.md +++ b/docs/components/MilestonesList.md @@ -16,7 +16,9 @@ An accessible, dismissible banner is surfaced above the milestones list when the - **Keyboard accessibility**: - The banner container has `role="status"` to announce updates to screen readers. - Links inside the banner target the specific milestone element ID (`#milestone-${id}`) to allow users to jump or scroll directly to the milestone card. + - Dismiss and in-list links use `focus-visible` rings so keyboard focus is visible without changing layout. - Focus is programmatically restored to the scrollable milestones list container when the banner is dismissed, preventing loss of keyboard focus (WCAG 2.1.1). + - Enter and Space activate the dismiss control; Tab reaches links, dismiss, then the scroll region. ## Implementation Details diff --git a/src/app/milestones/page.tsx b/src/app/milestones/page.tsx index 2af82fe3..314358b7 100644 --- a/src/app/milestones/page.tsx +++ b/src/app/milestones/page.tsx @@ -178,7 +178,7 @@ const MilestonesContent: React.FC = () => { type="button" onClick={handleDismissSampleBanner} aria-label="Dismiss sample data notice" - className="text-blue-500 hover:text-blue-700" + className="rounded-sm text-blue-500 hover:text-blue-700 focus-visible:outline focus-visible:outline-4 focus-visible:outline-offset-2 focus-visible:outline-blue-500" > × diff --git a/src/components/MilestonesList.tsx b/src/components/MilestonesList.tsx index 41aab3d9..91258a4d 100644 --- a/src/components/MilestonesList.tsx +++ b/src/components/MilestonesList.tsx @@ -144,7 +144,7 @@ const MilestonesList = ({ milestones, contractCurrency }: MilestonesListProps) = type="button" onClick={handleDismiss} aria-label="Dismiss reminder" - className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-amber-600 hover:bg-amber-100 hover:text-amber-800 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-1 dark:text-amber-400 dark:hover:bg-amber-500/10 dark:hover:text-amber-200 transition-colors" + className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-amber-600 hover:bg-amber-100 hover:text-amber-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-1 dark:text-amber-400 dark:hover:bg-amber-500/10 dark:hover:text-amber-200 transition-colors" > diff --git a/src/components/__tests__/MilestonesKeyboard.test.tsx b/src/components/__tests__/MilestonesKeyboard.test.tsx new file mode 100644 index 00000000..48bef653 --- /dev/null +++ b/src/components/__tests__/MilestonesKeyboard.test.tsx @@ -0,0 +1,360 @@ +/** + * #618 — Milestones keyboard operability + * + * Covers: + * - Visible focus styles on every interactive milestones control + * - Enter / Space activation + * - Logical tab order (filter → Add → due-soon → scroll region) + * - Edge cases: Cancel via keyboard, filter arrow keys, dismiss focus move + */ + +import React from 'react'; +import { act, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { axe } from 'jest-axe'; +import MilestonesPage from '@/app/milestones/page'; +import { MilestoneCreationForm } from '@/components/milestones/MilestoneCreationForm'; +import MilestoneFilter from '@/components/milestones/MilestoneFilter'; +import MilestonesList from '@/components/MilestonesList'; +import { listMilestones, saveMilestone } from '@/lib/repository'; +import { resetCache as resetSafeStorageCache } from '@/lib/safeStorage'; +import type { Milestone } from '@/types/domain'; + +const mockSearchParams = { + get: jest.fn(() => null), + toString: jest.fn(() => ''), +}; +const mockReplace = jest.fn(); + +jest.mock('next/navigation', () => ({ + useSearchParams: () => mockSearchParams, + useRouter: () => ({ replace: mockReplace, push: jest.fn(), prefetch: jest.fn() }), +})); + +jest.mock('@/lib/repository', () => ({ + listMilestones: jest.fn(), + saveMilestone: jest.fn(), +})); + +const mockedListMilestones = jest.mocked(listMilestones); +const mockedSaveMilestone = jest.mocked(saveMilestone); + +const FOCUS_OUTLINE = [ + 'focus-visible:outline', + 'focus-visible:outline-4', + 'focus-visible:outline-offset-2', + 'focus-visible:outline-blue-500', +] as const; + +const persisted: Milestone[] = [ + { + id: 'kb-1', + title: 'Keyboard Kickoff', + status: 'Pending', + payout: 1000, + currency: 'USD', + dueDate: '2026-07-25', + }, + { + id: 'kb-2', + title: 'Keyboard Review', + status: 'Active', + payout: 2000, + currency: 'USD', + dueDate: '2026-08-10', + }, +]; + +async function renderMilestonesPage() { + const result = render(); + await act(async () => {}); + return result; +} + +beforeEach(() => { + // Pin "today" so kb-1 falls inside the due-soon window (7 days). + jest.useFakeTimers({ + now: new Date('2026-07-22T12:00:00Z'), + advanceTimers: true, + }); + mockedListMilestones.mockReturnValue(persisted); + mockedSaveMilestone.mockImplementation(() => {}); + window.localStorage.clear(); + resetSafeStorageCache(); + mockSearchParams.get.mockReturnValue(null); + mockSearchParams.toString.mockReturnValue(''); + mockReplace.mockReset(); +}); + +afterEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + window.localStorage.clear(); + resetSafeStorageCache(); +}); + +// --------------------------------------------------------------------------- +// Visible focus styles +// --------------------------------------------------------------------------- + +describe('milestones keyboard — visible focus styles', () => { + it('Add Milestone toolbar button exposes focus-visible outline classes', async () => { + await renderMilestonesPage(); + + const addBtn = screen.getByRole('button', { name: /^add milestone$/i }); + FOCUS_OUTLINE.forEach((cls) => expect(addBtn).toHaveClass(cls)); + }); + + it('sample banner dismiss × exposes focus-visible outline classes', async () => { + mockedListMilestones.mockReturnValue([]); + await renderMilestonesPage(); + + const dismiss = screen.getByRole('button', { + name: /dismiss sample data notice/i, + }); + FOCUS_OUTLINE.forEach((cls) => expect(dismiss).toHaveClass(cls)); + }); + + it('sample banner Start from scratch exposes focus-visible outline classes', async () => { + mockedListMilestones.mockReturnValue([]); + await renderMilestonesPage(); + + const cta = screen.getByTestId('start-from-scratch-btn'); + FOCUS_OUTLINE.forEach((cls) => expect(cta).toHaveClass(cls)); + }); + + it('MilestoneCreationForm Cancel and Submit expose focus-visible outlines', () => { + render( + , + ); + + const cancel = screen.getByRole('button', { name: /cancel/i }); + const submit = screen.getByRole('button', { name: /add milestone/i }); + + FOCUS_OUTLINE.forEach((cls) => { + expect(cancel).toHaveClass(cls); + expect(submit).toHaveClass(cls); + }); + }); + + it('MilestoneFilter option labels expose focus-within ring classes', () => { + const { container } = render( + , + ); + + const labels = container.querySelectorAll('label'); + expect(labels.length).toBeGreaterThan(0); + labels.forEach((label) => { + expect(label.className).toMatch(/focus-within:ring-2/); + expect(label.className).toMatch(/focus-within:ring-indigo-500/); + }); + }); + + it('due-soon dismiss and in-list links expose focus-visible ring classes', () => { + render(); + + const dismiss = screen.getByRole('button', { name: /dismiss reminder/i }); + expect(dismiss).toHaveClass('focus-visible:ring-2'); + expect(dismiss).toHaveClass('focus-visible:ring-amber-500'); + expect(dismiss.className).not.toMatch(/(?:^|\s)focus:ring-2(?:\s|$)/); + + const link = screen.getByRole('link', { name: /keyboard kickoff/i }); + expect(link).toHaveClass('focus-visible:ring-2'); + }); +}); + +// --------------------------------------------------------------------------- +// Enter / Space activation +// --------------------------------------------------------------------------- + +describe('milestones keyboard — Enter/Space activation', () => { + it('Enter on Add Milestone opens the creation dialog', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + await renderMilestonesPage(); + + const addBtn = screen.getByRole('button', { name: /^add milestone$/i }); + addBtn.focus(); + await user.keyboard('{Enter}'); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('Space on Add Milestone opens the creation dialog', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + await renderMilestonesPage(); + + const addBtn = screen.getByRole('button', { name: /^add milestone$/i }); + addBtn.focus(); + await user.keyboard('[Space]'); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('Enter on sample dismiss × hides the banner', async () => { + mockedListMilestones.mockReturnValue([]); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + await renderMilestonesPage(); + + const dismiss = screen.getByRole('button', { + name: /dismiss sample data notice/i, + }); + dismiss.focus(); + await user.keyboard('{Enter}'); + + expect(screen.queryByTestId('sample-data-banner')).not.toBeInTheDocument(); + }); + + it('Space on sample dismiss × hides the banner', async () => { + mockedListMilestones.mockReturnValue([]); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + await renderMilestonesPage(); + + const dismiss = screen.getByRole('button', { + name: /dismiss sample data notice/i, + }); + dismiss.focus(); + await user.keyboard('[Space]'); + + expect(screen.queryByTestId('sample-data-banner')).not.toBeInTheDocument(); + }); + + it('Enter on Cancel closes the creation dialog', async () => { + const onCancel = jest.fn(); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render(); + + const cancel = screen.getByRole('button', { name: /cancel/i }); + cancel.focus(); + await user.keyboard('{Enter}'); + + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it('Space on Cancel closes the creation dialog', async () => { + const onCancel = jest.fn(); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render(); + + const cancel = screen.getByRole('button', { name: /cancel/i }); + cancel.focus(); + await user.keyboard('[Space]'); + + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it('Space selects a MilestoneFilter radio option', async () => { + const onChange = jest.fn(); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render( + , + ); + + const pending = screen.getByRole('radio', { name: 'Pending' }); + pending.focus(); + await user.keyboard('[Space]'); + + expect(onChange).toHaveBeenCalledWith('Pending'); + }); + + it('Enter/Space dismisses the due-soon reminder and moves focus to the list', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const { container } = render(); + + const dismiss = screen.getByRole('button', { name: /dismiss reminder/i }); + dismiss.focus(); + await user.keyboard('{Enter}'); + + expect( + screen.queryByRole('button', { name: /dismiss reminder/i }), + ).not.toBeInTheDocument(); + + const region = container.querySelector('[role="region"]'); + expect(region).toHaveFocus(); + }); +}); + +// --------------------------------------------------------------------------- +// Tab order +// --------------------------------------------------------------------------- + +describe('milestones keyboard — logical tab order', () => { + it('tabs from filter radios to Add Milestone to due-soon controls to scroll region', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const { container } = await renderMilestonesPage(); + + const allRadio = screen.getByRole('radio', { name: 'All' }); + allRadio.focus(); + expect(allRadio).toHaveFocus(); + + // Within a radiogroup only the checked radio is in the tab order; + // next Tab leaves the group to Add Milestone. + await user.tab(); + expect(screen.getByRole('button', { name: /^add milestone$/i })).toHaveFocus(); + + await user.tab(); + expect(screen.getByRole('link', { name: /keyboard kickoff/i })).toHaveFocus(); + + await user.tab(); + expect(screen.getByRole('button', { name: /dismiss reminder/i })).toHaveFocus(); + + await user.tab(); + const region = container.querySelector('[role="region"]'); + expect(region).toHaveFocus(); + }); + + it('creation form action buttons remain in tab order and can receive focus', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render( + , + ); + + const dialog = screen.getByRole('dialog'); + const cancel = within(dialog).getByRole('button', { name: /cancel/i }); + const submit = within(dialog).getByRole('button', { name: /add milestone/i }); + + expect(cancel).not.toHaveAttribute('tabindex', '-1'); + expect(submit).not.toHaveAttribute('tabindex', '-1'); + + cancel.focus(); + expect(cancel).toHaveFocus(); + await user.tab(); + expect(submit).toHaveFocus(); + }); + + it('arrow keys move selection within the MilestoneFilter radiogroup', async () => { + const onChange = jest.fn(); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render( + , + ); + + screen.getByRole('radio', { name: 'All' }).focus(); + await user.keyboard('{ArrowRight}'); + + expect(onChange).toHaveBeenCalledWith('Active'); + }); +}); + +// --------------------------------------------------------------------------- +// Axe +// --------------------------------------------------------------------------- + +describe('milestones keyboard — axe', () => { + beforeEach(() => { + jest.useRealTimers(); + }); + + it('page with toolbar controls has no axe violations', async () => { + mockedListMilestones.mockReturnValue(persisted); + const { container } = render(); + await act(async () => {}); + expect(await axe(container)).toHaveNoViolations(); + }, 20000); + + it('creation form actions have no axe violations', async () => { + const { container } = render( + , + ); + expect(await axe(container)).toHaveNoViolations(); + }, 20000); +}); diff --git a/src/components/milestones/MilestoneCreationForm.tsx b/src/components/milestones/MilestoneCreationForm.tsx index 2d7d22d9..adc58d63 100644 --- a/src/components/milestones/MilestoneCreationForm.tsx +++ b/src/components/milestones/MilestoneCreationForm.tsx @@ -255,13 +255,13 @@ export const MilestoneCreationForm: React.FC = ({