From 8ebe4614356e7a4f0ca65fb2a4a25744a4109a92 Mon Sep 17 00:00:00 2001 From: Tudor Date: Thu, 1 Oct 2026 15:27:14 +0100 Subject: [PATCH] fix(search): set the toolbar's line count by width, not by results The results toolbar wrapped wherever it ran out of room, and the List/Map switch only appears when there are results, so the same search took two lines with results and one without. From 1340px the controls never wrap away from the search, which takes what they leave (at least 12rem); phase and type chips cap at 11rem to fit. Between 641px and 1339px the controls always take a full line of their own. The switch now sits in FilterBar's row via a viewSwitch slot, so that line runs the full width instead of stopping short of it. Co-Authored-By: Claude Opus 5.5 --- e2e/tests/journeys.spec.ts | 32 +++++++++++ .../components/HomeView.staleFetch.test.tsx | 5 +- .../components/ResultsMapView.test.tsx | 5 +- nextjs-app/components/FilterBar.module.css | 53 +++++++++++++++++-- nextjs-app/components/FilterBar.tsx | 10 ++++ nextjs-app/components/HomeView.tsx | 44 +++++++-------- 6 files changed, 120 insertions(+), 29 deletions(-) diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts index f32d80c..f3a218c 100644 --- a/e2e/tests/journeys.spec.ts +++ b/e2e/tests/journeys.spec.ts @@ -572,6 +572,38 @@ test('the results toolbar stays pinned with its List/Map switch', async ({ page .toHaveAttribute('aria-pressed', 'true'); }); +/* + * The toolbar's line count is set by the screen width, never by the results. + * It once wrapped wherever it ran out of room, and the List/Map switch only + * appears when there are results — so the same search took two lines with + * 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'; + // No school type matches this, so the same search returns nothing. + const without = `${withResults}&school_type=no-such-type`; + + const lines = async (url: string) => { + await page.goto(url); + // By label: the input is a combobox when autosuggest is on. + const input = page.getByLabel('School name or postcode', { exact: true }); + const filters = page.getByRole('group', { name: 'Filters' }); + await expect(filters).toBeVisible({ timeout: 15_000 }); + const a = (await input.boundingBox())!; + const b = (await filters.boundingBox())!; + return b.y >= a.y + a.height ? 2 : 1; + }; + const view = page.getByRole('group', { name: 'Results view' }); + + for (const [width, expected] of [[1400, 1], [1100, 2]] as const) { + await page.setViewportSize({ width, height: 800 }); + expect(await lines(withResults), `${width}px with results`).toBe(expected); + await expect(view).toBeVisible(); + expect(await lines(without), `${width}px without results`).toBe(expected); + await expect(view).toHaveCount(0); + } +}); + /* * Desktop opens a postcode search on the map (mockup B): the list in a pane on * the left, the map filling the rest of the screen, and a card on the map for diff --git a/nextjs-app/__tests__/components/HomeView.staleFetch.test.tsx b/nextjs-app/__tests__/components/HomeView.staleFetch.test.tsx index 9fb0bfe..b8d788a 100644 --- a/nextjs-app/__tests__/components/HomeView.staleFetch.test.tsx +++ b/nextjs-app/__tests__/components/HomeView.staleFetch.test.tsx @@ -18,7 +18,10 @@ jest.mock('@/lib/api', () => ({ fetchNationalAverages: jest.fn(async () => ({})), fetchLAaverages: jest.fn(async () => ({ secondary: { attainment_8_by_la: {} } })), })); -jest.mock('@/components/FilterBar', () => ({ FilterBar: () => null })); +// Renders only the List/Map switch HomeView hands it, which lives in its row. +jest.mock('@/components/FilterBar', () => ({ + FilterBar: ({ viewSwitch }: { viewSwitch?: unknown }) => viewSwitch || null, +})); jest.mock('@/components/SchoolRow', () => ({ SchoolRow: ({ school }: {school: School}) =>
{school.school_name}
})); jest.mock('@/components/SchoolMap', () => ({ SchoolMap: ({ schools }: {schools: School[]}) =>
{schools.map(s => s.school_name).join(',')}
})); diff --git a/nextjs-app/__tests__/components/ResultsMapView.test.tsx b/nextjs-app/__tests__/components/ResultsMapView.test.tsx index 812a81f..2178630 100644 --- a/nextjs-app/__tests__/components/ResultsMapView.test.tsx +++ b/nextjs-app/__tests__/components/ResultsMapView.test.tsx @@ -24,7 +24,10 @@ jest.mock('@/lib/api', () => ({ fetchNationalAverages: jest.fn(), fetchLAaverages: jest.fn(async () => ({ secondary: { attainment_8_by_la: {} } })), })); -jest.mock('@/components/FilterBar', () => ({ FilterBar: () => null })); +// Renders only the List/Map switch HomeView hands it, which lives in its row. +jest.mock('@/components/FilterBar', () => ({ + FilterBar: ({ viewSwitch }: { viewSwitch?: unknown }) => viewSwitch || null, +})); jest.mock('@/components/SchoolMap', () => ({ SchoolMap: ({ selectedUrn, radiusMiles, onMarkerClick, schools }: { selectedUrn: number | null; radiusMiles?: number; diff --git a/nextjs-app/components/FilterBar.module.css b/nextjs-app/components/FilterBar.module.css index 611b03c..4d44b08 100644 --- a/nextjs-app/components/FilterBar.module.css +++ b/nextjs-app/components/FilterBar.module.css @@ -40,8 +40,19 @@ margin: 0 auto 1.5rem; } -/* One row where it fits: the search takes what the controls leave, and the - "More filters" panel breaks onto its own line below both. */ +/* + * One row on wide screens, two below 1340px — decided by the width alone, + * never by what the search returned. + * + * The row once wrapped wherever it ran out of room, and its contents change + * with the results: the List/Map switch beside it, the distance chip and Clear + * all come and go. So the same search folded onto two lines when it had + * results and sat on one when it had none. Now the controls never wrap away + * from the search on a wide screen; the search box takes what they leave, and + * 1340px is where the fullest toolbar (distance, phase, type, More filters, + * Clear and the switch) still leaves it 12rem. The "More filters" panel breaks + * onto its own line below both. + */ .filterBar:not(.heroMode) { display: flex; flex-wrap: wrap; @@ -56,10 +67,34 @@ } .filterBar:not(.heroMode) .searchSection { - flex: 1 1 320px; + flex: 1 1 0; min-width: 0; } +.filterBar:not(.heroMode) .controlsRow { + flex: 0 0 auto; + flex-wrap: nowrap; +} + +/* The List/Map switch closes the line; the "More filters" panel follows it. */ +.viewSwitchSlot { + flex: 0 0 auto; + order: 1; +} + +/* Below that, the switch stays up beside the search and the controls always + take a full-width line of their own, results or not. */ +@media (min-width: 641px) and (max-width: 1339px) { + .viewSwitchSlot { + order: 0; + } + + .filterBar:not(.heroMode) .controlsRow { + flex-basis: 100%; + flex-wrap: wrap; + } +} + /* Only phones fold the form away; see the 640px block. */ .searchSummary { display: none; @@ -324,8 +359,9 @@ font-weight: 500; white-space: nowrap; /* A select is as wide as its longest option, and a school type can run to - "Academy special sponsor led". Cap it; the chosen value truncates. */ - max-width: 14rem; + "Academy special sponsor led". Cap it; the chosen value truncates. The cap + is part of the one-line budget above. */ + max-width: 11rem; text-overflow: ellipsis; } @@ -363,6 +399,7 @@ pushing the results off a short screen. The 3px gutter keeps the selects' focus rings clear of the scroll clip. */ .filters { + order: 2; flex-basis: 100%; display: flex; gap: 0.625rem; @@ -579,6 +616,12 @@ display: none; } + /* Phones switch views with the floating button (HomeView, .mobileDock). An + empty slot would still take a gap in this column. */ + .viewSwitchSlot { + display: none; + } + /* 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 diff --git a/nextjs-app/components/FilterBar.tsx b/nextjs-app/components/FilterBar.tsx index c08211d..50119e0 100644 --- a/nextjs-app/components/FilterBar.tsx +++ b/nextjs-app/components/FilterBar.tsx @@ -22,6 +22,12 @@ interface FilterBarProps { geoError?: string | null; /** Server-read feature flag. Off means no listener, no fetch, no markup. */ autosuggest?: boolean; + /** + * The results page's List/Map switch. It sits in this bar's own row rather + * than beside it, so that when the bar takes two lines the filters' line + * runs the full width instead of stopping short of the switch. + */ + viewSwitch?: ReactNode; } /** @@ -54,6 +60,7 @@ export function FilterBar({ geoState = "idle", geoError, autosuggest = false, + viewSwitch, }: FilterBarProps) { const router = useRouter(); const pathname = usePathname(); @@ -452,6 +459,9 @@ export function FilterBar({ {!isHero && ( <> + {viewSwitch && ( +
{viewSwitch}
+ )} {/* Every control here is a real