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}) =>
}));
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 && (
+