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..4b50b2a --- /dev/null +++ b/nextjs-app/__tests__/components/ResultsToolbar.test.tsx @@ -0,0 +1,154 @@ +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 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(); + 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('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(); + 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..0df34a8 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), @@ -335,12 +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.75rem; - 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); } @@ -350,15 +380,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 +418,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 +445,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 +453,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 +512,92 @@ 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). 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: -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; + } + + .controlsRow > * { + flex: 0 0 auto; + } + + .controlsRow .advancedToggle { + order: -1; + } +} diff --git a/nextjs-app/components/FilterBar.tsx b/nextjs-app/components/FilterBar.tsx index b8d87c4..20f7dbf 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,50 @@ 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); + + // 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. + requestAnimationFrame(() => inputRef.current?.focus()); + }; + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ( @@ -186,6 +231,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 +307,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;