Merge pull request 'feat(search): filter on phones through one sheet' (#168) from feat/mobile-filter-sheet into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 44s
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 2m14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 4s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 3m4s

Reviewed-on: #168
This commit was merged in pull request #168.
This commit is contained in:
tudor committed 2026-10-02 08:51:17 +00:00
commit eb13ab0b5e
11 files changed
+1038 -169

No files matched your search

+64 -2
View File
@@ -712,12 +712,13 @@ for (const width of [360, 390, 402, 430]) {
// 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.
// the applied-filter chips scroll by design, so only their line's 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"]'))
&& !el.parentElement?.closest('[class*="chipsLine"]'))
.map((el) => ({ el: (el.className?.toString() || el.tagName).slice(0, 40),
right: Math.round(el.getBoundingClientRect().right) }))
.filter((o) => o.right > window.innerWidth);
@@ -749,6 +750,67 @@ for (const width of [360, 390, 402, 430]) {
});
}
/*
* Phones filter through one Filters button and a sheet with every filter in
* it. The row it replaced scrolled sideways with "More filters" first, so
* phase showed only in part, school type not at all, and the panel "More
* filters" opened held neither.
*/
test('a phone filters through one sheet, and sees what it applied as chips', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(LONG_LIST);
const trigger = page.getByRole('button', { name: 'Filters', exact: true });
await expect(trigger).toBeInViewport({ timeout: 15_000 });
// The desktop row is gone at this width: no phase select until the sheet.
await expect(page.getByRole('combobox', { name: 'Phase', exact: true })).toHaveCount(0);
await trigger.click();
const sheet = page.getByRole('dialog', { name: 'Filters' });
await expect(sheet).toBeVisible();
await expect(sheet.getByRole('radio', { name: 'Within 1 mile' })).toBeChecked();
for (const name of ['Phase', 'School type', 'Local authority']) {
await expect(sheet.getByRole('combobox', { name, exact: true })).toBeVisible();
}
// A change applies at once, and the sheet stays open for the next one.
await sheet.getByRole('combobox', { name: 'Phase', exact: true }).selectOption('primary');
await page.waitForURL(/[?&]phase=primary(&|$)/);
await expect(sheet).toBeVisible();
const show = sheet.getByRole('button', { name: /^(Show [\d,]+ schools?|No schools match)$/ });
await expect(show).toBeVisible();
// MOBILE.md: the sheet clears the bottom tab bar, and its targets are 44px.
// It slides up over 0.3s, so poll for where it comes to rest.
await expect.poll(async () => {
const box = (await show.boundingBox())!;
return Math.round(box.y + box.height);
}).toBeLessThanOrEqual(844);
const small = await sheet.evaluate((el) =>
[...el.querySelectorAll('button, select, label:has(input)')]
.filter((n): n is HTMLElement => !!(n as HTMLElement).offsetParent)
.map((n) => ({ t: n.innerText?.trim().slice(0, 24) || n.getAttribute('aria-label'),
w: n.getBoundingClientRect().width, h: n.getBoundingClientRect().height }))
.filter((o) => o.w < 44 || o.h < 44));
expect(small).toEqual([]);
await show.click();
await expect(sheet).toBeHidden();
// What was applied shows under the search, counted on the button, and comes
// off with a tap, keeping the search.
await expect(page.getByRole('button', { name: 'Filters, 1 applied' })).toBeInViewport();
const chip = page.getByRole('group', { name: 'Applied filters' })
.getByRole('button', { name: 'Remove filter: Primary' });
await expect(chip).toBeInViewport();
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth))
.toBe(0);
await chip.click();
await expect(page).not.toHaveURL(/[?&]phase=/);
await expect(page).toHaveURL(/[?&]postcode=B1(%20|\+)1BB/);
await expect(page.getByRole('group', { name: 'Applied filters' })).toHaveCount(0);
await expect(trigger).toBeInViewport();
});
test('comparing two schools shows the parent-first sections side by side', async ({ page }) => {
// Two same-phase (pure primary) schools so both stay on one tab.
const [urn0, urn1] = await twoPrimaryUrns(page);
@@ -0,0 +1,156 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import { FilterBar } from '@/components/FilterBar';
/*
* Phones filter through one "Filters" button and a bottom sheet holding every
* filter, rather than a sideways-scrolling row whose later chips (phase, type)
* sat off-screen beside a "More filters" panel that did not contain them.
* Which markup shows at which width is CSS and invisible to jsdom; these pin
* the behaviour and the accessible names.
*/
let params = new URLSearchParams('postcode=SW196AR&radius=1');
const push = jest.fn();
jest.mock('next/navigation', () => ({
useSearchParams: () => params,
usePathname: () => '/',
useRouter: () => ({ push, replace: jest.fn(), prefetch: jest.fn() }),
}));
jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
const filters = {
local_authorities: ['Wandsworth', 'Merton'], school_types: ['Community school'], years: [],
phases: ['Primary', 'Secondary'], genders: ['Girls', 'Mixed'], admissions_policies: [],
};
const pushedParams = () => new URLSearchParams(push.mock.calls.at(-1)![0].split('?')[1]);
beforeEach(() => {
params = new URLSearchParams('postcode=SW196AR&radius=1');
push.mockClear();
});
describe('the phone Filters button', () => {
it('sits beside the folded search summary', () => {
render(<FilterBar filters={filters} />);
const summary = screen.getByRole('button', { name: /Edit search/ });
expect(summary.parentElement).toContainElement(screen.getByRole('button', { name: 'Filters' }));
});
it('counts every applied filter, phase and type included', () => {
params = new URLSearchParams('postcode=SW196AR&radius=1&phase=primary&school_type=Community+school');
render(<FilterBar filters={filters} />);
expect(screen.getByRole('button', { name: 'Filters, 2 applied' })).toBeInTheDocument();
});
it('is still offered before anything has been searched', () => {
params = new URLSearchParams('local_authority=Wandsworth');
render(<FilterBar filters={filters} />);
expect(screen.getByRole('button', { name: 'Filters, 1 applied' })).toBeInTheDocument();
});
it('stays out of the hero', () => {
render(<FilterBar filters={filters} isHero />);
expect(screen.queryByRole('button', { name: /^Filters/ })).not.toBeInTheDocument();
});
});
describe('the filter sheet', () => {
const openSheet = () => {
fireEvent.click(screen.getByRole('button', { name: /^Filters/ }));
return screen.getByRole('dialog', { name: 'Filters' });
};
it('holds every filter in one place', () => {
render(<FilterBar filters={filters} />);
const sheet = openSheet();
expect(within(sheet).getByRole('radiogroup', { name: 'Distance' })).toBeInTheDocument();
for (const name of ['Phase', 'School type', 'Local authority']) {
expect(within(sheet).getByRole('combobox', { name })).toBeInTheDocument();
}
});
it('shows the secondary-only filters once they apply', () => {
params = new URLSearchParams('postcode=SW196AR&radius=1&phase=secondary');
render(<FilterBar filters={filters} />);
const sheet = openSheet();
for (const name of ['Gender', 'Sixth form']) {
expect(within(sheet).getByRole('combobox', { name })).toBeInTheDocument();
}
});
it('offers distance only for a postcode search', () => {
params = new URLSearchParams('search=southmead');
render(<FilterBar filters={filters} />);
expect(within(openSheet()).queryByRole('radiogroup', { name: 'Distance' })).not.toBeInTheDocument();
});
it('changes the distance', () => {
render(<FilterBar filters={filters} />);
const distance = within(openSheet()).getByRole('radiogroup', { name: 'Distance' });
expect(within(distance).getByRole('radio', { name: 'Within 1 mile' })).toBeChecked();
fireEvent.click(within(distance).getByRole('radio', { name: 'Within 3 miles' }));
expect(pushedParams().get('radius')).toBe('3');
});
it('applies a change straight away and stays open for the next one', () => {
render(<FilterBar filters={filters} />);
const sheet = openSheet();
fireEvent.change(within(sheet).getByRole('combobox', { name: 'Phase' }), { target: { value: 'primary' } });
expect(pushedParams().get('phase')).toBe('primary');
expect(screen.getByRole('dialog', { name: 'Filters' })).toBeInTheDocument();
});
it('closes on the results button, which gives the count', () => {
render(<FilterBar filters={filters} resultCount={12} />);
fireEvent.click(within(openSheet()).getByRole('button', { name: 'Show 12 schools' }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('says so when nothing matches', () => {
render(<FilterBar filters={filters} resultCount={0} />);
expect(within(openSheet()).getByRole('button', { name: 'No schools match' })).toBeInTheDocument();
});
it('clears the filters but keeps the search', () => {
params = new URLSearchParams('postcode=SW196AR&radius=1&phase=primary&local_authority=Wandsworth');
render(<FilterBar filters={filters} />);
fireEvent.click(within(openSheet()).getByRole('button', { name: 'Clear all' }));
const next = pushedParams();
expect(next.get('phase')).toBeNull();
expect(next.get('local_authority')).toBeNull();
expect(next.get('postcode')).toBe('SW196AR');
expect(next.get('radius')).toBe('1');
});
});
describe('the applied-filter chips', () => {
it('appear only when something is applied', () => {
render(<FilterBar filters={filters} />);
expect(screen.queryByRole('group', { name: 'Applied filters' })).not.toBeInTheDocument();
});
it('name each filter by its label and remove it on tap', () => {
params = new URLSearchParams('postcode=SW196AR&radius=1&phase=primary&gender=girls&has_sixth_form=no');
render(<FilterBar filters={filters} />);
const chips = screen.getByRole('group', { name: 'Applied filters' });
for (const label of ['Primary', 'Girls', 'Without sixth form']) {
expect(within(chips).getByRole('button', { name: `Remove filter: ${label}` })).toBeInTheDocument();
}
fireEvent.click(within(chips).getByRole('button', { name: 'Remove filter: Primary' }));
const next = pushedParams();
expect(next.get('phase')).toBeNull();
expect(next.get('gender')).toBe('girls');
expect(next.get('postcode')).toBe('SW196AR');
});
it('carry a Clear all that keeps the search', () => {
params = new URLSearchParams('search=southmead&school_type=Community+school');
render(<FilterBar filters={filters} />);
fireEvent.click(within(screen.getByRole('group', { name: 'Applied filters' }))
.getByRole('button', { name: 'Clear all' }));
const next = pushedParams();
expect(next.get('school_type')).toBeNull();
expect(next.get('search')).toBe('southmead');
});
});
@@ -0,0 +1,80 @@
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import { FilterBar } from '@/components/FilterBar';
/*
* While a filter change is navigating, the sheet's controls stay enabled: a
* control disabled under the user's focus drops it to <body>, and a keyboard or
* screen-reader user is thrown out of the sheet after every change. A second
* change made before the first lands must build on the first, not on the URL
* useSearchParams still reports.
*/
// Every transition stays pending, as a slow server render would.
jest.mock('react', () => ({
...jest.requireActual('react'),
useTransition: () => [true, (fn: () => void) => fn()],
}));
const params = new URLSearchParams('postcode=SW196AR&radius=1');
const push = jest.fn();
jest.mock('next/navigation', () => ({
useSearchParams: () => params,
usePathname: () => '/',
useRouter: () => ({ push, replace: jest.fn(), prefetch: jest.fn() }),
}));
jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
const filters = {
local_authorities: ['Wandsworth'], school_types: ['Community school'], years: [],
phases: ['Primary', 'Secondary'], genders: [], admissions_policies: [],
};
const pushedParams = () => new URLSearchParams(push.mock.calls.at(-1)![0].split('?')[1]);
const openSheet = () => {
fireEvent.click(screen.getByRole('button', { name: /^Filters/ }));
return screen.getByRole('dialog', { name: 'Filters' });
};
beforeEach(() => push.mockClear());
it('keeps the sheet usable, and says it is busy, while a change lands', () => {
render(<FilterBar filters={filters} />);
const sheet = openSheet();
for (const name of ['Phase', 'School type', 'Local authority']) {
expect(within(sheet).getByRole('combobox', { name })).toBeEnabled();
}
expect(within(sheet).getByRole('radio', { name: 'Within 3 miles' })).toBeEnabled();
expect(sheet.querySelector('[aria-busy="true"]')).not.toBeNull();
});
it('builds a second change on the first, not on the URL it has not reached', () => {
render(<FilterBar filters={filters} />);
const sheet = openSheet();
fireEvent.change(within(sheet).getByRole('combobox', { name: 'Phase' }), { target: { value: 'primary' } });
fireEvent.change(within(sheet).getByRole('combobox', { name: 'School type' }),
{ target: { value: 'Community school' } });
const next = pushedParams();
expect(next.get('phase')).toBe('primary');
expect(next.get('school_type')).toBe('Community school');
expect(next.get('postcode')).toBe('SW196AR');
});
describe('a screen that widens past phone width', () => {
let listeners: ((e: { matches: boolean }) => void)[] = [];
beforeEach(() => {
listeners = [];
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
matches: true, media: query,
addEventListener: (_: string, l: (e: { matches: boolean }) => void) => listeners.push(l),
removeEventListener: jest.fn(),
}));
});
it('closes the sheet, leaving the desktop row as the only filters', () => {
render(<FilterBar filters={filters} />);
openSheet();
act(() => listeners.forEach((l) => l({ matches: false })));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
@@ -105,11 +105,21 @@ describe('the toolbar filters', () => {
});
});
describe('the phone filter row', () => {
describe('the phone chips line', () => {
it('drops its "more this way" fade when nothing is left to scroll', () => {
params = new URLSearchParams('postcode=SW196AR&radius=1&phase=primary');
render(<FilterBar filters={filters} />);
// jsdom lays nothing out, so the row reads as not overflowing at all.
expect(screen.getByRole('group', { name: 'Filters' }).className).toMatch(/controlsAtEnd/);
// jsdom lays nothing out, so the line reads as not overflowing at all.
const line = screen.getByRole('group', { name: 'Applied filters' }).parentElement!;
expect(line.className).toMatch(/controlsAtEnd/);
});
});
describe('the phone filter sheet', () => {
it('offers the results total', () => {
render(<HomeView initialSchools={results()} filters={filters} />);
fireEvent.click(screen.getByRole('button', { name: 'Filters' }));
expect(screen.getByRole('button', { name: 'Show 1 school' })).toBeInTheDocument();
});
});
+115 -13
View File
@@ -95,8 +95,10 @@
}
}
/* Only phones fold the form away; see the 640px block. */
.searchSummary {
/* Only phones fold the form away and filter through the sheet; see the
640px block. */
.summaryRow,
.chipsLine {
display: none;
}
@@ -348,6 +350,11 @@
font-weight: 500;
}
/* In the phone sheet a select is a full-width field and a whole touch target. */
.sheetSelect {
min-height: 2.75rem;
}
/* A pill, 44px tall: these are the page's main controls now, not fine print,
and a phone needs the full touch target. */
.controlSelect {
@@ -553,16 +560,16 @@
/*
* Phones: the results toolbar is pinned, so it is held to two short lines.
*
* After a search the form folds into a one-line summary ("SW196AR · within
* 1 mile Edit") and the controls become a single row that scrolls sideways.
* "More filters" leads the row there: it is the one control that opens
* everything else, so it must never be the chip scrolled out of sight.
* After a search the form folds into a one-line summary ("SW196AR · 1 mi
* Edit") with the Filters button beside it, and every filter lives in
* the sheet that button opens. A second line, of the applied filters as
* chips, appears only once something is applied.
*/
@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",
* not the container: the search summary (then "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.
*/
@@ -622,13 +629,73 @@
display: none;
}
/* The desktop row and its panel give way to the Filters button, the
applied-filter chips and the sheet. */
.filterBar:not(.heroMode) .controlsRow,
.filterBar:not(.heroMode) .filters {
display: none;
}
.summaryRow {
display: flex;
align-items: stretch;
gap: 0.5rem;
}
.summaryRow .searchSummary {
flex: 1 1 auto;
min-width: 0;
}
.sheetTrigger {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 0.375rem;
min-height: 2.75rem;
padding: 0 0.875rem;
background: var(--bg-card);
border: 1px solid var(--text-secondary);
border-radius: var(--radius-md);
font-family: var(--font-ui);
font-size: var(--step--1);
font-weight: 600;
line-height: 1;
color: var(--text-primary);
cursor: pointer;
white-space: nowrap;
}
/* The same brand chip as "More filters" on desktop: the list is narrowed. */
.sheetTriggerActive {
border-color: var(--brand);
background: var(--brand-bg);
color: var(--brand);
}
.sheetTriggerCount {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.25rem;
height: 1.25rem;
padding: 0 0.3rem;
border-radius: 999px;
background: var(--brand);
color: var(--bg-card);
font-size: 0.75rem;
font-weight: 700;
}
/* 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
block padding is room for focus rings, which the scroll clip would
otherwise cut off above and below the chips. */
.controlsRow {
flex-wrap: nowrap;
.chipsLine {
display: flex;
align-items: center;
gap: 0.5rem;
overflow-x: auto;
margin: -4px -1rem;
padding: 4px 1rem;
@@ -640,15 +707,50 @@
mask-image: none;
}
.controlsRow::-webkit-scrollbar {
.chipsLine::-webkit-scrollbar {
display: none;
}
.controlsRow > * {
.chipsLine > *,
.chips > * {
flex: 0 0 auto;
}
.controlsRow .advancedToggle {
order: -1;
.chips {
display: flex;
align-items: center;
gap: 0.5rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.375rem;
min-height: 2.75rem;
padding: 0 0.75rem 0 1rem;
background: rgba(var(--sage-rgb), 0.38);
border: 1px solid var(--brand);
border-radius: 999px;
font-family: var(--font-ui);
font-size: var(--step--1);
font-weight: 600;
line-height: 1;
color: var(--brand-strong);
cursor: pointer;
white-space: nowrap;
}
/* A school type can run to "Academy special sponsor led". */
.chipLabel {
max-width: 11rem;
overflow: hidden;
text-overflow: ellipsis;
}
.chipsClear {
min-height: 2.75rem;
padding: 0 0.75rem;
font-size: var(--step--1);
font-weight: 500;
}
}
+354 -146
View File
@@ -7,6 +7,7 @@ import { DEFAULT_RADIUS_MILES, isValidPostcode, schoolUrl } from "@/lib/utils";
import { track } from "@/lib/analytics";
import { useSchoolSuggest } from "@/hooks/useSchoolSuggest";
import { SuggestList, suggestOptionId } from "./SuggestList";
import { FilterSheet, SheetField, RADIUS_OPTIONS, radiusLabel as milesLabel } from "./FilterSheet";
import type { Suggestion } from "@/lib/suggest";
import type { Filters, ResultFilters } from "@/lib/types";
import styles from "./FilterBar.module.css";
@@ -28,6 +29,8 @@ interface FilterBarProps {
* runs the full width instead of stopping short of the switch.
*/
viewSwitch?: ReactNode;
/** The results' total, for the phone filter sheet's "Show N schools". */
resultCount?: number;
}
/**
@@ -52,6 +55,37 @@ function SelectShell({
);
}
/** The filters a phone sees as chips and counts on its Filters button. */
const FILTER_KEYS = [
"phase",
"school_type",
"local_authority",
"gender",
"has_sixth_form",
"admissions_policy",
] as const;
type FilterKey = (typeof FILTER_KEYS)[number];
function SlidersIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden="true"
>
<path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12" />
<circle cx="16" cy="6" r="2" />
<circle cx="10" cy="12" r="2" />
<circle cx="18" cy="18" r="2" />
</svg>
);
}
export function FilterBar({
filters,
isHero,
@@ -61,6 +95,7 @@ export function FilterBar({
geoError,
autosuggest = false,
viewSwitch,
resultCount,
}: FilterBarProps) {
const router = useRouter();
const pathname = usePathname();
@@ -172,9 +207,9 @@ export function FilterBar({
setOmniValue(currentQuery);
}
// The phone row's right-edge fade says "more this way"; once there is no
// more, it only dims the last chip. Same rule as the school page's section
// nav (MOBILE.md, "Right-edge scroll-fade").
// The phone chips line's right-edge fade says "more this way"; once there is
// no more, it only dims the last chip. Same rule as the school page's
// section nav (MOBILE.md, "Right-edge scroll-fade").
const controlsRowRef = useRef<HTMLDivElement>(null);
const [controlsAtEnd, setControlsAtEnd] = useState(false);
const updateControlsAtEnd = useCallback(() => {
@@ -187,8 +222,8 @@ export function FilterBar({
window.addEventListener("resize", updateControlsAtEnd);
return () => window.removeEventListener("resize", updateControlsAtEnd);
}, [updateControlsAtEnd]);
// Chips come and go with the search (distance, Clear), so re-measure after
// every render rather than only on resize.
// Chips come and go with the filters, so re-measure after every render
// rather than only on resize.
useEffect(updateControlsAtEnd);
const openSearch = () => {
setSearchOpen(true);
@@ -212,9 +247,21 @@ export function FilterBar({
return () => document.removeEventListener("keydown", handleKeyDown);
}, []);
/*
* Where the navigation in flight is headed. useSearchParams only catches up
* once a navigation lands, so a second change made before then (the phone
* sheet stays usable while one lands) would otherwise be built on the old
* URL and undo the first. Dropped once nothing is pending, when
* searchParams is current again.
*/
const pendingQueryRef = useRef<string | null>(null);
useEffect(() => {
if (!isPending) pendingQueryRef.current = null;
}, [isPending]);
const updateURL = useCallback(
(updates: Record<string, string>) => {
const params = new URLSearchParams(searchParams);
const params = new URLSearchParams(pendingQueryRef.current ?? searchParams);
Object.entries(updates).forEach(([key, value]) => {
if (value && value !== "") {
@@ -225,6 +272,7 @@ export function FilterBar({
});
params.delete("page");
pendingQueryRef.current = params.toString();
startTransition(() => {
router.push(`${pathname}?${params.toString()}`);
@@ -281,8 +329,15 @@ export function FilterBar({
updateURL({ [key]: value });
};
// Every filter at once, keeping the search and its distance: what "Clear
// all" means beside the applied filters, where the search is not one of them.
const handleClearFilterValues = () => {
updateURL(Object.fromEntries(FILTER_KEYS.map((k) => [k, ""])));
};
const handleClearFilters = () => {
setOmniValue("");
pendingQueryRef.current = "";
startTransition(() => {
router.push(pathname);
});
@@ -317,13 +372,208 @@ export function FilterBar({
// only ever additive, so the control's behaviour is untouched.
const activeIf = (value: string) => (value ? ` ${styles.selectActive}` : "");
const radiusLabel = `${currentRadius} mile${currentRadius === "1" ? "" : "s"}`;
const radiusLabel = milesLabel(currentRadius);
/*
* Phones filter through one button and a sheet holding every filter. The
* desktop row's phone version scrolled sideways with "More filters" first,
* so phase showed only in part and school type not at all, and the panel
* "More filters" opened did not contain either of them.
*/
const [sheetOpen, setSheetOpen] = useState(false);
// The sheet is phone furniture: if the screen widens past phone width while
// it is open (rotation, a resized window), close it, or its selects and the
// desktop row's would both be showing.
useEffect(() => {
if (!sheetOpen || typeof window.matchMedia !== "function") return;
const phone = window.matchMedia("(max-width: 640px)");
const onChange = (e: { matches: boolean }) => {
if (!e.matches) setSheetOpen(false);
};
phone.addEventListener("change", onChange);
return () => phone.removeEventListener("change", onChange);
}, [sheetOpen]);
const values: Record<FilterKey, string> = {
phase: currentPhase,
school_type: currentType,
local_authority: currentLA,
gender: currentGender,
has_sixth_form: currentHasSixthForm,
admissions_policy: currentAdmissionsPolicy,
};
const labelFor = (key: FilterKey, value: string) => {
if (key === "has_sixth_form") {
return value === "yes" ? "With sixth form" : "Without sixth form";
}
// Phase, gender and admissions values are lowercased option names; the
// rest are the names themselves.
const named: Partial<Record<FilterKey, string[]>> = {
phase: phaseOptions,
gender: [...genderOptions, ...(filters.genders ?? [])],
admissions_policy: [
...admissionsPolicyOptions,
...(filters.admissions_policies ?? []),
],
};
return (named[key] ?? []).find((o) => o.toLowerCase() === value.toLowerCase()) ?? value;
};
const applied = FILTER_KEYS.filter((k) => values[k]).map((key) => ({
key,
label: labelFor(key, values[key]),
}));
const appliedCount = applied.length;
/*
* Each select is built here once and drawn twice: as a pill in the desktop
* row or panel, and as a full-width labelled field in the phone sheet. Only
* one copy is ever showing; the sheet's is mounted only while it is open.
* The sheet's stay enabled while a change lands: disabling the control
* under the user's focus would drop focus to <body>, out of the dialog.
*/
type Look = "pill" | "panel" | "sheet";
const selectClass = (look: Look, value: string) =>
`${look === "pill" ? styles.controlSelect : styles.filterSelect}${look === "sheet" ? ` ${styles.sheetSelect}` : ""}${activeIf(value)}`;
const phaseSelect = (look: Look) => (
<SelectShell wide={look !== "pill"}>
<select
value={currentPhase}
onChange={(e) => handleFilterChange("phase", e.target.value)}
className={selectClass(look, currentPhase)}
aria-label="Phase"
disabled={isPending && look !== "sheet"}
>
<option value="">Any phase</option>
{phaseOptions.map((p) => (
<option key={p} value={p.toLowerCase()}>
{p}
</option>
))}
</select>
</SelectShell>
);
const typeSelect = (look: Look) => (
<SelectShell wide={look !== "pill"}>
<select
value={currentType}
onChange={(e) => handleFilterChange("school_type", e.target.value)}
className={selectClass(look, currentType)}
aria-label="School type"
disabled={isPending && look !== "sheet"}
>
<option value="">Any school type</option>
{typeOptions.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</SelectShell>
);
const laSelect = (look: Look) => (
<SelectShell wide>
<select
value={currentLA}
onChange={(e) => handleFilterChange("local_authority", e.target.value)}
className={selectClass(look, currentLA)}
aria-label="Local authority"
disabled={isPending && look !== "sheet"}
>
<option value="">All Local Authorities</option>
{laOptions.map((la) => (
<option key={la} value={la}>
{la}
</option>
))}
</select>
</SelectShell>
);
const genderSelect = (look: Look) => (
<SelectShell wide>
<select
value={currentGender}
onChange={(e) => handleFilterChange("gender", e.target.value)}
className={selectClass(look, currentGender)}
aria-label="Gender"
disabled={isPending && look !== "sheet"}
>
<option value="">Boys, Girls &amp; Mixed</option>
{genderOptions.map((g) => (
<option key={g} value={g.toLowerCase()}>
{g}
</option>
))}
</select>
</SelectShell>
);
const sixthFormSelect = (look: Look) => (
<SelectShell wide>
<select
value={currentHasSixthForm}
onChange={(e) => handleFilterChange("has_sixth_form", e.target.value)}
className={selectClass(look, currentHasSixthForm)}
aria-label="Sixth form"
disabled={isPending && look !== "sheet"}
>
<option value="">With or without sixth form</option>
<option value="yes">With sixth form</option>
<option value="no">Without sixth form</option>
</select>
</SelectShell>
);
const admissionsSelect = (look: Look) => (
<SelectShell wide>
<select
value={currentAdmissionsPolicy}
onChange={(e) => handleFilterChange("admissions_policy", e.target.value)}
className={selectClass(look, currentAdmissionsPolicy)}
aria-label="Admissions"
disabled={isPending && look !== "sheet"}
>
<option value="">All admissions types</option>
{admissionsPolicyOptions.map((p) => (
<option key={p} value={p.toLowerCase()}>
{p}
</option>
))}
</select>
</SelectShell>
);
const sheetTrigger = (
<button
type="button"
className={`${styles.sheetTrigger}${appliedCount ? ` ${styles.sheetTriggerActive}` : ""}`}
onClick={() => setSheetOpen(true)}
aria-haspopup="dialog"
aria-label={appliedCount ? `Filters, ${appliedCount} applied` : "Filters"}
>
<SlidersIcon />
Filters
{appliedCount > 0 && (
<span className={styles.sheetTriggerCount} aria-hidden="true">
{appliedCount}
</span>
)}
</button>
);
// Beside the folded search summary; on the chips line when there is no
// summary to sit beside. While the search is being edited it steps aside,
// and comes back when the form folds.
const triggerBesideSummary = canFold && !searchOpen;
const triggerOnChipsLine = !canFold;
return (
<div
className={`${styles.filterBar} ${isPending ? styles.isLoading : ""} ${isHero ? styles.heroMode : ""}`}
>
{canFold && !searchOpen && (
{triggerBesideSummary && (
<div className={styles.summaryRow}>
<button
type="button"
className={styles.searchSummary}
@@ -345,10 +595,14 @@ export function FilterBar({
</svg>
<span className={styles.summaryText}>
<strong>{currentQuery}</strong>
{currentPostcode && <span> · within {radiusLabel}</span>}
{/* Short, so the distance survives beside the Filters button on a
360px screen; the button's name carries the long form. */}
{currentPostcode && <span> · {currentRadius} mi</span>}
</span>
<span className={styles.summaryEdit} aria-hidden="true">Edit</span>
</button>
{sheetTrigger}
</div>
)}
<form
onSubmit={handleSearchSubmit}
@@ -462,16 +716,10 @@ export function FilterBar({
{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. */}
<div
ref={controlsRowRef}
className={`${styles.controlsRow}${controlsAtEnd ? ` ${styles.controlsAtEnd}` : ""}`}
role="group"
aria-label="Filters"
onScroll={updateControlsAtEnd}
>
{/* Desktop and tablet. Every control here is a real <select> or
<button>, drawn as a pill. Phones hide the row and use the
Filters button and sheet instead. */}
<div className={styles.controlsRow} role="group" aria-label="Filters">
{currentPostcode && (
<SelectShell>
<select
@@ -481,52 +729,18 @@ export function FilterBar({
aria-label="Distance"
disabled={isPending}
>
<option value="0.25">Within 0.25 miles</option>
<option value="0.5">Within 0.5 miles</option>
<option value="1">Within 1 mile</option>
<option value="3">Within 3 miles</option>
<option value="5">Within 5 miles</option>
</select>
</SelectShell>
)}
{phaseOptions.length > 0 && (
<SelectShell>
<select
value={currentPhase}
onChange={(e) => handleFilterChange("phase", e.target.value)}
className={`${styles.controlSelect}${activeIf(currentPhase)}`}
aria-label="Phase"
disabled={isPending}
>
<option value="">Any phase</option>
{phaseOptions.map((p) => (
<option key={p} value={p.toLowerCase()}>
{p}
{RADIUS_OPTIONS.map((r) => (
<option key={r} value={r}>
Within {milesLabel(r)}
</option>
))}
</select>
</SelectShell>
)}
<SelectShell>
<select
value={currentType}
onChange={(e) =>
handleFilterChange("school_type", e.target.value)
}
className={`${styles.controlSelect}${activeIf(currentType)}`}
aria-label="School type"
disabled={isPending}
>
<option value="">Any school type</option>
{typeOptions.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</SelectShell>
{phaseOptions.length > 0 && phaseSelect("pill")}
{typeSelect("pill")}
<button
type="button"
@@ -534,21 +748,7 @@ export function FilterBar({
onClick={() => setFiltersOpen((v) => !v)}
aria-expanded={filtersOpen}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden="true"
>
<path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12" />
<circle cx="16" cy="6" r="2" />
<circle cx="10" cy="12" r="2" />
<circle cx="18" cy="18" r="2" />
</svg>
<SlidersIcon />
More filters
{hasActiveDropdownFilters
? ` (${activeDropdownFilters.length})`
@@ -572,85 +772,93 @@ export function FilterBar({
{filtersOpen && (
<div className={styles.filters}>
<SelectShell wide>
<select
value={currentLA}
onChange={(e) =>
handleFilterChange("local_authority", e.target.value)
}
className={`${styles.filterSelect}${activeIf(currentLA)}`}
aria-label="Local authority"
disabled={isPending}
>
<option value="">All Local Authorities</option>
{laOptions.map((la) => (
<option key={la} value={la}>
{la}
</option>
))}
</select>
</SelectShell>
{laSelect("panel")}
{isSecondaryMode && (
<>
{genderOptions.length > 0 && (
<SelectShell wide>
<select
value={currentGender}
onChange={(e) =>
handleFilterChange("gender", e.target.value)
}
className={`${styles.filterSelect}${activeIf(currentGender)}`}
disabled={isPending}
>
<option value="">Boys, Girls &amp; Mixed</option>
{genderOptions.map((g) => (
<option key={g} value={g.toLowerCase()}>
{g}
</option>
))}
</select>
</SelectShell>
)}
<SelectShell wide>
<select
value={currentHasSixthForm}
onChange={(e) =>
handleFilterChange("has_sixth_form", e.target.value)
}
className={`${styles.filterSelect}${activeIf(currentHasSixthForm)}`}
disabled={isPending}
>
<option value="">With or without sixth form</option>
<option value="yes">With sixth form</option>
<option value="no">Without sixth form</option>
</select>
</SelectShell>
{admissionsPolicyOptions.length > 0 && (
<SelectShell wide>
<select
value={currentAdmissionsPolicy}
onChange={(e) =>
handleFilterChange("admissions_policy", e.target.value)
}
className={`${styles.filterSelect}${activeIf(currentAdmissionsPolicy)}`}
disabled={isPending}
>
<option value="">All admissions types</option>
{admissionsPolicyOptions.map((p) => (
<option key={p} value={p.toLowerCase()}>
{p}
</option>
))}
</select>
</SelectShell>
)}
{genderOptions.length > 0 && genderSelect("panel")}
{sixthFormSelect("panel")}
{admissionsPolicyOptions.length > 0 && admissionsSelect("panel")}
</>
)}
</div>
)}
{/* Phones: what is applied, each removable with a tap. The line
scrolls sideways if it overflows, under the same right-edge fade
as the school page's section nav. */}
{(appliedCount > 0 || triggerOnChipsLine) && (
<div
ref={controlsRowRef}
className={`${styles.chipsLine}${controlsAtEnd ? ` ${styles.controlsAtEnd}` : ""}`}
onScroll={updateControlsAtEnd}
>
{triggerOnChipsLine && sheetTrigger}
{appliedCount > 0 && (
<div className={styles.chips} role="group" aria-label="Applied filters">
{applied.map(({ key, label }) => (
<button
key={key}
type="button"
className={styles.chip}
onClick={() => handleFilterChange(key, "")}
aria-label={`Remove filter: ${label}`}
disabled={isPending}
>
<span className={styles.chipLabel}>{label}</span>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
aria-hidden="true"
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
))}
<button
type="button"
className={`btn btn-tertiary ${styles.chipsClear}`}
onClick={handleClearFilterValues}
disabled={isPending}
>
Clear all
</button>
</div>
)}
</div>
)}
<FilterSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
radius={currentPostcode ? currentRadius : null}
onRadiusChange={(r) => updateURL({ radius: r })}
onClearAll={appliedCount > 0 ? handleClearFilterValues : undefined}
resultCount={resultCount}
isPending={isPending}
>
{phaseOptions.length > 0 && (
<SheetField label="Phase">{phaseSelect("sheet")}</SheetField>
)}
<SheetField label="School type">{typeSelect("sheet")}</SheetField>
<SheetField label="Local authority">{laSelect("sheet")}</SheetField>
{isSecondaryMode && (
<>
{genderOptions.length > 0 && (
<SheetField label="Gender">{genderSelect("sheet")}</SheetField>
)}
<SheetField label="Sixth form">{sixthFormSelect("sheet")}</SheetField>
{admissionsPolicyOptions.length > 0 && (
<SheetField label="Admissions">{admissionsSelect("sheet")}</SheetField>
)}
</>
)}
</FilterSheet>
</>
)}
</div>
@@ -0,0 +1,92 @@
/* ── Phone filter sheet ───────────────────────────────────────────── */
.body {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.fieldLabel {
font-family: var(--font-ui);
font-size: var(--step--1);
font-weight: 600;
color: var(--text-primary);
}
/* Distance: five joined segments, a full-width row. At 360px each is about
65px wide, clear of the 44px minimum. */
.segments {
display: flex;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
overflow: hidden;
}
.segment {
position: relative;
flex: 1 1 0;
display: flex;
}
.segment + .segment {
border-left: 1px solid var(--border);
}
/* The real radio stays in the tree for keyboard and screen readers; the span
is what is drawn. */
.segment input {
position: absolute;
inset: 0;
margin: 0;
opacity: 0;
cursor: pointer;
}
.segment span {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 2.75rem;
font-family: var(--font-ui);
font-size: var(--step--1);
font-weight: 500;
color: var(--text-primary);
background: var(--bg-card);
white-space: nowrap;
transition: background-color var(--transition), color var(--transition);
}
.segment input:checked + span {
background: rgba(var(--sage-rgb), 0.38);
color: var(--brand-strong);
font-weight: 700;
}
.segment input:focus-visible + span {
box-shadow: inset 0 0 0 2px var(--brand);
}
.footer {
display: flex;
align-items: center;
gap: 0.75rem;
}
.clearAll {
min-height: 2.75rem;
padding: 0 1rem;
}
.show {
flex: 1;
min-height: 2.75rem;
justify-content: center;
}
+121
View File
@@ -0,0 +1,121 @@
"use client";
import { useId } from "react";
import type { ReactNode } from "react";
import { Modal } from "./Modal";
import styles from "./FilterSheet.module.css";
export const RADIUS_OPTIONS = ["0.25", "0.5", "1", "3", "5"] as const;
export const radiusLabel = (r: string) => `${r} mile${r === "1" ? "" : "s"}`;
interface FilterSheetProps {
open: boolean;
onClose: () => void;
/** The current distance, or null when the search is not by postcode. */
radius: string | null;
onRadiusChange: (radius: string) => void;
/** The filter selects, each already labelled; FilterBar owns their state. */
children: ReactNode;
/** Shown only when something is applied. */
onClearAll?: () => void;
resultCount?: number;
isPending: boolean;
}
/*
* Every filter in one place, for phones. A change applies at once, as it does
* in the desktop toolbar, so the results behind the sheet are already the
* filtered ones and the footer button only has to close it.
*/
export function FilterSheet({
open,
onClose,
radius,
onRadiusChange,
children,
onClearAll,
resultCount,
isPending,
}: FilterSheetProps) {
const distanceId = useId();
const showLabel = isPending
? "Updating…"
: resultCount === undefined
? "Show schools"
: resultCount === 0
? "No schools match"
: `Show ${resultCount.toLocaleString()} school${resultCount === 1 ? "" : "s"}`;
return (
<Modal
isOpen={open}
onClose={onClose}
title="Filters"
size="small"
footer={
<div className={styles.footer}>
{onClearAll && (
<button
type="button"
className={`btn btn-tertiary ${styles.clearAll}`}
onClick={onClearAll}
>
Clear all
</button>
)}
<button
type="button"
className={`btn btn-action ${styles.show}`}
onClick={onClose}
>
{showLabel}
</button>
</div>
}
>
{/* Controls stay enabled while a change lands, so focus is never
dropped out of the dialog; aria-busy says the results are updating. */}
<div className={styles.body} aria-busy={isPending}>
{radius !== null && (
<div className={styles.field}>
<span id={distanceId} className={styles.fieldLabel}>
Distance
</span>
<div
className={styles.segments}
role="radiogroup"
aria-labelledby={distanceId}
>
{RADIUS_OPTIONS.map((r) => (
<label key={r} className={styles.segment}>
<input
type="radio"
name={`${distanceId}-radius`}
value={r}
checked={r === radius}
onChange={() => onRadiusChange(r)}
aria-label={`Within ${radiusLabel(r)}`}
/>
<span>{r} mi</span>
</label>
))}
</div>
</div>
)}
{children}
</div>
</Modal>
);
}
/** One labelled row of the sheet. */
export function SheetField({ label, children }: { label: string; children: ReactNode }) {
return (
<label className={styles.field}>
<span className={styles.fieldLabel}>{label}</span>
{children}
</label>
);
}
+1
View File
@@ -712,6 +712,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
filters={filters}
isHero={false}
resultFilters={initialSchools.result_filters}
resultCount={initialSchools.total}
onNearMe={handleNearMe}
geoState={geoState}
geoError={geoError}
+15 -1
View File
@@ -5,7 +5,9 @@
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
/* Above the pinned results toolbar (1001), the floating List/Map button
(1002) and the comparison toast (2000): a modal covers the page. */
z-index: 2100;
padding: 1rem;
animation: fadeIn 0.2s ease;
}
@@ -31,6 +33,10 @@
border: 1px solid var(--border);
}
.modal:focus {
outline: none;
}
@keyframes slideIn {
from {
transform: translateY(-20px);
@@ -96,6 +102,14 @@
flex: 1;
}
/* Clears the iPhone home indicator when the modal is a bottom sheet. */
.footer {
flex-shrink: 0;
padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom, 0px));
border-top: 1px solid var(--border);
background: var(--bg-card);
}
/* Scrollbar styles */
.content::-webkit-scrollbar {
width: 8px;
+27 -4
View File
@@ -5,7 +5,7 @@
'use client';
import { useEffect, useCallback, useRef } from 'react';
import { useEffect, useCallback, useId, useRef } from 'react';
import { createPortal } from 'react-dom';
import styles from './Modal.module.css';
@@ -15,10 +15,14 @@ interface ModalProps {
children: React.ReactNode;
title?: string;
size?: 'small' | 'medium' | 'large';
/** Pinned below the scrolling content, so its actions stay in reach. */
footer?: React.ReactNode;
}
export function Modal({ isOpen, onClose, children, title, size = 'medium' }: ModalProps) {
export function Modal({ isOpen, onClose, children, title, size = 'medium', footer }: ModalProps) {
const overlayRef = useRef<HTMLDivElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
const titleId = useId();
const handleEscape = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') {
@@ -41,6 +45,17 @@ export function Modal({ isOpen, onClose, children, title, size = 'medium' }: Mod
};
}, [isOpen, handleEscape]);
// Focus moves into the dialog when it opens and back to whatever opened it
// when it closes. A child that has already taken focus (an autoFocus input)
// keeps it: children's effects run before this one.
useEffect(() => {
if (!isOpen) return;
const opener = document.activeElement as HTMLElement | null;
const dialog = dialogRef.current;
if (dialog && !dialog.contains(document.activeElement)) dialog.focus();
return () => opener?.focus?.();
}, [isOpen]);
// Pin the overlay to the VISUAL viewport, not the layout viewport. On mobile
// the on-screen keyboard shrinks the visual viewport but not the layout one,
// so a `position: fixed; inset: 0` overlay keeps full height — leaving the
@@ -77,9 +92,16 @@ export function Modal({ isOpen, onClose, children, title, size = 'medium' }: Mod
return createPortal(
<div ref={overlayRef} className={styles.overlay} onClick={handleOverlayClick}>
<div className={`${styles.modal} ${styles[size]}`}>
<div
ref={dialogRef}
className={`${styles.modal} ${styles[size]}`}
role="dialog"
aria-modal="true"
aria-labelledby={title ? titleId : undefined}
tabIndex={-1}
>
<div className={styles.header}>
{title && <h2 className={styles.title}>{title}</h2>}
{title && <h2 id={titleId} className={styles.title}>{title}</h2>}
<button
className={styles.closeButton}
onClick={onClose}
@@ -103,6 +125,7 @@ export function Modal({ isOpen, onClose, children, title, size = 'medium' }: Mod
<div className={styles.content}>
{children}
</div>
{footer && <div className={styles.footer}>{footer}</div>}
</div>
</div>,
document.body