Compare commits

...
Author SHA1 Message Date
TudorandClaude Opus 5.5 68452681f8 fix(search): match primary phases by their words, not exact labels
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m20s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 12s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 18s
The secondary-only filters were hidden for an exact set of lowercased
labels, so a change of case, hyphen or spacing in a GIAS phase label
would have left them showing. Phases are now matched on their words, as
compareGroups already reads "primary".

The E2E journey no longer counts school types: it asserts that choosing
one leaves the same list on offer, whatever the data holds. A test now
pins that a filter with no options is left out, not shown with only its
"any" option.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:28:34 +01:00
TudorandClaude Opus 5.5 0cc4f52816 fix(search): offer every filter option, not only those in the results
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m17s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m22s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 15s
School type, gender and admissions took their options from the result
set, which the filter had already narrowed: choose "Girls" and only
"Girls" was offered, so switching to "Boys" meant clearing first. They
now offer the full lists from /api/filters, as phase already did. Local
authority stays scoped to the results, so a postcode search offers the
councils nearby rather than all 153.

Whether gender, sixth form and admissions show was also decided by the
results (any secondary school in them). It is now decided by the phase
alone: hidden for Primary, Nursery and Middle deemed primary, shown
otherwise. Choosing one of those phases clears the three filters, which
would otherwise stay applied with no control showing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:22:39 +01:00
tudor eb13ab0b5e Merge pull request 'feat(search): filter on phones through one sheet' (#168) from feat/mobile-filter-sheet into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 44s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m25s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 2m14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 4s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 3m4s
Reviewed-on: #168
2026-10-02 08:51:17 +00:00
TudorandClaude Opus 5.5 fa49164143 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>
2026-10-02 09:40:29 +01:00
TudorandClaude Opus 5.5 cf3c773f86 feat(search): filter on phones through one sheet
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 35s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m18s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m15s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 22s
On phones the results toolbar's filters were a sideways-scrolling row
led by "More filters", so phase showed only in part and school type not
at all, and the panel "More filters" opened held neither of them.

Phones now get a single Filters button beside the folded search summary,
counting every applied filter. It opens a bottom sheet with every filter:
distance as five segments, then phase, school type, local authority and
the secondary-only filters. Changes apply at once, as on desktop, so the
footer's "Show N schools" only closes the sheet. Applied filters show as
removable chips on a second line, which appears only when something is
applied. Desktop and tablet are unchanged.

Modal gains dialog semantics, a pinned footer and focus handling, and
moves above the pinned toolbar, the floating List/Map button and the
comparison toast, which its old z-index sat beneath.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:31:53 +01:00
tudor 4b54c25943 Merge pull request 'test(e2e): scroll a long list, and wait out the popup crossfade' (#167) from fix/e2e-after-half-mile-default into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 19s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m24s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 27s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 3m3s
Reviewed-on: #167
2026-10-01 21:22:34 +00:00
TudorandClaude Opus 5.5 e7645d1ba5 test(e2e): scroll a long list, and wait out the popup crossfade
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m17s
PR Checks / Backend Smoke (pull_request) Successful in 11s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m31s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 12s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 13s
The pinned-toolbar journeys scrolled the results of a typed B1 1BB search.
At the new half-mile default that is two schools, so the page ended before
the toolbar had anything to stay pinned over (desktop scrollY stopped at
229; on phones the scroll reached the footer). They now open the search
at a mile, which is what they were measuring.

Leaflet crossfades popups for 200ms, so for that window there are two
.sc-popup elements, and a strict locator fails at once rather than
retrying. Wait for one popup before reading it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:43:06 +01:00
tudor 29b5f85952 Merge pull request 'fix(map): draw the map card's View button like the list's' (#166) from fix/map-popup-view-button into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 0s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 20s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m23s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 29s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 4m4s
Reviewed-on: #166
2026-10-01 20:17:27 +00:00
13 changed files with 1231 additions and 183 deletions

No files matched your search

+1
View File
@@ -44,6 +44,7 @@ Their previous implementations remain recoverable from Git history.
| `nextjs-app/components/SchoolCard.tsx` and its CSS | Imported by its own tests, not application code. HomeView uses SchoolRow/SecondarySchoolRow. | Decide whether to retire the card design; if removed, remove its dedicated tests as well. Passing tests do not establish runtime use. |
| `backend/database.py: get_db`, `get_db_session` | No remaining callers after removing the importer. Current code creates SessionLocal directly. | Either adopt these helpers during session-lifecycle cleanup or remove them; do not rewrite active sessions in a documentation change. |
| `backend/schemas.py: COLUMN_MAPPINGS`, `NULL_VALUES`, `LA_CODE_TO_NAME` | No remaining Python consumers found after importer removal. Other constants in this module are active. | Remove individual constants after checking external data utilities; retain the module. |
| `backend/app.py: result_filters` keys `school_types`, `phases`, `genders`, `admissions_policies` | Since 2026-10-02 FilterBar offers these from `/api/filters`, because options scoped to the results left only the chosen value on offer. Only `local_authorities` is still read. | Stop computing the four keys in a focused API change; keep `local_authorities`. |
| `backend/config.py: data_dir`, `max_page_size`, `rate_limit_burst` | No active consumers found. `default_page_size` appears only in a branch that expects None, although the route supplies a concrete default. | Reconcile settings with route validation in a focused API change. |
## Legacy/manual paths requiring operational verification
+96 -5
View File
@@ -286,6 +286,25 @@ test('the phase filter switches straight from secondary to primary', async ({ pa
await expect(phase).toHaveValue('primary');
});
test('school type and gender switch straight to another value', async ({ page }) => {
// Their options came from the result set, which the filter had already
// narrowed, so with one value chosen it was the only one on offer.
await page.goto('/?search=school&gender=girls');
// An applied gender filter opens More filters by itself.
const gender = page.getByRole('combobox', { name: 'Gender', exact: true });
await expect(gender).toHaveValue('girls', { timeout: 15_000 });
await gender.selectOption('boys');
await expect(page).toHaveURL(/[?&]gender=boys(&|$)/);
// Whatever types the data holds, choosing one leaves the same list on offer.
const type = page.getByRole('combobox', { name: 'School type', exact: true });
const offered = await type.locator('option').allTextContents();
expect(offered.length, 'no school type to choose').toBeGreaterThan(1);
await type.selectOption(offered[1]);
await expect(page).toHaveURL(/[?&]school_type=/);
await expect.poll(() => type.locator('option').allTextContents()).toEqual(offered);
});
test('a phase outside primary/secondary filters to that phase, not to everything', async ({ page }) => {
// The search page offers every GIAS phase, but the API only knew the grouped
// ones and silently dropped the rest — so "Nursery" returned primaries.
@@ -541,6 +560,13 @@ test('results map fullscreen falls back to an overlay on iOS', async ({ page })
await expect(openFs).toBeVisible();
});
/*
* A postcode search with a list long enough to scroll. The default half mile
* finds two schools around B1 1BB, so the page ends before the toolbar has
* anything to stay pinned over; the scroll tests ask for a mile.
*/
const LONG_LIST = '/?postcode=B1%201BB&radius=1';
/*
* The results toolbar: search, filters and the List/Map switch pinned under
* the header, so none of them scroll away with the results.
@@ -551,7 +577,7 @@ test('results map fullscreen falls back to an overlay on iOS', async ({ page })
*/
test('the results toolbar stays pinned with its List/Map switch', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await searchByName(page, 'B1 1BB');
await page.goto(LONG_LIST);
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
const view = page.getByRole('group', { name: 'Results view' });
@@ -582,7 +608,7 @@ test('the results toolbar stays pinned with its List/Map switch', async ({ page
* results and one without.
*/
test('the results toolbar keeps its line count whether or not there are results', async ({ page }) => {
const withResults = '/?postcode=B1%201BB&radius=1';
const withResults = LONG_LIST;
// No school type matches this, so the same search returns nothing.
const without = `${withResults}&school_type=no-such-type`;
@@ -643,6 +669,8 @@ test('a desktop postcode search opens on the map with the list beside it', async
const name = (await card.locator('a').first().innerText()).trim();
await card.click({ position: { x: 6, y: 6 } });
await expect(page.locator('.sc-pin--selected')).toHaveCount(1);
// Leaflet crossfades popups for 200ms, so two exist until the old one goes.
await expect(page.locator('.sc-popup')).toHaveCount(1);
await expect(page.locator('.sc-popup')).toContainText(name);
// The card's View link is drawn like the list's, not in Leaflet's link blue.
// Polled, because .btn transitions its colour.
@@ -660,6 +688,7 @@ test('a desktop postcode search opens on the map with the list beside it', async
await expect(show).toBeVisible();
await page.keyboard.press('Enter');
await expect(show).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('.sc-popup')).toHaveCount(1);
await expect(page.locator('.sc-popup')).toContainText(secondName);
});
@@ -667,7 +696,7 @@ test('a desktop postcode search opens on the map with the list beside it', async
for (const width of [360, 390, 402, 430]) {
test(`a phone opens on the map, with the list a tap away, at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 800 });
await searchByName(page, 'B1 1BB');
await page.goto(LONG_LIST);
// On the map, with the floating button offering the list, clear of the
// bottom tab bar.
@@ -702,12 +731,13 @@ for (const width of [360, 390, 402, 430]) {
// scrollWidth alone cannot see this page's overflow: .main clips on x, so
// the search summary ran 40px off an iPhone 17 screen with scrollWidth
// still equal to the viewport. Measure the toolbar's own edges instead;
// the controls row scrolls by design, so only its box is held to the edge.
// the applied-filter chips scroll by design, so only their line's box is
// held to the edge.
const offscreen = await page.evaluate(() => {
const toolbar = document.querySelector('[class*="resultsToolbar"]');
return [...(toolbar?.querySelectorAll('*') ?? [])]
.filter((el) => (el as HTMLElement).offsetParent
&& !el.parentElement?.closest('[class*="controlsRow"]'))
&& !el.parentElement?.closest('[class*="chipsLine"]'))
.map((el) => ({ el: (el.className?.toString() || el.tagName).slice(0, 40),
right: Math.round(el.getBoundingClientRect().right) }))
.filter((o) => o.right > window.innerWidth);
@@ -739,6 +769,67 @@ for (const width of [360, 390, 402, 430]) {
});
}
/*
* Phones filter through one Filters button and a sheet with every filter in
* it. The row it replaced scrolled sideways with "More filters" first, so
* phase showed only in part, school type not at all, and the panel "More
* filters" opened held neither.
*/
test('a phone filters through one sheet, and sees what it applied as chips', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(LONG_LIST);
const trigger = page.getByRole('button', { name: 'Filters', exact: true });
await expect(trigger).toBeInViewport({ timeout: 15_000 });
// The desktop row is gone at this width: no phase select until the sheet.
await expect(page.getByRole('combobox', { name: 'Phase', exact: true })).toHaveCount(0);
await trigger.click();
const sheet = page.getByRole('dialog', { name: 'Filters' });
await expect(sheet).toBeVisible();
await expect(sheet.getByRole('radio', { name: 'Within 1 mile' })).toBeChecked();
for (const name of ['Phase', 'School type', 'Local authority']) {
await expect(sheet.getByRole('combobox', { name, exact: true })).toBeVisible();
}
// A change applies at once, and the sheet stays open for the next one.
await sheet.getByRole('combobox', { name: 'Phase', exact: true }).selectOption('primary');
await page.waitForURL(/[?&]phase=primary(&|$)/);
await expect(sheet).toBeVisible();
const show = sheet.getByRole('button', { name: /^(Show [\d,]+ schools?|No schools match)$/ });
await expect(show).toBeVisible();
// MOBILE.md: the sheet clears the bottom tab bar, and its targets are 44px.
// It slides up over 0.3s, so poll for where it comes to rest.
await expect.poll(async () => {
const box = (await show.boundingBox())!;
return Math.round(box.y + box.height);
}).toBeLessThanOrEqual(844);
const small = await sheet.evaluate((el) =>
[...el.querySelectorAll('button, select, label:has(input)')]
.filter((n): n is HTMLElement => !!(n as HTMLElement).offsetParent)
.map((n) => ({ t: n.innerText?.trim().slice(0, 24) || n.getAttribute('aria-label'),
w: n.getBoundingClientRect().width, h: n.getBoundingClientRect().height }))
.filter((o) => o.w < 44 || o.h < 44));
expect(small).toEqual([]);
await show.click();
await expect(sheet).toBeHidden();
// What was applied shows under the search, counted on the button, and comes
// off with a tap, keeping the search.
await expect(page.getByRole('button', { name: 'Filters, 1 applied' })).toBeInViewport();
const chip = page.getByRole('group', { name: 'Applied filters' })
.getByRole('button', { name: 'Remove filter: Primary' });
await expect(chip).toBeInViewport();
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth))
.toBe(0);
await chip.click();
await expect(page).not.toHaveURL(/[?&]phase=/);
await expect(page).toHaveURL(/[?&]postcode=B1(%20|\+)1BB/);
await expect(page.getByRole('group', { name: 'Applied filters' })).toHaveCount(0);
await expect(trigger).toBeInViewport();
});
test('comparing two schools shows the parent-first sections side by side', async ({ page }) => {
// Two same-phase (pure primary) schools so both stay on one tab.
const [urn0, urn1] = await twoPrimaryUrns(page);
@@ -0,0 +1,125 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import { FilterBar } from '@/components/FilterBar';
import type { ResultFilters } from '@/lib/types';
/*
* A filter's options must not come from the results it is filtering, or
* choosing one leaves only that one on offer: pick "Girls" and "Boys" is gone
* until the filter is cleared. School type, gender and admissions offer the
* full lists, as phase already did. Local authority stays scoped to the
* results, so a postcode search offers the councils nearby rather than 153.
*
* Gender, sixth form and admissions show unless the phase chosen is a primary
* one, so what the results happen to contain never decides which filters
* there are.
*/
let 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: ['Merton', 'Wandsworth'],
school_types: ['Academy converter', 'Community school'], years: [],
phases: ['Middle deemed primary', 'Nursery', 'Primary', 'Secondary', 'All-through'],
genders: ['Boys', 'Girls', 'Mixed'],
admissions_policies: ['Non-selective', 'Selective'],
};
// What the results came back with once narrowed by the chosen filters.
const narrowed: ResultFilters = {
local_authorities: ['Wandsworth'], school_types: ['Community school'],
phases: ['Secondary'], genders: ['Girls'], admissions_policies: ['Non-selective'],
};
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' });
};
const optionsOf = (sheet: HTMLElement, name: string) =>
within(within(sheet).getByRole('combobox', { name }))
.getAllByRole('option').map((o) => o.textContent);
beforeEach(() => {
params = new URLSearchParams('postcode=SW196AR&radius=1');
push.mockClear();
});
describe('filter options', () => {
it('offer every school type, gender and admissions policy, whatever the results hold', () => {
params = new URLSearchParams('postcode=SW196AR&radius=1&school_type=Community+school&gender=girls');
render(<FilterBar filters={filters} resultFilters={narrowed} />);
const sheet = openSheet();
expect(optionsOf(sheet, 'School type')).toEqual(['Any school type', 'Academy converter', 'Community school']);
expect(optionsOf(sheet, 'Gender')).toEqual(['Boys, Girls & Mixed', 'Boys', 'Girls', 'Mixed']);
expect(optionsOf(sheet, 'Admissions')).toEqual(['All admissions types', 'Non-selective', 'Selective']);
});
it('keep local authority to the councils in the results', () => {
render(<FilterBar filters={filters} resultFilters={narrowed} />);
expect(optionsOf(openSheet(), 'Local authority')).toEqual(['All Local Authorities', 'Wandsworth']);
});
it('offer the full school types in the desktop row too', () => {
render(<FilterBar filters={filters} resultFilters={narrowed} />);
const row = screen.getByRole('group', { name: 'Filters' });
expect(within(within(row).getByRole('combobox', { name: 'School type' }))
.getAllByRole('option')).toHaveLength(3);
});
});
describe('the secondary-only filters', () => {
const secondaryOnly = ['Gender', 'Sixth form', 'Admissions'];
const shown = (sheet: HTMLElement) =>
secondaryOnly.filter((name) => within(sheet).queryByRole('combobox', { name }));
it('show with any phase, even when no secondary school is in the results', () => {
const primariesOnly = { ...narrowed, phases: ['Primary'], genders: [], admissions_policies: [] };
render(<FilterBar filters={filters} resultFilters={primariesOnly} />);
expect(shown(openSheet())).toEqual(secondaryOnly);
});
it.each(['primary', 'nursery', 'middle deemed primary', 'Middle-deemed Primary'])(
'hide for the %s phase', (phase) => {
params = new URLSearchParams(`postcode=SW196AR&radius=1&phase=${encodeURIComponent(phase)}`);
render(<FilterBar filters={filters} />);
expect(shown(openSheet())).toEqual([]);
});
it('leave out a filter with no options, rather than show only its "any"', () => {
render(<FilterBar filters={{ ...filters, genders: [], admissions_policies: [] }} />);
expect(shown(openSheet())).toEqual(['Sixth form']);
});
it.each(['secondary', 'all-through', 'middle deemed secondary', '16 plus'])('show for the %s phase', (phase) => {
params = new URLSearchParams(`postcode=SW196AR&radius=1&phase=${phase}`);
render(<FilterBar filters={filters} />);
expect(shown(openSheet())).toEqual(secondaryOnly);
});
it('are cleared by choosing a primary phase, rather than left applied and hidden', () => {
params = new URLSearchParams(
'postcode=SW196AR&radius=1&phase=secondary&gender=girls&has_sixth_form=yes&admissions_policy=selective&school_type=Community+school');
render(<FilterBar filters={filters} />);
fireEvent.change(within(openSheet()).getByRole('combobox', { name: 'Phase' }), { target: { value: 'primary' } });
const next = pushedParams();
expect(next.get('phase')).toBe('primary');
for (const key of ['gender', 'has_sixth_form', 'admissions_policy']) expect(next.get(key)).toBeNull();
expect(next.get('school_type')).toBe('Community school');
});
it('are kept when the new phase still has them', () => {
params = new URLSearchParams('postcode=SW196AR&radius=1&phase=secondary&gender=girls');
render(<FilterBar filters={filters} />);
fireEvent.change(within(openSheet()).getByRole('combobox', { name: 'Phase' }), { target: { value: 'all-through' } });
expect(pushedParams().get('gender')).toBe('girls');
});
});
@@ -0,0 +1,156 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import { FilterBar } from '@/components/FilterBar';
/*
* Phones filter through one "Filters" button and a bottom sheet holding every
* filter, rather than a sideways-scrolling row whose later chips (phase, type)
* sat off-screen beside a "More filters" panel that did not contain them.
* Which markup shows at which width is CSS and invisible to jsdom; these pin
* the behaviour and the accessible names.
*/
let 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', 'Merton'], school_types: ['Community school'], years: [],
phases: ['Primary', 'Secondary'], genders: ['Girls', 'Mixed'], admissions_policies: [],
};
const pushedParams = () => new URLSearchParams(push.mock.calls.at(-1)![0].split('?')[1]);
beforeEach(() => {
params = new URLSearchParams('postcode=SW196AR&radius=1');
push.mockClear();
});
describe('the phone Filters button', () => {
it('sits beside the folded search summary', () => {
render(<FilterBar filters={filters} />);
const summary = screen.getByRole('button', { name: /Edit search/ });
expect(summary.parentElement).toContainElement(screen.getByRole('button', { name: 'Filters' }));
});
it('counts every applied filter, phase and type included', () => {
params = new URLSearchParams('postcode=SW196AR&radius=1&phase=primary&school_type=Community+school');
render(<FilterBar filters={filters} />);
expect(screen.getByRole('button', { name: 'Filters, 2 applied' })).toBeInTheDocument();
});
it('is still offered before anything has been searched', () => {
params = new URLSearchParams('local_authority=Wandsworth');
render(<FilterBar filters={filters} />);
expect(screen.getByRole('button', { name: 'Filters, 1 applied' })).toBeInTheDocument();
});
it('stays out of the hero', () => {
render(<FilterBar filters={filters} isHero />);
expect(screen.queryByRole('button', { name: /^Filters/ })).not.toBeInTheDocument();
});
});
describe('the filter sheet', () => {
const openSheet = () => {
fireEvent.click(screen.getByRole('button', { name: /^Filters/ }));
return screen.getByRole('dialog', { name: 'Filters' });
};
it('holds every filter in one place', () => {
render(<FilterBar filters={filters} />);
const sheet = openSheet();
expect(within(sheet).getByRole('radiogroup', { name: 'Distance' })).toBeInTheDocument();
for (const name of ['Phase', 'School type', 'Local authority']) {
expect(within(sheet).getByRole('combobox', { name })).toBeInTheDocument();
}
});
it('shows the secondary-only filters once they apply', () => {
params = new URLSearchParams('postcode=SW196AR&radius=1&phase=secondary');
render(<FilterBar filters={filters} />);
const sheet = openSheet();
for (const name of ['Gender', 'Sixth form']) {
expect(within(sheet).getByRole('combobox', { name })).toBeInTheDocument();
}
});
it('offers distance only for a postcode search', () => {
params = new URLSearchParams('search=southmead');
render(<FilterBar filters={filters} />);
expect(within(openSheet()).queryByRole('radiogroup', { name: 'Distance' })).not.toBeInTheDocument();
});
it('changes the distance', () => {
render(<FilterBar filters={filters} />);
const distance = within(openSheet()).getByRole('radiogroup', { name: 'Distance' });
expect(within(distance).getByRole('radio', { name: 'Within 1 mile' })).toBeChecked();
fireEvent.click(within(distance).getByRole('radio', { name: 'Within 3 miles' }));
expect(pushedParams().get('radius')).toBe('3');
});
it('applies a change straight away and stays open for the next one', () => {
render(<FilterBar filters={filters} />);
const sheet = openSheet();
fireEvent.change(within(sheet).getByRole('combobox', { name: 'Phase' }), { target: { value: 'primary' } });
expect(pushedParams().get('phase')).toBe('primary');
expect(screen.getByRole('dialog', { name: 'Filters' })).toBeInTheDocument();
});
it('closes on the results button, which gives the count', () => {
render(<FilterBar filters={filters} resultCount={12} />);
fireEvent.click(within(openSheet()).getByRole('button', { name: 'Show 12 schools' }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('says so when nothing matches', () => {
render(<FilterBar filters={filters} resultCount={0} />);
expect(within(openSheet()).getByRole('button', { name: 'No schools match' })).toBeInTheDocument();
});
it('clears the filters but keeps the search', () => {
params = new URLSearchParams('postcode=SW196AR&radius=1&phase=primary&local_authority=Wandsworth');
render(<FilterBar filters={filters} />);
fireEvent.click(within(openSheet()).getByRole('button', { name: 'Clear all' }));
const next = pushedParams();
expect(next.get('phase')).toBeNull();
expect(next.get('local_authority')).toBeNull();
expect(next.get('postcode')).toBe('SW196AR');
expect(next.get('radius')).toBe('1');
});
});
describe('the applied-filter chips', () => {
it('appear only when something is applied', () => {
render(<FilterBar filters={filters} />);
expect(screen.queryByRole('group', { name: 'Applied filters' })).not.toBeInTheDocument();
});
it('name each filter by its label and remove it on tap', () => {
params = new URLSearchParams('postcode=SW196AR&radius=1&phase=primary&gender=girls&has_sixth_form=no');
render(<FilterBar filters={filters} />);
const chips = screen.getByRole('group', { name: 'Applied filters' });
for (const label of ['Primary', 'Girls', 'Without sixth form']) {
expect(within(chips).getByRole('button', { name: `Remove filter: ${label}` })).toBeInTheDocument();
}
fireEvent.click(within(chips).getByRole('button', { name: 'Remove filter: Primary' }));
const next = pushedParams();
expect(next.get('phase')).toBeNull();
expect(next.get('gender')).toBe('girls');
expect(next.get('postcode')).toBe('SW196AR');
});
it('carry a Clear all that keeps the search', () => {
params = new URLSearchParams('search=southmead&school_type=Community+school');
render(<FilterBar filters={filters} />);
fireEvent.click(within(screen.getByRole('group', { name: 'Applied filters' }))
.getByRole('button', { name: 'Clear all' }));
const next = pushedParams();
expect(next.get('school_type')).toBeNull();
expect(next.get('search')).toBe('southmead');
});
});
@@ -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();
});
});
@@ -105,11 +105,21 @@ describe('the toolbar filters', () => {
});
});
describe('the phone filter row', () => {
describe('the phone chips line', () => {
it('drops its "more this way" fade when nothing is left to scroll', () => {
params = new URLSearchParams('postcode=SW196AR&radius=1&phase=primary');
render(<FilterBar filters={filters} />);
// jsdom lays nothing out, so the row reads as not overflowing at all.
expect(screen.getByRole('group', { name: 'Filters' }).className).toMatch(/controlsAtEnd/);
// jsdom lays nothing out, so the line reads as not overflowing at all.
const line = screen.getByRole('group', { name: 'Applied filters' }).parentElement!;
expect(line.className).toMatch(/controlsAtEnd/);
});
});
describe('the phone filter sheet', () => {
it('offers the results total', () => {
render(<HomeView initialSchools={results()} filters={filters} />);
fireEvent.click(screen.getByRole('button', { name: 'Filters' }));
expect(screen.getByRole('button', { name: 'Show 1 school' })).toBeInTheDocument();
});
});
+115 -13
View File
@@ -95,8 +95,10 @@
}
}
/* Only phones fold the form away; see the 640px block. */
.searchSummary {
/* Only phones fold the form away and filter through the sheet; see the
640px block. */
.summaryRow,
.chipsLine {
display: none;
}
@@ -348,6 +350,11 @@
font-weight: 500;
}
/* In the phone sheet a select is a full-width field and a whole touch target. */
.sheetSelect {
min-height: 2.75rem;
}
/* A pill, 44px tall: these are the page's main controls now, not fine print,
and a phone needs the full touch target. */
.controlSelect {
@@ -553,16 +560,16 @@
/*
* Phones: the results toolbar is pinned, so it is held to two short lines.
*
* After a search the form folds into a one-line summary ("SW196AR · within
* 1 mile Edit") and the controls become a single row that scrolls sideways.
* "More filters" leads the row there: it is the one control that opens
* everything else, so it must never be the chip scrolled out of sight.
* After a search the form folds into a one-line summary ("SW196AR · 1 mi
* Edit") with the Filters button beside it, and every filter lives in
* the sheet that button opens. A second line, of the applied filters as
* chips, appears only once something is applied.
*/
@media (max-width: 640px) {
/*
* nowrap matters as much as column. The desktop rule wraps, and in a
* wrapping flex container each line is as wide as its widest item's content,
* not the container: the search summary ("SW196AR · within 1 mile Edit",
* not the container: the search summary (then "SW196AR · within 1 mile Edit",
* about 410px) stretched the line, and the controls row with it, 40px past a
* 402px iPhone 17 screen. Single-line, stretch means the container's width.
*/
@@ -622,13 +629,73 @@
display: none;
}
/* The desktop row and its panel give way to the Filters button, the
applied-filter chips and the sheet. */
.filterBar:not(.heroMode) .controlsRow,
.filterBar:not(.heroMode) .filters {
display: none;
}
.summaryRow {
display: flex;
align-items: stretch;
gap: 0.5rem;
}
.summaryRow .searchSummary {
flex: 1 1 auto;
min-width: 0;
}
.sheetTrigger {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 0.375rem;
min-height: 2.75rem;
padding: 0 0.875rem;
background: var(--bg-card);
border: 1px solid var(--text-secondary);
border-radius: var(--radius-md);
font-family: var(--font-ui);
font-size: var(--step--1);
font-weight: 600;
line-height: 1;
color: var(--text-primary);
cursor: pointer;
white-space: nowrap;
}
/* The same brand chip as "More filters" on desktop: the list is narrowed. */
.sheetTriggerActive {
border-color: var(--brand);
background: var(--brand-bg);
color: var(--brand);
}
.sheetTriggerCount {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.25rem;
height: 1.25rem;
padding: 0 0.3rem;
border-radius: 999px;
background: var(--brand);
color: var(--bg-card);
font-size: 0.75rem;
font-weight: 700;
}
/* Bleeds to the screen edge so a chip scrolls out from under it, rather than
being cut off at the toolbar's padding. The toolbar's inline padding is
1rem at this width (HomeView.module.css, .resultsToolbar). The 4px of
block padding is room for focus rings, which the scroll clip would
otherwise cut off above and below the chips. */
.controlsRow {
flex-wrap: nowrap;
.chipsLine {
display: flex;
align-items: center;
gap: 0.5rem;
overflow-x: auto;
margin: -4px -1rem;
padding: 4px 1rem;
@@ -640,15 +707,50 @@
mask-image: none;
}
.controlsRow::-webkit-scrollbar {
.chipsLine::-webkit-scrollbar {
display: none;
}
.controlsRow > * {
.chipsLine > *,
.chips > * {
flex: 0 0 auto;
}
.controlsRow .advancedToggle {
order: -1;
.chips {
display: flex;
align-items: center;
gap: 0.5rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.375rem;
min-height: 2.75rem;
padding: 0 0.75rem 0 1rem;
background: rgba(var(--sage-rgb), 0.38);
border: 1px solid var(--brand);
border-radius: 999px;
font-family: var(--font-ui);
font-size: var(--step--1);
font-weight: 600;
line-height: 1;
color: var(--brand-strong);
cursor: pointer;
white-space: nowrap;
}
/* A school type can run to "Academy special sponsor led". */
.chipLabel {
max-width: 11rem;
overflow: hidden;
text-overflow: ellipsis;
}
.chipsClear {
min-height: 2.75rem;
padding: 0 0.75rem;
font-size: var(--step--1);
font-weight: 500;
}
}
+389 -157
View File
@@ -7,6 +7,7 @@ import { DEFAULT_RADIUS_MILES, isValidPostcode, schoolUrl } from "@/lib/utils";
import { track } from "@/lib/analytics";
import { useSchoolSuggest } from "@/hooks/useSchoolSuggest";
import { SuggestList, suggestOptionId } from "./SuggestList";
import { FilterSheet, SheetField, RADIUS_OPTIONS, radiusLabel as milesLabel } from "./FilterSheet";
import type { Suggestion } from "@/lib/suggest";
import type { Filters, ResultFilters } from "@/lib/types";
import styles from "./FilterBar.module.css";
@@ -28,6 +29,8 @@ interface FilterBarProps {
* runs the full width instead of stopping short of the switch.
*/
viewSwitch?: ReactNode;
/** The results' total, for the phone filter sheet's "Show N schools". */
resultCount?: number;
}
/**
@@ -52,6 +55,54 @@ function SelectShell({
);
}
/** The filters a phone sees as chips and counts on its Filters button. */
const FILTER_KEYS = [
"phase",
"school_type",
"local_authority",
"gender",
"has_sixth_form",
"admissions_policy",
] as const;
type FilterKey = (typeof FILTER_KEYS)[number];
/** Filters that only mean something for schools teaching beyond primary. */
const SECONDARY_ONLY_KEYS = ["gender", "has_sixth_form", "admissions_policy"] as const;
/**
* False for a phase with no secondary-age pupils, for which those filters are
* hidden: Primary, Nursery and Middle deemed primary. Matched on the words,
* not exact labels, so a change of case, hyphen or spacing in the GIAS label
* cannot leave them showing. The same reading of "primary" as
* compareGroups in lib/compareLogic.ts. The API's PHASE_GROUPS answers a
* different question (which phases a phase filter returns: primary includes
* all-through), so it is not this list.
*/
function hasSecondaryFilters(phase: string): boolean {
const p = phase.toLowerCase().replace(/[^a-z]+/g, " ").trim();
return !(p === "nursery" || (p.includes("primary") && !p.includes("secondary")));
}
function SlidersIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden="true"
>
<path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12" />
<circle cx="16" cy="6" r="2" />
<circle cx="10" cy="12" r="2" />
<circle cx="18" cy="18" r="2" />
</svg>
);
}
export function FilterBar({
filters,
isHero,
@@ -61,6 +112,7 @@ export function FilterBar({
geoError,
autosuggest = false,
viewSwitch,
resultCount,
}: FilterBarProps) {
const router = useRouter();
const pathname = usePathname();
@@ -172,9 +224,9 @@ export function FilterBar({
setOmniValue(currentQuery);
}
// The phone row's right-edge fade says "more this way"; once there is no
// more, it only dims the last chip. Same rule as the school page's section
// nav (MOBILE.md, "Right-edge scroll-fade").
// The phone chips line's right-edge fade says "more this way"; once there is
// no more, it only dims the last chip. Same rule as the school page's
// section nav (MOBILE.md, "Right-edge scroll-fade").
const controlsRowRef = useRef<HTMLDivElement>(null);
const [controlsAtEnd, setControlsAtEnd] = useState(false);
const updateControlsAtEnd = useCallback(() => {
@@ -187,8 +239,8 @@ export function FilterBar({
window.addEventListener("resize", updateControlsAtEnd);
return () => window.removeEventListener("resize", updateControlsAtEnd);
}, [updateControlsAtEnd]);
// Chips come and go with the search (distance, Clear), so re-measure after
// every render rather than only on resize.
// Chips come and go with the filters, so re-measure after every render
// rather than only on resize.
useEffect(updateControlsAtEnd);
const openSearch = () => {
setSearchOpen(true);
@@ -212,9 +264,21 @@ export function FilterBar({
return () => document.removeEventListener("keydown", handleKeyDown);
}, []);
/*
* Where the navigation in flight is headed. useSearchParams only catches up
* once a navigation lands, so a second change made before then (the phone
* sheet stays usable while one lands) would otherwise be built on the old
* URL and undo the first. Dropped once nothing is pending, when
* searchParams is current again.
*/
const pendingQueryRef = useRef<string | null>(null);
useEffect(() => {
if (!isPending) pendingQueryRef.current = null;
}, [isPending]);
const updateURL = useCallback(
(updates: Record<string, string>) => {
const params = new URLSearchParams(searchParams);
const params = new URLSearchParams(pendingQueryRef.current ?? searchParams);
Object.entries(updates).forEach(([key, value]) => {
if (value && value !== "") {
@@ -225,6 +289,7 @@ export function FilterBar({
});
params.delete("page");
pendingQueryRef.current = params.toString();
startTransition(() => {
router.push(`${pathname}?${params.toString()}`);
@@ -278,11 +343,24 @@ export function FilterBar({
};
const handleFilterChange = (key: string, value: string) => {
updateURL({ [key]: value });
// A primary phase hides the secondary-only filters, so it clears them
// too: left applied, they would empty the list with no control showing.
const cleared =
key === "phase" && !hasSecondaryFilters(value)
? Object.fromEntries(SECONDARY_ONLY_KEYS.map((k) => [k, ""]))
: {};
updateURL({ ...cleared, [key]: value });
};
// Every filter at once, keeping the search and its distance: what "Clear
// all" means beside the applied filters, where the search is not one of them.
const handleClearFilterValues = () => {
updateURL(Object.fromEntries(FILTER_KEYS.map((k) => [k, ""])));
};
const handleClearFilters = () => {
setOmniValue("");
pendingQueryRef.current = "";
startTransition(() => {
router.push(pathname);
});
@@ -298,32 +376,228 @@ export function FilterBar({
currentAdmissionsPolicy ||
currentHasSixthForm;
// Use result-scoped filter values when available, fall back to global
/*
* A filter's options come from the full lists, not from the results: the
* results have already been narrowed by that filter, so scoping to them
* would leave only the chosen value on offer, and switching (Girls to Boys,
* one school type to another) would need clearing first.
*
* Local authority is the exception, scoped to the results so a postcode
* search offers the councils nearby rather than all of England's.
*/
const laOptions =
resultFilters?.local_authorities ?? filters.local_authorities;
const typeOptions = resultFilters?.school_types ?? filters.school_types;
// Phase is the exception: always the full list. The result set has already
// been narrowed by the phase filter, so scoping to it would leave only the
// chosen phase on offer and switching phase would need "Any phase" first.
const typeOptions = filters.school_types;
const phaseOptions = filters.phases ?? [];
const genderOptions = resultFilters?.genders ?? filters.genders ?? [];
const admissionsPolicyOptions =
resultFilters?.admissions_policies ?? filters.admissions_policies ?? [];
const genderOptions = filters.genders ?? [];
const admissionsPolicyOptions = filters.admissions_policies ?? [];
const isSecondaryMode =
currentPhase === "secondary" || genderOptions.length > 0;
// Set by the phase chosen, never by what the results happen to contain.
const isSecondaryMode = hasSecondaryFilters(currentPhase);
// A select that is narrowing the results carries a sage tint; the class is
// only ever additive, so the control's behaviour is untouched.
const activeIf = (value: string) => (value ? ` ${styles.selectActive}` : "");
const radiusLabel = `${currentRadius} mile${currentRadius === "1" ? "" : "s"}`;
const radiusLabel = milesLabel(currentRadius);
/*
* Phones filter through one button and a sheet holding every filter. The
* desktop row's phone version scrolled sideways with "More filters" first,
* so phase showed only in part and school type not at all, and the panel
* "More filters" opened did not contain either of them.
*/
const [sheetOpen, setSheetOpen] = useState(false);
// The sheet is phone furniture: if the screen widens past phone width while
// it is open (rotation, a resized window), close it, or its selects and the
// desktop row's would both be showing.
useEffect(() => {
if (!sheetOpen || typeof window.matchMedia !== "function") return;
const phone = window.matchMedia("(max-width: 640px)");
const onChange = (e: { matches: boolean }) => {
if (!e.matches) setSheetOpen(false);
};
phone.addEventListener("change", onChange);
return () => phone.removeEventListener("change", onChange);
}, [sheetOpen]);
const values: Record<FilterKey, string> = {
phase: currentPhase,
school_type: currentType,
local_authority: currentLA,
gender: currentGender,
has_sixth_form: currentHasSixthForm,
admissions_policy: currentAdmissionsPolicy,
};
const labelFor = (key: FilterKey, value: string) => {
if (key === "has_sixth_form") {
return value === "yes" ? "With sixth form" : "Without sixth form";
}
// Phase, gender and admissions values are lowercased option names; the
// rest are the names themselves.
const named: Partial<Record<FilterKey, string[]>> = {
phase: phaseOptions,
gender: genderOptions,
admissions_policy: admissionsPolicyOptions,
};
return (named[key] ?? []).find((o) => o.toLowerCase() === value.toLowerCase()) ?? value;
};
const applied = FILTER_KEYS.filter((k) => values[k]).map((key) => ({
key,
label: labelFor(key, values[key]),
}));
const appliedCount = applied.length;
/*
* Each select is built here once and drawn twice: as a pill in the desktop
* row or panel, and as a full-width labelled field in the phone sheet. Only
* one copy is ever showing; the sheet's is mounted only while it is open.
* The sheet's stay enabled while a change lands: disabling the control
* under the user's focus would drop focus to <body>, out of the dialog.
*/
type Look = "pill" | "panel" | "sheet";
const selectClass = (look: Look, value: string) =>
`${look === "pill" ? styles.controlSelect : styles.filterSelect}${look === "sheet" ? ` ${styles.sheetSelect}` : ""}${activeIf(value)}`;
const phaseSelect = (look: Look) => (
<SelectShell wide={look !== "pill"}>
<select
value={currentPhase}
onChange={(e) => handleFilterChange("phase", e.target.value)}
className={selectClass(look, currentPhase)}
aria-label="Phase"
disabled={isPending && look !== "sheet"}
>
<option value="">Any phase</option>
{phaseOptions.map((p) => (
<option key={p} value={p.toLowerCase()}>
{p}
</option>
))}
</select>
</SelectShell>
);
const typeSelect = (look: Look) => (
<SelectShell wide={look !== "pill"}>
<select
value={currentType}
onChange={(e) => handleFilterChange("school_type", e.target.value)}
className={selectClass(look, currentType)}
aria-label="School type"
disabled={isPending && look !== "sheet"}
>
<option value="">Any school type</option>
{typeOptions.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</SelectShell>
);
const laSelect = (look: Look) => (
<SelectShell wide>
<select
value={currentLA}
onChange={(e) => handleFilterChange("local_authority", e.target.value)}
className={selectClass(look, currentLA)}
aria-label="Local authority"
disabled={isPending && look !== "sheet"}
>
<option value="">All Local Authorities</option>
{laOptions.map((la) => (
<option key={la} value={la}>
{la}
</option>
))}
</select>
</SelectShell>
);
const genderSelect = (look: Look) => (
<SelectShell wide>
<select
value={currentGender}
onChange={(e) => handleFilterChange("gender", e.target.value)}
className={selectClass(look, currentGender)}
aria-label="Gender"
disabled={isPending && look !== "sheet"}
>
<option value="">Boys, Girls &amp; Mixed</option>
{genderOptions.map((g) => (
<option key={g} value={g.toLowerCase()}>
{g}
</option>
))}
</select>
</SelectShell>
);
const sixthFormSelect = (look: Look) => (
<SelectShell wide>
<select
value={currentHasSixthForm}
onChange={(e) => handleFilterChange("has_sixth_form", e.target.value)}
className={selectClass(look, currentHasSixthForm)}
aria-label="Sixth form"
disabled={isPending && look !== "sheet"}
>
<option value="">With or without sixth form</option>
<option value="yes">With sixth form</option>
<option value="no">Without sixth form</option>
</select>
</SelectShell>
);
const admissionsSelect = (look: Look) => (
<SelectShell wide>
<select
value={currentAdmissionsPolicy}
onChange={(e) => handleFilterChange("admissions_policy", e.target.value)}
className={selectClass(look, currentAdmissionsPolicy)}
aria-label="Admissions"
disabled={isPending && look !== "sheet"}
>
<option value="">All admissions types</option>
{admissionsPolicyOptions.map((p) => (
<option key={p} value={p.toLowerCase()}>
{p}
</option>
))}
</select>
</SelectShell>
);
const sheetTrigger = (
<button
type="button"
className={`${styles.sheetTrigger}${appliedCount ? ` ${styles.sheetTriggerActive}` : ""}`}
onClick={() => setSheetOpen(true)}
aria-haspopup="dialog"
aria-label={appliedCount ? `Filters, ${appliedCount} applied` : "Filters"}
>
<SlidersIcon />
Filters
{appliedCount > 0 && (
<span className={styles.sheetTriggerCount} aria-hidden="true">
{appliedCount}
</span>
)}
</button>
);
// Beside the folded search summary; on the chips line when there is no
// summary to sit beside. While the search is being edited it steps aside,
// and comes back when the form folds.
const triggerBesideSummary = canFold && !searchOpen;
const triggerOnChipsLine = !canFold;
return (
<div
className={`${styles.filterBar} ${isPending ? styles.isLoading : ""} ${isHero ? styles.heroMode : ""}`}
>
{canFold && !searchOpen && (
{triggerBesideSummary && (
<div className={styles.summaryRow}>
<button
type="button"
className={styles.searchSummary}
@@ -345,10 +619,14 @@ export function FilterBar({
</svg>
<span className={styles.summaryText}>
<strong>{currentQuery}</strong>
{currentPostcode && <span> · within {radiusLabel}</span>}
{/* Short, so the distance survives beside the Filters button on a
360px screen; the button's name carries the long form. */}
{currentPostcode && <span> · {currentRadius} mi</span>}
</span>
<span className={styles.summaryEdit} aria-hidden="true">Edit</span>
</button>
{sheetTrigger}
</div>
)}
<form
onSubmit={handleSearchSubmit}
@@ -462,16 +740,10 @@ export function FilterBar({
{viewSwitch && (
<div className={styles.viewSwitchSlot}>{viewSwitch}</div>
)}
{/* Every control here is a real <select> or <button>, drawn as a
pill. On phones the row scrolls sideways rather than wrapping, so
the pinned toolbar stays two lines tall. */}
<div
ref={controlsRowRef}
className={`${styles.controlsRow}${controlsAtEnd ? ` ${styles.controlsAtEnd}` : ""}`}
role="group"
aria-label="Filters"
onScroll={updateControlsAtEnd}
>
{/* Desktop and tablet. Every control here is a real <select> or
<button>, drawn as a pill. Phones hide the row and use the
Filters button and sheet instead. */}
<div className={styles.controlsRow} role="group" aria-label="Filters">
{currentPostcode && (
<SelectShell>
<select
@@ -481,52 +753,18 @@ export function FilterBar({
aria-label="Distance"
disabled={isPending}
>
<option value="0.25">Within 0.25 miles</option>
<option value="0.5">Within 0.5 miles</option>
<option value="1">Within 1 mile</option>
<option value="3">Within 3 miles</option>
<option value="5">Within 5 miles</option>
</select>
</SelectShell>
)}
{phaseOptions.length > 0 && (
<SelectShell>
<select
value={currentPhase}
onChange={(e) => handleFilterChange("phase", e.target.value)}
className={`${styles.controlSelect}${activeIf(currentPhase)}`}
aria-label="Phase"
disabled={isPending}
>
<option value="">Any phase</option>
{phaseOptions.map((p) => (
<option key={p} value={p.toLowerCase()}>
{p}
{RADIUS_OPTIONS.map((r) => (
<option key={r} value={r}>
Within {milesLabel(r)}
</option>
))}
</select>
</SelectShell>
)}
<SelectShell>
<select
value={currentType}
onChange={(e) =>
handleFilterChange("school_type", e.target.value)
}
className={`${styles.controlSelect}${activeIf(currentType)}`}
aria-label="School type"
disabled={isPending}
>
<option value="">Any school type</option>
{typeOptions.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</SelectShell>
{phaseOptions.length > 0 && phaseSelect("pill")}
{typeSelect("pill")}
<button
type="button"
@@ -534,21 +772,7 @@ export function FilterBar({
onClick={() => setFiltersOpen((v) => !v)}
aria-expanded={filtersOpen}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden="true"
>
<path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12" />
<circle cx="16" cy="6" r="2" />
<circle cx="10" cy="12" r="2" />
<circle cx="18" cy="18" r="2" />
</svg>
<SlidersIcon />
More filters
{hasActiveDropdownFilters
? ` (${activeDropdownFilters.length})`
@@ -572,85 +796,93 @@ export function FilterBar({
{filtersOpen && (
<div className={styles.filters}>
<SelectShell wide>
<select
value={currentLA}
onChange={(e) =>
handleFilterChange("local_authority", e.target.value)
}
className={`${styles.filterSelect}${activeIf(currentLA)}`}
aria-label="Local authority"
disabled={isPending}
>
<option value="">All Local Authorities</option>
{laOptions.map((la) => (
<option key={la} value={la}>
{la}
</option>
))}
</select>
</SelectShell>
{laSelect("panel")}
{isSecondaryMode && (
<>
{genderOptions.length > 0 && (
<SelectShell wide>
<select
value={currentGender}
onChange={(e) =>
handleFilterChange("gender", e.target.value)
}
className={`${styles.filterSelect}${activeIf(currentGender)}`}
disabled={isPending}
>
<option value="">Boys, Girls &amp; Mixed</option>
{genderOptions.map((g) => (
<option key={g} value={g.toLowerCase()}>
{g}
</option>
))}
</select>
</SelectShell>
)}
<SelectShell wide>
<select
value={currentHasSixthForm}
onChange={(e) =>
handleFilterChange("has_sixth_form", e.target.value)
}
className={`${styles.filterSelect}${activeIf(currentHasSixthForm)}`}
disabled={isPending}
>
<option value="">With or without sixth form</option>
<option value="yes">With sixth form</option>
<option value="no">Without sixth form</option>
</select>
</SelectShell>
{admissionsPolicyOptions.length > 0 && (
<SelectShell wide>
<select
value={currentAdmissionsPolicy}
onChange={(e) =>
handleFilterChange("admissions_policy", e.target.value)
}
className={`${styles.filterSelect}${activeIf(currentAdmissionsPolicy)}`}
disabled={isPending}
>
<option value="">All admissions types</option>
{admissionsPolicyOptions.map((p) => (
<option key={p} value={p.toLowerCase()}>
{p}
</option>
))}
</select>
</SelectShell>
)}
{genderOptions.length > 0 && genderSelect("panel")}
{sixthFormSelect("panel")}
{admissionsPolicyOptions.length > 0 && admissionsSelect("panel")}
</>
)}
</div>
)}
{/* Phones: what is applied, each removable with a tap. The line
scrolls sideways if it overflows, under the same right-edge fade
as the school page's section nav. */}
{(appliedCount > 0 || triggerOnChipsLine) && (
<div
ref={controlsRowRef}
className={`${styles.chipsLine}${controlsAtEnd ? ` ${styles.controlsAtEnd}` : ""}`}
onScroll={updateControlsAtEnd}
>
{triggerOnChipsLine && sheetTrigger}
{appliedCount > 0 && (
<div className={styles.chips} role="group" aria-label="Applied filters">
{applied.map(({ key, label }) => (
<button
key={key}
type="button"
className={styles.chip}
onClick={() => handleFilterChange(key, "")}
aria-label={`Remove filter: ${label}`}
disabled={isPending}
>
<span className={styles.chipLabel}>{label}</span>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
aria-hidden="true"
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
))}
<button
type="button"
className={`btn btn-tertiary ${styles.chipsClear}`}
onClick={handleClearFilterValues}
disabled={isPending}
>
Clear all
</button>
</div>
)}
</div>
)}
<FilterSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
radius={currentPostcode ? currentRadius : null}
onRadiusChange={(r) => updateURL({ radius: r })}
onClearAll={appliedCount > 0 ? handleClearFilterValues : undefined}
resultCount={resultCount}
isPending={isPending}
>
{phaseOptions.length > 0 && (
<SheetField label="Phase">{phaseSelect("sheet")}</SheetField>
)}
<SheetField label="School type">{typeSelect("sheet")}</SheetField>
<SheetField label="Local authority">{laSelect("sheet")}</SheetField>
{isSecondaryMode && (
<>
{genderOptions.length > 0 && (
<SheetField label="Gender">{genderSelect("sheet")}</SheetField>
)}
<SheetField label="Sixth form">{sixthFormSelect("sheet")}</SheetField>
{admissionsPolicyOptions.length > 0 && (
<SheetField label="Admissions">{admissionsSelect("sheet")}</SheetField>
)}
</>
)}
</FilterSheet>
</>
)}
</div>
@@ -0,0 +1,92 @@
/* ── Phone filter sheet ───────────────────────────────────────────── */
.body {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.fieldLabel {
font-family: var(--font-ui);
font-size: var(--step--1);
font-weight: 600;
color: var(--text-primary);
}
/* Distance: five joined segments, a full-width row. At 360px each is about
65px wide, clear of the 44px minimum. */
.segments {
display: flex;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
overflow: hidden;
}
.segment {
position: relative;
flex: 1 1 0;
display: flex;
}
.segment + .segment {
border-left: 1px solid var(--border);
}
/* The real radio stays in the tree for keyboard and screen readers; the span
is what is drawn. */
.segment input {
position: absolute;
inset: 0;
margin: 0;
opacity: 0;
cursor: pointer;
}
.segment span {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 2.75rem;
font-family: var(--font-ui);
font-size: var(--step--1);
font-weight: 500;
color: var(--text-primary);
background: var(--bg-card);
white-space: nowrap;
transition: background-color var(--transition), color var(--transition);
}
.segment input:checked + span {
background: rgba(var(--sage-rgb), 0.38);
color: var(--brand-strong);
font-weight: 700;
}
.segment input:focus-visible + span {
box-shadow: inset 0 0 0 2px var(--brand);
}
.footer {
display: flex;
align-items: center;
gap: 0.75rem;
}
.clearAll {
min-height: 2.75rem;
padding: 0 1rem;
}
.show {
flex: 1;
min-height: 2.75rem;
justify-content: center;
}
+121
View File
@@ -0,0 +1,121 @@
"use client";
import { useId } from "react";
import type { ReactNode } from "react";
import { Modal } from "./Modal";
import styles from "./FilterSheet.module.css";
export const RADIUS_OPTIONS = ["0.25", "0.5", "1", "3", "5"] as const;
export const radiusLabel = (r: string) => `${r} mile${r === "1" ? "" : "s"}`;
interface FilterSheetProps {
open: boolean;
onClose: () => void;
/** The current distance, or null when the search is not by postcode. */
radius: string | null;
onRadiusChange: (radius: string) => void;
/** The filter selects, each already labelled; FilterBar owns their state. */
children: ReactNode;
/** Shown only when something is applied. */
onClearAll?: () => void;
resultCount?: number;
isPending: boolean;
}
/*
* Every filter in one place, for phones. A change applies at once, as it does
* in the desktop toolbar, so the results behind the sheet are already the
* filtered ones and the footer button only has to close it.
*/
export function FilterSheet({
open,
onClose,
radius,
onRadiusChange,
children,
onClearAll,
resultCount,
isPending,
}: FilterSheetProps) {
const distanceId = useId();
const showLabel = isPending
? "Updating…"
: resultCount === undefined
? "Show schools"
: resultCount === 0
? "No schools match"
: `Show ${resultCount.toLocaleString()} school${resultCount === 1 ? "" : "s"}`;
return (
<Modal
isOpen={open}
onClose={onClose}
title="Filters"
size="small"
footer={
<div className={styles.footer}>
{onClearAll && (
<button
type="button"
className={`btn btn-tertiary ${styles.clearAll}`}
onClick={onClearAll}
>
Clear all
</button>
)}
<button
type="button"
className={`btn btn-action ${styles.show}`}
onClick={onClose}
>
{showLabel}
</button>
</div>
}
>
{/* Controls stay enabled while a change lands, so focus is never
dropped out of the dialog; aria-busy says the results are updating. */}
<div className={styles.body} aria-busy={isPending}>
{radius !== null && (
<div className={styles.field}>
<span id={distanceId} className={styles.fieldLabel}>
Distance
</span>
<div
className={styles.segments}
role="radiogroup"
aria-labelledby={distanceId}
>
{RADIUS_OPTIONS.map((r) => (
<label key={r} className={styles.segment}>
<input
type="radio"
name={`${distanceId}-radius`}
value={r}
checked={r === radius}
onChange={() => onRadiusChange(r)}
aria-label={`Within ${radiusLabel(r)}`}
/>
<span>{r} mi</span>
</label>
))}
</div>
</div>
)}
{children}
</div>
</Modal>
);
}
/** One labelled row of the sheet. */
export function SheetField({ label, children }: { label: string; children: ReactNode }) {
return (
<label className={styles.field}>
<span className={styles.fieldLabel}>{label}</span>
{children}
</label>
);
}
+1
View File
@@ -712,6 +712,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
filters={filters}
isHero={false}
resultFilters={initialSchools.result_filters}
resultCount={initialSchools.total}
onNearMe={handleNearMe}
geoState={geoState}
geoError={geoError}
+15 -1
View File
@@ -5,7 +5,9 @@
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
/* Above the pinned results toolbar (1001), the floating List/Map button
(1002) and the comparison toast (2000): a modal covers the page. */
z-index: 2100;
padding: 1rem;
animation: fadeIn 0.2s ease;
}
@@ -31,6 +33,10 @@
border: 1px solid var(--border);
}
.modal:focus {
outline: none;
}
@keyframes slideIn {
from {
transform: translateY(-20px);
@@ -96,6 +102,14 @@
flex: 1;
}
/* Clears the iPhone home indicator when the modal is a bottom sheet. */
.footer {
flex-shrink: 0;
padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom, 0px));
border-top: 1px solid var(--border);
background: var(--bg-card);
}
/* Scrollbar styles */
.content::-webkit-scrollbar {
width: 8px;
+27 -4
View File
@@ -5,7 +5,7 @@
'use client';
import { useEffect, useCallback, useRef } from 'react';
import { useEffect, useCallback, useId, useRef } from 'react';
import { createPortal } from 'react-dom';
import styles from './Modal.module.css';
@@ -15,10 +15,14 @@ interface ModalProps {
children: React.ReactNode;
title?: string;
size?: 'small' | 'medium' | 'large';
/** Pinned below the scrolling content, so its actions stay in reach. */
footer?: React.ReactNode;
}
export function Modal({ isOpen, onClose, children, title, size = 'medium' }: ModalProps) {
export function Modal({ isOpen, onClose, children, title, size = 'medium', footer }: ModalProps) {
const overlayRef = useRef<HTMLDivElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
const titleId = useId();
const handleEscape = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') {
@@ -41,6 +45,17 @@ export function Modal({ isOpen, onClose, children, title, size = 'medium' }: Mod
};
}, [isOpen, handleEscape]);
// Focus moves into the dialog when it opens and back to whatever opened it
// when it closes. A child that has already taken focus (an autoFocus input)
// keeps it: children's effects run before this one.
useEffect(() => {
if (!isOpen) return;
const opener = document.activeElement as HTMLElement | null;
const dialog = dialogRef.current;
if (dialog && !dialog.contains(document.activeElement)) dialog.focus();
return () => opener?.focus?.();
}, [isOpen]);
// Pin the overlay to the VISUAL viewport, not the layout viewport. On mobile
// the on-screen keyboard shrinks the visual viewport but not the layout one,
// so a `position: fixed; inset: 0` overlay keeps full height — leaving the
@@ -77,9 +92,16 @@ export function Modal({ isOpen, onClose, children, title, size = 'medium' }: Mod
return createPortal(
<div ref={overlayRef} className={styles.overlay} onClick={handleOverlayClick}>
<div className={`${styles.modal} ${styles[size]}`}>
<div
ref={dialogRef}
className={`${styles.modal} ${styles[size]}`}
role="dialog"
aria-modal="true"
aria-labelledby={title ? titleId : undefined}
tabIndex={-1}
>
<div className={styles.header}>
{title && <h2 className={styles.title}>{title}</h2>}
{title && <h2 id={titleId} className={styles.title}>{title}</h2>}
<button
className={styles.closeButton}
onClick={onClose}
@@ -103,6 +125,7 @@ export function Modal({ isOpen, onClose, children, title, size = 'medium' }: Mod
<div className={styles.content}>
{children}
</div>
{footer && <div className={styles.footer}>{footer}</div>}
</div>
</div>,
document.body