feat(search): open postcode searches on the map on phones too
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 21s

Trial the map as the default for every postcode search, not only on
desktop (DEFAULT_LOCATION_VIEW in HomeView; results_view_changed shows
how often readers switch away). The view is now derived: the reader's
choice once made, else the default for the kind of search. That follows
a hero search through client-side navigation without syncing state.

With nothing left varying by device, the user-agent default goes
(lib/device.ts, the headers() read in page.tsx). That settles two review
points: the HTML no longer needs Vary: User-Agent, and there is no
narrow-window fallback left to flash the map before the list.

Review fix in LeafletMapInner: the card reopened only when the selection
or the school list changed, so a rebuild caused by the centre, reference
point or radius closed it for good while the page still had the school
selected. The selection effect now follows a pins-rebuilt counter.
SchoolMap memoises its fallback centre, which was a fresh array on every
render and would have rebuilt every pin each time.

Phones hide the map view's list pane, so it no longer builds up to 500
hidden cards there (decided after mount, so hydration still matches).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
TudorandClaude Opus 5.5 committed 2026-09-30 21:50:34 +01:00
1 parent 4e4b30e812
commit 1bb3e0360f
11 files changed
+130 -141

No files matched your search

+28 -20
View File
@@ -500,12 +500,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();
@@ -581,26 +577,29 @@ test('a desktop postcode search opens on the map with the list beside it', async
});
for (const width of [360, 390, 430]) {
test(`the floating Map button is in reach at ${width}px`, async ({ page }) => {
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();
const sheetBox = (await sheet.boundingBox())!;
expect(sheetBox.y + sheetBox.height).toBeLessThanOrEqual(barTop);
expect(sheetBox.y).toBeGreaterThanOrEqual(fabBox.y + fabBox.height);
// MOBILE.md: no horizontal overflow, and 44px targets in the new chrome.
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth))
@@ -616,8 +615,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();
});
}