feat(search): pin the results toolbar and float the map button on phones #157

Merged
tudor merged 2 commits from feat/results-toolbar into main 2026-09-30 19:53:28 +00:00
8 changed files with 716 additions and 214 deletions
Showing only changes of commit 983a581555 - Show all commits

No files matched your search

+75 -2
View File
@@ -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);
@@ -32,9 +32,9 @@ afterEach(() => { global.fetch = realFetch; });
describe('FilterBar autosuggest', () => {
it('is a combobox only when the flag is on', () => {
const { rerender } = render(<FilterBar filters={FILTERS} autosuggest={false} />);
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
expect(screen.queryByRole('combobox', { name: 'School name or postcode' })).not.toBeInTheDocument();
rerender(<FilterBar filters={FILTERS} autosuggest />);
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(<FilterBar filters={FILTERS} autosuggest />);
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(<FilterBar filters={FILTERS} autosuggest />);
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(<FilterBar filters={FILTERS} autosuggest />);
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(<FilterBar filters={FILTERS} autosuggest />);
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(<FilterBar filters={FILTERS} autosuggest />);
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();
@@ -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: () => <div data-testid="map" /> }));
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(<HomeView initialSchools={results()} filters={filters} />);
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(<HomeView initialSchools={results()} filters={filters} />);
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(<HomeView initialSchools={results()} filters={filters} />);
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(<HomeView initialSchools={results()} filters={filters} />);
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(<FilterBar filters={filters} />);
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(<FilterBar filters={filters} />);
expect(screen.getByRole('button', { name: /More filters \(1\)/ })).toBeInTheDocument();
});
});
describe('the folded search', () => {
it('summarises the search and unfolds on tap', () => {
render(<FilterBar filters={filters} />);
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(<FilterBar filters={filters} />);
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(<FilterBar filters={filters} isHero />);
expect(screen.queryByRole('button', { name: /Edit search/ })).not.toBeInTheDocument();
unmount();
params = new URLSearchParams('local_authority=Wandsworth');
render(<FilterBar filters={filters} />);
expect(screen.queryByRole('button', { name: /Edit search/ })).not.toBeInTheDocument();
});
});
+150 -71
View File
@@ -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;
}
}
+109 -40
View File
@@ -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 (
<div
className={`${styles.filterBar} ${isPending ? styles.isLoading : ""} ${isHero ? styles.heroMode : ""}`}
>
<form onSubmit={handleSearchSubmit} className={styles.searchSection}>
{canFold && !searchOpen && (
<button
type="button"
className={styles.searchSummary}
onClick={openSearch}
aria-label={`Edit search: ${currentQuery}${currentPostcode ? `, within ${radiusLabel}` : ""}`}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden="true"
>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
<span className={styles.summaryText}>
<strong>{currentQuery}</strong>
{currentPostcode && <span> · within {radiusLabel}</span>}
</span>
<span className={styles.summaryEdit} aria-hidden="true">Edit</span>
</button>
)}
<form
onSubmit={handleSearchSubmit}
className={`${styles.searchSection}${canFold && !searchOpen ? ` ${styles.searchFolded}` : ""}`}
>
<div className={styles.omniBoxContainer}>
{isHero && (
<span className={styles.omniIcon} aria-hidden="true">
@@ -292,6 +341,7 @@ export function FilterBar({
onKeyDown={handleOmniKeyDown}
onBlur={close}
placeholder="School name or postcode"
aria-label="School name or postcode"
className={styles.omniInput}
{...(autosuggest ? {
role: "combobox",
@@ -370,24 +420,25 @@ export function FilterBar({
{!isHero && (
<>
<div className={styles.controlsRow}>
{/* Every control here is a real <select> or <button>, drawn as a
pill. On phones the row scrolls sideways rather than wrapping, so
the pinned toolbar stays two lines tall. */}
<div className={styles.controlsRow} role="group" aria-label="Filters">
{currentPostcode && (
<div className={styles.radiusControl}>
<label className={styles.radiusLabel}>Within:</label>
<SelectShell>
<select
value={currentRadius}
onChange={(e) => updateURL({ radius: e.target.value })}
className={styles.controlSelect}
disabled={isPending}
>
<option value="0.5">0.5 miles</option>
<option value="1">1 mile</option>
<option value="3">3 miles</option>
<option value="5">5 miles</option>
</select>
</SelectShell>
</div>
<SelectShell>
<select
value={currentRadius}
onChange={(e) => updateURL({ radius: e.target.value })}
className={`${styles.controlSelect} ${styles.selectActive}`}
aria-label="Distance"
disabled={isPending}
>
<option value="0.5">Within 0.5 miles</option>
<option value="1">Within 1 mile</option>
<option value="3">Within 3 miles</option>
<option value="5">Within 5 miles</option>
</select>
</SelectShell>
)}
{phaseOptions.length > 0 && (
@@ -396,9 +447,10 @@ export function FilterBar({
value={currentPhase}
onChange={(e) => handleFilterChange("phase", e.target.value)}
className={`${styles.controlSelect}${activeIf(currentPhase)}`}
aria-label="Phase"
disabled={isPending}
>
<option value="">All Phases</option>
<option value="">Any phase</option>
{phaseOptions.map((p) => (
<option key={p} value={p.toLowerCase()}>
{p}
@@ -408,13 +460,47 @@ export function FilterBar({
</SelectShell>
)}
<SelectShell>
<select
value={currentType}
onChange={(e) =>
handleFilterChange("school_type", e.target.value)
}
className={`${styles.controlSelect}${activeIf(currentType)}`}
aria-label="School type"
disabled={isPending}
>
<option value="">Any school type</option>
{typeOptions.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</SelectShell>
<button
type="button"
className={`${styles.advancedToggle}${hasActiveDropdownFilters ? ` ${styles.advancedToggleActive}` : ''}`}
onClick={() => setFiltersOpen((v) => !v)}
aria-expanded={filtersOpen}
>
{hasActiveDropdownFilters ? 'Filters' : 'Advanced'}
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden="true"
>
<path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12" />
<circle cx="16" cy="6" r="2" />
<circle cx="10" cy="12" r="2" />
<circle cx="18" cy="18" r="2" />
</svg>
More filters
{hasActiveDropdownFilters
? ` (${activeDropdownFilters.length})`
: ""}
@@ -444,6 +530,7 @@ export function FilterBar({
handleFilterChange("local_authority", e.target.value)
}
className={`${styles.filterSelect}${activeIf(currentLA)}`}
aria-label="Local authority"
disabled={isPending}
>
<option value="">All Local Authorities</option>
@@ -455,24 +542,6 @@ export function FilterBar({
</select>
</SelectShell>
<SelectShell wide>
<select
value={currentType}
onChange={(e) =>
handleFilterChange("school_type", e.target.value)
}
className={`${styles.filterSelect}${activeIf(currentType)}`}
disabled={isPending}
>
<option value="">All School Types</option>
{typeOptions.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</SelectShell>
{isSecondaryMode && (
<>
{genderOptions.length > 0 && (
+124 -41
View File
@@ -484,42 +484,84 @@
}
}
/* View Toggle */
.viewToggle {
/* ── Results toolbar ───────────────────────────────────────────────────────
Search, filters and the List/Map switch in one card, pinned under the
header. It sits above Leaflet's controls (z-index 1000), which scroll up
beneath it, and below the comparison toast and the map's own fullscreen
overlay. */
.resultsToolbar {
position: sticky;
top: calc(64px + 0.5rem);
z-index: 1001;
display: flex;
gap: 0.2rem;
background: var(--bg-secondary);
padding: 0.2rem;
border-radius: 6px;
}
.viewToggleBtn {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.375rem 0.625rem;
font-size: 0.8125rem;
font-weight: 500;
background: transparent;
border: none;
border-radius: 5px;
cursor: pointer;
color: var(--text-secondary);
transition: all 0.2s ease;
}
.viewToggleBtn:hover {
color: var(--text-primary);
}
.viewToggleBtn.active {
align-items: flex-start;
gap: 0.75rem;
margin-bottom: 1.25rem;
padding: 0.875rem 1rem;
background: var(--bg-card);
color: var(--brand-strong);
box-shadow: 0 2px 4px rgba(var(--shadow-rgb), 0.08);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-soft);
}
.viewToggleBtn svg {
.resultsToolbar > :first-child {
flex: 1;
min-width: 0;
}
/* The one filled control in the toolbar: which view you are in is the first
thing a results page should answer. */
.viewSwitch {
flex-shrink: 0;
display: flex;
gap: 2px;
padding: 1px;
background: var(--bg-card);
border: 1.5px solid var(--brand);
border-radius: var(--radius-md);
}
.viewSwitchBtn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
min-height: 2.5rem;
padding: 0 1.125rem;
background: transparent;
border: 0;
border-radius: calc(var(--radius-md) - 3px);
font-family: var(--font-ui);
font-size: var(--step--1);
font-weight: 700;
color: var(--brand-strong);
cursor: pointer;
transition: background-color var(--transition), color var(--transition);
}
.viewSwitchBtn:hover {
background: var(--brand-bg);
}
.viewSwitchBtn[aria-pressed='true'] {
background: var(--brand);
color: var(--brand-on);
}
/* Phones: a full-bleed band flush under the header, and the switch gives way
to the floating button in .mobileDock. */
@media (max-width: 640px) {
.resultsToolbar {
top: 56px;
margin: -1rem -1rem 1rem;
padding: 0.75rem 1rem;
border-width: 0 0 1px;
border-radius: 0;
box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.06);
}
.viewSwitch {
display: none;
}
}
.results {
@@ -744,11 +786,6 @@
justify-content: space-between;
}
.viewToggle {
justify-content: center;
flex-shrink: 0;
}
/* The sort <select> sizes to its widest option ("Highest Reading, Writing
& Maths %"), which overflows a phone viewport — beside the view toggle it
ran off the right edge. Let it flex into the remaining space and shrink;
@@ -801,24 +838,38 @@
background: var(--bg-secondary);
}
/* Mobile Bottom Sheet */
.bottomSheetWrapper {
/* ── Mobile dock ───────────────────────────────────────────────────────────
The floating List/Map button (phones only) above the selected pin's card.
A column, so the button always clears the card instead of covering it. */
.mobileDock {
display: none;
}
.viewFab {
display: none;
}
@media (max-width: 768px) {
.bottomSheetWrapper {
display: block;
.mobileDock {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
position: fixed;
bottom: 0;
left: 0;
width: 100%;
z-index: 1000;
z-index: 1002;
padding: 1rem;
pointer-events: none;
}
.mobileDock > * {
pointer-events: auto;
}
.bottomSheet {
align-self: stretch;
position: relative;
background: var(--bg-card);
border-radius: 12px;
@@ -883,6 +934,38 @@
}
}
/* Above the tab bar, and riding with it when iOS Chrome's URL bar hides
(MOBILE.md, check 4). */
@media (max-width: 640px) {
.mobileDock {
bottom: calc(56px + env(safe-area-inset-bottom, 0px));
padding: 0.75rem;
transform: translate3d(0, var(--mobile-bar-offset, 0px), 0);
}
.viewFab {
display: inline-flex;
align-items: center;
gap: 0.5rem;
min-height: 3rem;
padding: 0 1.375rem;
background: var(--text-primary);
color: var(--bg-primary);
border: 0;
border-radius: 999px;
box-shadow: 0 8px 20px rgba(var(--shadow-rgb), 0.32);
font-family: var(--font-ui);
font-size: var(--step--1);
font-weight: 700;
cursor: pointer;
}
/* Room under the last card so the floating button never sits on it. */
.hasViewFab {
padding-bottom: 4.5rem;
}
}
.quickSearches {
display: flex;
align-items: center;
+119 -52
View File
@@ -135,6 +135,27 @@ function CalendarIcon() {
);
}
/* List / Map glyphs for the results view switch and its floating twin. */
function ListIcon() {
return (
<svg {...ICON_PROPS} width={18} height={18}>
<path d="M9 6h11M9 12h11M9 18h11" />
<circle cx="4.5" cy="6" r="1" />
<circle cx="4.5" cy="12" r="1" />
<circle cx="4.5" cy="18" r="1" />
</svg>
);
}
function MapIcon() {
return (
<svg {...ICON_PROPS} width={18} height={18}>
<path d="M9 4 3 6v14l6-2 6 2 6-2V4l-6 2-6-2Z" />
<path d="M9 4v14M15 6v14" />
</svg>
);
}
interface ValueProp {
icon: React.ReactNode;
tintClass: string;
@@ -213,6 +234,8 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
const [mapSchools, setMapSchools] = useState<School[]>([]);
const [isLoadingMap, setIsLoadingMap] = useState(false);
const prevSearchParamsRef = useRef(searchParams.toString());
const toolbarRef = useRef<HTMLDivElement>(null);
const resultsRef = useRef<HTMLElement>(null);
const mapParamsRef = useRef<string>('');
const loadMoreController = useRef<AbortController | null>(null);
// Identity changes even for A → B → A, so an old A response stays stale.
@@ -449,6 +472,30 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
});
}, [addSchool, selectedSchools.length]);
const hasViewSwitch = isLocationSearch && initialSchools.schools.length > 0;
/*
* Switching view from far down the list would otherwise leave the reader
* looking at whatever now sits at that scroll offset, usually the footer,
* because the map layout is much shorter than a long list. Bring the top of
* the results back to just under the pinned toolbar instead.
*/
const changeView = (view: 'list' | 'map', via: 'toolbar' | 'floating') => {
if (view === resultsView) return;
setResultsView(view);
track('results_view_changed', { view, via });
requestAnimationFrame(() => {
const results = resultsRef.current;
const toolbar = toolbarRef.current;
if (!results || !toolbar) return;
const toolbarBottom = toolbar.getBoundingClientRect().bottom;
const resultsTop = results.getBoundingClientRect().top;
if (resultsTop < toolbarBottom) {
window.scrollTo({ top: window.scrollY + resultsTop - toolbarBottom - 12 });
}
});
};
return (
/* The landing arrangement owns its own vertical rhythm (one gap, set in
CSS); the search arrangement is a filter bar directly above its results
@@ -523,15 +570,41 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
</ul>
</div>
) : (
<FilterBar
filters={filters}
isHero={false}
resultFilters={initialSchools.result_filters}
onNearMe={handleNearMe}
geoState={geoState}
geoError={geoError}
autosuggest={autosuggest}
/>
/* Pinned under the header, so the search, the filters and the List/Map
switch stay in reach however far down the results a reader is. */
<div ref={toolbarRef} className={styles.resultsToolbar}>
<FilterBar
filters={filters}
isHero={false}
resultFilters={initialSchools.result_filters}
onNearMe={handleNearMe}
geoState={geoState}
geoError={geoError}
autosuggest={autosuggest}
/>
{hasViewSwitch && (
<div className={styles.viewSwitch} role="group" aria-label="Results view">
<button
type="button"
className={styles.viewSwitchBtn}
aria-pressed={resultsView === 'list'}
onClick={() => changeView('list', 'toolbar')}
>
<ListIcon />
List
</button>
<button
type="button"
className={styles.viewSwitchBtn}
aria-pressed={resultsView === 'map'}
onClick={() => changeView('map', 'toolbar')}
>
<MapIcon />
Map
</button>
</div>
)}
</div>
)}
{/* Next admissions deadline — one bar, not four cards. */}
@@ -583,7 +656,10 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
nothing to list — an empty <section> is still a flex child, so it was
contributing a full section gap of blank space above the footer. */}
{(isSearchActive || initialSchools.schools.length > 0) && (
<section className={`${styles.results} ${resultsView === 'map' && isLocationSearch ? styles.mapViewResults : ''}`}>
<section
ref={resultsRef}
className={`${styles.results} ${resultsView === 'map' && isLocationSearch ? styles.mapViewResults : ''} ${hasViewSwitch ? styles.hasViewFab : ''}`}
>
{!hasSearch && initialSchools.schools.length > 0 && (
<div className={styles.sectionHeader}>
<h2>Featured Schools</h2>
@@ -602,34 +678,6 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
}
</h2>
<div className={styles.resultsHeaderActions}>
{isLocationSearch && initialSchools.schools.length > 0 && (
<div className={styles.viewToggle}>
<button
className={`${styles.viewToggleBtn} ${resultsView === 'list' ? styles.active : ''}`}
onClick={() => setResultsView('list')}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16">
<line x1="8" y1="6" x2="21" y2="6"/>
<line x1="8" y1="12" x2="21" y2="12"/>
<line x1="8" y1="18" x2="21" y2="18"/>
<line x1="3" y1="6" x2="3.01" y2="6"/>
<line x1="3" y1="12" x2="3.01" y2="12"/>
<line x1="3" y1="18" x2="3.01" y2="18"/>
</svg>
List
</button>
<button
className={`${styles.viewToggleBtn} ${resultsView === 'map' ? styles.active : ''}`}
onClick={() => setResultsView('map')}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
Map
</button>
</div>
)}
{resultsView === 'list' && (
<select
value={sortOrder}
@@ -705,20 +753,6 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
))}
</div>
{/* Mobile Bottom Sheet for Selected Map Pin */}
{selectedMapSchool && (
<div className={styles.bottomSheetWrapper}>
<div className={styles.bottomSheet}>
<button className={styles.closeSheetBtn} onClick={() => setSelectedMapSchool(null)}>×</button>
<CompactSchoolItem
school={selectedMapSchool}
onAddToCompare={addSchoolFromSearch}
isInCompare={selectedSchools.some(s => s.urn === selectedMapSchool.urn)}
nationalAvgRwm={nationalAvgRwm}
/>
</div>
</div>
)}
</div>
) : (
/* List View Layout */
@@ -769,6 +803,39 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
)}
</section>
)}
{/* 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 && (
<div className={styles.mobileDock}>
<button
type="button"
className={styles.viewFab}
aria-label={resultsView === 'list' ? 'Show map' : 'Show list'}
onClick={() => changeView(resultsView === 'list' ? 'map' : 'list', 'floating')}
>
{resultsView === 'list' ? <><MapIcon />Map</> : <><ListIcon />List</>}
</button>
{resultsView === 'map' && selectedMapSchool && (
<div className={styles.bottomSheet}>
<button
className={styles.closeSheetBtn}
onClick={() => setSelectedMapSchool(null)}
aria-label="Close"
>
×
</button>
<CompactSchoolItem
school={selectedMapSchool}
onAddToCompare={addSchoolFromSearch}
isInCompare={selectedSchools.some(s => s.urn === selectedMapSchool.urn)}
nationalAvgRwm={nationalAvgRwm}
/>
</div>
)}
</div>
)}
</div>
);
}
+2 -1
View File
@@ -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<string, Primitive>;