Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e211e1376d | ||
|
|
74418ca6b9 | ||
|
|
5df8c93420 | ||
|
|
ebf9c12446 | ||
|
|
ca4ddd2b12 | ||
|
|
dff3e210ab | ||
|
|
37bbda1da1 | ||
|
|
a37da15008 | ||
|
|
cff3854e63 | ||
|
|
1bb3e0360f | ||
|
|
4e4b30e812 | ||
|
|
0e177ca2ec |
No files matched your search
+118
-23
@@ -272,6 +272,17 @@ test('searching by postcode returns nearby schools', async ({ page }) => {
|
||||
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('the phase filter switches straight from secondary to primary', async ({ page }) => {
|
||||
// The phase options once came from the result set, which the phase filter
|
||||
// had already narrowed — so with secondary chosen, primary was not offered.
|
||||
await page.goto('/?search=school&phase=secondary');
|
||||
const phase = page.getByRole('combobox', { name: 'Phase' });
|
||||
await expect(phase).toHaveValue('secondary', { timeout: 15_000 });
|
||||
await phase.selectOption('primary');
|
||||
await expect(page).toHaveURL(/[?&]phase=primary(&|$)/);
|
||||
await expect(phase).toHaveValue('primary');
|
||||
});
|
||||
|
||||
test('a report-card school shows a Report Card badge in search results, not its old grade', async ({ page }) => {
|
||||
// List/map badges keyed off ofsted_grade (the carried-forward legacy grade)
|
||||
// and never reached the report-card branch, so report-card schools were
|
||||
@@ -500,12 +511,8 @@ test('results map fullscreen falls back to an overlay on iOS', async ({ page })
|
||||
delete Element.prototype.requestFullscreen;
|
||||
});
|
||||
|
||||
// A postcode search opens on the map, phones included; open it fullscreen.
|
||||
await searchByName(page, 'B1 1BB');
|
||||
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// 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();
|
||||
@@ -532,7 +539,10 @@ test('the results toolbar stays pinned with its List/Map switch', async ({ page
|
||||
|
||||
const view = page.getByRole('group', { name: 'Results view' });
|
||||
await expect(view).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'List' })).toHaveAttribute('aria-pressed', 'true');
|
||||
// Desktop opens on the map; the list is the long page that scrolls.
|
||||
await page.getByRole('button', { name: 'List', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: 'List', exact: true }))
|
||||
.toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
await page.evaluate(() => window.scrollTo(0, 1500));
|
||||
await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(1000);
|
||||
@@ -548,35 +558,111 @@ test('the results toolbar stays pinned with its List/Map switch', async ({ page
|
||||
.toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
for (const width of [360, 390, 430]) {
|
||||
test(`the floating Map button is in reach at ${width}px`, async ({ page }) => {
|
||||
/*
|
||||
* Desktop opens a postcode search on the map (mockup B): the list in a pane on
|
||||
* the left, the map filling the rest of the screen, and a card on the map for
|
||||
* the school picked in either.
|
||||
*/
|
||||
test('a desktop postcode search opens on the map with the list beside it', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await searchByName(page, 'B1 1BB');
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Map', exact: true }))
|
||||
.toHaveAttribute('aria-pressed', 'true', { timeout: 15_000 });
|
||||
const pane = page.locator('[class*="mapListPane"]');
|
||||
const card = pane.locator('[data-urn]').first();
|
||||
await expect(card).toBeVisible({ timeout: 15_000 });
|
||||
// The list view's own row, not a cut-down card: it carries the same View
|
||||
// link and Compare button.
|
||||
await expect(card.getByRole('link', { name: 'View', exact: true })).toBeVisible();
|
||||
await expect(card.getByRole('button', { name: /Compar/ })).toBeVisible();
|
||||
await expect(page.locator('.sc-pin').first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// The count and the sort share one line at the top of the list.
|
||||
const heading = await pane.getByRole('heading', { level: 2 }).boundingBox();
|
||||
const sort = await pane.getByRole('combobox').boundingBox();
|
||||
expect(Math.abs((heading!.y + heading!.height / 2) - (sort!.y + sort!.height / 2)))
|
||||
.toBeLessThan(8);
|
||||
|
||||
// The split runs to the bottom of the screen rather than stopping short.
|
||||
const split = await page.locator('[class*="mapViewContainer"]').boundingBox();
|
||||
expect(split!.y + split!.height).toBeGreaterThan(800 - 40);
|
||||
expect(split!.y + split!.height).toBeLessThanOrEqual(800);
|
||||
|
||||
// A card picks its pin and opens its card on the map. Clicked in its padding,
|
||||
// clear of the name link and the Compare button.
|
||||
const name = (await card.locator('a').first().innerText()).trim();
|
||||
await card.click({ position: { x: 6, y: 6 } });
|
||||
await expect(page.locator('.sc-pin--selected')).toHaveCount(1);
|
||||
await expect(page.locator('.sc-popup')).toContainText(name);
|
||||
|
||||
// And from the keyboard: each row has a "Show … on the map" button that
|
||||
// appears on focus.
|
||||
const second = pane.locator('[data-urn]').nth(1);
|
||||
const secondName = (await second.locator('a').first().innerText()).trim();
|
||||
const show = second.getByRole('button', { name: `Show ${secondName} on the map` });
|
||||
await show.focus();
|
||||
await expect(show).toBeVisible();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(show).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(page.locator('.sc-popup')).toContainText(secondName);
|
||||
});
|
||||
|
||||
// 402 is the iPhone 17, where the toolbar overflowed (see below).
|
||||
for (const width of [360, 390, 402, 430]) {
|
||||
test(`a phone opens on the map, with the list a tap away, 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())!;
|
||||
// On the map, with the floating button offering the list, clear of the
|
||||
// bottom tab bar.
|
||||
const toList = page.getByRole('button', { name: 'Show list' });
|
||||
await expect(toList).toBeInViewport({ timeout: 15_000 });
|
||||
await expect(page.locator('.sc-pin').first()).toBeAttached({ timeout: 15_000 });
|
||||
const barTop = await page.locator('nav[class*="bottomBar"]')
|
||||
.evaluate((el) => el.getBoundingClientRect().top);
|
||||
const fabBox = (await toList.boundingBox())!;
|
||||
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();
|
||||
// A pin opens the bottom sheet, stacked under the button, above the bar.
|
||||
// dispatchEvent, not click: a pin may sit under the button or the toolbar,
|
||||
// and Leaflet listens on the pin itself.
|
||||
await page.locator('.sc-pin').first().dispatchEvent('click');
|
||||
const sheet = page.locator('[class*="bottomSheet"]');
|
||||
await expect(sheet).toBeVisible();
|
||||
// The sheet slides up over 0.3s; measure where it comes to rest, not a
|
||||
// frame of the animation (which reads as below the tab bar).
|
||||
await expect.poll(async () => {
|
||||
const box = (await sheet.boundingBox())!;
|
||||
return Math.round(box.y + box.height);
|
||||
}).toBeLessThanOrEqual(barTop);
|
||||
// The dock is anchored at the bottom, so the sheet pushes the button up:
|
||||
// measure the button again now that the sheet is in.
|
||||
const fabNow = (await toList.boundingBox())!;
|
||||
expect((await sheet.boundingBox())!.y).toBeGreaterThanOrEqual(fabNow.y + fabNow.height);
|
||||
|
||||
// MOBILE.md: no horizontal overflow, and 44px targets in the new chrome.
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth))
|
||||
.toBe(0);
|
||||
// scrollWidth alone cannot see this page's overflow: .main clips on x, so
|
||||
// the search summary ran 40px off an iPhone 17 screen with scrollWidth
|
||||
// still equal to the viewport. Measure the toolbar's own edges instead;
|
||||
// the controls row scrolls by design, so only its box is held to the edge.
|
||||
const offscreen = await page.evaluate(() => {
|
||||
const toolbar = document.querySelector('[class*="resultsToolbar"]');
|
||||
return [...(toolbar?.querySelectorAll('*') ?? [])]
|
||||
.filter((el) => (el as HTMLElement).offsetParent
|
||||
&& !el.parentElement?.closest('[class*="controlsRow"]'))
|
||||
.map((el) => ({ el: (el.className?.toString() || el.tagName).slice(0, 40),
|
||||
right: Math.round(el.getBoundingClientRect().right) }))
|
||||
.filter((o) => o.right > window.innerWidth);
|
||||
});
|
||||
expect(offscreen).toEqual([]);
|
||||
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]
|
||||
const closeEl = document.querySelector('[class*="closeSheetBtn"]');
|
||||
return [...(toolbar?.querySelectorAll('a, button, input, select') ?? []), fabEl, closeEl]
|
||||
.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 }))
|
||||
@@ -584,8 +670,17 @@ for (const width of [360, 390, 430]) {
|
||||
});
|
||||
expect(small).toEqual([]);
|
||||
|
||||
await fab.click();
|
||||
await expect(page.getByRole('button', { name: 'Show list' })).toBeVisible();
|
||||
// The list: the search folds to a summary, and the pinned toolbar and
|
||||
// the button survive a scroll.
|
||||
await toList.click();
|
||||
const toMap = page.getByRole('button', { name: 'Show map' });
|
||||
await expect(toMap).toBeInViewport();
|
||||
await expect(schoolLinks(page).first()).toBeVisible();
|
||||
const summary = page.getByRole('button', { name: /^Edit search: B1 1BB/ });
|
||||
await page.evaluate(() => window.scrollTo(0, 1200));
|
||||
await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(600);
|
||||
await expect(summary).toBeInViewport();
|
||||
await expect(toMap).toBeInViewport();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { FilterBar } from '@/components/FilterBar';
|
||||
|
||||
let searchParams = new URLSearchParams();
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn() }),
|
||||
usePathname: () => '/',
|
||||
useSearchParams: () => searchParams,
|
||||
}));
|
||||
|
||||
const FILTERS = {
|
||||
local_authorities: [], school_types: [], years: [],
|
||||
phases: ['Primary', 'Secondary', 'All-through'],
|
||||
genders: [], admissions_policies: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* The phase options must not come from the result set. The backend scopes its
|
||||
* result filters to the schools it returns, and it applies the phase filter
|
||||
* first — so with "secondary" chosen the scoped list holds only secondary-ish
|
||||
* phases, and switching to primary meant going back to "Any phase" first.
|
||||
*/
|
||||
describe('FilterBar phase options', () => {
|
||||
it('offers every phase while a phase filter narrows the results', () => {
|
||||
searchParams = new URLSearchParams('search=hampton&phase=secondary');
|
||||
render(
|
||||
<FilterBar
|
||||
filters={FILTERS}
|
||||
resultFilters={{
|
||||
local_authorities: [], school_types: [],
|
||||
phases: ['Secondary', 'All-through'],
|
||||
genders: [], admissions_policies: [],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const phase = screen.getByRole('combobox', { name: 'Phase' });
|
||||
expect(within(phase).getByRole('option', { name: 'Primary' })).toBeInTheDocument();
|
||||
expect(phase).toHaveValue('secondary');
|
||||
});
|
||||
});
|
||||
@@ -39,15 +39,17 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
test('load-more results from an old search are discarded, even after returning to it', async () => {
|
||||
// Name searches: a postcode search opens on the map, which has no Load more.
|
||||
params = new URLSearchParams('search=abbey');
|
||||
const pending = deferred();
|
||||
jest.mocked(fetchSchools).mockReturnValueOnce(pending.promise);
|
||||
const view = render(<HomeView initialSchools={response('Initial A')} filters={filters} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Load more schools' }));
|
||||
const signal = jest.mocked(fetchSchools).mock.calls[0][1]?.signal;
|
||||
params = new URLSearchParams('postcode=SW2+1AA');
|
||||
params = new URLSearchParams('search=brecknock');
|
||||
view.rerender(<HomeView initialSchools={response('Initial B')} filters={filters} />);
|
||||
expect(signal?.aborted).toBe(true);
|
||||
params = new URLSearchParams('postcode=SW1A+1AA');
|
||||
params = new URLSearchParams('search=abbey');
|
||||
view.rerender(<HomeView initialSchools={response('Fresh A')} filters={filters} />);
|
||||
await act(async () => pending.resolve(response('Stale append')));
|
||||
expect(screen.queryByText('Stale append')).not.toBeInTheDocument();
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { act, fireEvent, render } from '@testing-library/react';
|
||||
import LeafletMapInner from '@/components/LeafletMapInner';
|
||||
import { primaryFixture } from '../support/schoolFixtures';
|
||||
import type { School } from '@/lib/types';
|
||||
|
||||
/*
|
||||
* The results map's own logic, against real Leaflet in jsdom: which pin is
|
||||
* selected, whether the card opens, and what the card offers. jsdom lays out
|
||||
* nothing, so this pins behaviour, never positions.
|
||||
*/
|
||||
|
||||
const base = primaryFixture.schoolInfo;
|
||||
const a: School = { ...base, urn: 1, school_name: 'Southmead Primary School', latitude: 51.43, longitude: -0.21, distance: 0.2, rwm_expected_pct: 52 };
|
||||
const b: School = { ...base, urn: 2, school_name: 'Greenmead School', latitude: 51.431, longitude: -0.205, distance: 0.2,
|
||||
school_type: 'Community special school', rwm_expected_pct: 0, reading_expected_pct: 0, writing_expected_pct: 0, maths_expected_pct: 0 };
|
||||
const schools = [a, b];
|
||||
const centre: [number, number] = [51.43, -0.21];
|
||||
|
||||
function setWide(wide: boolean) {
|
||||
window.matchMedia = ((q: string) => ({
|
||||
matches: wide, media: q, addEventListener() {}, removeEventListener() {},
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
}
|
||||
|
||||
function renderMap(props: Partial<React.ComponentProps<typeof LeafletMapInner>> = {}) {
|
||||
const all = {
|
||||
schools, center: centre, zoom: 13, referencePoint: centre, radiusMiles: 1,
|
||||
nationalAvgRwm: 62, ...props,
|
||||
};
|
||||
const view = render(<LeafletMapInner {...all} />);
|
||||
return { ...view, rerender: (next: Partial<typeof all>) => view.rerender(<LeafletMapInner {...all} {...next} />) };
|
||||
}
|
||||
|
||||
beforeEach(() => setWide(true));
|
||||
|
||||
it('draws a pin per school, the search location and the radius', () => {
|
||||
const { container } = renderMap();
|
||||
expect(container.querySelectorAll('.sc-pin')).toHaveLength(2);
|
||||
expect(container.querySelector('.sc-home')).not.toBeNull();
|
||||
expect(container.querySelector('.sc-radius-label')).toHaveTextContent('1 mile');
|
||||
});
|
||||
|
||||
it('reports a pin click, and marks and opens the selected school', () => {
|
||||
const onMarkerClick = jest.fn();
|
||||
const { container, rerender } = renderMap({ onMarkerClick });
|
||||
fireEvent.click(container.querySelectorAll('.sc-pin')[0]);
|
||||
expect(onMarkerClick).toHaveBeenCalledWith(a);
|
||||
|
||||
rerender({ onMarkerClick, selectedUrn: 1 });
|
||||
expect(container.querySelectorAll('.sc-pin--selected')).toHaveLength(1);
|
||||
expect(container.querySelector('.sc-popup')).toHaveTextContent('Southmead Primary School');
|
||||
expect(container.querySelector('.sc-popup')).toHaveTextContent('52% RWM -10 pts');
|
||||
});
|
||||
|
||||
it('keeps the special-school card free of a benchmark and a placeholder 0%', () => {
|
||||
const { container } = renderMap({ selectedUrn: 2 });
|
||||
const card = container.querySelector('.sc-popup')!;
|
||||
expect(card).toHaveTextContent('Greenmead School');
|
||||
expect(card).not.toHaveTextContent(/%|pts/);
|
||||
});
|
||||
|
||||
it('adds to compare from the card, and the card follows the basket', () => {
|
||||
const onAddToCompare = jest.fn();
|
||||
const { container, rerender } = renderMap({ onAddToCompare, selectedUrn: 1, compareUrns: [] });
|
||||
fireEvent.click(container.querySelector('[data-compare]')!);
|
||||
expect(onAddToCompare).toHaveBeenCalledWith(a);
|
||||
|
||||
rerender({ onAddToCompare, selectedUrn: 1, compareUrns: [1] });
|
||||
expect(container.querySelector('[data-compare]')).toHaveTextContent('✓ Comparing');
|
||||
fireEvent.click(container.querySelector('[data-compare]')!);
|
||||
expect(onAddToCompare).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('tells the page when the card is closed from the map, but not when replaced', () => {
|
||||
const onDeselect = jest.fn();
|
||||
const { container, rerender } = renderMap({ onDeselect, selectedUrn: 1 });
|
||||
rerender({ onDeselect, selectedUrn: 2 });
|
||||
expect(onDeselect).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(container.querySelector('.leaflet-popup-close-button')!);
|
||||
expect(onDeselect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the selection when the results reload under it', () => {
|
||||
const onDeselect = jest.fn();
|
||||
const { container, rerender } = renderMap({ onDeselect, selectedUrn: 1 });
|
||||
act(() => rerender({ onDeselect, selectedUrn: 1, schools: [...schools] }));
|
||||
expect(onDeselect).not.toHaveBeenCalled();
|
||||
expect(container.querySelector('.sc-popup')).toHaveTextContent('Southmead Primary School');
|
||||
});
|
||||
|
||||
it('opens no card on a narrow screen, where the page shows a bottom sheet', () => {
|
||||
setWide(false);
|
||||
const { container } = renderMap({ selectedUrn: 1 });
|
||||
expect(container.querySelectorAll('.sc-pin--selected')).toHaveLength(1);
|
||||
expect(container.querySelector('.sc-popup')).toBeNull();
|
||||
});
|
||||
|
||||
it('puts the card back when the pins are rebuilt for a reason other than the schools', () => {
|
||||
const onDeselect = jest.fn();
|
||||
const { container, rerender } = renderMap({ onDeselect, selectedUrn: 1 });
|
||||
act(() => rerender({ onDeselect, selectedUrn: 1, radiusMiles: 3, referencePoint: [51.43, -0.21] }));
|
||||
expect(onDeselect).not.toHaveBeenCalled();
|
||||
expect(container.querySelector('.sc-radius-label')).toHaveTextContent('3 miles');
|
||||
expect(container.querySelector('.sc-popup')).toHaveTextContent('Southmead Primary School');
|
||||
expect(container.querySelectorAll('.sc-pin--selected')).toHaveLength(1);
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { HomeView } from '@/components/HomeView';
|
||||
import { fetchSchools, fetchNationalAverages } from '@/lib/api';
|
||||
import { primaryFixture } from '../support/schoolFixtures';
|
||||
import type { School, SchoolsResponse } from '@/lib/types';
|
||||
|
||||
/*
|
||||
* The map view: the list beside the map (mockup B), where every postcode
|
||||
* search opens. The map itself is Leaflet and mocked here; what is pinned is what
|
||||
* HomeView hands it and the list it draws beside it.
|
||||
*/
|
||||
|
||||
let params = new URLSearchParams('postcode=SW196AR&radius=1');
|
||||
jest.mock('next/navigation', () => ({
|
||||
useSearchParams: () => params,
|
||||
usePathname: () => '/',
|
||||
useRouter: () => ({ push: jest.fn(), 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(),
|
||||
fetchLAaverages: jest.fn(async () => ({ secondary: { attainment_8_by_la: {} } })),
|
||||
}));
|
||||
jest.mock('@/components/FilterBar', () => ({ FilterBar: () => null }));
|
||||
jest.mock('@/components/SchoolMap', () => ({
|
||||
SchoolMap: ({ selectedUrn, radiusMiles, onMarkerClick, schools }: {
|
||||
selectedUrn: number | null; radiusMiles?: number;
|
||||
onMarkerClick: (s: School) => void; schools: School[];
|
||||
}) => (
|
||||
<div data-testid="map" data-selected={selectedUrn ?? ''} data-radius={radiusMiles}>
|
||||
<button type="button" onClick={() => onMarkerClick(schools[1])}>pin</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const base = primaryFixture.schoolInfo;
|
||||
const southmead: School = {
|
||||
...base, urn: 2, school_name: 'Southmead Primary School', distance: 0.2,
|
||||
school_type: 'Community school', rwm_expected_pct: 52, total_pupils: 269,
|
||||
};
|
||||
const greenmead: School = {
|
||||
...base, urn: 3, school_name: 'Greenmead School', distance: 0.2,
|
||||
school_type: 'Community special school', rwm_expected_pct: 0,
|
||||
reading_expected_pct: 0, writing_expected_pct: 0, maths_expected_pct: 0, total_pupils: 62,
|
||||
};
|
||||
const ourLady: School = {
|
||||
...base, urn: 1, school_name: 'Our Lady Queen of Heaven RC School', distance: 0,
|
||||
school_type: 'Voluntary aided school', rwm_expected_pct: 70, total_pupils: 224,
|
||||
};
|
||||
|
||||
function results(): SchoolsResponse {
|
||||
return {
|
||||
schools: [ourLady, southmead, greenmead], total: 3, page: 1, page_size: 25, total_pages: 1,
|
||||
location_info: { postcode: 'SW196AR', radius: 1.60934, coordinates: [51.42, -0.21] },
|
||||
} as SchoolsResponse;
|
||||
}
|
||||
const filters = { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] };
|
||||
|
||||
beforeEach(() => {
|
||||
params = new URLSearchParams('postcode=SW196AR&radius=1');
|
||||
jest.mocked(fetchSchools).mockReset().mockResolvedValue(results());
|
||||
jest.mocked(fetchNationalAverages).mockResolvedValue({ primary: { rwm_expected_pct: 62 } } as never);
|
||||
setWide(true);
|
||||
});
|
||||
|
||||
/** Desktop unless a test says otherwise: the list pane is shown from 769px. */
|
||||
function setWide(wide: boolean) {
|
||||
window.matchMedia = ((q: string) => ({
|
||||
matches: wide, media: q, addEventListener() {}, removeEventListener() {},
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
}
|
||||
|
||||
async function renderMap() {
|
||||
const view = render(<HomeView initialSchools={results()} filters={filters} />);
|
||||
await act(async () => {});
|
||||
return view;
|
||||
}
|
||||
|
||||
it('opens a postcode search on the map', async () => {
|
||||
await renderMap();
|
||||
expect(screen.getByTestId('map')).toHaveAttribute('data-radius', '1');
|
||||
expect(screen.getByRole('button', { name: 'Map' })).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('lists a name search, which has no map', async () => {
|
||||
params = new URLSearchParams('search=southmead');
|
||||
render(<HomeView initialSchools={results()} filters={filters} />);
|
||||
await act(async () => {});
|
||||
expect(screen.queryByTestId('map')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('puts the count and the sort in the list beside the map, once', async () => {
|
||||
await renderMap();
|
||||
// Short beside the map, so it shares one line with the sort.
|
||||
expect(screen.getAllByRole('heading', { name: '3 schools within 1 mile' })).toHaveLength(1);
|
||||
expect(screen.getAllByRole('combobox')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('selects the pin from the card, and the card from the pin', async () => {
|
||||
const { container } = await renderMap();
|
||||
const card = (urn: number) => container.querySelector(`[data-urn="${urn}"]`) as HTMLElement;
|
||||
|
||||
fireEvent.click(within(card(1)).getByText(/pupils/));
|
||||
expect(screen.getByTestId('map')).toHaveAttribute('data-selected', '1');
|
||||
expect(card(1).className).toMatch(/mapRowSelected/);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'pin' }));
|
||||
expect(card(2).className).toMatch(/mapRowSelected/);
|
||||
expect(card(1).className).not.toMatch(/mapRowSelected/);
|
||||
});
|
||||
|
||||
it('clicking a card\'s link or button does not also select it', async () => {
|
||||
const { container } = await renderMap();
|
||||
const card = container.querySelector('[data-urn="1"]') as HTMLElement;
|
||||
fireEvent.click(within(card).getByRole('button', { name: '+ Compare' }));
|
||||
expect(screen.getByTestId('map')).toHaveAttribute('data-selected', '');
|
||||
});
|
||||
|
||||
it('shows the England comparison for mainstream schools only, and never a placeholder 0%', async () => {
|
||||
const { container } = await renderMap();
|
||||
const card = (urn: number) => container.querySelector(`[data-urn="${urn}"]`) as HTMLElement;
|
||||
expect(card(2)).toHaveTextContent('52%Reading, Writing & Maths-10 pts vs national');
|
||||
expect(card(2)).toHaveTextContent('269pupils');
|
||||
expect(card(3)).toHaveTextContent('62pupils');
|
||||
expect(card(3)).not.toHaveTextContent(/%|pts/);
|
||||
});
|
||||
|
||||
it('opens the map after a hero search, and keeps the reader\'s choice after that', async () => {
|
||||
// Landing page, then a hero search: the same instance gets new props.
|
||||
params = new URLSearchParams('');
|
||||
const empty = { schools: [], total: 0, page: 1, page_size: 25, total_pages: 0 } as SchoolsResponse;
|
||||
const view = render(<HomeView initialSchools={empty} filters={filters} />);
|
||||
params = new URLSearchParams('postcode=SW196AR&radius=1');
|
||||
view.rerender(<HomeView initialSchools={results()} filters={filters} />);
|
||||
await act(async () => {});
|
||||
expect(screen.getByTestId('map')).toBeInTheDocument();
|
||||
|
||||
// Chosen: a later search keeps the list.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'List' }));
|
||||
params = new URLSearchParams('postcode=SW170AA&radius=1');
|
||||
view.rerender(<HomeView initialSchools={results()} filters={filters} />);
|
||||
await act(async () => {});
|
||||
expect(screen.queryByTestId('map')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('builds no list cards on a phone, where the pane is hidden', async () => {
|
||||
setWide(false);
|
||||
const { container } = await renderMap();
|
||||
expect(screen.getByTestId('map')).toBeInTheDocument();
|
||||
expect(container.querySelectorAll('[data-urn]')).toHaveLength(0);
|
||||
// The count stays: it is the pane's heading, shown above the map.
|
||||
expect(screen.getByRole('heading', { name: /3 schools within/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('draws the list view\'s own row beside the map, with the same content', async () => {
|
||||
const { container } = await renderMap();
|
||||
const beside = container.querySelector('[data-urn="2"] > [class~="row"]')!.textContent;
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'List' }));
|
||||
const row = screen.getByRole('link', { name: 'Southmead Primary School' }).closest('[class~="row"]')!;
|
||||
expect(row.parentElement?.className).toMatch(/schoolList/);
|
||||
expect(row.textContent).toBe(beside);
|
||||
});
|
||||
|
||||
it('lets a keyboard pick a pin from the list, with a real button', async () => {
|
||||
await renderMap();
|
||||
const show = screen.getByRole('button', { name: 'Show Southmead Primary School on the map' });
|
||||
expect(show).toHaveAttribute('aria-pressed', 'false');
|
||||
fireEvent.click(show);
|
||||
expect(screen.getByTestId('map')).toHaveAttribute('data-selected', '2');
|
||||
expect(show).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('keeps the postcode in the heading in list view, where there is room', async () => {
|
||||
await renderMap();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'List' }));
|
||||
expect(screen.getByRole('heading', { name: '3 schools within 1.0 miles of SW196AR' })).toBeInTheDocument();
|
||||
});
|
||||
@@ -54,25 +54,28 @@ describe('the List/Map switch', () => {
|
||||
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');
|
||||
// A postcode search opens on the map.
|
||||
expect(screen.getByRole('button', { name: 'Map' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: 'List' })).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' });
|
||||
|
||||
await act(async () => {});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show list' }));
|
||||
expect(screen.queryByTestId('map')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Show map' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'List' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(track).toHaveBeenCalledWith('results_view_changed', { view: 'list', via: 'floating' });
|
||||
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Show map' })));
|
||||
expect(screen.getByTestId('map')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Show list' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not track a click on the view already showing', () => {
|
||||
it('does not track a click on the view already showing', async () => {
|
||||
render(<HomeView initialSchools={results()} filters={filters} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'List' }));
|
||||
await act(async () => {});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Map' }));
|
||||
expect(track).not.toHaveBeenCalledWith('results_view_changed', expect.anything());
|
||||
});
|
||||
|
||||
|
||||
@@ -119,10 +119,13 @@ describe('third-party surfaces under themed text', () => {
|
||||
|
||||
it('still finds a component painting themed text into a Leaflet popup', () => {
|
||||
// Guards the rule below against passing vacuously if the popups are ever
|
||||
// rewritten as React components rather than HTML strings.
|
||||
// rewritten as React components rather than HTML strings. The results
|
||||
// map's card themes its text through classes in SchoolMap.module.css
|
||||
// rather than inline, so a class-built popup counts as well.
|
||||
const themed = sources(COMPONENTS).filter((file) => {
|
||||
const src = fs.readFileSync(file, 'utf8');
|
||||
return /bindPopup\(/.test(src) && /color:var\(--|color: var\(--/.test(src);
|
||||
return /bindPopup\(|L\.popup\(/.test(src)
|
||||
&& (/color:var\(--|color: var\(--/.test(src) || /class="sc-card/.test(src));
|
||||
});
|
||||
|
||||
expect(themed.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/*
|
||||
* SchoolRow and SecondarySchoolRow switch to their narrow layout with a
|
||||
* container query on a `results` container, not a media query, because the
|
||||
* same row fills the phone list and the narrow list beside the desktop map.
|
||||
* Outside a `results` container the query never matches and the row keeps its
|
||||
* wide layout on a phone, a silent regression rather than an error.
|
||||
*
|
||||
* HomeView provides the container on every list it renders the rows into.
|
||||
* Anything else that starts rendering them must do the same; this fails so
|
||||
* that the person adding it reads this first.
|
||||
*/
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const DIRS = ['app', 'components', 'lib'];
|
||||
const ROW_IMPORT = /from\s+['"][^'"]*\/(SchoolRow|SecondarySchoolRow)['"]/;
|
||||
|
||||
function sources(dir: string): string[] {
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) return entry.name === 'node_modules' ? [] : sources(full);
|
||||
return /\.tsx?$/.test(entry.name) ? [full] : [];
|
||||
});
|
||||
}
|
||||
|
||||
it('renders the results rows only where a `results` container is provided', () => {
|
||||
const importers = DIRS.flatMap((d) => sources(path.join(ROOT, d)))
|
||||
.filter((file) => ROW_IMPORT.test(fs.readFileSync(file, 'utf8')))
|
||||
.map((file) => path.relative(ROOT, file));
|
||||
expect(importers).toEqual(['components/HomeView.tsx']);
|
||||
});
|
||||
|
||||
it('gives each of HomeView\'s row lists the `results` container', () => {
|
||||
const css = fs.readFileSync(path.join(ROOT, 'components', 'HomeView.module.css'), 'utf8');
|
||||
for (const list of ['.schoolList', '.compactList', '.bottomSheet']) {
|
||||
const rule = new RegExp(`\\${list}\\s*\\{[^}]*container:\\s*results\\s*/\\s*inline-size`);
|
||||
expect({ list, provided: rule.test(css) }).toEqual({ list, provided: true });
|
||||
}
|
||||
});
|
||||
@@ -522,8 +522,16 @@
|
||||
* everything else, so it must never be the chip scrolled out of sight.
|
||||
*/
|
||||
@media (max-width: 640px) {
|
||||
/*
|
||||
* nowrap matters as much as column. The desktop rule wraps, and in a
|
||||
* wrapping flex container each line is as wide as its widest item's content,
|
||||
* not the container: the search summary ("SW196AR · within 1 mile Edit",
|
||||
* about 410px) stretched the line, and the controls row with it, 40px past a
|
||||
* 402px iPhone 17 screen. Single-line, stretch means the container's width.
|
||||
*/
|
||||
.filterBar:not(.heroMode) {
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
align-items: stretch;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
@@ -295,7 +295,10 @@ export function FilterBar({
|
||||
const laOptions =
|
||||
resultFilters?.local_authorities ?? filters.local_authorities;
|
||||
const typeOptions = resultFilters?.school_types ?? filters.school_types;
|
||||
const phaseOptions = resultFilters?.phases ?? filters.phases ?? [];
|
||||
// Phase is the exception: always the full list. The result set has already
|
||||
// been narrowed by the phase filter, so scoping to it would leave only the
|
||||
// chosen phase on offer and switching phase would need "Any phase" first.
|
||||
const phaseOptions = filters.phases ?? [];
|
||||
const genderOptions = resultFilters?.genders ?? filters.genders ?? [];
|
||||
const admissionsPolicyOptions =
|
||||
resultFilters?.admissions_policies ?? filters.admissions_policies ?? [];
|
||||
|
||||
@@ -572,136 +572,132 @@
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Map View Layout */
|
||||
/* ── Map view ──────────────────────────────────────────────────────────────
|
||||
The list on the left, the map filling the rest, in one card that runs from
|
||||
under the pinned toolbar to the bottom of the screen. --toolbar-h is the
|
||||
toolbar's measured height, written by HomeView. */
|
||||
/* Where the split starts before any scrolling: header, the page's top
|
||||
padding (globals.css .main), the toolbar and its margin. */
|
||||
.homeView {
|
||||
--map-top: calc(64px + 1.25rem + var(--toolbar-h, 76px) + 1.25rem);
|
||||
--map-bottom: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.homeView {
|
||||
--map-top: calc(64px + 1rem + var(--toolbar-h, 76px) + 1.25rem);
|
||||
}
|
||||
}
|
||||
|
||||
.mapViewContainer {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 340px;
|
||||
gap: 1rem;
|
||||
height: calc(100dvh - 280px);
|
||||
min-height: 520px;
|
||||
max-height: 800px;
|
||||
}
|
||||
|
||||
.mapContainer {
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.compactList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
padding-right: 0.375rem;
|
||||
}
|
||||
|
||||
.compactList::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.compactList::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.compactList::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.compactList::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Compact School Item */
|
||||
.compactItem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
grid-template-columns: minmax(360px, 460px) minmax(0, 1fr);
|
||||
height: calc(100dvh - var(--map-top) - var(--map-bottom));
|
||||
min-height: 480px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.compactItem:hover {
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 2px 6px rgba(var(--shadow-rgb), 0.05);
|
||||
.mapListPane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--bg-primary);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.compactItemContent {
|
||||
/* One line: the count takes what the sort leaves, and truncates rather than
|
||||
wrap if a narrow pane cannot fit both. */
|
||||
.mapListPane .resultsHeader {
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.mapListPane .resultsHeader h2 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.compactItemHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.compactItemName {
|
||||
font-weight: 600;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
font-size: var(--step-0);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.compactItemName:hover {
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.distanceBadge {
|
||||
.mapListPane .resultsHeaderActions {
|
||||
flex-shrink: 0;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
background: var(--brand);
|
||||
color: var(--brand-on);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.compactItemMeta {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.25rem;
|
||||
/* A select is as wide as its longest option ("Highest Reading, Writing &
|
||||
Maths %", 281px), more than half the pane. Cap it; a long choice truncates. */
|
||||
.mapListPane .sortSelect {
|
||||
max-width: 11rem;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.compactItemMeta span:not(:last-child)::after {
|
||||
content: '·';
|
||||
margin-left: 0.375rem;
|
||||
color: var(--text-muted);
|
||||
.mapContainer {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.compactItemStats {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.compactStat strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.compactItemActions {
|
||||
.compactList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
gap: 0.625rem;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0.125rem 1rem 1rem;
|
||||
scrollbar-width: thin;
|
||||
/* The rows lay themselves out by this list's width (SchoolRow.module.css),
|
||||
which here is always narrow, whatever the screen. */
|
||||
container: results / inline-size;
|
||||
}
|
||||
|
||||
/* A row in the list beside the map: clicking it picks its pin. */
|
||||
.mapRow {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.mapRowSelected > :last-child {
|
||||
outline: 2px solid var(--brand);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Visually hidden until focused, then a pill over the row's top edge. */
|
||||
.showOnMap {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.showOnMap:focus-visible {
|
||||
top: -0.5rem;
|
||||
right: 0.75rem;
|
||||
z-index: 1;
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: 0.375rem 0.75rem;
|
||||
overflow: visible;
|
||||
clip-path: none;
|
||||
background: var(--brand);
|
||||
color: var(--brand-on);
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--step--1);
|
||||
font-weight: 700;
|
||||
outline: 2px solid var(--text-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.sectionHeader {
|
||||
margin-bottom: 1rem;
|
||||
@@ -738,6 +734,8 @@
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
/* The rows lay themselves out by this list's width (SchoolRow.module.css). */
|
||||
container: results / inline-size;
|
||||
}
|
||||
|
||||
/* Staggered fade-in for rows */
|
||||
@@ -795,49 +793,11 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mapViewContainer {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 260px auto;
|
||||
height: auto;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.mapContainer {
|
||||
height: 260px;
|
||||
}
|
||||
|
||||
.compactList {
|
||||
height: auto;
|
||||
max-height: 350px;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.compactItem {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.compactItemActions {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.compactItemActions > * {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
padding: 2rem 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Highlighted List Item */
|
||||
.highlightedItem .compactItem {
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 1px var(--brand);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* ── 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. */
|
||||
@@ -878,36 +838,37 @@
|
||||
animation: slideUpSheet 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.bottomSheet .compactItem {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.bottomSheet .compactItem:hover {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* A 30px circle, drawn by ::before, inside a 44px target (MOBILE.md). */
|
||||
.closeSheetBtn {
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
right: -12px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
top: -19px;
|
||||
right: -15px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.1);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.closeSheetBtn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 7px;
|
||||
z-index: -1;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.1);
|
||||
}
|
||||
|
||||
@keyframes slideUpSheet {
|
||||
from {
|
||||
transform: translateY(120%);
|
||||
@@ -917,21 +878,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* When map view on mobile, expand map and hide list */
|
||||
/* Map only: the pane keeps its heading, the cards give way to the pins and
|
||||
the bottom sheet. */
|
||||
.mapViewContainer {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
height: calc(100dvh - 280px);
|
||||
min-height: 400px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.mapContainer {
|
||||
height: 100%;
|
||||
.mapListPane {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.mapListPane .sortSelect,
|
||||
.compactList {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The sheet holds one results row, which is the card itself. It is not
|
||||
inside a `results` container, so give it one: a phone-width sheet takes
|
||||
the row's narrow layout. */
|
||||
.bottomSheet {
|
||||
container: results / inline-size;
|
||||
}
|
||||
|
||||
.mapListPane .resultsHeader {
|
||||
padding: 0.625rem 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Above the tab bar, and riding with it when iOS Chrome's URL bar hides
|
||||
@@ -964,6 +938,16 @@
|
||||
.hasViewFab {
|
||||
padding-bottom: 4.5rem;
|
||||
}
|
||||
|
||||
/* The map runs down to the tab bar; the button floats over the map. */
|
||||
.mapViewResults.hasViewFab {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.homeView {
|
||||
--map-top: calc(56px + var(--toolbar-h, 124px) + 1rem);
|
||||
--map-bottom: calc(56px + env(safe-area-inset-bottom, 0px) + 1rem);
|
||||
}
|
||||
}
|
||||
|
||||
.quickSearches {
|
||||
|
||||
+182
-189
@@ -5,7 +5,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
|
||||
import { FilterBar } from './FilterBar';
|
||||
import { SchoolRow } from './SchoolRow';
|
||||
@@ -16,7 +16,6 @@ import { HeroIllustration } from './Illustration';
|
||||
import { useComparisonContext } from '@/context/ComparisonContext';
|
||||
import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api';
|
||||
import type { SchoolsResponse, Filters, School } from '@/lib/types';
|
||||
import { schoolUrl, buildOfstedListBadge } from '@/lib/utils';
|
||||
import { track } from '@/lib/analytics';
|
||||
import styles from './HomeView.module.css';
|
||||
|
||||
@@ -33,6 +32,13 @@ interface HomeViewProps {
|
||||
autosuggest?: boolean;
|
||||
}
|
||||
|
||||
/*
|
||||
* The view a postcode search opens in, on every screen size. The map is on
|
||||
* trial as the default (2026-09-30); results_view_changed shows how often
|
||||
* readers switch away from it. Name searches have no map and always list.
|
||||
*/
|
||||
const DEFAULT_LOCATION_VIEW: 'list' | 'map' = 'map';
|
||||
|
||||
function daysUntil(month: number, day: number): number {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
@@ -222,7 +228,13 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { addSchool, removeSchool, selectedSchools } = useComparisonContext();
|
||||
const [resultsView, setResultsView] = useState<'list' | 'map'>('list');
|
||||
/*
|
||||
* The reader's choice, once they make one; until then the default for the
|
||||
* kind of search. Derived rather than seeded into state, because a hero
|
||||
* search reaches the results by client-side navigation: the same instance
|
||||
* gets new props, and a state seeded on the landing page would stay "list".
|
||||
*/
|
||||
const [chosenView, setChosenView] = useState<'list' | 'map' | null>(null);
|
||||
const [selectedMapSchool, setSelectedMapSchool] = useState<School | null>(null);
|
||||
const sortOrder = searchParams.get('sort') || 'default';
|
||||
const [allSchools, setAllSchools] = useState<School[]>(initialSchools.schools);
|
||||
@@ -236,6 +248,8 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
const prevSearchParamsRef = useRef(searchParams.toString());
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const resultsRef = useRef<HTMLElement>(null);
|
||||
const mapListRef = useRef<HTMLDivElement>(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.
|
||||
@@ -283,6 +297,18 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
const isSecondaryView = currentPhase.toLowerCase().includes('secondary')
|
||||
|| (!currentPhase && secondaryCount > primaryCount);
|
||||
const isMixedView = primaryCount > 0 && secondaryCount > 0 && !currentPhase;
|
||||
const resultsView: 'list' | 'map' = chosenView ?? (isLocationSearch ? DEFAULT_LOCATION_VIEW : 'list');
|
||||
|
||||
// The map view fills the screen below the pinned toolbar, whose height
|
||||
// depends on how its controls wrap. Measure it rather than guess.
|
||||
const [toolbarHeight, setToolbarHeight] = useState<number | null>(null);
|
||||
useEffect(() => {
|
||||
const el = toolbarRef.current;
|
||||
if (!el || typeof ResizeObserver === 'undefined') return;
|
||||
const observer = new ResizeObserver(() => setToolbarHeight(Math.round(el.getBoundingClientRect().height)));
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [isSearchActive]);
|
||||
|
||||
// Reset pagination and map cache when search params change
|
||||
useEffect(() => {
|
||||
@@ -419,7 +445,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
);
|
||||
}, [router]);
|
||||
|
||||
const sortedSchools = [...allSchools].sort((a, b) => {
|
||||
const sortSchools = (list: School[]) => [...list].sort((a, b) => {
|
||||
if (sortOrder === 'rwm_desc') return (b.rwm_expected_pct ?? -Infinity) - (a.rwm_expected_pct ?? -Infinity);
|
||||
if (sortOrder === 'rwm_asc') return (a.rwm_expected_pct ?? Infinity) - (b.rwm_expected_pct ?? Infinity);
|
||||
if (sortOrder === 'att8_desc') return (b.attainment_8_score ?? -Infinity) - (a.attainment_8_score ?? -Infinity);
|
||||
@@ -428,6 +454,14 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
if (sortOrder === 'name_asc') return a.school_name.localeCompare(b.school_name);
|
||||
return 0;
|
||||
});
|
||||
const sortedSchools = sortSchools(allSchools);
|
||||
// The map gets a stable array (a new one rebuilds every pin and closes the
|
||||
// open card); only the list beside it is sorted.
|
||||
const mapResultSchools = mapSchools.length > 0 ? mapSchools : initialSchools.schools;
|
||||
const mapListSchools = sortSchools(mapResultSchools);
|
||||
const radiusMiles = initialSchools.location_info
|
||||
? Number((initialSchools.location_info.radius / 1.60934).toFixed(1))
|
||||
: undefined;
|
||||
|
||||
// Empty-results sentinel: track when a search returns nothing.
|
||||
useEffect(() => {
|
||||
@@ -473,6 +507,31 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
}, [addSchool, selectedSchools.length]);
|
||||
|
||||
const hasViewSwitch = isLocationSearch && initialSchools.schools.length > 0;
|
||||
const compareUrns = useMemo(() => selectedSchools.map(s => s.urn), [selectedSchools]);
|
||||
|
||||
/*
|
||||
* Below 769px the map view hides its list pane (HomeView.module.css), and
|
||||
* with the map now the default on phones, every postcode search there
|
||||
* would otherwise build up to 500 hidden cards. Decided after mount, so
|
||||
* the server's HTML and the first client render still agree.
|
||||
*/
|
||||
const [listPaneShown, setListPaneShown] = useState(true);
|
||||
useEffect(() => {
|
||||
if (typeof window.matchMedia !== 'function') return;
|
||||
const query = window.matchMedia('(min-width: 769px)');
|
||||
const update = () => setListPaneShown(query.matches);
|
||||
update();
|
||||
query.addEventListener('change', update);
|
||||
return () => query.removeEventListener('change', update);
|
||||
}, []);
|
||||
const clearMapSelection = useCallback(() => setSelectedMapSchool(null), []);
|
||||
|
||||
// A pin chosen on the map brings its card into view in the list beside it.
|
||||
useEffect(() => {
|
||||
if (!selectedMapSchool) return;
|
||||
const card = mapListRef.current?.querySelector<HTMLElement>(`[data-urn="${selectedMapSchool.urn}"]`);
|
||||
if (card?.offsetParent) card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}, [selectedMapSchool]);
|
||||
|
||||
/*
|
||||
* Switching view from far down the list would otherwise leave the reader
|
||||
@@ -482,7 +541,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
*/
|
||||
const changeView = (view: 'list' | 'map', via: 'toolbar' | 'floating') => {
|
||||
if (view === resultsView) return;
|
||||
setResultsView(view);
|
||||
setChosenView(view);
|
||||
track('results_view_changed', { view, via });
|
||||
requestAnimationFrame(() => {
|
||||
const results = resultsRef.current;
|
||||
@@ -496,11 +555,84 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
});
|
||||
};
|
||||
|
||||
const isMapView = initialSchools.schools.length > 0 && resultsView === 'map' && isLocationSearch;
|
||||
|
||||
// One school as a results row: the list view, the list beside the map and
|
||||
// the phone's bottom sheet all draw the same thing.
|
||||
const renderRow = (school: School) => (
|
||||
school.attainment_8_score != null ? (
|
||||
<SecondarySchoolRow
|
||||
key={school.urn}
|
||||
school={school}
|
||||
isLocationSearch={isLocationSearch}
|
||||
onAddToCompare={addSchoolFromSearch}
|
||||
onRemoveFromCompare={removeSchool}
|
||||
isInCompare={compareUrns.includes(school.urn)}
|
||||
laAvgAttainment8={school.local_authority ? laAverages[school.local_authority] ?? null : null}
|
||||
/>
|
||||
) : (
|
||||
<SchoolRow
|
||||
key={school.urn}
|
||||
school={school}
|
||||
isLocationSearch={isLocationSearch}
|
||||
onAddToCompare={addSchoolFromSearch}
|
||||
onRemoveFromCompare={removeSchool}
|
||||
isInCompare={compareUrns.includes(school.urn)}
|
||||
nationalAvgRwm={nationalAvgRwm}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
const schoolCount = `${initialSchools.total.toLocaleString()} school${initialSchools.total !== 1 ? 's' : ''}`;
|
||||
|
||||
// The count and the sort. Above the list in list view; at the top of the
|
||||
// list pane, beside the map, in map view.
|
||||
const resultsHeader = (
|
||||
<div className={styles.resultsHeader}>
|
||||
<h2 aria-live="polite" aria-atomic="true">
|
||||
{/* Beside the map the heading shares one line with the sort, so it
|
||||
drops the postcode, which the search box just above already shows. */}
|
||||
{isMapView && radiusMiles != null
|
||||
? `${schoolCount} within ${radiusMiles} mile${radiusMiles === 1 ? '' : 's'}`
|
||||
: isLocationSearch && initialSchools.location_info
|
||||
? `${schoolCount} within ${(initialSchools.location_info.radius / 1.60934).toFixed(1)} miles of ${initialSchools.location_info.postcode}`
|
||||
: `${schoolCount} found`
|
||||
}
|
||||
</h2>
|
||||
<div className={styles.resultsHeaderActions}>
|
||||
<select
|
||||
value={sortOrder}
|
||||
onChange={e => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (e.target.value === 'default') {
|
||||
params.delete('sort');
|
||||
} else {
|
||||
params.set('sort', e.target.value);
|
||||
}
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
}}
|
||||
className={styles.sortSelect}
|
||||
>
|
||||
<option value="default">Sort: Relevance</option>
|
||||
{(!isSecondaryView || isMixedView) && <option value="rwm_desc">Highest Reading, Writing & Maths %</option>}
|
||||
{(!isSecondaryView || isMixedView) && <option value="rwm_asc">Lowest Reading, Writing & Maths %</option>}
|
||||
{(isSecondaryView || isMixedView) && <option value="att8_desc">Highest Attainment 8</option>}
|
||||
{(isSecondaryView || isMixedView) && <option value="att8_asc">Lowest Attainment 8</option>}
|
||||
{isLocationSearch && <option value="distance">Nearest first</option>}
|
||||
<option value="name_asc">Name A–Z</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
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
|
||||
and wants none of it. */
|
||||
<div className={isSearchActive ? styles.homeView : `${styles.homeView} ${styles.landing}`}>
|
||||
<div
|
||||
className={isSearchActive ? styles.homeView : `${styles.homeView} ${styles.landing}`}
|
||||
style={toolbarHeight ? { '--toolbar-h': `${toolbarHeight}px` } as React.CSSProperties : undefined}
|
||||
>
|
||||
{/* Hero: a Sand panel with the proposition and the search on the left and
|
||||
the brand landscape bleeding to the panel edge on the right. The
|
||||
search lives inside the panel here and above the results elsewhere,
|
||||
@@ -669,43 +801,9 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSearch && (
|
||||
<div className={styles.resultsHeader}>
|
||||
<h2 aria-live="polite" aria-atomic="true">
|
||||
{isLocationSearch && initialSchools.location_info
|
||||
? `${initialSchools.total.toLocaleString()} school${initialSchools.total !== 1 ? 's' : ''} within ${(initialSchools.location_info.radius / 1.60934).toFixed(1)} miles of ${initialSchools.location_info.postcode}`
|
||||
: `${initialSchools.total.toLocaleString()} school${initialSchools.total !== 1 ? 's' : ''} found`
|
||||
}
|
||||
</h2>
|
||||
<div className={styles.resultsHeaderActions}>
|
||||
{resultsView === 'list' && (
|
||||
<select
|
||||
value={sortOrder}
|
||||
onChange={e => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (e.target.value === 'default') {
|
||||
params.delete('sort');
|
||||
} else {
|
||||
params.set('sort', e.target.value);
|
||||
}
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
}}
|
||||
className={styles.sortSelect}
|
||||
>
|
||||
<option value="default">Sort: Relevance</option>
|
||||
{(!isSecondaryView || isMixedView) && <option value="rwm_desc">Highest Reading, Writing & Maths %</option>}
|
||||
{(!isSecondaryView || isMixedView) && <option value="rwm_asc">Lowest Reading, Writing & Maths %</option>}
|
||||
{(isSecondaryView || isMixedView) && <option value="att8_desc">Highest Attainment 8</option>}
|
||||
{(isSecondaryView || isMixedView) && <option value="att8_asc">Lowest Attainment 8</option>}
|
||||
{isLocationSearch && <option value="distance">Nearest first</option>}
|
||||
<option value="name_asc">Name A–Z</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasSearch && !isMapView && resultsHeader}
|
||||
|
||||
{isSearchActive && (
|
||||
{isSearchActive && !isMapView && (
|
||||
<div className={styles.activeFilters}>
|
||||
{searchParams.get('search') && <span className={styles.filterChip}>Search: {searchParams.get('search')}<a href="/" className={styles.chipRemove} onClick={e => { e.preventDefault(); }}>×</a></span>}
|
||||
{searchParams.get('local_authority') && <span className={styles.filterChip}>{searchParams.get('local_authority')}</span>}
|
||||
@@ -724,63 +822,62 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : initialSchools.schools.length > 0 && resultsView === 'map' && isLocationSearch ? (
|
||||
/* Map View Layout */
|
||||
) : isMapView ? (
|
||||
/* Map View Layout: the list beside the map, as one card. */
|
||||
<div className={styles.mapViewContainer}>
|
||||
<div className={styles.mapListPane}>
|
||||
{resultsHeader}
|
||||
<div ref={mapListRef} className={styles.compactList}>
|
||||
{/* The list view's own rows, so both views show the same thing.
|
||||
Clicking a row (not its links or buttons) picks its pin. */}
|
||||
{listPaneShown && mapListSchools.map((school) => (
|
||||
<div
|
||||
key={school.urn}
|
||||
data-urn={school.urn}
|
||||
className={`${styles.mapRow} ${selectedMapSchool?.urn === school.urn ? styles.mapRowSelected : ''}`}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest('a, button')) return;
|
||||
setSelectedMapSchool(school);
|
||||
}}
|
||||
>
|
||||
{/* The keyboard's way to pick the pin: hidden until it has
|
||||
focus, since a pointer just clicks the row. The row
|
||||
itself cannot be the button, as it holds links and
|
||||
buttons of its own. */}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.showOnMap}
|
||||
aria-pressed={selectedMapSchool?.urn === school.urn}
|
||||
onClick={() => setSelectedMapSchool(school)}
|
||||
>
|
||||
Show {school.school_name} on the map
|
||||
</button>
|
||||
{renderRow(school)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.mapContainer}>
|
||||
<SchoolMap
|
||||
schools={isLoadingMap ? initialSchools.schools : mapSchools}
|
||||
schools={mapResultSchools}
|
||||
center={initialSchools.location_info?.coordinates}
|
||||
referencePoint={initialSchools.location_info?.coordinates}
|
||||
radiusMiles={radiusMiles}
|
||||
selectedUrn={selectedMapSchool?.urn ?? null}
|
||||
onMarkerClick={setSelectedMapSchool}
|
||||
onDeselect={clearMapSelection}
|
||||
onAddToCompare={addSchoolFromSearch}
|
||||
compareUrns={compareUrns}
|
||||
nationalAvgRwm={nationalAvgRwm}
|
||||
laAverages={laAverages}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.compactList}>
|
||||
{(isLoadingMap ? initialSchools.schools : mapSchools).map((school) => (
|
||||
<div
|
||||
key={school.urn}
|
||||
className={`${styles.listItemWrapper} ${selectedMapSchool?.urn === school.urn ? styles.highlightedItem : ''}`}
|
||||
>
|
||||
<CompactSchoolItem
|
||||
school={school}
|
||||
onAddToCompare={addSchoolFromSearch}
|
||||
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
|
||||
nationalAvgRwm={nationalAvgRwm}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
) : (
|
||||
/* List View Layout */
|
||||
<>
|
||||
<div className={styles.schoolList}>
|
||||
{sortedSchools.map((school) => (
|
||||
school.attainment_8_score != null ? (
|
||||
<SecondarySchoolRow
|
||||
key={school.urn}
|
||||
school={school}
|
||||
isLocationSearch={isLocationSearch}
|
||||
onAddToCompare={addSchoolFromSearch}
|
||||
onRemoveFromCompare={removeSchool}
|
||||
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
|
||||
laAvgAttainment8={school.local_authority ? laAverages[school.local_authority] ?? null : null}
|
||||
/>
|
||||
) : (
|
||||
<SchoolRow
|
||||
key={school.urn}
|
||||
school={school}
|
||||
isLocationSearch={isLocationSearch}
|
||||
onAddToCompare={addSchoolFromSearch}
|
||||
onRemoveFromCompare={removeSchool}
|
||||
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
|
||||
nationalAvgRwm={nationalAvgRwm}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
{sortedSchools.map(renderRow)}
|
||||
</div>
|
||||
|
||||
{(hasMore || allSchools.length < initialSchools.total) && (
|
||||
@@ -826,12 +923,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<CompactSchoolItem
|
||||
school={selectedMapSchool}
|
||||
onAddToCompare={addSchoolFromSearch}
|
||||
isInCompare={selectedSchools.some(s => s.urn === selectedMapSchool.urn)}
|
||||
nationalAvgRwm={nationalAvgRwm}
|
||||
/>
|
||||
{renderRow(selectedMapSchool)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -839,102 +931,3 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Compact School Item for Map View */
|
||||
interface CompactSchoolItemProps {
|
||||
school: School;
|
||||
onAddToCompare: (school: School) => void;
|
||||
isInCompare: boolean;
|
||||
nationalAvgRwm?: number | null;
|
||||
}
|
||||
|
||||
function CompactSchoolItem({ school, onAddToCompare, isInCompare, nationalAvgRwm }: CompactSchoolItemProps) {
|
||||
const ofstedBadge = buildOfstedListBadge(school);
|
||||
const isSecondary = school.attainment_8_score != null;
|
||||
|
||||
// vs-national delta for primary schools
|
||||
const rwmDelta =
|
||||
!isSecondary && school.rwm_expected_pct != null && nationalAvgRwm != null
|
||||
? Math.round(school.rwm_expected_pct - nationalAvgRwm)
|
||||
: null;
|
||||
|
||||
const deltaStyle: React.CSSProperties =
|
||||
rwmDelta == null
|
||||
? {}
|
||||
: rwmDelta >= 2
|
||||
? { fontSize: '0.7rem', color: 'var(--status-above)', fontWeight: 600 }
|
||||
: rwmDelta <= -2
|
||||
? { fontSize: '0.7rem', color: 'var(--brand)', fontWeight: 600 }
|
||||
: { fontSize: '0.7rem', color: 'var(--text-muted)' };
|
||||
|
||||
return (
|
||||
<div className={styles.compactItem}>
|
||||
<div className={styles.compactItemContent}>
|
||||
<div className={styles.compactItemHeader}>
|
||||
<a href={schoolUrl(school.urn, school.school_name)} className={styles.compactItemName}>
|
||||
{school.school_name}
|
||||
</a>
|
||||
{school.distance !== undefined && school.distance !== null && (
|
||||
<span className={styles.distanceBadge}>
|
||||
{school.distance.toFixed(1)} mi
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Ofsted badge */}
|
||||
<div style={{ marginBottom: '0.25rem' }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '0.0625rem 0.375rem',
|
||||
fontSize: '0.625rem',
|
||||
fontWeight: 600,
|
||||
borderRadius: '3px',
|
||||
whiteSpace: 'nowrap',
|
||||
...(ofstedBadge.cssClass === 'ofsted1' ? { background: 'var(--status-above-bg)', color: 'var(--status-above)' } :
|
||||
ofstedBadge.cssClass === 'ofsted2' ? { background: 'var(--status-above-bg)', color: 'var(--status-above)' } :
|
||||
ofstedBadge.cssClass === 'ofsted3' ? { background: 'var(--status-below-bg)', color: 'var(--status-below)' } :
|
||||
ofstedBadge.cssClass === 'ofsted4' ? { background: 'var(--status-below)', color: 'var(--text-inverse)' } :
|
||||
ofstedBadge.cssClass === 'ofstedRc' ? { background: 'var(--phase-secondary-text)', color: 'var(--text-inverse)' } :
|
||||
ofstedBadge.cssClass === 'ofstedPending' ? { background: 'var(--bg-secondary)', color: 'var(--text-muted)' } :
|
||||
{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }),
|
||||
}}
|
||||
>
|
||||
{ofstedBadge.label}
|
||||
</span>
|
||||
</div>
|
||||
{/* Headline metric + delta */}
|
||||
<div className={styles.compactItemStats}>
|
||||
<span className={styles.compactStat}>
|
||||
<strong>
|
||||
{isSecondary
|
||||
? (school.attainment_8_score != null ? school.attainment_8_score.toFixed(1) : '-')
|
||||
: (school.rwm_expected_pct != null ? `${school.rwm_expected_pct}%` : '-')}
|
||||
</strong>
|
||||
{' '}
|
||||
{isSecondary ? 'Att 8' : 'RWM'}
|
||||
</span>
|
||||
{rwmDelta != null && (
|
||||
<span style={deltaStyle}>
|
||||
{rwmDelta >= 2
|
||||
? `+${rwmDelta} pts vs national`
|
||||
: rwmDelta <= -2
|
||||
? `${rwmDelta} pts vs national`
|
||||
: '≈ national avg'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.compactItemActions}>
|
||||
<button
|
||||
className={isInCompare ? 'btn btn-active btn-sm' : 'btn btn-secondary btn-sm'}
|
||||
onClick={() => onAddToCompare(school)}
|
||||
>
|
||||
{isInCompare ? '✓ Comparing' : '+ Compare'}
|
||||
</button>
|
||||
<a href={schoolUrl(school.urn, school.school_name)} className="btn btn-tertiary btn-sm">
|
||||
View
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,213 +5,283 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import type { School } from '@/lib/types';
|
||||
import { schoolUrl } from '@/lib/utils';
|
||||
|
||||
// Fix for default marker icons in Next.js
|
||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
|
||||
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
|
||||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
|
||||
});
|
||||
import { schoolUrl, isSpecialSchool, buildOfstedListBadge, listRwmValue } from '@/lib/utils';
|
||||
|
||||
interface LeafletMapInnerProps {
|
||||
schools: School[];
|
||||
center: [number, number];
|
||||
zoom: number;
|
||||
referencePoint?: [number, number];
|
||||
/** Search radius in miles. Drawn as a dashed circle the view fits to. */
|
||||
radiusMiles?: number;
|
||||
/** The school whose pin is highlighted and, on wide screens, whose card is open. */
|
||||
selectedUrn?: number | null;
|
||||
onMarkerClick?: (school: School) => void;
|
||||
/** The open card was closed from the map itself (its × or a click elsewhere). */
|
||||
onDeselect?: () => void;
|
||||
onAddToCompare?: (school: School) => void;
|
||||
compareUrns?: number[];
|
||||
nationalAvgRwm?: number | null;
|
||||
laAverages?: Record<string, number | null>;
|
||||
}
|
||||
|
||||
const METRES_PER_MILE = 1609.344;
|
||||
|
||||
/*
|
||||
* Wide screens show the selected school as a card on the map. Narrow ones do
|
||||
* not: HomeView shows the same card as a bottom sheet there, and a popup on a
|
||||
* 390px map would cover most of it.
|
||||
*/
|
||||
const CARD_ON_MAP_QUERY = '(min-width: 769px)';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Popup helpers (must work in plain JS string templates — no React / CSS Modules)
|
||||
// Card helpers (must work in plain JS string templates — no React / CSS Modules)
|
||||
// Class names are global and styled in SchoolMap.module.css.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
interface PopupBadge {
|
||||
label: string;
|
||||
style: string;
|
||||
function pinIcon(selected: boolean): L.DivIcon {
|
||||
const size = selected ? 32 : 22;
|
||||
return L.divIcon({
|
||||
className: selected ? 'sc-pin sc-pin--selected' : 'sc-pin',
|
||||
html: '',
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
});
|
||||
}
|
||||
|
||||
function buildPopupBadge(school: School): PopupBadge {
|
||||
// A report card wins over any carried-forward legacy grade — its presence is
|
||||
// signalled by ofsted_rc_date (the list has no full report_card object, and
|
||||
// ofsted_framework is the raw event grouping, never "ReportCard").
|
||||
if (school.ofsted_rc_date) {
|
||||
const rcYear = new Date(school.ofsted_rc_date).getFullYear();
|
||||
return { label: `Report Card · ${rcYear}`, style: 'background:var(--phase-secondary-text);color:var(--text-inverse)' };
|
||||
}
|
||||
const year = school.ofsted_date ? new Date(school.ofsted_date).getFullYear() : null;
|
||||
const yearStr = year ? ` · ${year}` : '';
|
||||
if (school.ofsted_grade) {
|
||||
const labels: Record<number, string> = { 1: 'Outstanding', 2: 'Good', 3: 'Req. Improvement', 4: 'Inadequate' };
|
||||
const colours: Record<number, string> = {
|
||||
1: 'background:var(--status-above-bg);color:var(--status-above)',
|
||||
2: 'background:var(--status-above-bg);color:var(--status-above)',
|
||||
3: 'background:var(--status-below-bg);color:var(--status-below)',
|
||||
4: 'background:var(--status-below);color:var(--text-inverse)',
|
||||
};
|
||||
return { label: `${labels[school.ofsted_grade]}${yearStr}`, style: colours[school.ofsted_grade] };
|
||||
}
|
||||
return { label: 'Not yet inspected', style: 'background:var(--bg-secondary);color:var(--text-muted)' };
|
||||
interface CardContext {
|
||||
inCompare: boolean;
|
||||
nationalAvgRwm?: number | null;
|
||||
laAverages?: Record<string, number | null>;
|
||||
}
|
||||
|
||||
export default function LeafletMapInner({ schools, center, zoom, referencePoint, onMarkerClick, nationalAvgRwm, laAverages }: LeafletMapInnerProps) {
|
||||
/** The headline figure and its comparison, the same rule as the list rows. */
|
||||
function metricHtml(school: School, { nationalAvgRwm, laAverages }: CardContext): string {
|
||||
const special = isSpecialSchool(school);
|
||||
if (school.attainment_8_score != null) {
|
||||
const score = school.attainment_8_score;
|
||||
const laAvg = school.local_authority ? (laAverages?.[school.local_authority] ?? null) : null;
|
||||
let delta = '';
|
||||
if (!special && laAvg != null) {
|
||||
const diff = Math.round((score - laAvg) * 10) / 10;
|
||||
// Att8 runs 0–90 in 0.1 steps; ±0.5 is meaningful, where RWM needs ±2.
|
||||
const cls = diff >= 0.5 ? 'sc-up' : diff <= -0.5 ? 'sc-down' : '';
|
||||
delta = ` <span class="${cls}">${diff >= 0 ? '+' : ''}${diff} vs LA</span>`;
|
||||
}
|
||||
return `<strong>${score.toFixed(1)}</strong> Att 8${delta}`;
|
||||
}
|
||||
const rwm = listRwmValue(school);
|
||||
if (rwm != null) {
|
||||
let delta = '';
|
||||
if (!special && nationalAvgRwm != null) {
|
||||
const diff = Math.round(rwm - nationalAvgRwm);
|
||||
const cls = diff >= 2 ? 'sc-up' : diff <= -2 ? 'sc-down' : '';
|
||||
const text = diff >= 2 ? `+${diff} pts` : diff <= -2 ? `${diff} pts` : '≈ national';
|
||||
delta = ` <span class="${cls}">${text}</span>`;
|
||||
}
|
||||
return `<strong>${rwm}%</strong> RWM${delta}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function cardHtml(school: School, ctx: CardContext): string {
|
||||
const badge = buildOfstedListBadge(school);
|
||||
const href = schoolUrl(school.urn, school.school_name);
|
||||
const distance = school.distance != null
|
||||
? `<span class="sc-dist">${school.distance.toFixed(1)} mi</span>` : '';
|
||||
const type = school.school_type
|
||||
? `<span class="sc-tag">${escapeHtml(school.school_type)}</span>` : '';
|
||||
const metric = metricHtml(school, ctx);
|
||||
const pupils = school.total_pupils != null
|
||||
? `${metric ? ' · ' : ''}${school.total_pupils.toLocaleString('en-GB')} pupils` : '';
|
||||
return `<div class="sc-card">
|
||||
<div class="sc-card-head"><a class="sc-card-name" href="${href}">${escapeHtml(school.school_name)}</a>${distance}</div>
|
||||
<div class="sc-card-tags"><span class="sc-ofsted sc-${badge.cssClass}">${escapeHtml(badge.label)}</span>${type}</div>
|
||||
${metric || pupils ? `<div class="sc-card-metric">${metric}${pupils}</div>` : ''}
|
||||
<div class="sc-card-actions">
|
||||
<a class="btn btn-tertiary btn-sm" href="${href}">View</a>
|
||||
<button type="button" class="btn ${ctx.inCompare ? 'btn-active' : 'btn-secondary'} btn-sm" data-compare>${ctx.inCompare ? '✓ Comparing' : '+ Compare'}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export default function LeafletMapInner({
|
||||
schools, center, zoom, referencePoint, radiusMiles, selectedUrn,
|
||||
onMarkerClick, onDeselect, onAddToCompare, compareUrns,
|
||||
nationalAvgRwm, laAverages,
|
||||
}: LeafletMapInnerProps) {
|
||||
const mapRef = useRef<L.Map | null>(null);
|
||||
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||||
const refMarkerRef = useRef<L.Marker | null>(null);
|
||||
const markersRef = useRef<Map<number, L.Marker>>(new Map());
|
||||
const overlayRef = useRef<L.LayerGroup | null>(null);
|
||||
const popupRef = useRef<L.Popup | null>(null);
|
||||
const selectedRef = useRef<number | null>(null);
|
||||
// Bumped whenever the pins are rebuilt, for any reason, so the selection
|
||||
// effect puts the card back on the new pin rather than only when the
|
||||
// selection or the school list changes.
|
||||
const [pinsVersion, setPinsVersion] = useState(0);
|
||||
|
||||
// The popup is plain HTML outside React, so its handlers read the latest
|
||||
// props through a ref rather than closing over the render they were made in.
|
||||
const latest = useRef({ onMarkerClick, onDeselect, onAddToCompare, compareUrns, nationalAvgRwm, laAverages });
|
||||
latest.current = { onMarkerClick, onDeselect, onAddToCompare, compareUrns, nationalAvgRwm, laAverages };
|
||||
|
||||
const cardHtmlFor = (school: School) => cardHtml(school, {
|
||||
inCompare: !!latest.current.compareUrns?.includes(school.urn),
|
||||
nationalAvgRwm: latest.current.nationalAvgRwm,
|
||||
laAverages: latest.current.laAverages,
|
||||
});
|
||||
|
||||
// Map, tiles and controls: once.
|
||||
useEffect(() => {
|
||||
if (!mapContainerRef.current) return;
|
||||
if (!mapContainerRef.current || mapRef.current) return;
|
||||
const map = L.map(mapContainerRef.current, { zoomControl: false }).setView(center, zoom);
|
||||
L.control.zoom({ position: 'topright' }).addTo(map);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
overlayRef.current = L.layerGroup().addTo(map);
|
||||
mapRef.current = map;
|
||||
// center/zoom only seed the first view; later changes go through the
|
||||
// markers effect, which fits the view to the results.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Initialize map
|
||||
if (!mapRef.current) {
|
||||
mapRef.current = L.map(mapContainerRef.current).setView(center, zoom);
|
||||
// Search location, radius and school pins.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
const overlay = overlayRef.current;
|
||||
if (!map || !overlay) return;
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
maxZoom: 19,
|
||||
}).addTo(mapRef.current);
|
||||
}
|
||||
// Forget the open card before closing it, so its close handler does not
|
||||
// read this rebuild as the reader deselecting; the selection effect below
|
||||
// reopens it on the new pin.
|
||||
popupRef.current = null;
|
||||
map.closePopup();
|
||||
overlay.clearLayers();
|
||||
markersRef.current.clear();
|
||||
|
||||
// Clear existing school markers (not the reference pin)
|
||||
mapRef.current.eachLayer((layer) => {
|
||||
if (layer instanceof L.Marker && layer !== refMarkerRef.current) {
|
||||
mapRef.current!.removeLayer(layer);
|
||||
let radiusBounds: L.LatLngBounds | null = null;
|
||||
if (referencePoint) {
|
||||
if (radiusMiles) {
|
||||
const circle = L.circle(referencePoint, {
|
||||
radius: radiusMiles * METRES_PER_MILE,
|
||||
className: 'sc-radius',
|
||||
interactive: false,
|
||||
}).addTo(overlay);
|
||||
radiusBounds = circle.getBounds();
|
||||
const label = radiusMiles === 1 ? '1 mile' : `${radiusMiles} miles`;
|
||||
L.marker([radiusBounds.getNorth(), referencePoint[1]], {
|
||||
icon: L.divIcon({ className: 'sc-radius-label', html: label, iconSize: undefined }),
|
||||
interactive: false,
|
||||
keyboard: false,
|
||||
}).addTo(overlay);
|
||||
}
|
||||
});
|
||||
|
||||
// Add reference pin (search location)
|
||||
if (refMarkerRef.current) {
|
||||
refMarkerRef.current.remove();
|
||||
refMarkerRef.current = null;
|
||||
}
|
||||
if (referencePoint && mapRef.current) {
|
||||
const refIcon = L.divIcon({
|
||||
html: `<div style="
|
||||
width: 20px; height: 20px;
|
||||
background: var(--brand);
|
||||
border: 3px solid white;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.35);
|
||||
"></div>`,
|
||||
iconSize: [20, 20],
|
||||
iconAnchor: [10, 10],
|
||||
className: '',
|
||||
});
|
||||
refMarkerRef.current = L.marker(referencePoint, { icon: refIcon, zIndexOffset: 1000 })
|
||||
.addTo(mapRef.current)
|
||||
.bindPopup('<strong>Search location</strong>');
|
||||
L.marker(referencePoint, {
|
||||
icon: L.divIcon({ className: 'sc-home', html: '', iconSize: [16, 16], iconAnchor: [8, 8] }),
|
||||
zIndexOffset: 500,
|
||||
title: 'Search location',
|
||||
alt: 'Search location',
|
||||
}).addTo(overlay);
|
||||
}
|
||||
|
||||
// Add markers for schools
|
||||
schools.forEach((school) => {
|
||||
if (school.latitude && school.longitude && mapRef.current) {
|
||||
const marker = L.marker([school.latitude, school.longitude]).addTo(mapRef.current);
|
||||
for (const school of schools) {
|
||||
if (!school.latitude || !school.longitude) continue;
|
||||
const selected = school.urn === selectedRef.current;
|
||||
const marker = L.marker([school.latitude, school.longitude], {
|
||||
icon: pinIcon(selected),
|
||||
zIndexOffset: selected ? 1000 : 0,
|
||||
title: school.school_name,
|
||||
alt: school.school_name,
|
||||
riseOnHover: true,
|
||||
}).addTo(overlay);
|
||||
marker.on('click', () => latest.current.onMarkerClick?.(school));
|
||||
markersRef.current.set(school.urn, marker);
|
||||
}
|
||||
|
||||
// Create popup content
|
||||
const badge = buildPopupBadge(school);
|
||||
const isSecondary = school.attainment_8_score != null;
|
||||
|
||||
// Phase label
|
||||
const rawPhase = (school.phase ?? '').toLowerCase();
|
||||
const phaseLabel =
|
||||
rawPhase.includes('secondary') ? 'Secondary' :
|
||||
rawPhase === 'all-through' ? 'All-through' :
|
||||
rawPhase.includes('primary') ? 'Primary' :
|
||||
isSecondary ? 'Secondary' : 'Primary';
|
||||
|
||||
// Distance string
|
||||
const distanceStr =
|
||||
school.distance != null ? ` · ${school.distance.toFixed(1)} mi` : '';
|
||||
|
||||
// Headline metric
|
||||
let metricHtml = '';
|
||||
if (isSecondary) {
|
||||
const score = school.attainment_8_score!;
|
||||
const laAvg = school.local_authority ? (laAverages?.[school.local_authority] ?? null) : null;
|
||||
let deltaLine = '';
|
||||
if (laAvg != null) {
|
||||
const diff = Math.round((score - laAvg) * 10) / 10;
|
||||
const sign = diff >= 0 ? '+' : '';
|
||||
// Att8 scores range 0–90 in 0.1 increments; ±0.5 is meaningful here
|
||||
// vs primary RWM % where ±2 pts is the threshold
|
||||
const colour = diff >= 0.5 ? 'var(--status-above)' : diff <= -0.5 ? 'var(--brand)' : 'var(--text-muted)';
|
||||
const laName = escapeHtml(school.local_authority ?? 'LA');
|
||||
deltaLine = `<div style="font-size:11px;font-weight:600;color:${colour}">${sign}${diff} vs ${laName} avg</div>`;
|
||||
}
|
||||
metricHtml = `<div style="margin-bottom:4px">
|
||||
<span style="font-size:20px;font-weight:700;color:var(--text-primary);font-family:Georgia,serif">${score.toFixed(1)}</span>
|
||||
<span style="font-size:11px;color:var(--text-muted);margin-left:4px">Attainment 8</span>
|
||||
${deltaLine}
|
||||
</div>`;
|
||||
} else if (school.rwm_expected_pct != null) {
|
||||
const rwm = school.rwm_expected_pct;
|
||||
let deltaLine = '';
|
||||
if (nationalAvgRwm != null) {
|
||||
const diff = Math.round(rwm - nationalAvgRwm);
|
||||
const colour = diff >= 2 ? 'var(--status-above)' : diff <= -2 ? 'var(--brand)' : 'var(--text-muted)';
|
||||
const text =
|
||||
diff >= 2 ? `+${diff} pts vs national` :
|
||||
diff <= -2 ? `${diff} pts vs national` :
|
||||
'≈ national avg';
|
||||
deltaLine = `<div style="font-size:11px;font-weight:600;color:${colour}">${text}</div>`;
|
||||
}
|
||||
metricHtml = `<div style="margin-bottom:4px">
|
||||
<span style="font-size:20px;font-weight:700;color:var(--text-primary);font-family:Georgia,serif">${rwm}%</span>
|
||||
<span style="font-size:11px;color:var(--text-muted);margin-left:4px">Reading, Writing & Maths</span>
|
||||
${deltaLine}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const slug = schoolUrl(school.urn, school.school_name);
|
||||
|
||||
const popupContent = `<div style="font-family:system-ui,sans-serif;min-width:240px;max-width:280px;padding:0">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;margin-bottom:6px">
|
||||
<strong style="font-size:13px;color:var(--text-primary);line-height:1.3">${escapeHtml(school.school_name)}</strong>
|
||||
<span style="font-size:10px;font-weight:700;padding:2px 6px;border-radius:3px;white-space:nowrap;flex-shrink:0;${badge.style}">${badge.label}</span>
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--text-muted);margin-bottom:8px">
|
||||
${phaseLabel}${school.local_authority ? ` · ${escapeHtml(school.local_authority)}` : ''}${distanceStr}
|
||||
</div>
|
||||
${metricHtml}
|
||||
<a href="${slug}" style="display:block;text-align:center;padding:6px;background:var(--status-above);color:var(--text-inverse);border-radius:5px;text-decoration:none;font-size:12px;font-weight:600;margin-top:8px">View Details →</a>
|
||||
</div>`;
|
||||
|
||||
marker.bindPopup(popupContent);
|
||||
|
||||
if (onMarkerClick) {
|
||||
marker.on('click', () => onMarkerClick(school));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update map view
|
||||
if (schools.length > 1) {
|
||||
const bounds = L.latLngBounds(
|
||||
schools
|
||||
.filter(s => s.latitude && s.longitude)
|
||||
.map(s => [s.latitude!, s.longitude!] as [number, number])
|
||||
);
|
||||
mapRef.current.fitBounds(bounds, { padding: [50, 50] });
|
||||
// Fit the search radius when there is one: every result lies inside it,
|
||||
// and it keeps the view the same shape whatever the pins happen to be.
|
||||
if (radiusBounds) {
|
||||
map.fitBounds(radiusBounds, { padding: [16, 16] });
|
||||
} else if (schools.length > 1) {
|
||||
const points = schools
|
||||
.filter(s => s.latitude && s.longitude)
|
||||
.map(s => [s.latitude!, s.longitude!] as [number, number]);
|
||||
if (points.length) map.fitBounds(L.latLngBounds(points), { padding: [50, 50] });
|
||||
} else {
|
||||
mapRef.current.setView(center, zoom);
|
||||
map.setView(center, zoom);
|
||||
}
|
||||
setPinsVersion(v => v + 1);
|
||||
}, [schools, center, zoom, referencePoint, radiusMiles]);
|
||||
|
||||
// Selection: restyle the pin, and on wide screens open its card.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
const previous = selectedRef.current;
|
||||
selectedRef.current = selectedUrn ?? null;
|
||||
|
||||
if (previous != null && previous !== selectedUrn) {
|
||||
const old = markersRef.current.get(previous);
|
||||
old?.setIcon(pinIcon(false));
|
||||
old?.setZIndexOffset(0);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
// Don't destroy map on every update, just clean markers
|
||||
};
|
||||
}, [schools, center, zoom, referencePoint, onMarkerClick, nationalAvgRwm, laAverages]);
|
||||
const marker = selectedUrn != null ? markersRef.current.get(selectedUrn) : undefined;
|
||||
const school = selectedUrn != null ? schools.find(s => s.urn === selectedUrn) : undefined;
|
||||
if (!marker || !school) {
|
||||
popupRef.current = null;
|
||||
map.closePopup();
|
||||
return;
|
||||
}
|
||||
marker.setIcon(pinIcon(true));
|
||||
marker.setZIndexOffset(1000);
|
||||
|
||||
if (!window.matchMedia(CARD_ON_MAP_QUERY).matches) {
|
||||
if (!map.getBounds().contains(marker.getLatLng())) map.panTo(marker.getLatLng());
|
||||
return;
|
||||
}
|
||||
|
||||
const popup = L.popup({
|
||||
className: 'sc-popup',
|
||||
maxWidth: 300,
|
||||
minWidth: 260,
|
||||
offset: [0, -12],
|
||||
autoPanPadding: [24, 24],
|
||||
}).setLatLng(marker.getLatLng()).setContent(cardHtmlFor(school));
|
||||
popup.on('remove', () => {
|
||||
// Closed from the map, not replaced by another selection.
|
||||
if (popupRef.current === popup) {
|
||||
popupRef.current = null;
|
||||
latest.current.onDeselect?.();
|
||||
}
|
||||
});
|
||||
popupRef.current = popup;
|
||||
popup.openOn(map);
|
||||
// One listener on the card's shell: it outlives the content swaps below.
|
||||
popup.getElement()?.addEventListener('click', (e) => {
|
||||
if ((e.target as HTMLElement).closest('[data-compare]')) latest.current.onAddToCompare?.(school);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedUrn, pinsVersion]);
|
||||
|
||||
// The open card follows the compare basket and the averages as they arrive.
|
||||
useEffect(() => {
|
||||
const popup = popupRef.current;
|
||||
if (!popup || selectedUrn == null) return;
|
||||
const school = schools.find(s => s.urn === selectedUrn);
|
||||
if (school) popup.setContent(cardHtmlFor(school));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [compareUrns, nationalAvgRwm, laAverages]);
|
||||
|
||||
// Cleanup map on unmount
|
||||
useEffect(() => {
|
||||
|
||||
@@ -74,3 +74,173 @@
|
||||
font-size: 0.9375rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Results map skin ──────────────────────────────────────────────────────
|
||||
Leaflet renders these outside React, so they are global class names, scoped
|
||||
under .mapWrapper to keep this module pure. Pins are brand teal, the chosen
|
||||
one coral; the search location is ink, ringed. */
|
||||
|
||||
/* Muted tiles so the pins, not the streets, carry the colour. */
|
||||
.mapWrapper :global(.leaflet-tile-pane) {
|
||||
filter: saturate(0.45);
|
||||
}
|
||||
|
||||
/* The fullscreen button owns the top-right corner; zoom stacks under it. */
|
||||
.mapWrapper :global(.leaflet-top.leaflet-right) {
|
||||
top: 2.75rem;
|
||||
}
|
||||
|
||||
.mapWrapper :global(.sc-pin) {
|
||||
box-sizing: border-box;
|
||||
border-radius: 50%;
|
||||
background: var(--brand);
|
||||
border: 2.5px solid var(--bg-card);
|
||||
box-shadow: 0 2px 5px rgba(var(--shadow-rgb), 0.35);
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
|
||||
.mapWrapper :global(.sc-pin:hover) {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
.mapWrapper :global(.sc-pin--selected) {
|
||||
background: var(--action);
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.mapWrapper :global(.sc-pin:focus-visible) {
|
||||
outline: 3px solid var(--text-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.mapWrapper :global(.sc-home) {
|
||||
border-radius: 50%;
|
||||
background: var(--text-primary);
|
||||
box-shadow: 0 0 0 6px rgba(var(--shadow-rgb), 0.18);
|
||||
}
|
||||
|
||||
.mapWrapper :global(.sc-radius) {
|
||||
stroke: var(--brand);
|
||||
stroke-width: 2px;
|
||||
stroke-dasharray: 6 6;
|
||||
fill: var(--brand);
|
||||
fill-opacity: 0.05;
|
||||
}
|
||||
|
||||
.mapWrapper :global(.sc-radius-label) {
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
transform-origin: center;
|
||||
translate: -50% -50%;
|
||||
padding: 0.125rem 0.625rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--brand);
|
||||
border-radius: 999px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--brand-strong);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The selected school's card, drawn as a card rather than a speech bubble. */
|
||||
.mapWrapper :global(.sc-popup .leaflet-popup-content-wrapper) {
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 10px 28px rgba(var(--shadow-rgb), 0.22);
|
||||
}
|
||||
|
||||
.mapWrapper :global(.sc-popup .leaflet-popup-content) {
|
||||
margin: 0.875rem 1rem;
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
.mapWrapper :global(.sc-card) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.mapWrapper :global(.sc-card-head) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding-right: 1rem; /* clear of the popup's × */
|
||||
}
|
||||
|
||||
.mapWrapper :global(.sc-card-name) {
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mapWrapper :global(.sc-card-name:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.mapWrapper :global(.sc-dist) {
|
||||
flex-shrink: 0;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
background: var(--brand);
|
||||
color: var(--brand-on);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mapWrapper :global(.sc-card-tags) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.mapWrapper :global(.sc-ofsted),
|
||||
.mapWrapper :global(.sc-tag) {
|
||||
padding: 0.125rem 0.4375rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mapWrapper :global(.sc-tag) {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mapWrapper :global(.sc-ofsted1),
|
||||
.mapWrapper :global(.sc-ofsted2) { background: var(--status-above-bg); color: var(--status-above); }
|
||||
.mapWrapper :global(.sc-ofsted3) { background: var(--status-below-bg); color: var(--status-below); }
|
||||
.mapWrapper :global(.sc-ofsted4) { background: var(--status-below); color: var(--text-inverse); }
|
||||
.mapWrapper :global(.sc-ofstedRc) { background: var(--phase-secondary-text); color: var(--text-inverse); }
|
||||
.mapWrapper :global(.sc-ofstedInspected) { background: var(--phase-primary-bg); color: var(--phase-primary-text); }
|
||||
.mapWrapper :global(.sc-ofstedPending) { background: var(--border); color: var(--text-muted); }
|
||||
|
||||
.mapWrapper :global(.sc-card-metric) {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.mapWrapper :global(.sc-card-metric strong) {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.mapWrapper :global(.sc-up) { color: var(--status-above); font-weight: 600; }
|
||||
.mapWrapper :global(.sc-down) { color: var(--status-below); font-weight: 600; }
|
||||
|
||||
.mapWrapper :global(.sc-card-actions) {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.mapWrapper :global(.sc-card-actions > *) {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import { useRef, useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import type { School } from '@/lib/types';
|
||||
import styles from './SchoolMap.module.css';
|
||||
|
||||
@@ -26,12 +26,17 @@ interface SchoolMapProps {
|
||||
center?: [number, number];
|
||||
zoom?: number;
|
||||
referencePoint?: [number, number];
|
||||
radiusMiles?: number;
|
||||
selectedUrn?: number | null;
|
||||
onMarkerClick?: (school: School) => void;
|
||||
onDeselect?: () => void;
|
||||
onAddToCompare?: (school: School) => void;
|
||||
compareUrns?: number[];
|
||||
nationalAvgRwm?: number | null;
|
||||
laAverages?: Record<string, number | null>;
|
||||
}
|
||||
|
||||
export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarkerClick, nationalAvgRwm, laAverages }: SchoolMapProps) {
|
||||
export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarkerClick, nationalAvgRwm, laAverages, ...selection }: SchoolMapProps) {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [nativeFullscreen, setNativeFullscreen] = useState(false);
|
||||
// iOS Safari has no Element.requestFullscreen — fall back to a fixed-position
|
||||
@@ -80,8 +85,9 @@ export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarker
|
||||
}
|
||||
}, [fallbackFullscreen]);
|
||||
|
||||
// Calculate center if not provided
|
||||
const mapCenter: [number, number] = center || (() => {
|
||||
// Calculate center if not provided. Memoised: a fresh array on every render
|
||||
// would make the map refit and rebuild every pin each time.
|
||||
const mapCenter = useMemo<[number, number]>(() => center || (() => {
|
||||
if (schools.length === 0) return [51.5074, -0.1278];
|
||||
if (schools.length === 1 && schools[0].latitude && schools[0].longitude) {
|
||||
return [schools[0].latitude, schools[0].longitude];
|
||||
@@ -90,8 +96,8 @@ export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarker
|
||||
if (validSchools.length === 0) return [51.5074, -0.1278];
|
||||
const avgLat = validSchools.reduce((sum, s) => sum + (s.latitude || 0), 0) / validSchools.length;
|
||||
const avgLng = validSchools.reduce((sum, s) => sum + (s.longitude || 0), 0) / validSchools.length;
|
||||
return [avgLat, avgLng];
|
||||
})();
|
||||
return [avgLat, avgLng] as [number, number];
|
||||
})(), [center, schools]);
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className={`${styles.mapWrapper} ${isFullscreen ? styles.fullscreen : ''} ${fallbackFullscreen ? styles.fsFallback : ''}`}>
|
||||
@@ -123,6 +129,7 @@ export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarker
|
||||
onMarkerClick={onMarkerClick}
|
||||
nationalAvgRwm={nationalAvgRwm}
|
||||
laAverages={laAverages}
|
||||
{...selection}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -220,7 +220,16 @@
|
||||
.vsNationalFlat { font-size: 0.7rem; color: var(--text-muted); }
|
||||
|
||||
/* ── Mobile ──────────────────────────────────────────── */
|
||||
@media (max-width: 640px) {
|
||||
/*
|
||||
* Narrow: content full width, actions in a row beneath. Keyed to the list the
|
||||
* row sits in, not the screen, because the same row fills the phone list and
|
||||
* the ~430px list beside the map on desktop. HomeView makes its lists a
|
||||
* `results` container; outside one, the row keeps its wide layout, which is
|
||||
* why rowContainerGuard.test.ts fails if anything else renders this row.
|
||||
* 608px is exact: below 769px the page pads 1rem each side, so a 640px screen
|
||||
* gives a 608px list, matching the old max-width: 640px media query.
|
||||
*/
|
||||
@container results (max-width: 608px) {
|
||||
.row {
|
||||
flex-wrap: wrap;
|
||||
padding: 0.875rem;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import type { School } from '@/lib/types';
|
||||
import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange, isProposedToClose, isSpecialSchool } from '@/lib/utils';
|
||||
import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange, isProposedToClose, isSpecialSchool, listRwmValue } from '@/lib/utils';
|
||||
import styles from './SchoolRow.module.css';
|
||||
|
||||
interface SchoolRowProps {
|
||||
@@ -38,18 +38,10 @@ export function SchoolRow({
|
||||
school.religious_denomination &&
|
||||
school.religious_denomination !== 'Does not apply';
|
||||
|
||||
// A placeholder all-zero row (every subject 0 — a special/suppressed
|
||||
// signature, matching SchoolDetailView's ks2Placeholder) isn't a real score,
|
||||
// so its figure is hidden. A genuine 0% combined (some pupils met individual
|
||||
// subjects but not all three) is NOT all-zero and stays shown.
|
||||
const rwmPlaceholder =
|
||||
school.rwm_expected_pct === 0 &&
|
||||
(school.reading_expected_pct ?? 0) === 0 &&
|
||||
(school.writing_expected_pct ?? 0) === 0 &&
|
||||
(school.maths_expected_pct ?? 0) === 0;
|
||||
// The school's OWN figure and its year-over-year trend are same-school
|
||||
// measures — shown whenever there's a real value, special schools included.
|
||||
const showRwmValue = school.rwm_expected_pct != null && !rwmPlaceholder;
|
||||
// measures — shown whenever there's a real value (not the all-zero
|
||||
// placeholder, see listRwmValue), special schools included.
|
||||
const showRwmValue = listRwmValue(school) != null;
|
||||
// The vs-England delta is a mainstream benchmark: additionally dropped for
|
||||
// special schools / PRUs / AP, whose pupils aren't measured against it fairly.
|
||||
const rwmDelta =
|
||||
|
||||
@@ -232,7 +232,16 @@
|
||||
}
|
||||
|
||||
/* ── Mobile ──────────────────────────────────────────── */
|
||||
@media (max-width: 640px) {
|
||||
/*
|
||||
* Narrow: content full width, actions in a row beneath. Keyed to the list the
|
||||
* row sits in, not the screen, because the same row fills the phone list and
|
||||
* the ~430px list beside the map on desktop. HomeView makes its lists a
|
||||
* `results` container; outside one, the row keeps its wide layout, which is
|
||||
* why rowContainerGuard.test.ts fails if anything else renders this row.
|
||||
* 608px is exact: below 769px the page pads 1rem each side, so a 640px screen
|
||||
* gives a 608px list, matching the old max-width: 640px media query.
|
||||
*/
|
||||
@container results (max-width: 608px) {
|
||||
.row {
|
||||
flex-wrap: wrap;
|
||||
padding: 0.875rem;
|
||||
|
||||
@@ -898,3 +898,27 @@ export function isSpecialSchool(school: { school_type?: string | null }): boolea
|
||||
const t = (school.school_type ?? '').toLowerCase();
|
||||
return /\bspecial\b/.test(t) || /pupil referral/.test(t) || /alternative provision/.test(t);
|
||||
}
|
||||
|
||||
/**
|
||||
* The school's combined Reading, Writing & Maths figure, or null when there is
|
||||
* no real one to show.
|
||||
*
|
||||
* A placeholder all-zero row (every subject 0, the special/suppressed
|
||||
* signature that SchoolDetailView calls ks2Placeholder) is not a score. A
|
||||
* genuine 0% combined, where some pupils met individual subjects but not all
|
||||
* three, is not all-zero and stays shown.
|
||||
*/
|
||||
export function listRwmValue(school: {
|
||||
rwm_expected_pct?: number | null;
|
||||
reading_expected_pct?: number | null;
|
||||
writing_expected_pct?: number | null;
|
||||
maths_expected_pct?: number | null;
|
||||
}): number | null {
|
||||
if (school.rwm_expected_pct == null) return null;
|
||||
const placeholder =
|
||||
school.rwm_expected_pct === 0 &&
|
||||
(school.reading_expected_pct ?? 0) === 0 &&
|
||||
(school.writing_expected_pct ?? 0) === 0 &&
|
||||
(school.maths_expected_pct ?? 0) === 0;
|
||||
return placeholder ? null : school.rwm_expected_pct;
|
||||
}
|
||||
Reference in new issue
Block a user