fix(search): set the toolbar's line count by width, not by results
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 18s
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 18s
The results toolbar 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. From 1340px the controls never wrap away from the search, which takes what they leave (at least 12rem); phase and type chips cap at 11rem to fit. Between 641px and 1339px the controls always take a full line of their own. The switch now sits in FilterBar's row via a viewSwitch slot, so that line runs the full width instead of stopping short of it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2002529137
commit
8ebe461435
6 files changed
+120
-29
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');
|
.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
|
* 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
|
* 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 () => ({})),
|
fetchNationalAverages: jest.fn(async () => ({})),
|
||||||
fetchLAaverages: jest.fn(async () => ({ secondary: { attainment_8_by_la: {} } })),
|
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/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> }));
|
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(),
|
fetchNationalAverages: jest.fn(),
|
||||||
fetchLAaverages: jest.fn(async () => ({ secondary: { attainment_8_by_la: {} } })),
|
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', () => ({
|
jest.mock('@/components/SchoolMap', () => ({
|
||||||
SchoolMap: ({ selectedUrn, radiusMiles, onMarkerClick, schools }: {
|
SchoolMap: ({ selectedUrn, radiusMiles, onMarkerClick, schools }: {
|
||||||
selectedUrn: number | null; radiusMiles?: number;
|
selectedUrn: number | null; radiusMiles?: number;
|
||||||
|
|||||||
@@ -40,8 +40,19 @@
|
|||||||
margin: 0 auto 1.5rem;
|
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) {
|
.filterBar:not(.heroMode) {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -56,10 +67,34 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.filterBar:not(.heroMode) .searchSection {
|
.filterBar:not(.heroMode) .searchSection {
|
||||||
flex: 1 1 320px;
|
flex: 1 1 0;
|
||||||
min-width: 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. */
|
/* Only phones fold the form away; see the 640px block. */
|
||||||
.searchSummary {
|
.searchSummary {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -324,8 +359,9 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
/* A select is as wide as its longest option, and a school type can run to
|
/* 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. */
|
"Academy special sponsor led". Cap it; the chosen value truncates. The cap
|
||||||
max-width: 14rem;
|
is part of the one-line budget above. */
|
||||||
|
max-width: 11rem;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,6 +399,7 @@
|
|||||||
pushing the results off a short screen. The 3px gutter keeps the selects'
|
pushing the results off a short screen. The 3px gutter keeps the selects'
|
||||||
focus rings clear of the scroll clip. */
|
focus rings clear of the scroll clip. */
|
||||||
.filters {
|
.filters {
|
||||||
|
order: 2;
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.625rem;
|
gap: 0.625rem;
|
||||||
@@ -579,6 +616,12 @@
|
|||||||
display: none;
|
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
|
/* 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
|
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
|
1rem at this width (HomeView.module.css, .resultsToolbar). The 4px of
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ interface FilterBarProps {
|
|||||||
geoError?: string | null;
|
geoError?: string | null;
|
||||||
/** Server-read feature flag. Off means no listener, no fetch, no markup. */
|
/** Server-read feature flag. Off means no listener, no fetch, no markup. */
|
||||||
autosuggest?: boolean;
|
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",
|
geoState = "idle",
|
||||||
geoError,
|
geoError,
|
||||||
autosuggest = false,
|
autosuggest = false,
|
||||||
|
viewSwitch,
|
||||||
}: FilterBarProps) {
|
}: FilterBarProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -452,6 +459,9 @@ export function FilterBar({
|
|||||||
|
|
||||||
{!isHero && (
|
{!isHero && (
|
||||||
<>
|
<>
|
||||||
|
{viewSwitch && (
|
||||||
|
<div className={styles.viewSwitchSlot}>{viewSwitch}</div>
|
||||||
|
)}
|
||||||
{/* Every control here is a real <select> or <button>, drawn as a
|
{/* Every control here is a real <select> or <button>, drawn as a
|
||||||
pill. On phones the row scrolls sideways rather than wrapping, so
|
pill. On phones the row scrolls sideways rather than wrapping, so
|
||||||
the pinned toolbar stays two lines tall. */}
|
the pinned toolbar stays two lines tall. */}
|
||||||
|
|||||||
@@ -713,29 +713,29 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
geoState={geoState}
|
geoState={geoState}
|
||||||
geoError={geoError}
|
geoError={geoError}
|
||||||
autosuggest={autosuggest}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user