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