feat(search): show the list view's rows beside the map #160

Merged
tudor merged 2 commits from feat/map-list-rows into main 2026-10-01 09:04:31 +00:00
7 changed files with 118 additions and 12 deletions
Showing only changes of commit ca4ddd2b12 - Show all commits

No files matched your search

+11
View File
@@ -578,6 +578,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).
@@ -156,10 +156,19 @@ it('builds no list cards on a phone, where the pane is hidden', async () => {
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"]') as HTMLElement).textContent;
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');
});
@@ -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 });
}
});
+32 -1
View File
@@ -638,15 +638,46 @@
/* A row in the list beside the map: clicking it picks its pin. */
.mapRow {
position: relative;
cursor: pointer;
border-radius: 10px;
}
.mapRowSelected > * {
.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 {
+12
View File
@@ -834,6 +834,18 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
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>
))}
+6 -5
View File
@@ -223,12 +223,13 @@
/*
* 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 ~400px list beside the map on desktop. HomeView makes both lists a
* `results` container; outside one, the row keeps its wide layout.
* 600px of list is a 632px screen less the page's padding, so phones behave
* as they did under the old max-width: 640px media query.
* 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: 600px) {
@container results (max-width: 608px) {
.row {
flex-wrap: wrap;
padding: 0.875rem;
@@ -235,12 +235,13 @@
/*
* 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 ~400px list beside the map on desktop. HomeView makes both lists a
* `results` container; outside one, the row keeps its wide layout.
* 600px of list is a 632px screen less the page's padding, so phones behave
* as they did under the old max-width: 640px media query.
* 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: 600px) {
@container results (max-width: 608px) {
.row {
flex-wrap: wrap;
padding: 0.875rem;