feat(search): pin the results toolbar and float the map button on phones
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 33s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m16s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 26s
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 33s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m16s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 26s
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
b34feb8e98
commit
983a581555
8 files changed
+716
-214
No files matched your search
@@ -503,8 +503,9 @@ test('results map fullscreen falls back to an overlay on iOS', async ({ page })
|
|||||||
await searchByName(page, 'B1 1BB');
|
await searchByName(page, 'B1 1BB');
|
||||||
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
// Switch to the map view, then open the map fullscreen.
|
// Switch to the map view with the floating button (the toolbar's switch is
|
||||||
await page.getByRole('button', { name: 'Map', exact: true }).click();
|
// 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' });
|
const openFs = page.getByRole('button', { name: 'View map fullscreen' });
|
||||||
await expect(openFs).toBeVisible({ timeout: 15_000 });
|
await expect(openFs).toBeVisible({ timeout: 15_000 });
|
||||||
await openFs.click();
|
await openFs.click();
|
||||||
@@ -516,6 +517,78 @@ test('results map fullscreen falls back to an overlay on iOS', async ({ page })
|
|||||||
await expect(openFs).toBeVisible();
|
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 }) => {
|
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.
|
// Two same-phase (pure primary) schools so both stay on one tab.
|
||||||
const [urn0, urn1] = await twoPrimaryUrns(page);
|
const [urn0, urn1] = await twoPrimaryUrns(page);
|
||||||
|
|||||||
@@ -32,9 +32,9 @@ afterEach(() => { global.fetch = realFetch; });
|
|||||||
describe('FilterBar autosuggest', () => {
|
describe('FilterBar autosuggest', () => {
|
||||||
it('is a combobox only when the flag is on', () => {
|
it('is a combobox only when the flag is on', () => {
|
||||||
const { rerender } = render(<FilterBar filters={FILTERS} autosuggest={false} />);
|
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 />);
|
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 () => {
|
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 () => {
|
it('shows suggestions and navigates when one is chosen', async () => {
|
||||||
render(<FilterBar filters={FILTERS} autosuggest />);
|
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/ });
|
const option = await screen.findByRole('option', { name: /Brecknock/ });
|
||||||
await userEvent.click(option);
|
await userEvent.click(option);
|
||||||
expect(push).toHaveBeenCalledWith(
|
expect(push).toHaveBeenCalledWith(
|
||||||
@@ -61,7 +61,7 @@ describe('FilterBar autosuggest', () => {
|
|||||||
// "NW1" is not a postcode, so a request for it is correct behaviour. Only
|
// "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.
|
// the settled value is the assertion, so set it in one go.
|
||||||
render(<FilterBar filters={FILTERS} autosuggest />);
|
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
|
await new Promise((r) => setTimeout(r, 300)); // past the 200ms debounce
|
||||||
expect(global.fetch).not.toHaveBeenCalled();
|
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 () => {
|
it('Enter with no active option still submits the free-text search', async () => {
|
||||||
// The existing behaviour is preserved, not replaced.
|
// The existing behaviour is preserved, not replaced.
|
||||||
render(<FilterBar filters={FILTERS} autosuggest />);
|
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}');
|
await userEvent.type(input, 'brecknock{Enter}');
|
||||||
// updateURL pushes inside startTransition, so the call is not synchronous.
|
// updateURL pushes inside startTransition, so the call is not synchronous.
|
||||||
await waitFor(() => expect(push).toHaveBeenCalledWith(
|
await waitFor(() => expect(push).toHaveBeenCalledWith(
|
||||||
@@ -90,7 +90,7 @@ describe('FilterBar autosuggest does not reopen over results', () => {
|
|||||||
searchParams = new URLSearchParams('search=brecknock');
|
searchParams = new URLSearchParams('search=brecknock');
|
||||||
render(<FilterBar filters={FILTERS} autosuggest />);
|
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
|
await new Promise((r) => setTimeout(r, 300)); // past the 200ms debounce
|
||||||
expect(global.fetch).not.toHaveBeenCalled();
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
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 () => {
|
it('closes the dropdown when the search is submitted', async () => {
|
||||||
render(<FilterBar filters={FILTERS} autosuggest />);
|
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');
|
await userEvent.type(input, 'brecknock');
|
||||||
expect(await screen.findByRole('listbox')).toBeInTheDocument();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,8 +4,10 @@
|
|||||||
* hero a single white pill on the Sand hero panel, carrying the one
|
* 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
|
* 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.
|
* 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
|
* compact the results toolbar — search, then a row of pill controls. The
|
||||||
* controls, brand for what is active, sage for what is selected.
|
* 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
|
* Colour comes from the token layer only; the disclosure arrows are drawn
|
||||||
* from currentColor rather than a background image so they follow the theme.
|
* from currentColor rather than a background image so they follow the theme.
|
||||||
@@ -38,6 +40,31 @@
|
|||||||
margin: 0 auto 1.5rem;
|
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 ───────────────────────────────────────────────────────── */
|
/* ── Search ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.searchSection {
|
.searchSection {
|
||||||
@@ -115,8 +142,13 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filterBar:not(.heroMode) .omniInput {
|
||||||
|
min-height: 2.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.searchButton {
|
.searchButton {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
min-height: 2.75rem;
|
||||||
padding: 0.6875rem 1.25rem;
|
padding: 0.6875rem 1.25rem;
|
||||||
font-size: var(--step--1);
|
font-size: var(--step--1);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
@@ -217,34 +249,13 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Controls row (radius + phase + advanced toggle) ──────────────── */
|
/* ── Controls row (distance, phase, type, more filters) ───────────── */
|
||||||
|
|
||||||
.controlsRow {
|
.controlsRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
flex-wrap: wrap;
|
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 ──────────────────────────────────────────────────────── */
|
/* ── Selects ──────────────────────────────────────────────────────── */
|
||||||
@@ -302,11 +313,24 @@
|
|||||||
font-weight: 500;
|
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 {
|
.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-size: var(--step--1);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
white-space: nowrap;
|
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),
|
.filterSelect:hover:not(:disabled),
|
||||||
@@ -336,10 +360,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.filters {
|
.filters {
|
||||||
|
flex-basis: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.625rem;
|
gap: 0.625rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.25rem;
|
||||||
padding-top: 0.75rem;
|
padding-top: 0.75rem;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
@@ -350,15 +375,16 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.375rem;
|
gap: 0.375rem;
|
||||||
padding: 0.4375rem 0.75rem;
|
min-height: 2.75rem;
|
||||||
background: none;
|
padding: 0 1rem;
|
||||||
border: 1px solid var(--border);
|
background: var(--bg-card);
|
||||||
border-radius: var(--radius-sm);
|
border: 1px solid var(--text-secondary);
|
||||||
|
border-radius: 999px;
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: var(--step--1);
|
font-size: var(--step--1);
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: var(--text-secondary);
|
color: var(--text-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
transition: border-color var(--transition), background-color var(--transition),
|
transition: border-color var(--transition), background-color var(--transition),
|
||||||
@@ -387,7 +413,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.clearButton {
|
.clearButton {
|
||||||
padding: 0.4375rem 0.875rem;
|
min-height: 2.75rem;
|
||||||
|
padding: 0 1rem;
|
||||||
font-size: var(--step--1);
|
font-size: var(--step--1);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
@@ -413,31 +440,6 @@
|
|||||||
/* ── Narrow ───────────────────────────────────────────────────────── */
|
/* ── Narrow ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@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 {
|
.filters {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -446,19 +448,6 @@
|
|||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controlsRow {
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controlsRow .advancedToggle {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controlSelect {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 140px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchHint {
|
.searchHint {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
@@ -518,3 +507,93 @@
|
|||||||
padding: 0.75rem 0.875rem;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,10 +128,11 @@ export function FilterBar({
|
|||||||
const currentAdmissionsPolicy = searchParams.get("admissions_policy") || "";
|
const currentAdmissionsPolicy = searchParams.get("admissions_policy") || "";
|
||||||
const currentHasSixthForm = searchParams.get("has_sixth_form") || "";
|
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 = [
|
const activeDropdownFilters = [
|
||||||
currentLA,
|
currentLA,
|
||||||
currentType,
|
|
||||||
currentGender,
|
currentGender,
|
||||||
currentAdmissionsPolicy,
|
currentAdmissionsPolicy,
|
||||||
currentHasSixthForm,
|
currentHasSixthForm,
|
||||||
@@ -144,6 +145,21 @@ export function FilterBar({
|
|||||||
if (hasActiveDropdownFilters) setFiltersOpen(true);
|
if (hasActiveDropdownFilters) setFiltersOpen(true);
|
||||||
}, [hasActiveDropdownFilters]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (
|
if (
|
||||||
@@ -186,6 +202,7 @@ export function FilterBar({
|
|||||||
// The search has been made; the suggestions that led to it are spent.
|
// The search has been made; the suggestions that led to it are spent.
|
||||||
setHasTyped(false);
|
setHasTyped(false);
|
||||||
close();
|
close();
|
||||||
|
setSearchOpen(false);
|
||||||
if (!omniValue.trim()) {
|
if (!omniValue.trim()) {
|
||||||
updateURL({ search: "", postcode: "", radius: "" });
|
updateURL({ search: "", postcode: "", radius: "" });
|
||||||
return;
|
return;
|
||||||
@@ -261,11 +278,43 @@ export function FilterBar({
|
|||||||
// only ever additive, so the control's behaviour is untouched.
|
// only ever additive, so the control's behaviour is untouched.
|
||||||
const activeIf = (value: string) => (value ? ` ${styles.selectActive}` : "");
|
const activeIf = (value: string) => (value ? ` ${styles.selectActive}` : "");
|
||||||
|
|
||||||
|
const radiusLabel = `${currentRadius} mile${currentRadius === "1" ? "" : "s"}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${styles.filterBar} ${isPending ? styles.isLoading : ""} ${isHero ? styles.heroMode : ""}`}
|
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}>
|
<div className={styles.omniBoxContainer}>
|
||||||
{isHero && (
|
{isHero && (
|
||||||
<span className={styles.omniIcon} aria-hidden="true">
|
<span className={styles.omniIcon} aria-hidden="true">
|
||||||
@@ -292,6 +341,7 @@ export function FilterBar({
|
|||||||
onKeyDown={handleOmniKeyDown}
|
onKeyDown={handleOmniKeyDown}
|
||||||
onBlur={close}
|
onBlur={close}
|
||||||
placeholder="School name or postcode"
|
placeholder="School name or postcode"
|
||||||
|
aria-label="School name or postcode"
|
||||||
className={styles.omniInput}
|
className={styles.omniInput}
|
||||||
{...(autosuggest ? {
|
{...(autosuggest ? {
|
||||||
role: "combobox",
|
role: "combobox",
|
||||||
@@ -370,24 +420,25 @@ export function FilterBar({
|
|||||||
|
|
||||||
{!isHero && (
|
{!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 && (
|
{currentPostcode && (
|
||||||
<div className={styles.radiusControl}>
|
<SelectShell>
|
||||||
<label className={styles.radiusLabel}>Within:</label>
|
<select
|
||||||
<SelectShell>
|
value={currentRadius}
|
||||||
<select
|
onChange={(e) => updateURL({ radius: e.target.value })}
|
||||||
value={currentRadius}
|
className={`${styles.controlSelect} ${styles.selectActive}`}
|
||||||
onChange={(e) => updateURL({ radius: e.target.value })}
|
aria-label="Distance"
|
||||||
className={styles.controlSelect}
|
disabled={isPending}
|
||||||
disabled={isPending}
|
>
|
||||||
>
|
<option value="0.5">Within 0.5 miles</option>
|
||||||
<option value="0.5">0.5 miles</option>
|
<option value="1">Within 1 mile</option>
|
||||||
<option value="1">1 mile</option>
|
<option value="3">Within 3 miles</option>
|
||||||
<option value="3">3 miles</option>
|
<option value="5">Within 5 miles</option>
|
||||||
<option value="5">5 miles</option>
|
</select>
|
||||||
</select>
|
</SelectShell>
|
||||||
</SelectShell>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{phaseOptions.length > 0 && (
|
{phaseOptions.length > 0 && (
|
||||||
@@ -396,9 +447,10 @@ export function FilterBar({
|
|||||||
value={currentPhase}
|
value={currentPhase}
|
||||||
onChange={(e) => handleFilterChange("phase", e.target.value)}
|
onChange={(e) => handleFilterChange("phase", e.target.value)}
|
||||||
className={`${styles.controlSelect}${activeIf(currentPhase)}`}
|
className={`${styles.controlSelect}${activeIf(currentPhase)}`}
|
||||||
|
aria-label="Phase"
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
<option value="">All Phases</option>
|
<option value="">Any phase</option>
|
||||||
{phaseOptions.map((p) => (
|
{phaseOptions.map((p) => (
|
||||||
<option key={p} value={p.toLowerCase()}>
|
<option key={p} value={p.toLowerCase()}>
|
||||||
{p}
|
{p}
|
||||||
@@ -408,13 +460,47 @@ export function FilterBar({
|
|||||||
</SelectShell>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${styles.advancedToggle}${hasActiveDropdownFilters ? ` ${styles.advancedToggleActive}` : ''}`}
|
className={`${styles.advancedToggle}${hasActiveDropdownFilters ? ` ${styles.advancedToggleActive}` : ''}`}
|
||||||
onClick={() => setFiltersOpen((v) => !v)}
|
onClick={() => setFiltersOpen((v) => !v)}
|
||||||
aria-expanded={filtersOpen}
|
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
|
{hasActiveDropdownFilters
|
||||||
? ` (${activeDropdownFilters.length})`
|
? ` (${activeDropdownFilters.length})`
|
||||||
: ""}
|
: ""}
|
||||||
@@ -444,6 +530,7 @@ export function FilterBar({
|
|||||||
handleFilterChange("local_authority", e.target.value)
|
handleFilterChange("local_authority", e.target.value)
|
||||||
}
|
}
|
||||||
className={`${styles.filterSelect}${activeIf(currentLA)}`}
|
className={`${styles.filterSelect}${activeIf(currentLA)}`}
|
||||||
|
aria-label="Local authority"
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
<option value="">All Local Authorities</option>
|
<option value="">All Local Authorities</option>
|
||||||
@@ -455,24 +542,6 @@ export function FilterBar({
|
|||||||
</select>
|
</select>
|
||||||
</SelectShell>
|
</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 && (
|
{isSecondaryMode && (
|
||||||
<>
|
<>
|
||||||
{genderOptions.length > 0 && (
|
{genderOptions.length > 0 && (
|
||||||
|
|||||||
@@ -484,42 +484,84 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* View Toggle */
|
/* ── Results toolbar ───────────────────────────────────────────────────────
|
||||||
.viewToggle {
|
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;
|
display: flex;
|
||||||
gap: 0.2rem;
|
align-items: flex-start;
|
||||||
background: var(--bg-secondary);
|
gap: 0.75rem;
|
||||||
padding: 0.2rem;
|
margin-bottom: 1.25rem;
|
||||||
border-radius: 6px;
|
padding: 0.875rem 1rem;
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
color: var(--brand-strong);
|
border: 1px solid var(--border);
|
||||||
box-shadow: 0 2px 4px rgba(var(--shadow-rgb), 0.08);
|
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;
|
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 {
|
.results {
|
||||||
@@ -744,11 +786,6 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewToggle {
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The sort <select> sizes to its widest option ("Highest Reading, Writing
|
/* The sort <select> sizes to its widest option ("Highest Reading, Writing
|
||||||
& Maths %"), which overflows a phone viewport — beside the view toggle it
|
& 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;
|
ran off the right edge. Let it flex into the remaining space and shrink;
|
||||||
@@ -801,24 +838,38 @@
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile Bottom Sheet */
|
/* ── Mobile dock ───────────────────────────────────────────────────────────
|
||||||
.bottomSheetWrapper {
|
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;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.bottomSheetWrapper {
|
.mobileDock {
|
||||||
display: block;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
z-index: 1000;
|
z-index: 1002;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobileDock > * {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.bottomSheet {
|
.bottomSheet {
|
||||||
|
align-self: stretch;
|
||||||
position: relative;
|
position: relative;
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border-radius: 12px;
|
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 {
|
.quickSearches {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -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 {
|
interface ValueProp {
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
tintClass: string;
|
tintClass: string;
|
||||||
@@ -213,6 +234,8 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
const [mapSchools, setMapSchools] = useState<School[]>([]);
|
const [mapSchools, setMapSchools] = useState<School[]>([]);
|
||||||
const [isLoadingMap, setIsLoadingMap] = useState(false);
|
const [isLoadingMap, setIsLoadingMap] = useState(false);
|
||||||
const prevSearchParamsRef = useRef(searchParams.toString());
|
const prevSearchParamsRef = useRef(searchParams.toString());
|
||||||
|
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||||
|
const resultsRef = useRef<HTMLElement>(null);
|
||||||
const mapParamsRef = useRef<string>('');
|
const mapParamsRef = useRef<string>('');
|
||||||
const loadMoreController = useRef<AbortController | null>(null);
|
const loadMoreController = useRef<AbortController | null>(null);
|
||||||
// Identity changes even for A → B → A, so an old A response stays stale.
|
// 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]);
|
}, [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 (
|
return (
|
||||||
/* The landing arrangement owns its own vertical rhythm (one gap, set in
|
/* The landing arrangement owns its own vertical rhythm (one gap, set in
|
||||||
CSS); the search arrangement is a filter bar directly above its results
|
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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<FilterBar
|
/* Pinned under the header, so the search, the filters and the List/Map
|
||||||
filters={filters}
|
switch stay in reach however far down the results a reader is. */
|
||||||
isHero={false}
|
<div ref={toolbarRef} className={styles.resultsToolbar}>
|
||||||
resultFilters={initialSchools.result_filters}
|
<FilterBar
|
||||||
onNearMe={handleNearMe}
|
filters={filters}
|
||||||
geoState={geoState}
|
isHero={false}
|
||||||
geoError={geoError}
|
resultFilters={initialSchools.result_filters}
|
||||||
autosuggest={autosuggest}
|
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. */}
|
{/* 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
|
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. */}
|
contributing a full section gap of blank space above the footer. */}
|
||||||
{(isSearchActive || initialSchools.schools.length > 0) && (
|
{(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 && (
|
{!hasSearch && initialSchools.schools.length > 0 && (
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<h2>Featured Schools</h2>
|
<h2>Featured Schools</h2>
|
||||||
@@ -602,34 +678,6 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
}
|
}
|
||||||
</h2>
|
</h2>
|
||||||
<div className={styles.resultsHeaderActions}>
|
<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' && (
|
{resultsView === 'list' && (
|
||||||
<select
|
<select
|
||||||
value={sortOrder}
|
value={sortOrder}
|
||||||
@@ -705,20 +753,6 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* List View Layout */
|
/* List View Layout */
|
||||||
@@ -769,6 +803,39 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
)}
|
)}
|
||||||
</section>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ export type EventName =
|
|||||||
| 'compare_focus_school'
|
| 'compare_focus_school'
|
||||||
// Operational
|
// Operational
|
||||||
| 'api_error'
|
| 'api_error'
|
||||||
| 'results_load_more';
|
| 'results_load_more'
|
||||||
|
| 'results_view_changed';
|
||||||
|
|
||||||
type Primitive = string | number | boolean;
|
type Primitive = string | number | boolean;
|
||||||
type Payload = Record<string, Primitive>;
|
type Payload = Record<string, Primitive>;
|
||||||
|
|||||||
Reference in new issue
Block a user