Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ebe461435 | ||
|
|
2002529137 |
No files matched your search
@@ -572,6 +572,38 @@ test('the results toolbar stays pinned with its List/Map switch', async ({ page
|
||||
.toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
/*
|
||||
* The toolbar's line count is set by the screen width, never by the results.
|
||||
* It once wrapped wherever it ran out of room, and the List/Map switch only
|
||||
* appears when there are results — so the same search took two lines with
|
||||
* results and one without.
|
||||
*/
|
||||
test('the results toolbar keeps its line count whether or not there are results', async ({ page }) => {
|
||||
const withResults = '/?postcode=B1%201BB&radius=1';
|
||||
// No school type matches this, so the same search returns nothing.
|
||||
const without = `${withResults}&school_type=no-such-type`;
|
||||
|
||||
const lines = async (url: string) => {
|
||||
await page.goto(url);
|
||||
// By label: the input is a combobox when autosuggest is on.
|
||||
const input = page.getByLabel('School name or postcode', { exact: true });
|
||||
const filters = page.getByRole('group', { name: 'Filters' });
|
||||
await expect(filters).toBeVisible({ timeout: 15_000 });
|
||||
const a = (await input.boundingBox())!;
|
||||
const b = (await filters.boundingBox())!;
|
||||
return b.y >= a.y + a.height ? 2 : 1;
|
||||
};
|
||||
const view = page.getByRole('group', { name: 'Results view' });
|
||||
|
||||
for (const [width, expected] of [[1400, 1], [1100, 2]] as const) {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
expect(await lines(withResults), `${width}px with results`).toBe(expected);
|
||||
await expect(view).toBeVisible();
|
||||
expect(await lines(without), `${width}px without results`).toBe(expected);
|
||||
await expect(view).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* 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
|
||||
|
||||
@@ -18,7 +18,10 @@ jest.mock('@/lib/api', () => ({
|
||||
fetchNationalAverages: jest.fn(async () => ({})),
|
||||
fetchLAaverages: jest.fn(async () => ({ secondary: { attainment_8_by_la: {} } })),
|
||||
}));
|
||||
jest.mock('@/components/FilterBar', () => ({ FilterBar: () => null }));
|
||||
// Renders only the List/Map switch HomeView hands it, which lives in its row.
|
||||
jest.mock('@/components/FilterBar', () => ({
|
||||
FilterBar: ({ viewSwitch }: { viewSwitch?: unknown }) => viewSwitch || null,
|
||||
}));
|
||||
jest.mock('@/components/SchoolRow', () => ({ SchoolRow: ({ school }: {school: School}) => <div>{school.school_name}</div> }));
|
||||
jest.mock('@/components/SchoolMap', () => ({ SchoolMap: ({ schools }: {schools: School[]}) => <div data-testid="map">{schools.map(s => s.school_name).join(',')}</div> }));
|
||||
|
||||
|
||||
@@ -24,7 +24,10 @@ jest.mock('@/lib/api', () => ({
|
||||
fetchNationalAverages: jest.fn(),
|
||||
fetchLAaverages: jest.fn(async () => ({ secondary: { attainment_8_by_la: {} } })),
|
||||
}));
|
||||
jest.mock('@/components/FilterBar', () => ({ FilterBar: () => null }));
|
||||
// Renders only the List/Map switch HomeView hands it, which lives in its row.
|
||||
jest.mock('@/components/FilterBar', () => ({
|
||||
FilterBar: ({ viewSwitch }: { viewSwitch?: unknown }) => viewSwitch || null,
|
||||
}));
|
||||
jest.mock('@/components/SchoolMap', () => ({
|
||||
SchoolMap: ({ selectedUrn, radiusMiles, onMarkerClick, schools }: {
|
||||
selectedUrn: number | null; radiusMiles?: number;
|
||||
|
||||
@@ -40,8 +40,19 @@
|
||||
margin: 0 auto 1.5rem;
|
||||
}
|
||||
|
||||
/* One row where it fits: the search takes what the controls leave, and the
|
||||
"More filters" panel breaks onto its own line below both. */
|
||||
/*
|
||||
* One row on wide screens, two below 1340px — decided by the width alone,
|
||||
* never by what the search returned.
|
||||
*
|
||||
* The row once wrapped wherever it ran out of room, and its contents change
|
||||
* with the results: the List/Map switch beside it, the distance chip and Clear
|
||||
* all come and go. So the same search folded onto two lines when it had
|
||||
* results and sat on one when it had none. Now the controls never wrap away
|
||||
* from the search on a wide screen; the search box takes what they leave, and
|
||||
* 1340px is where the fullest toolbar (distance, phase, type, More filters,
|
||||
* Clear and the switch) still leaves it 12rem. The "More filters" panel breaks
|
||||
* onto its own line below both.
|
||||
*/
|
||||
.filterBar:not(.heroMode) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -56,10 +67,34 @@
|
||||
}
|
||||
|
||||
.filterBar:not(.heroMode) .searchSection {
|
||||
flex: 1 1 320px;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filterBar:not(.heroMode) .controlsRow {
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
/* The List/Map switch closes the line; the "More filters" panel follows it. */
|
||||
.viewSwitchSlot {
|
||||
flex: 0 0 auto;
|
||||
order: 1;
|
||||
}
|
||||
|
||||
/* Below that, the switch stays up beside the search and the controls always
|
||||
take a full-width line of their own, results or not. */
|
||||
@media (min-width: 641px) and (max-width: 1339px) {
|
||||
.viewSwitchSlot {
|
||||
order: 0;
|
||||
}
|
||||
|
||||
.filterBar:not(.heroMode) .controlsRow {
|
||||
flex-basis: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* Only phones fold the form away; see the 640px block. */
|
||||
.searchSummary {
|
||||
display: none;
|
||||
@@ -324,8 +359,9 @@
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
/* A select is as wide as its longest option, and a school type can run to
|
||||
"Academy special sponsor led". Cap it; the chosen value truncates. */
|
||||
max-width: 14rem;
|
||||
"Academy special sponsor led". Cap it; the chosen value truncates. The cap
|
||||
is part of the one-line budget above. */
|
||||
max-width: 11rem;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@@ -363,6 +399,7 @@
|
||||
pushing the results off a short screen. The 3px gutter keeps the selects'
|
||||
focus rings clear of the scroll clip. */
|
||||
.filters {
|
||||
order: 2;
|
||||
flex-basis: 100%;
|
||||
display: flex;
|
||||
gap: 0.625rem;
|
||||
@@ -579,6 +616,12 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Phones switch views with the floating button (HomeView, .mobileDock). An
|
||||
empty slot would still take a gap in this column. */
|
||||
.viewSwitchSlot {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Bleeds to the screen edge so a chip scrolls out from under it, rather than
|
||||
being cut off at the toolbar's padding. The toolbar's inline padding is
|
||||
1rem at this width (HomeView.module.css, .resultsToolbar). The 4px of
|
||||
|
||||
@@ -22,6 +22,12 @@ interface FilterBarProps {
|
||||
geoError?: string | null;
|
||||
/** Server-read feature flag. Off means no listener, no fetch, no markup. */
|
||||
autosuggest?: boolean;
|
||||
/**
|
||||
* The results page's List/Map switch. It sits in this bar's own row rather
|
||||
* than beside it, so that when the bar takes two lines the filters' line
|
||||
* runs the full width instead of stopping short of the switch.
|
||||
*/
|
||||
viewSwitch?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,6 +60,7 @@ export function FilterBar({
|
||||
geoState = "idle",
|
||||
geoError,
|
||||
autosuggest = false,
|
||||
viewSwitch,
|
||||
}: FilterBarProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -452,6 +459,9 @@ export function FilterBar({
|
||||
|
||||
{!isHero && (
|
||||
<>
|
||||
{viewSwitch && (
|
||||
<div className={styles.viewSwitchSlot}>{viewSwitch}</div>
|
||||
)}
|
||||
{/* Every control here is a real <select> or <button>, drawn as a
|
||||
pill. On phones the row scrolls sideways rather than wrapping, so
|
||||
the pinned toolbar stays two lines tall. */}
|
||||
|
||||
@@ -713,29 +713,29 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
geoState={geoState}
|
||||
geoError={geoError}
|
||||
autosuggest={autosuggest}
|
||||
viewSwitch={hasViewSwitch && (
|
||||
<div className={styles.viewSwitch} role="group" aria-label="Results view">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.viewSwitchBtn}
|
||||
aria-pressed={resultsView === 'list'}
|
||||
onClick={() => changeView('list', 'toolbar')}
|
||||
>
|
||||
<ListIcon />
|
||||
List
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.viewSwitchBtn}
|
||||
aria-pressed={resultsView === 'map'}
|
||||
onClick={() => changeView('map', 'toolbar')}
|
||||
>
|
||||
<MapIcon />
|
||||
Map
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{hasViewSwitch && (
|
||||
<div className={styles.viewSwitch} role="group" aria-label="Results view">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.viewSwitchBtn}
|
||||
aria-pressed={resultsView === 'list'}
|
||||
onClick={() => changeView('list', 'toolbar')}
|
||||
>
|
||||
<ListIcon />
|
||||
List
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.viewSwitchBtn}
|
||||
aria-pressed={resultsView === 'map'}
|
||||
onClick={() => changeView('map', 'toolbar')}
|
||||
>
|
||||
<MapIcon />
|
||||
Map
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in new issue
Block a user