feat(search): pin the results toolbar and float the map button on phones #157
No files matched your search
@@ -503,8 +503,9 @@ test('results map fullscreen falls back to an overlay on iOS', async ({ page })
|
|||||||
await searchByName(page, 'B1 1BB');
|
await searchByName(page, 'B1 1BB');
|
||||||
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
// Switch to the map view, then open the map fullscreen.
|
// Switch to the map view with the floating button (the toolbar's switch is
|
||||||
await page.getByRole('button', { name: 'Map', exact: true }).click();
|
// hidden at phone width), then open the map fullscreen.
|
||||||
|
await page.getByRole('button', { name: 'Show map' }).click();
|
||||||
const openFs = page.getByRole('button', { name: 'View map fullscreen' });
|
const openFs = page.getByRole('button', { name: 'View map fullscreen' });
|
||||||
await expect(openFs).toBeVisible({ timeout: 15_000 });
|
await expect(openFs).toBeVisible({ timeout: 15_000 });
|
||||||
await openFs.click();
|
await openFs.click();
|
||||||
@@ -516,6 +517,78 @@ test('results map fullscreen falls back to an overlay on iOS', async ({ page })
|
|||||||
await expect(openFs).toBeVisible();
|
await expect(openFs).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The results toolbar: search, filters and the List/Map switch pinned under
|
||||||
|
* the header, so none of them scroll away with the results.
|
||||||
|
*
|
||||||
|
* Positions are read after window.scrollTo, never after a click: Playwright
|
||||||
|
* scrolls a click target into view first, which would make "still on screen
|
||||||
|
* after scrolling" true of anything.
|
||||||
|
*/
|
||||||
|
test('the results toolbar stays pinned with its List/Map switch', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1280, height: 800 });
|
||||||
|
await searchByName(page, 'B1 1BB');
|
||||||
|
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
const view = page.getByRole('group', { name: 'Results view' });
|
||||||
|
await expect(view).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'List' })).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
|
||||||
|
await page.evaluate(() => window.scrollTo(0, 1500));
|
||||||
|
await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(1000);
|
||||||
|
const box = await view.boundingBox();
|
||||||
|
expect(box, 'the switch left the viewport').not.toBeNull();
|
||||||
|
// Just under the 64px header, not scrolled off the top.
|
||||||
|
expect(box!.y).toBeGreaterThanOrEqual(64);
|
||||||
|
expect(box!.y).toBeLessThan(160);
|
||||||
|
await expect(page.getByRole('combobox', { name: 'Distance' })).toBeInViewport();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Map', exact: true }).click();
|
||||||
|
await expect(page.getByRole('button', { name: 'Map', exact: true }))
|
||||||
|
.toHaveAttribute('aria-pressed', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const width of [360, 390, 430]) {
|
||||||
|
test(`the floating Map button is in reach at ${width}px`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width, height: 800 });
|
||||||
|
await searchByName(page, 'B1 1BB');
|
||||||
|
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
// Visible without scrolling, and clear of the bottom tab bar.
|
||||||
|
const fab = page.getByRole('button', { name: 'Show map' });
|
||||||
|
await expect(fab).toBeInViewport();
|
||||||
|
const fabBox = (await fab.boundingBox())!;
|
||||||
|
const barTop = await page.locator('nav[class*="bottomBar"]')
|
||||||
|
.evaluate((el) => el.getBoundingClientRect().top);
|
||||||
|
expect(fabBox.y + fabBox.height).toBeLessThanOrEqual(barTop);
|
||||||
|
|
||||||
|
// The search folds to a summary, and the pinned toolbar survives a scroll.
|
||||||
|
const summary = page.getByRole('button', { name: /^Edit search: B1 1BB/ });
|
||||||
|
await expect(summary).toBeVisible();
|
||||||
|
await page.evaluate(() => window.scrollTo(0, 1200));
|
||||||
|
await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(600);
|
||||||
|
await expect(summary).toBeInViewport();
|
||||||
|
await expect(fab).toBeInViewport();
|
||||||
|
|
||||||
|
// MOBILE.md: no horizontal overflow, and 44px targets in the new chrome.
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth))
|
||||||
|
.toBe(0);
|
||||||
|
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]
|
||||||
|
.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 }))
|
||||||
|
.filter((o) => o.w < 44 || o.h < 44);
|
||||||
|
});
|
||||||
|
expect(small).toEqual([]);
|
||||||
|
|
||||||
|
await fab.click();
|
||||||
|
await expect(page.getByRole('button', { name: 'Show list' })).toBeVisible();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test('comparing two schools shows the parent-first sections side by side', async ({ page }) => {
|
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.
|
// Two same-phase (pure primary) schools so both stay on one tab.
|
||||||
const [urn0, urn1] = await twoPrimaryUrns(page);
|
const [urn0, urn1] = await twoPrimaryUrns(page);
|
||||||
|
|||||||
@@ -32,9 +32,9 @@ afterEach(() => { global.fetch = realFetch; });
|
|||||||
describe('FilterBar autosuggest', () => {
|
describe('FilterBar autosuggest', () => {
|
||||||
it('is a combobox only when the flag is on', () => {
|
it('is a combobox only when the flag is on', () => {
|
||||||
const { rerender } = render(<FilterBar filters={FILTERS} autosuggest={false} />);
|
const { rerender } = render(<FilterBar filters={FILTERS} autosuggest={false} />);
|
||||||
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
|
expect(screen.queryByRole('combobox', { name: 'School name or postcode' })).not.toBeInTheDocument();
|
||||||
rerender(<FilterBar filters={FILTERS} autosuggest />);
|
rerender(<FilterBar filters={FILTERS} autosuggest />);
|
||||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
expect(screen.getByRole('combobox', { name: 'School name or postcode' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('makes no request while the flag is off', async () => {
|
it('makes no request while the flag is off', async () => {
|
||||||
@@ -47,7 +47,7 @@ describe('FilterBar autosuggest', () => {
|
|||||||
|
|
||||||
it('shows suggestions and navigates when one is chosen', async () => {
|
it('shows suggestions and navigates when one is chosen', async () => {
|
||||||
render(<FilterBar filters={FILTERS} autosuggest />);
|
render(<FilterBar filters={FILTERS} autosuggest />);
|
||||||
await userEvent.type(screen.getByRole('combobox'), 'brecknock');
|
await userEvent.type(screen.getByRole('combobox', { name: 'School name or postcode' }), 'brecknock');
|
||||||
const option = await screen.findByRole('option', { name: /Brecknock/ });
|
const option = await screen.findByRole('option', { name: /Brecknock/ });
|
||||||
await userEvent.click(option);
|
await userEvent.click(option);
|
||||||
expect(push).toHaveBeenCalledWith(
|
expect(push).toHaveBeenCalledWith(
|
||||||
@@ -61,7 +61,7 @@ describe('FilterBar autosuggest', () => {
|
|||||||
// "NW1" is not a postcode, so a request for it is correct behaviour. Only
|
// "NW1" is not a postcode, so a request for it is correct behaviour. Only
|
||||||
// the settled value is the assertion, so set it in one go.
|
// the settled value is the assertion, so set it in one go.
|
||||||
render(<FilterBar filters={FILTERS} autosuggest />);
|
render(<FilterBar filters={FILTERS} autosuggest />);
|
||||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'NW1 1AA' } });
|
fireEvent.change(screen.getByRole('combobox', { name: 'School name or postcode' }), { target: { value: 'NW1 1AA' } });
|
||||||
await new Promise((r) => setTimeout(r, 300)); // past the 200ms debounce
|
await new Promise((r) => setTimeout(r, 300)); // past the 200ms debounce
|
||||||
expect(global.fetch).not.toHaveBeenCalled();
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -69,7 +69,7 @@ describe('FilterBar autosuggest', () => {
|
|||||||
it('Enter with no active option still submits the free-text search', async () => {
|
it('Enter with no active option still submits the free-text search', async () => {
|
||||||
// The existing behaviour is preserved, not replaced.
|
// The existing behaviour is preserved, not replaced.
|
||||||
render(<FilterBar filters={FILTERS} autosuggest />);
|
render(<FilterBar filters={FILTERS} autosuggest />);
|
||||||
const input = screen.getByRole('combobox');
|
const input = screen.getByRole('combobox', { name: 'School name or postcode' });
|
||||||
await userEvent.type(input, 'brecknock{Enter}');
|
await userEvent.type(input, 'brecknock{Enter}');
|
||||||
// updateURL pushes inside startTransition, so the call is not synchronous.
|
// updateURL pushes inside startTransition, so the call is not synchronous.
|
||||||
await waitFor(() => expect(push).toHaveBeenCalledWith(
|
await waitFor(() => expect(push).toHaveBeenCalledWith(
|
||||||
@@ -90,7 +90,7 @@ describe('FilterBar autosuggest does not reopen over results', () => {
|
|||||||
searchParams = new URLSearchParams('search=brecknock');
|
searchParams = new URLSearchParams('search=brecknock');
|
||||||
render(<FilterBar filters={FILTERS} autosuggest />);
|
render(<FilterBar filters={FILTERS} autosuggest />);
|
||||||
|
|
||||||
expect(screen.getByRole('combobox')).toHaveValue('brecknock');
|
expect(screen.getByRole('combobox', { name: 'School name or postcode' })).toHaveValue('brecknock');
|
||||||
await new Promise((r) => setTimeout(r, 300)); // past the 200ms debounce
|
await new Promise((r) => setTimeout(r, 300)); // past the 200ms debounce
|
||||||
expect(global.fetch).not.toHaveBeenCalled();
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||||
@@ -98,7 +98,7 @@ describe('FilterBar autosuggest does not reopen over results', () => {
|
|||||||
|
|
||||||
it('closes the dropdown when the search is submitted', async () => {
|
it('closes the dropdown when the search is submitted', async () => {
|
||||||
render(<FilterBar filters={FILTERS} autosuggest />);
|
render(<FilterBar filters={FILTERS} autosuggest />);
|
||||||
const input = screen.getByRole('combobox');
|
const input = screen.getByRole('combobox', { name: 'School name or postcode' });
|
||||||
|
|
||||||
await userEvent.type(input, 'brecknock');
|
await userEvent.type(input, 'brecknock');
|
||||||
expect(await screen.findByRole('listbox')).toBeInTheDocument();
|
expect(await screen.findByRole('listbox')).toBeInTheDocument();
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { HomeView } from '@/components/HomeView';
|
||||||
|
import { FilterBar } from '@/components/FilterBar';
|
||||||
|
import { fetchSchools } from '@/lib/api';
|
||||||
|
import { track } from '@/lib/analytics';
|
||||||
|
import { primaryFixture } from '../support/schoolFixtures';
|
||||||
|
import type { SchoolsResponse } from '@/lib/types';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The results toolbar (option B of the 2026-09-30 results-controls mockups):
|
||||||
|
* search, filters and the List/Map switch pinned under the header, with a
|
||||||
|
* floating List/Map button standing in for the switch on phones. Layout is CSS
|
||||||
|
* and not visible to jsdom; these pin the behaviour and the accessible names
|
||||||
|
* the E2E journeys rely on.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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('@/context/ComparisonContext', () => ({
|
||||||
|
useComparisonContext: () => ({ addSchool: jest.fn(), removeSchool: jest.fn(), selectedSchools: [] }),
|
||||||
|
}));
|
||||||
|
jest.mock('@/lib/api', () => ({
|
||||||
|
fetchSchools: jest.fn(),
|
||||||
|
fetchNationalAverages: jest.fn(async () => ({})),
|
||||||
|
fetchLAaverages: jest.fn(async () => ({ secondary: { attainment_8_by_la: {} } })),
|
||||||
|
}));
|
||||||
|
jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
|
||||||
|
jest.mock('@/components/SchoolMap', () => ({ SchoolMap: () => <div data-testid="map" /> }));
|
||||||
|
|
||||||
|
const filters = {
|
||||||
|
local_authorities: ['Wandsworth'], school_types: ['Community school'], years: [],
|
||||||
|
phases: ['Primary', 'Secondary'], genders: [], admissions_policies: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function results(): SchoolsResponse {
|
||||||
|
return { schools: [{ ...primaryFixture.schoolInfo, school_name: 'Southmead Primary School' }],
|
||||||
|
total: 1, page: 1, page_size: 25, total_pages: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
params = new URLSearchParams('postcode=SW196AR&radius=1');
|
||||||
|
push.mockClear();
|
||||||
|
jest.mocked(track).mockClear();
|
||||||
|
jest.mocked(fetchSchools).mockReset().mockResolvedValue(results());
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the List/Map switch', () => {
|
||||||
|
it('lives in the toolbar with the filters and says which view is on', () => {
|
||||||
|
render(<HomeView initialSchools={results()} filters={filters} />);
|
||||||
|
const view = screen.getByRole('group', { name: 'Results view' });
|
||||||
|
expect(view.closest('div[class*="resultsToolbar"]')).not.toBeNull();
|
||||||
|
expect(screen.getByRole('button', { name: 'List' })).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
expect(screen.getByRole('button', { name: 'Map' })).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has a floating twin that flips between map and list', async () => {
|
||||||
|
render(<HomeView initialSchools={results()} filters={filters} />);
|
||||||
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Show map' })));
|
||||||
|
expect(screen.getByTestId('map')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Map' })).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
expect(track).toHaveBeenCalledWith('results_view_changed', { view: 'map', via: 'floating' });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Show list' }));
|
||||||
|
expect(screen.queryByTestId('map')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Show map' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not track a click on the view already showing', () => {
|
||||||
|
render(<HomeView initialSchools={results()} filters={filters} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'List' }));
|
||||||
|
expect(track).not.toHaveBeenCalledWith('results_view_changed', expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is absent from a name search, which has no map', () => {
|
||||||
|
params = new URLSearchParams('search=southmead');
|
||||||
|
render(<HomeView initialSchools={results()} filters={filters} />);
|
||||||
|
expect(screen.queryByRole('group', { name: 'Results view' })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: 'Show map' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the toolbar filters', () => {
|
||||||
|
it('keeps distance, phase and school type in the row, not behind More filters', () => {
|
||||||
|
render(<FilterBar filters={filters} />);
|
||||||
|
const row = screen.getByRole('group', { name: 'Filters' });
|
||||||
|
for (const name of ['Distance', 'Phase', 'School type']) {
|
||||||
|
expect(row).toContainElement(screen.getByRole('combobox', { name }));
|
||||||
|
}
|
||||||
|
expect(screen.getByRole('combobox', { name: 'Distance' })).toHaveDisplayValue('Within 1 mile');
|
||||||
|
expect(screen.queryByRole('combobox', { name: 'Local authority' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts only what More filters hides', () => {
|
||||||
|
params = new URLSearchParams('postcode=SW196AR&radius=1&school_type=Community+school&local_authority=Wandsworth');
|
||||||
|
render(<FilterBar filters={filters} />);
|
||||||
|
expect(screen.getByRole('button', { name: /More filters \(1\)/ })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the phone filter row', () => {
|
||||||
|
it('drops its "more this way" fade when nothing is left to scroll', () => {
|
||||||
|
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the folded search', () => {
|
||||||
|
it('summarises the search and unfolds on tap', () => {
|
||||||
|
render(<FilterBar filters={filters} />);
|
||||||
|
const summary = screen.getByRole('button', { name: 'Edit search: SW196AR, within 1 mile' });
|
||||||
|
fireEvent.click(summary);
|
||||||
|
expect(screen.queryByRole('button', { name: /Edit search/ })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('folds again once the edited search is submitted', () => {
|
||||||
|
render(<FilterBar filters={filters} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Edit search/ }));
|
||||||
|
const input = screen.getByRole('searchbox', { name: 'School name or postcode' });
|
||||||
|
fireEvent.change(input, { target: { value: 'SW19 1AA' } });
|
||||||
|
fireEvent.submit(input.closest('form')!);
|
||||||
|
expect(screen.getByRole('button', { name: /Edit search/ })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refolds and shows the new text when the search changes some other way', () => {
|
||||||
|
const view = render(<FilterBar filters={filters} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Edit search/ }));
|
||||||
|
fireEvent.change(screen.getByRole('searchbox', { name: 'School name or postcode' }),
|
||||||
|
{ target: { value: 'half-typed' } });
|
||||||
|
|
||||||
|
// Back button: the URL changes under the component, nothing is submitted.
|
||||||
|
params = new URLSearchParams('postcode=SW170AA&radius=3');
|
||||||
|
view.rerender(<FilterBar filters={filters} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'Edit search: SW170AA, within 3 miles' }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('searchbox', { name: 'School name or postcode' }))
|
||||||
|
.toHaveValue('SW170AA');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never appears in the hero, or before anything has been searched', () => {
|
||||||
|
const { unmount } = render(<FilterBar filters={filters} isHero />);
|
||||||
|
expect(screen.queryByRole('button', { name: /Edit search/ })).not.toBeInTheDocument();
|
||||||
|
unmount();
|
||||||
|
params = new URLSearchParams('local_authority=Wandsworth');
|
||||||
|
render(<FilterBar filters={filters} />);
|
||||||
|
expect(screen.queryByRole('button', { name: /Edit search/ })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,8 +4,10 @@
|
|||||||
* hero a single white pill on the Sand hero panel, carrying the one
|
* hero a single white pill on the Sand hero panel, carrying the one
|
||||||
* coral action the page exists for. No card of its own: the panel
|
* coral action the page exists for. No card of its own: the panel
|
||||||
* behind it belongs to the page, so the pill reads as the object.
|
* behind it belongs to the page, so the pill reads as the object.
|
||||||
* compact the filter rail above the results — a white card of quiet
|
* compact the results toolbar — search, then a row of pill controls. The
|
||||||
* controls, brand for what is active, sage for what is selected.
|
* card around it belongs to HomeView, which pins it under the
|
||||||
|
* header and puts the List/Map switch beside it, so the compact
|
||||||
|
* bar draws no card of its own.
|
||||||
*
|
*
|
||||||
* Colour comes from the token layer only; the disclosure arrows are drawn
|
* Colour comes from the token layer only; the disclosure arrows are drawn
|
||||||
* from currentColor rather than a background image so they follow the theme.
|
* from currentColor rather than a background image so they follow the theme.
|
||||||
@@ -38,6 +40,31 @@
|
|||||||
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. */
|
||||||
|
.filterBar:not(.heroMode) {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterBar:not(.heroMode) .searchSection {
|
||||||
|
flex: 1 1 320px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Only phones fold the form away; see the 640px block. */
|
||||||
|
.searchSummary {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Search ───────────────────────────────────────────────────────── */
|
/* ── Search ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.searchSection {
|
.searchSection {
|
||||||
@@ -115,8 +142,13 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filterBar:not(.heroMode) .omniInput {
|
||||||
|
min-height: 2.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.searchButton {
|
.searchButton {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
min-height: 2.75rem;
|
||||||
padding: 0.6875rem 1.25rem;
|
padding: 0.6875rem 1.25rem;
|
||||||
font-size: var(--step--1);
|
font-size: var(--step--1);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
@@ -217,34 +249,13 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Controls row (radius + phase + advanced toggle) ──────────────── */
|
/* ── Controls row (distance, phase, type, more filters) ───────────── */
|
||||||
|
|
||||||
.controlsRow {
|
.controlsRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-top: 0.875rem;
|
|
||||||
padding-top: 0.875rem;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.controlsRow .advancedToggle {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radiusControl {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radiusLabel {
|
|
||||||
font-size: var(--step--2);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Selects ──────────────────────────────────────────────────────── */
|
/* ── Selects ──────────────────────────────────────────────────────── */
|
||||||
@@ -302,11 +313,24 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A pill, 44px tall: these are the page's main controls now, not fine print,
|
||||||
|
and a phone needs the full touch target. */
|
||||||
.controlSelect {
|
.controlSelect {
|
||||||
padding: 0.4375rem 1.875rem 0.4375rem 0.75rem;
|
min-height: 2.75rem;
|
||||||
|
padding: 0 2.25rem 0 1rem;
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
border-radius: 999px;
|
||||||
font-size: var(--step--1);
|
font-size: var(--step--1);
|
||||||
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
|
||||||
|
"Academy special sponsor led". Cap it; the chosen value truncates. */
|
||||||
|
max-width: 14rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectShell:has(.controlSelect) .selectChevron {
|
||||||
|
right: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filterSelect:hover:not(:disabled),
|
.filterSelect:hover:not(:disabled),
|
||||||
@@ -335,12 +359,18 @@
|
|||||||
border-color: var(--brand);
|
border-color: var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Capped so an open panel scrolls inside the pinned toolbar instead of
|
||||||
|
pushing the results off a short screen. The 3px gutter keeps the selects'
|
||||||
|
focus rings clear of the scroll clip. */
|
||||||
.filters {
|
.filters {
|
||||||
|
flex-basis: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.625rem;
|
gap: 0.625rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-top: 0.75rem;
|
max-height: 50dvh;
|
||||||
padding-top: 0.75rem;
|
overflow-y: auto;
|
||||||
|
margin: 0.25rem -3px 0;
|
||||||
|
padding: 0.75rem 3px 3px;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,15 +380,16 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.375rem;
|
gap: 0.375rem;
|
||||||
padding: 0.4375rem 0.75rem;
|
min-height: 2.75rem;
|
||||||
background: none;
|
padding: 0 1rem;
|
||||||
border: 1px solid var(--border);
|
background: var(--bg-card);
|
||||||
border-radius: var(--radius-sm);
|
border: 1px solid var(--text-secondary);
|
||||||
|
border-radius: 999px;
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: var(--step--1);
|
font-size: var(--step--1);
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: var(--text-secondary);
|
color: var(--text-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
transition: border-color var(--transition), background-color var(--transition),
|
transition: border-color var(--transition), background-color var(--transition),
|
||||||
@@ -387,7 +418,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.clearButton {
|
.clearButton {
|
||||||
padding: 0.4375rem 0.875rem;
|
min-height: 2.75rem;
|
||||||
|
padding: 0 1rem;
|
||||||
font-size: var(--step--1);
|
font-size: var(--step--1);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
@@ -413,31 +445,6 @@
|
|||||||
/* ── Narrow ───────────────────────────────────────────────────────── */
|
/* ── Narrow ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
/*
|
|
||||||
* Scoped, like the two rules below it.
|
|
||||||
*
|
|
||||||
* The results filter bar is a card — background, border, shadow — and needs
|
|
||||||
* inner padding. The hero's search is not a card: .heroMode zeroes the
|
|
||||||
* padding, border and background so the search sits directly on the panel.
|
|
||||||
* Unscoped, this rule put 14px back, which indented the search box, the hint
|
|
||||||
* and the location link 14px past the headline they sit under, and cost the
|
|
||||||
* search field 28px of width on a 390px screen.
|
|
||||||
*/
|
|
||||||
.filterBar:not(.heroMode) {
|
|
||||||
padding: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The hero pill stays a pill at every width — stacking it would lose the
|
|
||||||
one shape the page is built around. Only the results bar stacks. */
|
|
||||||
.filterBar:not(.heroMode) .omniBoxContainer {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filterBar:not(.heroMode) .searchButton {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filters {
|
.filters {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -446,19 +453,6 @@
|
|||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controlsRow {
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controlsRow .advancedToggle {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controlSelect {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 140px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchHint {
|
.searchHint {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
@@ -518,3 +512,92 @@
|
|||||||
padding: 0.75rem 0.875rem;
|
padding: 0.75rem 0.875rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.filterBar:not(.heroMode) {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterBar:not(.heroMode) .searchSection {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchSummary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
padding: 0 0.875rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--step--1);
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryText {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryText strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryEdit {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchFolded {
|
||||||
|
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
|
||||||
|
block padding is room for focus rings, which the scroll clip would
|
||||||
|
otherwise cut off above and below the chips. */
|
||||||
|
.controlsRow {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: -4px -1rem;
|
||||||
|
padding: 4px 1rem;
|
||||||
|
scrollbar-width: none;
|
||||||
|
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlsAtEnd {
|
||||||
|
mask-image: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlsRow::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlsRow > * {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlsRow .advancedToggle {
|
||||||
|
order: -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,10 +128,11 @@ export function FilterBar({
|
|||||||
const currentAdmissionsPolicy = searchParams.get("admissions_policy") || "";
|
const currentAdmissionsPolicy = searchParams.get("admissions_policy") || "";
|
||||||
const currentHasSixthForm = searchParams.get("has_sixth_form") || "";
|
const currentHasSixthForm = searchParams.get("has_sixth_form") || "";
|
||||||
|
|
||||||
// Count active dropdown filters (not search/postcode, not phase since it's always visible)
|
// Count the filters behind "More filters". Search, postcode, distance, phase
|
||||||
|
// and school type each have their own control in the toolbar, so they are
|
||||||
|
// visible without opening anything and are not counted here.
|
||||||
const activeDropdownFilters = [
|
const activeDropdownFilters = [
|
||||||
currentLA,
|
currentLA,
|
||||||
currentType,
|
|
||||||
currentGender,
|
currentGender,
|
||||||
currentAdmissionsPolicy,
|
currentAdmissionsPolicy,
|
||||||
currentHasSixthForm,
|
currentHasSixthForm,
|
||||||
@@ -144,6 +145,50 @@ export function FilterBar({
|
|||||||
if (hasActiveDropdownFilters) setFiltersOpen(true);
|
if (hasActiveDropdownFilters) setFiltersOpen(true);
|
||||||
}, [hasActiveDropdownFilters]);
|
}, [hasActiveDropdownFilters]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* On phones the results toolbar is pinned, so it has to stay short. Once a
|
||||||
|
* search has been made the form folds into a one-line summary of it, and
|
||||||
|
* tapping the summary unfolds the form again. Desktop never folds: the
|
||||||
|
* summary is hidden there by CSS and the form is always shown.
|
||||||
|
*/
|
||||||
|
const currentQuery = currentPostcode || currentSearch;
|
||||||
|
const canFold = !isHero && !!currentQuery;
|
||||||
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
|
||||||
|
// A search that arrives any other way (back/forward, "Use my location", a
|
||||||
|
// link) must fold the form and show its own text, or the summary and the
|
||||||
|
// unfolded input would describe two different searches.
|
||||||
|
const [shownQuery, setShownQuery] = useState(currentQuery);
|
||||||
|
if (shownQuery !== currentQuery) {
|
||||||
|
setShownQuery(currentQuery);
|
||||||
|
setSearchOpen(false);
|
||||||
|
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").
|
||||||
|
const controlsRowRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [controlsAtEnd, setControlsAtEnd] = useState(false);
|
||||||
|
const updateControlsAtEnd = useCallback(() => {
|
||||||
|
const el = controlsRowRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const overflow = el.scrollWidth - el.clientWidth;
|
||||||
|
setControlsAtEnd(overflow <= 1 || el.scrollLeft >= overflow - 2);
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
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.
|
||||||
|
useEffect(updateControlsAtEnd);
|
||||||
|
const openSearch = () => {
|
||||||
|
setSearchOpen(true);
|
||||||
|
// After the form is displayed, or focus() lands on a hidden input.
|
||||||
|
requestAnimationFrame(() => inputRef.current?.focus());
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (
|
if (
|
||||||
@@ -186,6 +231,7 @@ export function FilterBar({
|
|||||||
// The search has been made; the suggestions that led to it are spent.
|
// The search has been made; the suggestions that led to it are spent.
|
||||||
setHasTyped(false);
|
setHasTyped(false);
|
||||||
close();
|
close();
|
||||||
|
setSearchOpen(false);
|
||||||
if (!omniValue.trim()) {
|
if (!omniValue.trim()) {
|
||||||
updateURL({ search: "", postcode: "", radius: "" });
|
updateURL({ search: "", postcode: "", radius: "" });
|
||||||
return;
|
return;
|
||||||
@@ -261,11 +307,43 @@ export function FilterBar({
|
|||||||
// only ever additive, so the control's behaviour is untouched.
|
// only ever additive, so the control's behaviour is untouched.
|
||||||
const activeIf = (value: string) => (value ? ` ${styles.selectActive}` : "");
|
const activeIf = (value: string) => (value ? ` ${styles.selectActive}` : "");
|
||||||
|
|
||||||
|
const radiusLabel = `${currentRadius} mile${currentRadius === "1" ? "" : "s"}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${styles.filterBar} ${isPending ? styles.isLoading : ""} ${isHero ? styles.heroMode : ""}`}
|
className={`${styles.filterBar} ${isPending ? styles.isLoading : ""} ${isHero ? styles.heroMode : ""}`}
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSearchSubmit} className={styles.searchSection}>
|
{canFold && !searchOpen && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.searchSummary}
|
||||||
|
onClick={openSearch}
|
||||||
|
aria-label={`Edit search: ${currentQuery}${currentPostcode ? `, within ${radiusLabel}` : ""}`}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="7" />
|
||||||
|
<path d="m20 20-3.5-3.5" />
|
||||||
|
</svg>
|
||||||
|
<span className={styles.summaryText}>
|
||||||
|
<strong>{currentQuery}</strong>
|
||||||
|
{currentPostcode && <span> · within {radiusLabel}</span>}
|
||||||
|
</span>
|
||||||
|
<span className={styles.summaryEdit} aria-hidden="true">Edit</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<form
|
||||||
|
onSubmit={handleSearchSubmit}
|
||||||
|
className={`${styles.searchSection}${canFold && !searchOpen ? ` ${styles.searchFolded}` : ""}`}
|
||||||
|
>
|
||||||
<div className={styles.omniBoxContainer}>
|
<div className={styles.omniBoxContainer}>
|
||||||
{isHero && (
|
{isHero && (
|
||||||
<span className={styles.omniIcon} aria-hidden="true">
|
<span className={styles.omniIcon} aria-hidden="true">
|
||||||
@@ -292,6 +370,7 @@ export function FilterBar({
|
|||||||
onKeyDown={handleOmniKeyDown}
|
onKeyDown={handleOmniKeyDown}
|
||||||
onBlur={close}
|
onBlur={close}
|
||||||
placeholder="School name or postcode"
|
placeholder="School name or postcode"
|
||||||
|
aria-label="School name or postcode"
|
||||||
className={styles.omniInput}
|
className={styles.omniInput}
|
||||||
{...(autosuggest ? {
|
{...(autosuggest ? {
|
||||||
role: "combobox",
|
role: "combobox",
|
||||||
@@ -370,24 +449,31 @@ export function FilterBar({
|
|||||||
|
|
||||||
{!isHero && (
|
{!isHero && (
|
||||||
<>
|
<>
|
||||||
<div className={styles.controlsRow}>
|
{/* 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}
|
||||||
|
>
|
||||||
{currentPostcode && (
|
{currentPostcode && (
|
||||||
<div className={styles.radiusControl}>
|
<SelectShell>
|
||||||
<label className={styles.radiusLabel}>Within:</label>
|
<select
|
||||||
<SelectShell>
|
value={currentRadius}
|
||||||
<select
|
onChange={(e) => updateURL({ radius: e.target.value })}
|
||||||
value={currentRadius}
|
className={`${styles.controlSelect} ${styles.selectActive}`}
|
||||||
onChange={(e) => updateURL({ radius: e.target.value })}
|
aria-label="Distance"
|
||||||
className={styles.controlSelect}
|
disabled={isPending}
|
||||||
disabled={isPending}
|
>
|
||||||
>
|
<option value="0.5">Within 0.5 miles</option>
|
||||||
<option value="0.5">0.5 miles</option>
|
<option value="1">Within 1 mile</option>
|
||||||
<option value="1">1 mile</option>
|
<option value="3">Within 3 miles</option>
|
||||||
<option value="3">3 miles</option>
|
<option value="5">Within 5 miles</option>
|
||||||
<option value="5">5 miles</option>
|
</select>
|
||||||
</select>
|
</SelectShell>
|
||||||
</SelectShell>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{phaseOptions.length > 0 && (
|
{phaseOptions.length > 0 && (
|
||||||
@@ -396,9 +482,10 @@ export function FilterBar({
|
|||||||
value={currentPhase}
|
value={currentPhase}
|
||||||
onChange={(e) => handleFilterChange("phase", e.target.value)}
|
onChange={(e) => handleFilterChange("phase", e.target.value)}
|
||||||
className={`${styles.controlSelect}${activeIf(currentPhase)}`}
|
className={`${styles.controlSelect}${activeIf(currentPhase)}`}
|
||||||
|
aria-label="Phase"
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
<option value="">All Phases</option>
|
<option value="">Any phase</option>
|
||||||
{phaseOptions.map((p) => (
|
{phaseOptions.map((p) => (
|
||||||
<option key={p} value={p.toLowerCase()}>
|
<option key={p} value={p.toLowerCase()}>
|
||||||
{p}
|
{p}
|
||||||
@@ -408,13 +495,47 @@ export function FilterBar({
|
|||||||
</SelectShell>
|
</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>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${styles.advancedToggle}${hasActiveDropdownFilters ? ` ${styles.advancedToggleActive}` : ''}`}
|
className={`${styles.advancedToggle}${hasActiveDropdownFilters ? ` ${styles.advancedToggleActive}` : ''}`}
|
||||||
onClick={() => setFiltersOpen((v) => !v)}
|
onClick={() => setFiltersOpen((v) => !v)}
|
||||||
aria-expanded={filtersOpen}
|
aria-expanded={filtersOpen}
|
||||||
>
|
>
|
||||||
{hasActiveDropdownFilters ? 'Filters' : 'Advanced'}
|
<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>
|
||||||
|
More filters
|
||||||
{hasActiveDropdownFilters
|
{hasActiveDropdownFilters
|
||||||
? ` (${activeDropdownFilters.length})`
|
? ` (${activeDropdownFilters.length})`
|
||||||
: ""}
|
: ""}
|
||||||
@@ -444,6 +565,7 @@ export function FilterBar({
|
|||||||
handleFilterChange("local_authority", e.target.value)
|
handleFilterChange("local_authority", e.target.value)
|
||||||
}
|
}
|
||||||
className={`${styles.filterSelect}${activeIf(currentLA)}`}
|
className={`${styles.filterSelect}${activeIf(currentLA)}`}
|
||||||
|
aria-label="Local authority"
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
<option value="">All Local Authorities</option>
|
<option value="">All Local Authorities</option>
|
||||||
@@ -455,24 +577,6 @@ export function FilterBar({
|
|||||||
</select>
|
</select>
|
||||||
</SelectShell>
|
</SelectShell>
|
||||||
|
|
||||||
<SelectShell wide>
|
|
||||||
<select
|
|
||||||
value={currentType}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleFilterChange("school_type", e.target.value)
|
|
||||||
}
|
|
||||||
className={`${styles.filterSelect}${activeIf(currentType)}`}
|
|
||||||
disabled={isPending}
|
|
||||||
>
|
|
||||||
<option value="">All School Types</option>
|
|
||||||
{typeOptions.map((type) => (
|
|
||||||
<option key={type} value={type}>
|
|
||||||
{type}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</SelectShell>
|
|
||||||
|
|
||||||
{isSecondaryMode && (
|
{isSecondaryMode && (
|
||||||
<>
|
<>
|
||||||
{genderOptions.length > 0 && (
|
{genderOptions.length > 0 && (
|
||||||
|
|||||||
@@ -484,42 +484,84 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* View Toggle */
|
/* ── Results toolbar ───────────────────────────────────────────────────────
|
||||||
.viewToggle {
|
Search, filters and the List/Map switch in one card, pinned under the
|
||||||
|
header. It sits above Leaflet's controls (z-index 1000), which scroll up
|
||||||
|
beneath it, and below the comparison toast and the map's own fullscreen
|
||||||
|
overlay. */
|
||||||
|
.resultsToolbar {
|
||||||
|
position: sticky;
|
||||||
|
top: calc(64px + 0.5rem);
|
||||||
|
z-index: 1001;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.2rem;
|
align-items: flex-start;
|
||||||
background: var(--bg-secondary);
|
gap: 0.75rem;
|
||||||
padding: 0.2rem;
|
margin-bottom: 1.25rem;
|
||||||
border-radius: 6px;
|
padding: 0.875rem 1rem;
|
||||||
}
|
|
||||||
|
|
||||||
.viewToggleBtn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
padding: 0.375rem 0.625rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 500;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
border-radius: 5px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.viewToggleBtn:hover {
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.viewToggleBtn.active {
|
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
color: var(--brand-strong);
|
border: 1px solid var(--border);
|
||||||
box-shadow: 0 2px 4px rgba(var(--shadow-rgb), 0.08);
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewToggleBtn svg {
|
.resultsToolbar > :first-child {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The one filled control in the toolbar: which view you are in is the first
|
||||||
|
thing a results page should answer. */
|
||||||
|
.viewSwitch {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 1px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1.5px solid var(--brand);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewSwitchBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-height: 2.5rem;
|
||||||
|
padding: 0 1.125rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: calc(var(--radius-md) - 3px);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--step--1);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--brand-strong);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--transition), color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewSwitchBtn:hover {
|
||||||
|
background: var(--brand-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewSwitchBtn[aria-pressed='true'] {
|
||||||
|
background: var(--brand);
|
||||||
|
color: var(--brand-on);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Phones: a full-bleed band flush under the header, and the switch gives way
|
||||||
|
to the floating button in .mobileDock. */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.resultsToolbar {
|
||||||
|
top: 56px;
|
||||||
|
margin: -1rem -1rem 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-width: 0 0 1px;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewSwitch {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.results {
|
.results {
|
||||||
@@ -744,11 +786,6 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewToggle {
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The sort <select> sizes to its widest option ("Highest Reading, Writing
|
/* The sort <select> sizes to its widest option ("Highest Reading, Writing
|
||||||
& Maths %"), which overflows a phone viewport — beside the view toggle it
|
& Maths %"), which overflows a phone viewport — beside the view toggle it
|
||||||
ran off the right edge. Let it flex into the remaining space and shrink;
|
ran off the right edge. Let it flex into the remaining space and shrink;
|
||||||
@@ -801,24 +838,38 @@
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile Bottom Sheet */
|
/* ── Mobile dock ───────────────────────────────────────────────────────────
|
||||||
.bottomSheetWrapper {
|
The floating List/Map button (phones only) above the selected pin's card.
|
||||||
|
A column, so the button always clears the card instead of covering it. */
|
||||||
|
.mobileDock {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewFab {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.bottomSheetWrapper {
|
.mobileDock {
|
||||||
display: block;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
z-index: 1000;
|
z-index: 1002;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobileDock > * {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.bottomSheet {
|
.bottomSheet {
|
||||||
|
align-self: stretch;
|
||||||
position: relative;
|
position: relative;
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@@ -883,6 +934,38 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Above the tab bar, and riding with it when iOS Chrome's URL bar hides
|
||||||
|
(MOBILE.md, check 4). */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.mobileDock {
|
||||||
|
bottom: calc(56px + env(safe-area-inset-bottom, 0px));
|
||||||
|
padding: 0.75rem;
|
||||||
|
transform: translate3d(0, var(--mobile-bar-offset, 0px), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewFab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-height: 3rem;
|
||||||
|
padding: 0 1.375rem;
|
||||||
|
background: var(--text-primary);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
box-shadow: 0 8px 20px rgba(var(--shadow-rgb), 0.32);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--step--1);
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Room under the last card so the floating button never sits on it. */
|
||||||
|
.hasViewFab {
|
||||||
|
padding-bottom: 4.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.quickSearches {
|
.quickSearches {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -135,6 +135,27 @@ function CalendarIcon() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* List / Map glyphs for the results view switch and its floating twin. */
|
||||||
|
function ListIcon() {
|
||||||
|
return (
|
||||||
|
<svg {...ICON_PROPS} width={18} height={18}>
|
||||||
|
<path d="M9 6h11M9 12h11M9 18h11" />
|
||||||
|
<circle cx="4.5" cy="6" r="1" />
|
||||||
|
<circle cx="4.5" cy="12" r="1" />
|
||||||
|
<circle cx="4.5" cy="18" r="1" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MapIcon() {
|
||||||
|
return (
|
||||||
|
<svg {...ICON_PROPS} width={18} height={18}>
|
||||||
|
<path d="M9 4 3 6v14l6-2 6 2 6-2V4l-6 2-6-2Z" />
|
||||||
|
<path d="M9 4v14M15 6v14" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface ValueProp {
|
interface ValueProp {
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
tintClass: string;
|
tintClass: string;
|
||||||
@@ -213,6 +234,8 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
const [mapSchools, setMapSchools] = useState<School[]>([]);
|
const [mapSchools, setMapSchools] = useState<School[]>([]);
|
||||||
const [isLoadingMap, setIsLoadingMap] = useState(false);
|
const [isLoadingMap, setIsLoadingMap] = useState(false);
|
||||||
const prevSearchParamsRef = useRef(searchParams.toString());
|
const prevSearchParamsRef = useRef(searchParams.toString());
|
||||||
|
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||||
|
const resultsRef = useRef<HTMLElement>(null);
|
||||||
const mapParamsRef = useRef<string>('');
|
const mapParamsRef = useRef<string>('');
|
||||||
const loadMoreController = useRef<AbortController | null>(null);
|
const loadMoreController = useRef<AbortController | null>(null);
|
||||||
// Identity changes even for A → B → A, so an old A response stays stale.
|
// Identity changes even for A → B → A, so an old A response stays stale.
|
||||||
@@ -449,6 +472,30 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
});
|
});
|
||||||
}, [addSchool, selectedSchools.length]);
|
}, [addSchool, selectedSchools.length]);
|
||||||
|
|
||||||
|
const hasViewSwitch = isLocationSearch && initialSchools.schools.length > 0;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Switching view from far down the list would otherwise leave the reader
|
||||||
|
* looking at whatever now sits at that scroll offset, usually the footer,
|
||||||
|
* because the map layout is much shorter than a long list. Bring the top of
|
||||||
|
* the results back to just under the pinned toolbar instead.
|
||||||
|
*/
|
||||||
|
const changeView = (view: 'list' | 'map', via: 'toolbar' | 'floating') => {
|
||||||
|
if (view === resultsView) return;
|
||||||
|
setResultsView(view);
|
||||||
|
track('results_view_changed', { view, via });
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const results = resultsRef.current;
|
||||||
|
const toolbar = toolbarRef.current;
|
||||||
|
if (!results || !toolbar) return;
|
||||||
|
const toolbarBottom = toolbar.getBoundingClientRect().bottom;
|
||||||
|
const resultsTop = results.getBoundingClientRect().top;
|
||||||
|
if (resultsTop < toolbarBottom) {
|
||||||
|
window.scrollTo({ top: window.scrollY + resultsTop - toolbarBottom - 12 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
/* The landing arrangement owns its own vertical rhythm (one gap, set in
|
/* The landing arrangement owns its own vertical rhythm (one gap, set in
|
||||||
CSS); the search arrangement is a filter bar directly above its results
|
CSS); the search arrangement is a filter bar directly above its results
|
||||||
@@ -523,15 +570,41 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<FilterBar
|
/* Pinned under the header, so the search, the filters and the List/Map
|
||||||
filters={filters}
|
switch stay in reach however far down the results a reader is. */
|
||||||
isHero={false}
|
<div ref={toolbarRef} className={styles.resultsToolbar}>
|
||||||
resultFilters={initialSchools.result_filters}
|
<FilterBar
|
||||||
onNearMe={handleNearMe}
|
filters={filters}
|
||||||
geoState={geoState}
|
isHero={false}
|
||||||
geoError={geoError}
|
resultFilters={initialSchools.result_filters}
|
||||||
autosuggest={autosuggest}
|
onNearMe={handleNearMe}
|
||||||
/>
|
geoState={geoState}
|
||||||
|
geoError={geoError}
|
||||||
|
autosuggest={autosuggest}
|
||||||
|
/>
|
||||||
|
{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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Next admissions deadline — one bar, not four cards. */}
|
{/* Next admissions deadline — one bar, not four cards. */}
|
||||||
@@ -583,7 +656,10 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
nothing to list — an empty <section> is still a flex child, so it was
|
nothing to list — an empty <section> is still a flex child, so it was
|
||||||
contributing a full section gap of blank space above the footer. */}
|
contributing a full section gap of blank space above the footer. */}
|
||||||
{(isSearchActive || initialSchools.schools.length > 0) && (
|
{(isSearchActive || initialSchools.schools.length > 0) && (
|
||||||
<section className={`${styles.results} ${resultsView === 'map' && isLocationSearch ? styles.mapViewResults : ''}`}>
|
<section
|
||||||
|
ref={resultsRef}
|
||||||
|
className={`${styles.results} ${resultsView === 'map' && isLocationSearch ? styles.mapViewResults : ''} ${hasViewSwitch ? styles.hasViewFab : ''}`}
|
||||||
|
>
|
||||||
{!hasSearch && initialSchools.schools.length > 0 && (
|
{!hasSearch && initialSchools.schools.length > 0 && (
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<h2>Featured Schools</h2>
|
<h2>Featured Schools</h2>
|
||||||
@@ -602,34 +678,6 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
}
|
}
|
||||||
</h2>
|
</h2>
|
||||||
<div className={styles.resultsHeaderActions}>
|
<div className={styles.resultsHeaderActions}>
|
||||||
{isLocationSearch && initialSchools.schools.length > 0 && (
|
|
||||||
<div className={styles.viewToggle}>
|
|
||||||
<button
|
|
||||||
className={`${styles.viewToggleBtn} ${resultsView === 'list' ? styles.active : ''}`}
|
|
||||||
onClick={() => setResultsView('list')}
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16">
|
|
||||||
<line x1="8" y1="6" x2="21" y2="6"/>
|
|
||||||
<line x1="8" y1="12" x2="21" y2="12"/>
|
|
||||||
<line x1="8" y1="18" x2="21" y2="18"/>
|
|
||||||
<line x1="3" y1="6" x2="3.01" y2="6"/>
|
|
||||||
<line x1="3" y1="12" x2="3.01" y2="12"/>
|
|
||||||
<line x1="3" y1="18" x2="3.01" y2="18"/>
|
|
||||||
</svg>
|
|
||||||
List
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`${styles.viewToggleBtn} ${resultsView === 'map' ? styles.active : ''}`}
|
|
||||||
onClick={() => setResultsView('map')}
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16">
|
|
||||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
|
|
||||||
<circle cx="12" cy="10" r="3"/>
|
|
||||||
</svg>
|
|
||||||
Map
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{resultsView === 'list' && (
|
{resultsView === 'list' && (
|
||||||
<select
|
<select
|
||||||
value={sortOrder}
|
value={sortOrder}
|
||||||
@@ -705,20 +753,6 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Bottom Sheet for Selected Map Pin */}
|
|
||||||
{selectedMapSchool && (
|
|
||||||
<div className={styles.bottomSheetWrapper}>
|
|
||||||
<div className={styles.bottomSheet}>
|
|
||||||
<button className={styles.closeSheetBtn} onClick={() => setSelectedMapSchool(null)}>×</button>
|
|
||||||
<CompactSchoolItem
|
|
||||||
school={selectedMapSchool}
|
|
||||||
onAddToCompare={addSchoolFromSearch}
|
|
||||||
isInCompare={selectedSchools.some(s => s.urn === selectedMapSchool.urn)}
|
|
||||||
nationalAvgRwm={nationalAvgRwm}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* List View Layout */
|
/* List View Layout */
|
||||||
@@ -769,6 +803,39 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Phones and small tablets: the selected pin's card, and on phones the
|
||||||
|
floating List/Map button, stacked above the bottom tab bar. The
|
||||||
|
toolbar's switch is hidden at phone width, where this replaces it. */}
|
||||||
|
{hasViewSwitch && (
|
||||||
|
<div className={styles.mobileDock}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.viewFab}
|
||||||
|
aria-label={resultsView === 'list' ? 'Show map' : 'Show list'}
|
||||||
|
onClick={() => changeView(resultsView === 'list' ? 'map' : 'list', 'floating')}
|
||||||
|
>
|
||||||
|
{resultsView === 'list' ? <><MapIcon />Map</> : <><ListIcon />List</>}
|
||||||
|
</button>
|
||||||
|
{resultsView === 'map' && selectedMapSchool && (
|
||||||
|
<div className={styles.bottomSheet}>
|
||||||
|
<button
|
||||||
|
className={styles.closeSheetBtn}
|
||||||
|
onClick={() => setSelectedMapSchool(null)}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<CompactSchoolItem
|
||||||
|
school={selectedMapSchool}
|
||||||
|
onAddToCompare={addSchoolFromSearch}
|
||||||
|
isInCompare={selectedSchools.some(s => s.urn === selectedMapSchool.urn)}
|
||||||
|
nationalAvgRwm={nationalAvgRwm}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ export type EventName =
|
|||||||
| 'compare_focus_school'
|
| 'compare_focus_school'
|
||||||
// Operational
|
// Operational
|
||||||
| 'api_error'
|
| 'api_error'
|
||||||
| 'results_load_more';
|
| 'results_load_more'
|
||||||
|
| 'results_view_changed';
|
||||||
|
|
||||||
type Primitive = string | number | boolean;
|
type Primitive = string | number | boolean;
|
||||||
type Payload = Record<string, Primitive>;
|
type Payload = Record<string, Primitive>;
|
||||||
|
|||||||
Reference in new issue
Block a user