Compare commits

..
Author SHA1 Message Date
TudorandClaude Opus 5.5 5df8c93420 fix(search): keep the map list's count and sort on one line
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 1m21s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 18s
Beside the map, "19 schools within 1.0 miles of SW196AR" (359px) and the
sort menu (281px, as wide as its longest option) could not share the
pane's 427px, so the sort dropped to a second line.

In the map view the heading now reads "19 schools within 1 mile": the
postcode is already in the search box just above, and the radius reads
as the toolbar's does. The sort menu is capped at 11rem, truncating a
long choice, and the header no longer wraps; on a narrow pane the count
truncates rather than pushing the sort down. List view keeps the full
heading. Tried on staging by injecting the change: 243px + 176px, one
line at 1440.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:18:26 +01:00
tudor ebf9c12446 Merge pull request 'feat(search): show the list view's rows beside the map' (#160) from feat/map-list-rows into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 20s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m24s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 28s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 3m1s
Reviewed-on: #160
2026-10-01 09:04:30 +00:00
TudorandClaude Opus 5.5 ca4ddd2b12 fix(search): address review on the shared result rows
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
- The rows' narrow layout now switches at a 608px list, not 600px. Below
  769px the page pads 1rem each side, so a 640px screen gives a 608px
  list: exactly the old max-width: 640px media query, where 600px left
  633-640px screens on the wide layout.
- rowContainerGuard.test.ts fails if anything other than HomeView renders
  SchoolRow or SecondarySchoolRow, or if one of HomeView's row lists loses
  its `results` container. Outside one the rows silently keep their wide
  layout on phones. (Checked: HomeView is the only importer today.)
- Picking a pin from the list beside the map now works from the keyboard:
  each row carries a "Show <school> on the map" button, visually hidden
  until focused, with aria-pressed for the selected school. The row itself
  cannot be the button, since it holds links and buttons of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 09:09:47 +01:00
TudorandClaude Opus 5.5 dff3e210ab feat(search): show the list view's rows beside the map
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 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m20s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 20s
The list beside the map used its own cut-down card, so it showed less
than the list view (no phase, age range, denomination, trend, local
authority) and looked different. It now renders the list view's
SchoolRow / SecondarySchoolRow, through one renderRow shared by the list
view, the map pane and the phone's bottom sheet, which also gains
Remove from compare. CompactSchoolItem and its styles are gone.

The rows switched to their stacked layout on a max-width: 640px media
query, which never fires in a ~430px pane on a desktop screen. It is now
a container query on the list the row sits in (`results`, set on the
list view, the map pane and the bottom sheet). 600px of list is a 632px
screen less padding, so phones lay out as before.

Clicking a row, outside its links and buttons, still picks its pin, and
the selected row is outlined. The pane widens to 360–460px for the
taller rows.

The bottom sheet's close button was a 30px target; it is now 44px with
the same 30px circle (MOBILE.md), and the phone journey checks it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 09:03:50 +01:00
tudor 37bbda1da1 Merge pull request 'fix(search): keep the phone results toolbar inside the screen' (#159) from fix/phone-toolbar-width into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 19s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m26s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 27s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 3m5s
Reviewed-on: #159
2026-09-30 22:21:16 +00:00
TudorandClaude Opus 5.5 a37da15008 fix(search): keep the phone results toolbar inside the screen
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 9s
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 15s
The search summary ("SW196AR · within 1 mile  Edit") ran about 40px off
the right edge on every phone, cutting "Edit" in half, and the filter row
bled past the screen with it (reported on an iPhone 17, 402px).

The phone toolbar is a column flex container, but it kept flex-wrap:
wrap from the desktop rule. In a wrapping flex container each line is as
wide as its widest item's content, not the container, so the summary's
~410px of text stretched the line past a 370px toolbar. nowrap makes
stretch mean the container's width. Verified on staging by injecting the
rule: nothing in the toolbar passes the right edge at 360 or 402px.

The E2E overflow check could not see this: .main clips on x, so
scrollWidth stayed equal to the viewport. The phone journey now measures
the toolbar's own right edges, and runs at 402px as well. It also waits
for the bottom sheet's slide-up to finish before measuring, and measures
the Map/List button again after the sheet pushes it up; both assertions
read a mid-animation or stale box before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 22:08:56 +01:00
tudor cff3854e63 Merge pull request 'feat(search): align the map view with the mockup and open postcode searches on it' (#158) from feat/results-map-view into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 20s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m25s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 26s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 3m1s
Reviewed-on: #158
2026-09-30 20:58:40 +00:00
8 changed files with 295 additions and 275 deletions

No files matched your search

+49 -5
View File
@@ -561,8 +561,18 @@ test('a desktop postcode search opens on the map with the list beside it', async
const pane = page.locator('[class*="mapListPane"]'); const pane = page.locator('[class*="mapListPane"]');
const card = pane.locator('[data-urn]').first(); const card = pane.locator('[data-urn]').first();
await expect(card).toBeVisible({ timeout: 15_000 }); 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 }); 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. // The split runs to the bottom of the screen rather than stopping short.
const split = await page.locator('[class*="mapViewContainer"]').boundingBox(); const split = await page.locator('[class*="mapViewContainer"]').boundingBox();
expect(split!.y + split!.height).toBeGreaterThan(800 - 40); expect(split!.y + split!.height).toBeGreaterThan(800 - 40);
@@ -574,9 +584,21 @@ test('a desktop postcode search opens on the map with the list beside it', async
await card.click({ position: { x: 6, y: 6 } }); await card.click({ position: { x: 6, y: 6 } });
await expect(page.locator('.sc-pin--selected')).toHaveCount(1); await expect(page.locator('.sc-pin--selected')).toHaveCount(1);
await expect(page.locator('.sc-popup')).toContainText(name); 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);
}); });
for (const width of [360, 390, 430]) { // 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 }) => { 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 page.setViewportSize({ width, height: 800 });
await searchByName(page, 'B1 1BB'); await searchByName(page, 'B1 1BB');
@@ -597,17 +619,39 @@ for (const width of [360, 390, 430]) {
await page.locator('.sc-pin').first().dispatchEvent('click'); await page.locator('.sc-pin').first().dispatchEvent('click');
const sheet = page.locator('[class*="bottomSheet"]'); const sheet = page.locator('[class*="bottomSheet"]');
await expect(sheet).toBeVisible(); await expect(sheet).toBeVisible();
const sheetBox = (await sheet.boundingBox())!; // The sheet slides up over 0.3s; measure where it comes to rest, not a
expect(sheetBox.y + sheetBox.height).toBeLessThanOrEqual(barTop); // frame of the animation (which reads as below the tab bar).
expect(sheetBox.y).toBeGreaterThanOrEqual(fabBox.y + fabBox.height); 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. // MOBILE.md: no horizontal overflow, and 44px targets in the new chrome.
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)) expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth))
.toBe(0); .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 small = await page.evaluate(() => {
const toolbar = document.querySelector('[class*="resultsToolbar"]'); const toolbar = document.querySelector('[class*="resultsToolbar"]');
const fabEl = document.querySelector('[class*="viewFab"]'); 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) .filter((el): el is HTMLElement => !!el && !!(el as HTMLElement).offsetParent)
.map((el) => ({ t: el.innerText?.trim().slice(0, 24) || el.getAttribute('aria-label'), .map((el) => ({ t: el.innerText?.trim().slice(0, 24) || el.getAttribute('aria-label'),
w: el.getBoundingClientRect().width, h: el.getBoundingClientRect().height })) w: el.getBoundingClientRect().width, h: el.getBoundingClientRect().height }))
@@ -94,7 +94,8 @@ it('lists a name search, which has no map', async () => {
it('puts the count and the sort in the list beside the map, once', async () => { it('puts the count and the sort in the list beside the map, once', async () => {
await renderMap(); await renderMap();
expect(screen.getAllByRole('heading', { name: /3 schools within 1.0 miles of SW196AR/ })).toHaveLength(1); // 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); expect(screen.getAllByRole('combobox')).toHaveLength(1);
}); });
@@ -104,11 +105,11 @@ it('selects the pin from the card, and the card from the pin', async () => {
fireEvent.click(within(card(1)).getByText(/pupils/)); fireEvent.click(within(card(1)).getByText(/pupils/));
expect(screen.getByTestId('map')).toHaveAttribute('data-selected', '1'); expect(screen.getByTestId('map')).toHaveAttribute('data-selected', '1');
expect(card(1).className).toMatch(/compactItemSelected/); expect(card(1).className).toMatch(/mapRowSelected/);
fireEvent.click(screen.getByRole('button', { name: 'pin' })); fireEvent.click(screen.getByRole('button', { name: 'pin' }));
expect(card(2).className).toMatch(/compactItemSelected/); expect(card(2).className).toMatch(/mapRowSelected/);
expect(card(1).className).not.toMatch(/compactItemSelected/); expect(card(1).className).not.toMatch(/mapRowSelected/);
}); });
it('clicking a card\'s link or button does not also select it', async () => { it('clicking a card\'s link or button does not also select it', async () => {
@@ -121,7 +122,8 @@ it('clicking a card\'s link or button does not also select it', async () => {
it('shows the England comparison for mainstream schools only, and never a placeholder 0%', async () => { it('shows the England comparison for mainstream schools only, and never a placeholder 0%', async () => {
const { container } = await renderMap(); const { container } = await renderMap();
const card = (urn: number) => container.querySelector(`[data-urn="${urn}"]`) as HTMLElement; const card = (urn: number) => container.querySelector(`[data-urn="${urn}"]`) as HTMLElement;
expect(card(2)).toHaveTextContent('52% RWM -10 pts · 269 pupils'); 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)).toHaveTextContent('62pupils');
expect(card(3)).not.toHaveTextContent(/%|pts/); expect(card(3)).not.toHaveTextContent(/%|pts/);
}); });
@@ -152,3 +154,28 @@ it('builds no list cards on a phone, where the pane is hidden', async () => {
// The count stays: it is the pane's heading, shown above the map. // The count stays: it is the pane's heading, shown above the map.
expect(screen.getByRole('heading', { name: /3 schools within/ })).toBeInTheDocument(); 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();
});
@@ -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. * everything else, so it must never be the chip scrolled out of sight.
*/ */
@media (max-width: 640px) { @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) { .filterBar:not(.heroMode) {
flex-direction: column; flex-direction: column;
flex-wrap: nowrap;
align-items: stretch; align-items: stretch;
gap: 0.625rem; gap: 0.625rem;
} }
+84 -116
View File
@@ -591,7 +591,7 @@
.mapViewContainer { .mapViewContainer {
display: grid; display: grid;
grid-template-columns: minmax(340px, 420px) minmax(0, 1fr); grid-template-columns: minmax(360px, 460px) minmax(0, 1fr);
height: calc(100dvh - var(--map-top) - var(--map-bottom)); height: calc(100dvh - var(--map-top) - var(--map-bottom));
min-height: 480px; min-height: 480px;
background: var(--bg-card); background: var(--bg-card);
@@ -608,13 +608,32 @@
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
} }
/* One line: the count takes what the sort leaves, and truncates rather than
wrap if a narrow pane cannot fit both. */
.mapListPane .resultsHeader { .mapListPane .resultsHeader {
gap: 0.5rem; flex-wrap: nowrap;
gap: 0.75rem;
padding: 0.875rem 1rem 0.75rem; padding: 0.875rem 1rem 0.75rem;
} }
.mapListPane .resultsHeader h2 { .mapListPane .resultsHeader h2 {
font-size: var(--step-1); flex: 1;
min-width: 0;
font-size: var(--step-0);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mapListPane .resultsHeaderActions {
flex-shrink: 0;
}
/* 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;
} }
.mapContainer { .mapContainer {
@@ -631,117 +650,53 @@
overflow-y: auto; overflow-y: auto;
padding: 0.125rem 1rem 1rem; padding: 0.125rem 1rem 1rem;
scrollbar-width: thin; 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;
} }
/* Compact School Item: the list pane's card, and the phone's bottom sheet. */ /* A row in the list beside the map: clicking it picks its pin. */
.compactItem { .mapRow {
display: flex; position: relative;
flex-direction: column;
gap: 0.4375rem;
padding: 0.75rem 0.875rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
cursor: pointer; cursor: pointer;
transition: border-color var(--transition), box-shadow var(--transition); border-radius: 10px;
} }
.compactItem:hover { .mapRowSelected > :last-child {
border-color: var(--border-strong); outline: 2px solid var(--brand);
outline-offset: 1px;
} }
.compactItemSelected, /* Visually hidden until focused, then a pill over the row's top edge. */
.compactItemSelected:hover { .showOnMap {
border-color: var(--brand); position: absolute;
box-shadow: 0 0 0 2px rgba(var(--brand-rgb), 0.28); width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
} }
.compactItemHeader { .showOnMap:focus-visible {
display: flex; top: -0.5rem;
justify-content: space-between; right: 0.75rem;
align-items: flex-start; z-index: 1;
gap: 0.625rem; width: auto;
} height: auto;
padding: 0.375rem 0.75rem;
.compactItemName { overflow: visible;
font-family: var(--font-display); clip-path: none;
font-size: 0.9375rem;
font-weight: 700;
line-height: 1.3;
color: var(--text-primary);
text-decoration: none;
}
.compactItemName:hover {
color: var(--brand-strong);
text-decoration: underline;
}
.distanceBadge {
flex-shrink: 0;
padding: 0.125rem 0.375rem;
font-size: 0.75rem;
font-weight: 700;
background: var(--brand); background: var(--brand);
color: var(--brand-on); color: var(--brand-on);
border-radius: 4px; border: 0;
white-space: nowrap; 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;
} }
.compactItemTags {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.compactBadge,
.compactTag {
padding: 0.125rem 0.4375rem;
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 600;
white-space: nowrap;
}
.compactTag {
background: var(--bg-secondary);
color: var(--text-secondary);
font-weight: 500;
}
.ofsted1,
.ofsted2 { background: var(--status-above-bg); color: var(--status-above); }
.ofsted3 { background: var(--status-below-bg); color: var(--status-below); }
.ofsted4 { background: var(--status-below); color: var(--text-inverse); }
.ofstedRc { background: var(--phase-secondary-text); color: var(--text-inverse); }
.ofstedInspected { background: var(--phase-primary-bg); color: var(--phase-primary-text); }
.ofstedPending { background: var(--border); color: var(--text-muted); }
.compactItemFooter {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
}
.compactStat {
font-size: 0.8125rem;
color: var(--text-secondary);
}
.compactStat strong {
font-size: 0.9375rem;
color: var(--text-primary);
}
.deltaUp { color: var(--status-above); font-weight: 600; }
.deltaDown { color: var(--status-below); font-weight: 600; }
.compactItemActions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.sectionHeader { .sectionHeader {
@@ -779,6 +734,8 @@
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
margin-bottom: 1.25rem; 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 */ /* Staggered fade-in for rows */
@@ -881,33 +838,37 @@
animation: slideUpSheet 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards; animation: slideUpSheet 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
} }
.bottomSheet .compactItem {
border: none;
box-shadow: none;
background: transparent;
padding: 1rem;
cursor: default;
}
/* A 30px circle, drawn by ::before, inside a 44px target (MOBILE.md). */
.closeSheetBtn { .closeSheetBtn {
position: absolute; position: absolute;
top: -12px; top: -19px;
right: -12px; right: -15px;
width: 30px; width: 44px;
height: 30px; height: 44px;
background: var(--bg-card); padding: 0;
border: 1px solid var(--border); background: none;
border-radius: 50%; border: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 1.25rem; font-size: 1.25rem;
color: var(--text-secondary); color: var(--text-secondary);
cursor: pointer; cursor: pointer;
box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.1);
z-index: 10; 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 { @keyframes slideUpSheet {
from { from {
transform: translateY(120%); transform: translateY(120%);
@@ -935,6 +896,13 @@
display: none; 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 { .mapListPane .resultsHeader {
padding: 0.625rem 0.875rem; padding: 0.625rem 0.875rem;
} }
+61 -147
View File
@@ -16,7 +16,6 @@ import { HeroIllustration } from './Illustration';
import { useComparisonContext } from '@/context/ComparisonContext'; import { useComparisonContext } from '@/context/ComparisonContext';
import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api'; import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api';
import type { SchoolsResponse, Filters, School } from '@/lib/types'; import type { SchoolsResponse, Filters, School } from '@/lib/types';
import { schoolUrl, buildOfstedListBadge, isSpecialSchool, listRwmValue } from '@/lib/utils';
import { track } from '@/lib/analytics'; import { track } from '@/lib/analytics';
import styles from './HomeView.module.css'; import styles from './HomeView.module.css';
@@ -558,14 +557,46 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
const isMapView = initialSchools.schools.length > 0 && resultsView === 'map' && isLocationSearch; 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 // The count and the sort. Above the list in list view; at the top of the
// list pane, beside the map, in map view. // list pane, beside the map, in map view.
const resultsHeader = ( const resultsHeader = (
<div className={styles.resultsHeader}> <div className={styles.resultsHeader}>
<h2 aria-live="polite" aria-atomic="true"> <h2 aria-live="polite" aria-atomic="true">
{isLocationSearch && initialSchools.location_info {/* Beside the map the heading shares one line with the sort, so it
? `${initialSchools.total.toLocaleString()} school${initialSchools.total !== 1 ? 's' : ''} within ${(initialSchools.location_info.radius / 1.60934).toFixed(1)} miles of ${initialSchools.location_info.postcode}` drops the postcode, which the search box just above already shows. */}
: `${initialSchools.total.toLocaleString()} school${initialSchools.total !== 1 ? 's' : ''} found` {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> </h2>
<div className={styles.resultsHeaderActions}> <div className={styles.resultsHeaderActions}>
@@ -797,17 +828,32 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
<div className={styles.mapListPane}> <div className={styles.mapListPane}>
{resultsHeader} {resultsHeader}
<div ref={mapListRef} className={styles.compactList}> <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) => ( {listPaneShown && mapListSchools.map((school) => (
<CompactSchoolItem <div
key={school.urn} key={school.urn}
school={school} data-urn={school.urn}
onAddToCompare={addSchoolFromSearch} className={`${styles.mapRow} ${selectedMapSchool?.urn === school.urn ? styles.mapRowSelected : ''}`}
isInCompare={compareUrns.includes(school.urn)} onClick={(e) => {
nationalAvgRwm={nationalAvgRwm} if ((e.target as HTMLElement).closest('a, button')) return;
laAverages={laAverages} setSelectedMapSchool(school);
isSelected={selectedMapSchool?.urn === school.urn} }}
onSelect={setSelectedMapSchool} >
/> {/* 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> </div>
@@ -831,29 +877,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
/* List View Layout */ /* List View Layout */
<> <>
<div className={styles.schoolList}> <div className={styles.schoolList}>
{sortedSchools.map((school) => ( {sortedSchools.map(renderRow)}
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}
/>
)
))}
</div> </div>
{(hasMore || allSchools.length < initialSchools.total) && ( {(hasMore || allSchools.length < initialSchools.total) && (
@@ -899,14 +923,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
> >
× ×
</button> </button>
<CompactSchoolItem {renderRow(selectedMapSchool)}
school={selectedMapSchool}
onAddToCompare={addSchoolFromSearch}
isInCompare={compareUrns.includes(selectedMapSchool.urn)}
nationalAvgRwm={nationalAvgRwm}
laAverages={laAverages}
showView
/>
</div> </div>
)} )}
</div> </div>
@@ -914,106 +931,3 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
</div> </div>
); );
} }
/* Compact School Item: a card in the map view's list, and the phone's bottom sheet. */
interface CompactSchoolItemProps {
school: School;
onAddToCompare: (school: School) => void;
isInCompare: boolean;
nationalAvgRwm?: number | null;
laAverages?: Record<string, number>;
isSelected?: boolean;
/** Clicking the card (not its link or button) picks its pin on the map. */
onSelect?: (school: School) => void;
/** The bottom sheet has no list around it, so it carries its own View. */
showView?: boolean;
}
function CompactSchoolItem({
school, onAddToCompare, isInCompare, nationalAvgRwm, laAverages, isSelected, onSelect, showView,
}: CompactSchoolItemProps) {
const ofstedBadge = buildOfstedListBadge(school);
const special = isSpecialSchool(school);
const href = schoolUrl(school.urn, school.school_name);
/*
* The headline figure, then its comparison. Same rules as the list rows:
* no placeholder all-zero RWM, and no mainstream benchmark for special
* schools, PRUs or AP.
*/
let figure: React.ReactNode = null;
if (school.attainment_8_score != null) {
const laAvg = school.local_authority ? laAverages?.[school.local_authority] : undefined;
const diff = !special && laAvg != null
? Math.round((school.attainment_8_score - laAvg) * 10) / 10 : null;
figure = (
<>
<strong>{school.attainment_8_score.toFixed(1)}</strong> Att 8
{diff != null && (
<span className={diff >= 0.5 ? styles.deltaUp : diff <= -0.5 ? styles.deltaDown : undefined}>
{' '}{diff >= 0 ? '+' : ''}{diff} vs LA
</span>
)}
</>
);
} else {
const rwm = listRwmValue(school);
if (rwm != null) {
const diff = !special && nationalAvgRwm != null ? Math.round(rwm - nationalAvgRwm) : null;
figure = (
<>
<strong>{rwm}%</strong> RWM
{diff != null && (
<span className={diff >= 2 ? styles.deltaUp : diff <= -2 ? styles.deltaDown : undefined}>
{' '}{diff >= 2 ? `+${diff} pts` : diff <= -2 ? `${diff} pts` : '≈ national'}
</span>
)}
</>
);
}
}
const handleClick = (e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest('a, button')) return;
onSelect?.(school);
};
return (
<div
data-urn={school.urn}
className={`${styles.compactItem} ${isSelected ? styles.compactItemSelected : ''}`}
onClick={onSelect ? handleClick : undefined}
>
<div className={styles.compactItemHeader}>
<a href={href} className={styles.compactItemName}>{school.school_name}</a>
{school.distance != null && (
<span className={styles.distanceBadge}>{school.distance.toFixed(1)} mi</span>
)}
</div>
<div className={styles.compactItemTags}>
<span className={`${styles.compactBadge} ${styles[ofstedBadge.cssClass] ?? ''}`}>
{ofstedBadge.label}
</span>
{school.school_type && <span className={styles.compactTag}>{school.school_type}</span>}
</div>
<div className={styles.compactItemFooter}>
<span className={styles.compactStat}>
{figure}
{school.total_pupils != null && (
<>{figure ? ' · ' : ''}{school.total_pupils.toLocaleString('en-GB')} pupils</>
)}
</span>
<div className={styles.compactItemActions}>
{showView && <a href={href} className="btn btn-tertiary btn-sm">View</a>}
<button
type="button"
className={isInCompare ? 'btn btn-active btn-sm' : 'btn btn-secondary btn-sm'}
onClick={() => onAddToCompare(school)}
>
{isInCompare ? '✓ Comparing' : '+ Compare'}
</button>
</div>
</div>
</div>
);
}
+10 -1
View File
@@ -220,7 +220,16 @@
.vsNationalFlat { font-size: 0.7rem; color: var(--text-muted); } .vsNationalFlat { font-size: 0.7rem; color: var(--text-muted); }
/* ── Mobile ──────────────────────────────────────────── */ /* ── 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 { .row {
flex-wrap: wrap; flex-wrap: wrap;
padding: 0.875rem; padding: 0.875rem;
@@ -232,7 +232,16 @@
} }
/* ── Mobile ──────────────────────────────────────────── */ /* ── 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 { .row {
flex-wrap: wrap; flex-wrap: wrap;
padding: 0.875rem; padding: 0.875rem;