diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts
index 1b85744..3a7f1c0 100644
--- a/e2e/tests/journeys.spec.ts
+++ b/e2e/tests/journeys.spec.ts
@@ -503,8 +503,9 @@ test('results map fullscreen falls back to an overlay on iOS', async ({ page })
await searchByName(page, 'B1 1BB');
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
- // Switch to the map view, then open the map fullscreen.
- await page.getByRole('button', { name: 'Map', exact: true }).click();
+ // Switch to the map view with the floating button (the toolbar's switch is
+ // hidden at phone width), then open the map fullscreen.
+ await page.getByRole('button', { name: 'Show map' }).click();
const openFs = page.getByRole('button', { name: 'View map fullscreen' });
await expect(openFs).toBeVisible({ timeout: 15_000 });
await openFs.click();
@@ -516,6 +517,78 @@ test('results map fullscreen falls back to an overlay on iOS', async ({ page })
await expect(openFs).toBeVisible();
});
+/*
+ * The results toolbar: search, filters and the List/Map switch pinned under
+ * the header, so none of them scroll away with the results.
+ *
+ * Positions are read after window.scrollTo, never after a click: Playwright
+ * scrolls a click target into view first, which would make "still on screen
+ * after scrolling" true of anything.
+ */
+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 expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
+
+ const view = page.getByRole('group', { name: 'Results view' });
+ await expect(view).toBeVisible();
+ await expect(page.getByRole('button', { name: 'List' })).toHaveAttribute('aria-pressed', 'true');
+
+ await page.evaluate(() => window.scrollTo(0, 1500));
+ await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(1000);
+ const box = await view.boundingBox();
+ expect(box, 'the switch left the viewport').not.toBeNull();
+ // Just under the 64px header, not scrolled off the top.
+ expect(box!.y).toBeGreaterThanOrEqual(64);
+ expect(box!.y).toBeLessThan(160);
+ await expect(page.getByRole('combobox', { name: 'Distance' })).toBeInViewport();
+
+ await page.getByRole('button', { name: 'Map', exact: true }).click();
+ await expect(page.getByRole('button', { name: 'Map', exact: true }))
+ .toHaveAttribute('aria-pressed', 'true');
+});
+
+for (const width of [360, 390, 430]) {
+ test(`the floating Map button is in reach at ${width}px`, async ({ page }) => {
+ await page.setViewportSize({ width, height: 800 });
+ await searchByName(page, 'B1 1BB');
+ await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
+
+ // Visible without scrolling, and clear of the bottom tab bar.
+ const fab = page.getByRole('button', { name: 'Show map' });
+ await expect(fab).toBeInViewport();
+ const fabBox = (await fab.boundingBox())!;
+ const barTop = await page.locator('nav[class*="bottomBar"]')
+ .evaluate((el) => el.getBoundingClientRect().top);
+ expect(fabBox.y + fabBox.height).toBeLessThanOrEqual(barTop);
+
+ // The search folds to a summary, and the pinned toolbar survives a scroll.
+ const summary = page.getByRole('button', { name: /^Edit search: B1 1BB/ });
+ await expect(summary).toBeVisible();
+ await page.evaluate(() => window.scrollTo(0, 1200));
+ await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(600);
+ await expect(summary).toBeInViewport();
+ await expect(fab).toBeInViewport();
+
+ // MOBILE.md: no horizontal overflow, and 44px targets in the new chrome.
+ expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth))
+ .toBe(0);
+ const small = await page.evaluate(() => {
+ const toolbar = document.querySelector('[class*="resultsToolbar"]');
+ const fabEl = document.querySelector('[class*="viewFab"]');
+ return [...(toolbar?.querySelectorAll('a, button, input, select') ?? []), fabEl]
+ .filter((el): el is HTMLElement => !!el && !!(el as HTMLElement).offsetParent)
+ .map((el) => ({ t: el.innerText?.trim().slice(0, 24) || el.getAttribute('aria-label'),
+ w: el.getBoundingClientRect().width, h: el.getBoundingClientRect().height }))
+ .filter((o) => o.w < 44 || o.h < 44);
+ });
+ expect(small).toEqual([]);
+
+ await fab.click();
+ await expect(page.getByRole('button', { name: 'Show list' })).toBeVisible();
+ });
+}
+
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);
diff --git a/nextjs-app/__tests__/components/FilterBarSuggest.test.tsx b/nextjs-app/__tests__/components/FilterBarSuggest.test.tsx
index 8e2c962..a408ad0 100644
--- a/nextjs-app/__tests__/components/FilterBarSuggest.test.tsx
+++ b/nextjs-app/__tests__/components/FilterBarSuggest.test.tsx
@@ -32,9 +32,9 @@ afterEach(() => { global.fetch = realFetch; });
describe('FilterBar autosuggest', () => {
it('is a combobox only when the flag is on', () => {
const { rerender } = render();
- expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
+ expect(screen.queryByRole('combobox', { name: 'School name or postcode' })).not.toBeInTheDocument();
rerender();
- expect(screen.getByRole('combobox')).toBeInTheDocument();
+ expect(screen.getByRole('combobox', { name: 'School name or postcode' })).toBeInTheDocument();
});
it('makes no request while the flag is off', async () => {
@@ -47,7 +47,7 @@ describe('FilterBar autosuggest', () => {
it('shows suggestions and navigates when one is chosen', async () => {
render();
- await userEvent.type(screen.getByRole('combobox'), 'brecknock');
+ await userEvent.type(screen.getByRole('combobox', { name: 'School name or postcode' }), 'brecknock');
const option = await screen.findByRole('option', { name: /Brecknock/ });
await userEvent.click(option);
expect(push).toHaveBeenCalledWith(
@@ -61,7 +61,7 @@ describe('FilterBar autosuggest', () => {
// "NW1" is not a postcode, so a request for it is correct behaviour. Only
// the settled value is the assertion, so set it in one go.
render();
- fireEvent.change(screen.getByRole('combobox'), { target: { value: 'NW1 1AA' } });
+ fireEvent.change(screen.getByRole('combobox', { name: 'School name or postcode' }), { target: { value: 'NW1 1AA' } });
await new Promise((r) => setTimeout(r, 300)); // past the 200ms debounce
expect(global.fetch).not.toHaveBeenCalled();
});
@@ -69,7 +69,7 @@ describe('FilterBar autosuggest', () => {
it('Enter with no active option still submits the free-text search', async () => {
// The existing behaviour is preserved, not replaced.
render();
- const input = screen.getByRole('combobox');
+ const input = screen.getByRole('combobox', { name: 'School name or postcode' });
await userEvent.type(input, 'brecknock{Enter}');
// updateURL pushes inside startTransition, so the call is not synchronous.
await waitFor(() => expect(push).toHaveBeenCalledWith(
@@ -90,7 +90,7 @@ describe('FilterBar autosuggest does not reopen over results', () => {
searchParams = new URLSearchParams('search=brecknock');
render();
- expect(screen.getByRole('combobox')).toHaveValue('brecknock');
+ expect(screen.getByRole('combobox', { name: 'School name or postcode' })).toHaveValue('brecknock');
await new Promise((r) => setTimeout(r, 300)); // past the 200ms debounce
expect(global.fetch).not.toHaveBeenCalled();
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
@@ -98,7 +98,7 @@ describe('FilterBar autosuggest does not reopen over results', () => {
it('closes the dropdown when the search is submitted', async () => {
render();
- const input = screen.getByRole('combobox');
+ const input = screen.getByRole('combobox', { name: 'School name or postcode' });
await userEvent.type(input, 'brecknock');
expect(await screen.findByRole('listbox')).toBeInTheDocument();
diff --git a/nextjs-app/__tests__/components/ResultsToolbar.test.tsx b/nextjs-app/__tests__/components/ResultsToolbar.test.tsx
new file mode 100644
index 0000000..85ceb14
--- /dev/null
+++ b/nextjs-app/__tests__/components/ResultsToolbar.test.tsx
@@ -0,0 +1,130 @@
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { HomeView } from '@/components/HomeView';
+import { FilterBar } from '@/components/FilterBar';
+import { fetchSchools } from '@/lib/api';
+import { track } from '@/lib/analytics';
+import { primaryFixture } from '../support/schoolFixtures';
+import type { SchoolsResponse } from '@/lib/types';
+
+/*
+ * The results toolbar (option B of the 2026-09-30 results-controls mockups):
+ * search, filters and the List/Map switch pinned under the header, with a
+ * floating List/Map button standing in for the switch on phones. Layout is CSS
+ * and not visible to jsdom; these pin the behaviour and the accessible names
+ * the E2E journeys rely on.
+ */
+
+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('@/context/ComparisonContext', () => ({
+ useComparisonContext: () => ({ addSchool: jest.fn(), removeSchool: jest.fn(), selectedSchools: [] }),
+}));
+jest.mock('@/lib/api', () => ({
+ fetchSchools: jest.fn(),
+ fetchNationalAverages: jest.fn(async () => ({})),
+ fetchLAaverages: jest.fn(async () => ({ secondary: { attainment_8_by_la: {} } })),
+}));
+jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
+jest.mock('@/components/SchoolMap', () => ({ SchoolMap: () =>
}));
+
+const filters = {
+ local_authorities: ['Wandsworth'], school_types: ['Community school'], years: [],
+ phases: ['Primary', 'Secondary'], genders: [], admissions_policies: [],
+};
+
+function results(): SchoolsResponse {
+ return { schools: [{ ...primaryFixture.schoolInfo, school_name: 'Southmead Primary School' }],
+ total: 1, page: 1, page_size: 25, total_pages: 1 };
+}
+
+beforeEach(() => {
+ params = new URLSearchParams('postcode=SW196AR&radius=1');
+ push.mockClear();
+ jest.mocked(track).mockClear();
+ jest.mocked(fetchSchools).mockReset().mockResolvedValue(results());
+});
+
+describe('the List/Map switch', () => {
+ it('lives in the toolbar with the filters and says which view is on', () => {
+ render();
+ const view = screen.getByRole('group', { name: 'Results view' });
+ expect(view.closest('div[class*="resultsToolbar"]')).not.toBeNull();
+ expect(screen.getByRole('button', { name: 'List' })).toHaveAttribute('aria-pressed', 'true');
+ expect(screen.getByRole('button', { name: 'Map' })).toHaveAttribute('aria-pressed', 'false');
+ });
+
+ it('has a floating twin that flips between map and list', async () => {
+ render();
+ await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Show map' })));
+ expect(screen.getByTestId('map')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Map' })).toHaveAttribute('aria-pressed', 'true');
+ expect(track).toHaveBeenCalledWith('results_view_changed', { view: 'map', via: 'floating' });
+
+ fireEvent.click(screen.getByRole('button', { name: 'Show list' }));
+ expect(screen.queryByTestId('map')).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Show map' })).toBeInTheDocument();
+ });
+
+ it('does not track a click on the view already showing', () => {
+ render();
+ fireEvent.click(screen.getByRole('button', { name: 'List' }));
+ expect(track).not.toHaveBeenCalledWith('results_view_changed', expect.anything());
+ });
+
+ it('is absent from a name search, which has no map', () => {
+ params = new URLSearchParams('search=southmead');
+ render();
+ expect(screen.queryByRole('group', { name: 'Results view' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Show map' })).not.toBeInTheDocument();
+ });
+});
+
+describe('the toolbar filters', () => {
+ it('keeps distance, phase and school type in the row, not behind More filters', () => {
+ render();
+ const row = screen.getByRole('group', { name: 'Filters' });
+ for (const name of ['Distance', 'Phase', 'School type']) {
+ expect(row).toContainElement(screen.getByRole('combobox', { name }));
+ }
+ expect(screen.getByRole('combobox', { name: 'Distance' })).toHaveDisplayValue('Within 1 mile');
+ expect(screen.queryByRole('combobox', { name: 'Local authority' })).not.toBeInTheDocument();
+ });
+
+ it('counts only what More filters hides', () => {
+ params = new URLSearchParams('postcode=SW196AR&radius=1&school_type=Community+school&local_authority=Wandsworth');
+ render();
+ expect(screen.getByRole('button', { name: /More filters \(1\)/ })).toBeInTheDocument();
+ });
+});
+
+describe('the folded search', () => {
+ it('summarises the search and unfolds on tap', () => {
+ render();
+ const summary = screen.getByRole('button', { name: 'Edit search: SW196AR, within 1 mile' });
+ fireEvent.click(summary);
+ expect(screen.queryByRole('button', { name: /Edit search/ })).not.toBeInTheDocument();
+ });
+
+ it('folds again once the edited search is submitted', () => {
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /Edit search/ }));
+ const input = screen.getByRole('searchbox', { name: 'School name or postcode' });
+ fireEvent.change(input, { target: { value: 'SW19 1AA' } });
+ fireEvent.submit(input.closest('form')!);
+ expect(screen.getByRole('button', { name: /Edit search/ })).toBeInTheDocument();
+ });
+
+ it('never appears in the hero, or before anything has been searched', () => {
+ const { unmount } = render();
+ expect(screen.queryByRole('button', { name: /Edit search/ })).not.toBeInTheDocument();
+ unmount();
+ params = new URLSearchParams('local_authority=Wandsworth');
+ render();
+ expect(screen.queryByRole('button', { name: /Edit search/ })).not.toBeInTheDocument();
+ });
+});
diff --git a/nextjs-app/components/FilterBar.module.css b/nextjs-app/components/FilterBar.module.css
index 287c6d3..45af2ec 100644
--- a/nextjs-app/components/FilterBar.module.css
+++ b/nextjs-app/components/FilterBar.module.css
@@ -4,8 +4,10 @@
* hero a single white pill on the Sand hero panel, carrying the one
* coral action the page exists for. No card of its own: the panel
* behind it belongs to the page, so the pill reads as the object.
- * compact the filter rail above the results — a white card of quiet
- * controls, brand for what is active, sage for what is selected.
+ * compact the results toolbar — search, then a row of pill controls. The
+ * card around it belongs to HomeView, which pins it under the
+ * header and puts the List/Map switch beside it, so the compact
+ * bar draws no card of its own.
*
* Colour comes from the token layer only; the disclosure arrows are drawn
* from currentColor rather than a background image so they follow the theme.
@@ -38,6 +40,31 @@
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. */
+.filterBar:not(.heroMode) {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem;
+ background: none;
+ border: 0;
+ border-radius: 0;
+ box-shadow: none;
+ padding: 0;
+ margin: 0;
+}
+
+.filterBar:not(.heroMode) .searchSection {
+ flex: 1 1 320px;
+ min-width: 0;
+}
+
+/* Only phones fold the form away; see the 640px block. */
+.searchSummary {
+ display: none;
+}
+
/* ── Search ───────────────────────────────────────────────────────── */
.searchSection {
@@ -115,8 +142,13 @@
box-shadow: none;
}
+.filterBar:not(.heroMode) .omniInput {
+ min-height: 2.75rem;
+}
+
.searchButton {
flex: 0 0 auto;
+ min-height: 2.75rem;
padding: 0.6875rem 1.25rem;
font-size: var(--step--1);
border-radius: var(--radius-sm);
@@ -217,34 +249,13 @@
text-align: center;
}
-/* ── Controls row (radius + phase + advanced toggle) ──────────────── */
+/* ── Controls row (distance, phase, type, more filters) ───────────── */
.controlsRow {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
- margin-top: 0.875rem;
- padding-top: 0.875rem;
- border-top: 1px solid var(--border);
-}
-
-.controlsRow .advancedToggle {
- margin-left: auto;
-}
-
-.radiusControl {
- display: flex;
- align-items: center;
- gap: 0.375rem;
- flex-shrink: 0;
-}
-
-.radiusLabel {
- font-size: var(--step--2);
- font-weight: 600;
- color: var(--text-secondary);
- white-space: nowrap;
}
/* ── Selects ──────────────────────────────────────────────────────── */
@@ -302,11 +313,24 @@
font-weight: 500;
}
+/* A pill, 44px tall: these are the page's main controls now, not fine print,
+ and a phone needs the full touch target. */
.controlSelect {
- padding: 0.4375rem 1.875rem 0.4375rem 0.75rem;
+ min-height: 2.75rem;
+ padding: 0 2.25rem 0 1rem;
+ border-color: var(--border-strong);
+ border-radius: 999px;
font-size: var(--step--1);
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;
+ text-overflow: ellipsis;
+}
+
+.selectShell:has(.controlSelect) .selectChevron {
+ right: 1rem;
}
.filterSelect:hover:not(:disabled),
@@ -336,10 +360,11 @@
}
.filters {
+ flex-basis: 100%;
display: flex;
gap: 0.625rem;
flex-wrap: wrap;
- margin-top: 0.75rem;
+ margin-top: 0.25rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border);
}
@@ -350,15 +375,16 @@
display: inline-flex;
align-items: center;
gap: 0.375rem;
- padding: 0.4375rem 0.75rem;
- background: none;
- border: 1px solid var(--border);
- border-radius: var(--radius-sm);
+ min-height: 2.75rem;
+ padding: 0 1rem;
+ background: var(--bg-card);
+ border: 1px solid var(--text-secondary);
+ border-radius: 999px;
font-family: var(--font-ui);
font-size: var(--step--1);
- font-weight: 500;
+ font-weight: 600;
line-height: 1;
- color: var(--text-secondary);
+ color: var(--text-primary);
cursor: pointer;
white-space: nowrap;
transition: border-color var(--transition), background-color var(--transition),
@@ -387,7 +413,8 @@
}
.clearButton {
- padding: 0.4375rem 0.875rem;
+ min-height: 2.75rem;
+ padding: 0 1rem;
font-size: var(--step--1);
font-weight: 500;
border-radius: var(--radius-sm);
@@ -413,31 +440,6 @@
/* ── Narrow ───────────────────────────────────────────────────────── */
@media (max-width: 768px) {
- /*
- * Scoped, like the two rules below it.
- *
- * The results filter bar is a card — background, border, shadow — and needs
- * inner padding. The hero's search is not a card: .heroMode zeroes the
- * padding, border and background so the search sits directly on the panel.
- * Unscoped, this rule put 14px back, which indented the search box, the hint
- * and the location link 14px past the headline they sit under, and cost the
- * search field 28px of width on a 390px screen.
- */
- .filterBar:not(.heroMode) {
- padding: 0.875rem;
- }
-
- /* The hero pill stays a pill at every width — stacking it would lose the
- one shape the page is built around. Only the results bar stacks. */
- .filterBar:not(.heroMode) .omniBoxContainer {
- flex-direction: column;
- align-items: stretch;
- }
-
- .filterBar:not(.heroMode) .searchButton {
- width: 100%;
- }
-
.filters {
flex-direction: column;
}
@@ -446,19 +448,6 @@
min-width: 100%;
}
- .controlsRow {
- gap: 0.5rem;
- }
-
- .controlsRow .advancedToggle {
- margin-left: 0;
- }
-
- .controlSelect {
- flex: 1;
- min-width: 140px;
- }
-
.searchHint {
text-align: left;
}
@@ -518,3 +507,93 @@
padding: 0.75rem 0.875rem;
}
}
+
+/*
+ * 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.
+ */
+@media (max-width: 640px) {
+ .filterBar:not(.heroMode) {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 0.625rem;
+ }
+
+ .filterBar:not(.heroMode) .searchSection {
+ flex: 0 0 auto;
+ }
+
+ .searchSummary {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ min-height: 2.75rem;
+ padding: 0 0.875rem;
+ background: var(--bg-card);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-md);
+ font-family: var(--font-ui);
+ font-size: var(--step--1);
+ color: var(--text-muted);
+ text-align: left;
+ cursor: pointer;
+ }
+
+ .summaryText {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+
+ .summaryText strong {
+ color: var(--text-primary);
+ font-weight: 700;
+ }
+
+ .summaryEdit {
+ flex-shrink: 0;
+ font-weight: 600;
+ color: var(--brand);
+ }
+
+ .searchFolded {
+ 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). */
+ .controlsRow {
+ flex-wrap: nowrap;
+ overflow-x: auto;
+ margin-inline: -1rem;
+ padding-inline: 1rem;
+ scrollbar-width: none;
+ mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
+ }
+
+ .controlsRow::-webkit-scrollbar {
+ display: none;
+ }
+
+ .controlsRow > * {
+ flex: 0 0 auto;
+ }
+
+ .controlsRow .advancedToggle {
+ order: -1;
+ }
+
+ /* An open panel scrolls inside the pinned toolbar instead of pushing the
+ results off a short screen. */
+ .filters {
+ max-height: 50dvh;
+ overflow-y: auto;
+ }
+}
diff --git a/nextjs-app/components/FilterBar.tsx b/nextjs-app/components/FilterBar.tsx
index b8d87c4..310292a 100644
--- a/nextjs-app/components/FilterBar.tsx
+++ b/nextjs-app/components/FilterBar.tsx
@@ -128,10 +128,11 @@ export function FilterBar({
const currentAdmissionsPolicy = searchParams.get("admissions_policy") || "";
const currentHasSixthForm = searchParams.get("has_sixth_form") || "";
- // Count active dropdown filters (not search/postcode, not phase since it's always visible)
+ // Count the filters behind "More filters". Search, postcode, distance, phase
+ // and school type each have their own control in the toolbar, so they are
+ // visible without opening anything and are not counted here.
const activeDropdownFilters = [
currentLA,
- currentType,
currentGender,
currentAdmissionsPolicy,
currentHasSixthForm,
@@ -144,6 +145,21 @@ export function FilterBar({
if (hasActiveDropdownFilters) setFiltersOpen(true);
}, [hasActiveDropdownFilters]);
+ /*
+ * On phones the results toolbar is pinned, so it has to stay short. Once a
+ * search has been made the form folds into a one-line summary of it, and
+ * tapping the summary unfolds the form again. Desktop never folds: the
+ * summary is hidden there by CSS and the form is always shown.
+ */
+ const currentQuery = currentPostcode || currentSearch;
+ const canFold = !isHero && !!currentQuery;
+ const [searchOpen, setSearchOpen] = useState(false);
+ const openSearch = () => {
+ setSearchOpen(true);
+ // After the form is displayed, or focus() lands on a hidden input.
+ requestAnimationFrame(() => inputRef.current?.focus());
+ };
+
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (
@@ -186,6 +202,7 @@ export function FilterBar({
// The search has been made; the suggestions that led to it are spent.
setHasTyped(false);
close();
+ setSearchOpen(false);
if (!omniValue.trim()) {
updateURL({ search: "", postcode: "", radius: "" });
return;
@@ -261,11 +278,43 @@ export function FilterBar({
// only ever additive, so the control's behaviour is untouched.
const activeIf = (value: string) => (value ? ` ${styles.selectActive}` : "");
+ const radiusLabel = `${currentRadius} mile${currentRadius === "1" ? "" : "s"}`;
+
return (