fix(search): keep the filter sheet usable while a change lands
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m13s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 32s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m14s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 20s

The sheet's controls were disabled while a filter change navigated, and
a control disabled under focus drops it to <body>, out of the dialog.
They now stay enabled, with aria-busy on the sheet instead.

The disabling had also been covering a race: updateURL built from
useSearchParams, which only catches up once a navigation lands, so a
second change made before then undid the first. It now builds on the
URL the navigation in flight is heading to.

The sheet also closes if the screen widens past phone width while it is
open, so its selects and the desktop row's are never both showing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
TudorandClaude Opus 5.5 committed 2026-10-02 09:40:29 +01:00
1 parent cf3c773f86
commit fa49164143
4 files changed
+118 -14

No files matched your search

@@ -0,0 +1,80 @@
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import { FilterBar } from '@/components/FilterBar';
/*
* While a filter change is navigating, the sheet's controls stay enabled: a
* control disabled under the user's focus drops it to <body>, and a keyboard or
* screen-reader user is thrown out of the sheet after every change. A second
* change made before the first lands must build on the first, not on the URL
* useSearchParams still reports.
*/
// Every transition stays pending, as a slow server render would.
jest.mock('react', () => ({
...jest.requireActual('react'),
useTransition: () => [true, (fn: () => void) => fn()],
}));
const params = new URLSearchParams('postcode=SW196AR&radius=1');
const push = jest.fn();
jest.mock('next/navigation', () => ({
useSearchParams: () => params,
usePathname: () => '/',
useRouter: () => ({ push, replace: jest.fn(), prefetch: jest.fn() }),
}));
jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
const filters = {
local_authorities: ['Wandsworth'], school_types: ['Community school'], years: [],
phases: ['Primary', 'Secondary'], genders: [], admissions_policies: [],
};
const pushedParams = () => new URLSearchParams(push.mock.calls.at(-1)![0].split('?')[1]);
const openSheet = () => {
fireEvent.click(screen.getByRole('button', { name: /^Filters/ }));
return screen.getByRole('dialog', { name: 'Filters' });
};
beforeEach(() => push.mockClear());
it('keeps the sheet usable, and says it is busy, while a change lands', () => {
render(<FilterBar filters={filters} />);
const sheet = openSheet();
for (const name of ['Phase', 'School type', 'Local authority']) {
expect(within(sheet).getByRole('combobox', { name })).toBeEnabled();
}
expect(within(sheet).getByRole('radio', { name: 'Within 3 miles' })).toBeEnabled();
expect(sheet.querySelector('[aria-busy="true"]')).not.toBeNull();
});
it('builds a second change on the first, not on the URL it has not reached', () => {
render(<FilterBar filters={filters} />);
const sheet = openSheet();
fireEvent.change(within(sheet).getByRole('combobox', { name: 'Phase' }), { target: { value: 'primary' } });
fireEvent.change(within(sheet).getByRole('combobox', { name: 'School type' }),
{ target: { value: 'Community school' } });
const next = pushedParams();
expect(next.get('phase')).toBe('primary');
expect(next.get('school_type')).toBe('Community school');
expect(next.get('postcode')).toBe('SW196AR');
});
describe('a screen that widens past phone width', () => {
let listeners: ((e: { matches: boolean }) => void)[] = [];
beforeEach(() => {
listeners = [];
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
matches: true, media: query,
addEventListener: (_: string, l: (e: { matches: boolean }) => void) => listeners.push(l),
removeEventListener: jest.fn(),
}));
});
it('closes the sheet, leaving the desktop row as the only filters', () => {
render(<FilterBar filters={filters} />);
openSheet();
act(() => listeners.forEach((l) => l({ matches: false })));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});