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
7 changed files with 262 additions and 272 deletions

No files matched your search

+23 -1
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 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);
@@ -574,6 +584,17 @@ test('a desktop postcode search opens on the map with the list beside it', async
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).
@@ -629,7 +650,8 @@ for (const width of [360, 390, 402, 430]) {
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 }))
@@ -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 () => {
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);
});
@@ -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/));
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' }));
expect(card(2).className).toMatch(/compactItemSelected/);
expect(card(1).className).not.toMatch(/compactItemSelected/);
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 () => {
@@ -121,8 +122,9 @@ 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 () => {
const { container } = await renderMap();
const card = (urn: number) => container.querySelector(`[data-urn="${urn}"]`) as HTMLElement;
expect(card(2)).toHaveTextContent('52% RWM -10 pts · 269 pupils');
expect(card(3)).toHaveTextContent('62 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)).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.
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 });
}
});
+84 -116
View File
@@ -591,7 +591,7 @@
.mapViewContainer {
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));
min-height: 480px;
background: var(--bg-card);
@@ -608,13 +608,32 @@
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 {
gap: 0.5rem;
flex-wrap: nowrap;
gap: 0.75rem;
padding: 0.875rem 1rem 0.75rem;
}
.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 {
@@ -631,117 +650,53 @@
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;
}
/* Compact School Item: the list pane's card, and the phone's bottom sheet. */
.compactItem {
display: flex;
flex-direction: column;
gap: 0.4375rem;
padding: 0.75rem 0.875rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
/* A row in the list beside the map: clicking it picks its pin. */
.mapRow {
position: relative;
cursor: pointer;
transition: border-color var(--transition), box-shadow var(--transition);
border-radius: 10px;
}
.compactItem:hover {
border-color: var(--border-strong);
.mapRowSelected > :last-child {
outline: 2px solid var(--brand);
outline-offset: 1px;
}
.compactItemSelected,
.compactItemSelected:hover {
border-color: var(--brand);
box-shadow: 0 0 0 2px rgba(var(--brand-rgb), 0.28);
/* 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;
}
.compactItemHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.625rem;
}
.compactItemName {
font-family: var(--font-display);
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;
.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-radius: 4px;
white-space: nowrap;
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;
}
.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 {
@@ -779,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 */
@@ -881,33 +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;
cursor: default;
}
/* 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%);
@@ -935,6 +896,13 @@
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;
}
+61 -147
View File
@@ -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, isSpecialSchool, listRwmValue } from '@/lib/utils';
import { track } from '@/lib/analytics';
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;
// 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">
{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`
{/* 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}>
@@ -797,17 +828,32 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
<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) => (
<CompactSchoolItem
<div
key={school.urn}
school={school}
onAddToCompare={addSchoolFromSearch}
isInCompare={compareUrns.includes(school.urn)}
nationalAvgRwm={nationalAvgRwm}
laAverages={laAverages}
isSelected={selectedMapSchool?.urn === school.urn}
onSelect={setSelectedMapSchool}
/>
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>
@@ -831,29 +877,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
/* 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) && (
@@ -899,14 +923,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
>
×
</button>
<CompactSchoolItem
school={selectedMapSchool}
onAddToCompare={addSchoolFromSearch}
isInCompare={compareUrns.includes(selectedMapSchool.urn)}
nationalAvgRwm={nationalAvgRwm}
laAverages={laAverages}
showView
/>
{renderRow(selectedMapSchool)}
</div>
)}
</div>
@@ -914,106 +931,3 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
</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); }
/* ── 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;
@@ -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;