From 983a581555415aec31ec94c7032f8ed3648c499e Mon Sep 17 00:00:00 2001 From: Tudor Date: Wed, 30 Sep 2026 18:35:37 +0100 Subject: [PATCH 1/2] feat(search): pin the results toolbar and float the map button on phones The List/Map switch was a small grey control beside the results heading, and the filters were plain dropdowns labelled "All Phases" and "Advanced". Both scrolled away with the first result. Search, filters and the List/Map switch now share one card pinned under the header. Distance, phase and school type are pill controls in the row; "Advanced" becomes "More filters" and counts only what it hides. The switch is filled brand teal and says which view is on (aria-pressed). On phones the search folds to a one-line summary once made, the filter pills scroll sideways, and a floating Map/List button sits above the tab bar in place of the toolbar switch. The selected pin's card now stacks under that button instead of covering the tab bar. Switching view from far down the list scrolls back to the top of the results, and each switch is tracked as results_view_changed. Co-Authored-By: Claude Opus 5.5 --- e2e/tests/journeys.spec.ts | 77 +++++- .../components/FilterBarSuggest.test.tsx | 14 +- .../components/ResultsToolbar.test.tsx | 130 +++++++++++ nextjs-app/components/FilterBar.module.css | 221 ++++++++++++------ nextjs-app/components/FilterBar.tsx | 149 ++++++++---- nextjs-app/components/HomeView.module.css | 165 +++++++++---- nextjs-app/components/HomeView.tsx | 171 +++++++++----- nextjs-app/lib/analytics.ts | 3 +- 8 files changed, 716 insertions(+), 214 deletions(-) create mode 100644 nextjs-app/__tests__/components/ResultsToolbar.test.tsx 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 (
-
+ {canFold && !searchOpen && ( + + )} +
{isHero && (
-
- )}
) : ( /* List View Layout */ @@ -769,6 +803,39 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed )} )} + + {/* Phones and small tablets: the selected pin's card, and on phones the + floating List/Map button, stacked above the bottom tab bar. The + toolbar's switch is hidden at phone width, where this replaces it. */} + {hasViewSwitch && ( +
+ + {resultsView === 'map' && selectedMapSchool && ( +
+ + s.urn === selectedMapSchool.urn)} + nationalAvgRwm={nationalAvgRwm} + /> +
+ )} +
+ )} ); } diff --git a/nextjs-app/lib/analytics.ts b/nextjs-app/lib/analytics.ts index 591ed7d..502c059 100644 --- a/nextjs-app/lib/analytics.ts +++ b/nextjs-app/lib/analytics.ts @@ -33,7 +33,8 @@ export type EventName = | 'compare_focus_school' // Operational | 'api_error' - | 'results_load_more'; + | 'results_load_more' + | 'results_view_changed'; type Primitive = string | number | boolean; type Payload = Record; -- 2.54.0 From 367a07c15d9f042ca70c5d6fc4240fea150f8e4f Mon Sep 17 00:00:00 2001 From: Tudor Date: Wed, 30 Sep 2026 20:47:57 +0100 Subject: [PATCH 2/2] fix(search): address review on the results toolbar - Refold the phone search, and resync its text, when the query changes without a submit (back/forward, "Use my location"). The summary and the unfolded input could otherwise describe two different searches. - Cap the "More filters" panel at every width, not only on phones, so an open panel scrolls inside the pinned toolbar on a short screen. - Drop the phone filter row's right-edge fade once it is scrolled to the end or does not overflow (the MOBILE.md pattern), and give focus rings room inside the scroll clip. Co-Authored-By: Claude Opus 5.5 --- .../components/ResultsToolbar.test.tsx | 24 ++++++++++++ nextjs-app/components/FilterBar.module.css | 28 ++++++++------ nextjs-app/components/FilterBar.tsx | 37 ++++++++++++++++++- 3 files changed, 76 insertions(+), 13 deletions(-) diff --git a/nextjs-app/__tests__/components/ResultsToolbar.test.tsx b/nextjs-app/__tests__/components/ResultsToolbar.test.tsx index 85ceb14..4b50b2a 100644 --- a/nextjs-app/__tests__/components/ResultsToolbar.test.tsx +++ b/nextjs-app/__tests__/components/ResultsToolbar.test.tsx @@ -102,6 +102,14 @@ describe('the toolbar filters', () => { }); }); +describe('the phone filter row', () => { + it('drops its "more this way" fade when nothing is left to scroll', () => { + render(); + // jsdom lays nothing out, so the row reads as not overflowing at all. + expect(screen.getByRole('group', { name: 'Filters' }).className).toMatch(/controlsAtEnd/); + }); +}); + describe('the folded search', () => { it('summarises the search and unfolds on tap', () => { render(); @@ -119,6 +127,22 @@ describe('the folded search', () => { expect(screen.getByRole('button', { name: /Edit search/ })).toBeInTheDocument(); }); + it('refolds and shows the new text when the search changes some other way', () => { + const view = render(); + fireEvent.click(screen.getByRole('button', { name: /Edit search/ })); + fireEvent.change(screen.getByRole('searchbox', { name: 'School name or postcode' }), + { target: { value: 'half-typed' } }); + + // Back button: the URL changes under the component, nothing is submitted. + params = new URLSearchParams('postcode=SW170AA&radius=3'); + view.rerender(); + + expect(screen.getByRole('button', { name: 'Edit search: SW170AA, within 3 miles' })) + .toBeInTheDocument(); + expect(screen.getByRole('searchbox', { name: 'School name or postcode' })) + .toHaveValue('SW170AA'); + }); + it('never appears in the hero, or before anything has been searched', () => { const { unmount } = 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 45af2ec..0df34a8 100644 --- a/nextjs-app/components/FilterBar.module.css +++ b/nextjs-app/components/FilterBar.module.css @@ -359,13 +359,18 @@ border-color: var(--brand); } +/* Capped so an open panel scrolls inside the pinned toolbar instead of + pushing the results off a short screen. The 3px gutter keeps the selects' + focus rings clear of the scroll clip. */ .filters { flex-basis: 100%; display: flex; gap: 0.625rem; flex-wrap: wrap; - margin-top: 0.25rem; - padding-top: 0.75rem; + max-height: 50dvh; + overflow-y: auto; + margin: 0.25rem -3px 0; + padding: 0.75rem 3px 3px; border-top: 1px solid var(--border); } @@ -568,16 +573,22 @@ /* 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). */ + 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; overflow-x: auto; - margin-inline: -1rem; - padding-inline: 1rem; + margin: -4px -1rem; + padding: 4px 1rem; scrollbar-width: none; mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); } + .controlsAtEnd { + mask-image: none; + } + .controlsRow::-webkit-scrollbar { display: none; } @@ -589,11 +600,4 @@ .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 310292a..20f7dbf 100644 --- a/nextjs-app/components/FilterBar.tsx +++ b/nextjs-app/components/FilterBar.tsx @@ -154,6 +154,35 @@ export function FilterBar({ const currentQuery = currentPostcode || currentSearch; const canFold = !isHero && !!currentQuery; const [searchOpen, setSearchOpen] = useState(false); + + // A search that arrives any other way (back/forward, "Use my location", a + // link) must fold the form and show its own text, or the summary and the + // unfolded input would describe two different searches. + const [shownQuery, setShownQuery] = useState(currentQuery); + if (shownQuery !== currentQuery) { + setShownQuery(currentQuery); + setSearchOpen(false); + 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"). + const controlsRowRef = useRef(null); + const [controlsAtEnd, setControlsAtEnd] = useState(false); + const updateControlsAtEnd = useCallback(() => { + const el = controlsRowRef.current; + if (!el) return; + const overflow = el.scrollWidth - el.clientWidth; + setControlsAtEnd(overflow <= 1 || el.scrollLeft >= overflow - 2); + }, []); + useEffect(() => { + 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. + useEffect(updateControlsAtEnd); const openSearch = () => { setSearchOpen(true); // After the form is displayed, or focus() lands on a hidden input. @@ -423,7 +452,13 @@ export function FilterBar({ {/* Every control here is a real