Skip to content
Merged
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
64 changes: 51 additions & 13 deletions src/app/__tests__/milestones-filter-url.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import MilestonesPage from '../milestones/page';

// Mock next/navigation hooks
jest.mock('next/navigation', () => {
const original = jest.requireActual('next/navigation');
return {
Expand All @@ -13,45 +12,84 @@ jest.mock('next/navigation', () => {
};
});

jest.mock('@/lib/repository', () => ({
listMilestones: jest.fn(() => []),
saveMilestone: jest.fn(),
}));

jest.mock('@/lib/safeStorage', () => ({
getItem: jest.fn(() => null),
setItem: jest.fn(),
}));

import { useSearchParams, useRouter } from 'next/navigation';

describe('Milestones page filter URL sync', () => {
describe('Milestones page URL state sync', () => {
const replaceMock = jest.fn();

beforeEach(() => {
jest.useFakeTimers();
replaceMock.mockReset();
(useRouter as jest.Mock).mockReturnValue({ replace: replaceMock });
});

it('initializes filter from URL query', () => {
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});

it('initializes filter and sort from the URL query', () => {
(useSearchParams as jest.Mock).mockReturnValue({
get: (key: string) => (key === 'status' ? 'Paid' : null),
toString: () => 'status=Paid',
get: (key: string) => (key === 'status' ? 'Paid' : key === 'sort' ? 'oldest' : null),
toString: () => 'status=Paid&sort=oldest',
});

render(<MilestonesPage />);

const paidRadio = screen.getByRole('radio', { name: 'Paid' }) as HTMLInputElement;
const sortSelect = screen.getByLabelText('Sort milestones') as HTMLSelectElement;

expect(paidRadio.checked).toBe(true);
expect(sortSelect.value).toBe('oldest');
});

it('defaults to All for unknown status', () => {
it('defaults to safe values for invalid status and sort params', () => {
(useSearchParams as jest.Mock).mockReturnValue({
get: () => 'Foo',
toString: () => 'status=Foo',
get: (key: string) => (key === 'status' ? 'Bogus' : key === 'sort' ? 'middle' : null),
toString: () => 'status=Bogus&sort=middle',
});

render(<MilestonesPage />);

const allRadio = screen.getByRole('radio', { name: 'All' }) as HTMLInputElement;
const sortSelect = screen.getByLabelText('Sort milestones') as HTMLSelectElement;

expect(allRadio.checked).toBe(true);
expect(sortSelect.value).toBe('newest');
});

it('updates URL when filter changes', async () => {
it('debounces URL updates when filter or sort changes', async () => {
(useSearchParams as jest.Mock).mockReturnValue({
get: () => null,
toString: () => '',
});

render(<MilestonesPage />);
const pendingRadio = screen.getByRole('radio', { name: 'Pending' }) as HTMLInputElement;
fireEvent.click(pendingRadio);

fireEvent.click(screen.getByRole('radio', { name: 'Pending' }));
fireEvent.change(screen.getByLabelText('Sort milestones'), { target: { value: 'oldest' } });

act(() => {
jest.advanceTimersByTime(149);
});
expect(replaceMock).not.toHaveBeenCalled();

act(() => {
jest.advanceTimersByTime(1);
});

await waitFor(() => {
expect(replaceMock).toHaveBeenCalledWith('?status=Pending');
expect(replaceMock).toHaveBeenCalledWith('?status=Pending&sort=oldest');
});
});
});
116 changes: 96 additions & 20 deletions src/app/milestones/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,39 +56,77 @@ export const SAMPLE_MILESTONES: Milestone[] = [
];

const VALID_STATUSES: MilestoneStatusFilter[] = ['All', 'Pending', 'Completed', 'Paid', 'Disputed'];
type MilestoneSortOption = 'newest' | 'oldest';
const VALID_SORT_OPTIONS: MilestoneSortOption[] = ['newest', 'oldest'];

function getValidStatus(param: string | null): MilestoneStatusFilter {
return param && (VALID_STATUSES as string[]).includes(param)
? (param as MilestoneStatusFilter)
: 'All';
}

function getValidSortOption(param: string | null): MilestoneSortOption {
return param && (VALID_SORT_OPTIONS as string[]).includes(param)
? (param as MilestoneSortOption)
: 'newest';
}

const MilestonesContent: React.FC = () => {
const [milestones, setMilestones] = useState<Milestone[]>(SAMPLE_MILESTONES);
const [isDismissed, setIsDismissed] = useState<boolean>(false);
const searchParams = useSearchParams();
const router = useRouter();
const startFromScratchRef = useRef<HTMLButtonElement | null>(null);
const hasAppliedUrlStateRef = useRef(false);

const initialStatus = getValidStatus(searchParams.get('status'));
const initialSort = getValidSortOption(searchParams.get('sort'));
const [statusFilter, setStatusFilter] = useState<MilestoneStatusFilter>(initialStatus);
const [sortOrder, setSortOrder] = useState<MilestoneSortOption>(initialSort);
const [showForm, setShowForm] = useState(false);

// Sync state if searchParams change externally (e.g. back/forward navigation)
useEffect(() => {
const currentParam = searchParams.get('status');
setStatusFilter(getValidStatus(currentParam));
setStatusFilter(getValidStatus(searchParams.get('status')));
setSortOrder(getValidSortOption(searchParams.get('sort')));
}, [searchParams]);

// Sync statusFilter state changes to URL without adding browser history entries
// Sync filter/sort state changes to the URL without adding browser history entries.
useEffect(() => {
const currentUrlStatus = searchParams.get('status');
if (currentUrlStatus !== statusFilter && !(currentUrlStatus === null && statusFilter === 'All')) {
const params = new URLSearchParams(searchParams.toString());
params.set('status', statusFilter);
router.replace(`?${params.toString()}`);
if (!hasAppliedUrlStateRef.current) {
hasAppliedUrlStateRef.current = true;
return;
}
}, [statusFilter, router, searchParams]);

const currentStatusParam = searchParams.get('status');
const currentSortParam = searchParams.get('sort');
const nextStatusParam = statusFilter === 'All' ? null : statusFilter;
const nextSortParam = sortOrder === 'newest' ? null : sortOrder;

if (currentStatusParam === nextStatusParam && currentSortParam === nextSortParam) {
return;
}

const timeoutId = window.setTimeout(() => {
const params = new URLSearchParams(searchParams.toString());
if (nextStatusParam) {
params.set('status', nextStatusParam);
} else {
params.delete('status');
}

if (nextSortParam) {
params.set('sort', nextSortParam);
} else {
params.delete('sort');
}

const query = params.toString();
router.replace(query ? `?${query}` : '?');
}, 150);

return () => window.clearTimeout(timeoutId);
}, [statusFilter, sortOrder, router, searchParams]);

// Rehydrate from localStorage after the client mounts to avoid SSR mismatches.
useEffect(() => {
Expand Down Expand Up @@ -129,6 +167,26 @@ const MilestonesContent: React.FC = () => {
return displayMilestones.filter((m) => m.status === statusFilter);
}, [displayMilestones, statusFilter]);

const sortedMilestones = useMemo(() => {
const nextMilestones = [...filtered];

if (sortOrder === 'oldest') {
nextMilestones.sort((left, right) => {
const leftTime = left.dueDate ? Date.parse(left.dueDate) : Number.POSITIVE_INFINITY;
const rightTime = right.dueDate ? Date.parse(right.dueDate) : Number.POSITIVE_INFINITY;
return leftTime - rightTime;
});
} else {
nextMilestones.sort((left, right) => {
const leftTime = left.dueDate ? Date.parse(left.dueDate) : Number.NEGATIVE_INFINITY;
const rightTime = right.dueDate ? Date.parse(right.dueDate) : Number.NEGATIVE_INFINITY;
return rightTime - leftTime;
});
}

return nextMilestones;
}, [filtered, sortOrder]);

const handleAddMilestone = useCallback(() => {
setShowForm(true);
}, []);
Expand Down Expand Up @@ -196,22 +254,40 @@ const MilestonesContent: React.FC = () => {
/>
) : (
<>
<div className="mb-4 flex items-center justify-between gap-4">
<div className="mb-4 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<MilestoneFilter
selected={statusFilter}
onChange={setStatusFilter}
resultCount={filtered.length}
resultCount={sortedMilestones.length}
/>
<button
type="button"
onClick={handleAddMilestone}
className="flex-shrink-0 rounded-2xl bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700 focus-visible:outline focus-visible:outline-4 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
>
Add Milestone
</button>
<div className="flex flex-wrap items-center gap-3">
<label
htmlFor="milestone-sort"
className="flex items-center gap-2 rounded-2xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-600 shadow-sm"
>
<span className="font-medium text-slate-700">Sort</span>
<select
id="milestone-sort"
aria-label="Sort milestones"
value={sortOrder}
onChange={(event) => setSortOrder(event.target.value as MilestoneSortOption)}
className="rounded-xl border border-slate-200 bg-transparent px-2 py-1 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-blue-500/20"
>
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
</select>
</label>
<button
type="button"
onClick={handleAddMilestone}
className="flex-shrink-0 rounded-2xl bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700 focus-visible:outline focus-visible:outline-4 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
>
Add Milestone
</button>
</div>
</div>

{filtered.length === 0 ? (
{sortedMilestones.length === 0 ? (
<EmptyState
illustration="milestones"
title="No milestones match this filter"
Expand All @@ -220,7 +296,7 @@ const MilestonesContent: React.FC = () => {
onAction={handleAddMilestone}
/>
) : (
<MilestonesList milestones={filtered} />
<MilestonesList milestones={sortedMilestones} />
)}
</>
)}
Expand Down
6 changes: 4 additions & 2 deletions src/components/RouteAnnouncer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ export default function RouteAnnouncer() {
if (prevPathname.current === pathname) return;
prevPathname.current = pathname;

const main = document.querySelector('main');
main?.focus();
const main = document.querySelector<HTMLElement>('main');
requestAnimationFrame(() => {
main?.focus();
});

const h1 = document.querySelector('h1');
const title = h1?.textContent?.trim() || `Page: ${pathname}`;
Expand Down
53 changes: 12 additions & 41 deletions src/components/settings/SettingsPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
'use client';

import React, { useRef, useEffect } from 'react';
import React, { useRef } from 'react';
import { usePreferences, Theme, AmountFormat, ToastDensity } from '@/lib/preferences';
import { useDialogFocusTrap } from '@/hooks/useDialogFocusTrap';

const FOCUSABLE_SELECTORS =

Check failure on line 7 in src/components/settings/SettingsPanel.tsx

View workflow job for this annotation

GitHub Actions / build-and-test

'FOCUSABLE_SELECTORS' is assigned a value but never used. Allowed unused vars must match /^_/u
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';

interface SettingsPanelProps {
Expand All @@ -14,47 +15,15 @@
export function SettingsPanel({ isOpen, onClose }: SettingsPanelProps) {
const { preferences, updatePreference } = usePreferences();
const panelRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);

/**
* Focus management effect for modal dialog accessibility.
* - Sets initial focus to the close button when dialog opens
* - Implements focus trapping to prevent focus from leaving the dialog
* - Handles Tab key wrapping from last to first element
* - Handles Shift+Tab wrapping from first to last element
* - Closes dialog on Escape key press
*/
useEffect(() => {
if (!isOpen) return;
const panel = panelRef.current;
if (!panel) return;

// Set initial focus to the close button
const closeBtn = panel.querySelector<HTMLElement>('[aria-label="Close settings"]');
closeBtn?.focus();

const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
return;
}
if (e.key === 'Tab') {
const els = Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS));
if (els.length === 0) return;
const first = els[0];
const last = els[els.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};

document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
useDialogFocusTrap({
isOpen,
dialogRef: panelRef,
initialFocusRef: closeButtonRef,
onEscape: onClose,
restoreFocus: true,
});

if (!isOpen) return null;

Expand All @@ -72,11 +41,13 @@
role="dialog"
aria-modal="true"
aria-labelledby="settings-panel-title"
tabIndex={-1}
className="relative w-full max-w-md bg-[var(--background)] shadow-xl flex flex-col h-full border-l border-[var(--border)]"
>
<div className="flex items-center justify-between p-6 border-b border-[var(--border)]">
<h2 id="settings-panel-title" className="text-xl font-bold text-[var(--foreground)]">Settings</h2>
<button
ref={closeButtonRef}
onClick={onClose}
className="p-2 rounded-full hover:bg-[var(--accent)] text-[var(--muted-foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--primary)] focus-visible:ring-offset-2"
aria-label="Close settings"
Expand Down
Loading
Loading