feat(search): pin the results toolbar and float the map button on phones
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 33s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m16s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 26s
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 33s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m16s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 26s
The List/Map switch was a small grey control beside the results heading, and the filters were plain dropdowns labelled "All Phases" and "Advanced". Both scrolled away with the first result. Search, filters and the List/Map switch now share one card pinned under the header. Distance, phase and school type are pill controls in the row; "Advanced" becomes "More filters" and counts only what it hides. The switch is filled brand teal and says which view is on (aria-pressed). On phones the search folds to a one-line summary once made, the filter pills scroll sideways, and a floating Map/List button sits above the tab bar in place of the toolbar switch. The selected pin's card now stacks under that button instead of covering the tab bar. Switching view from far down the list scrolls back to the top of the results, and each switch is tracked as results_view_changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b34feb8e98
commit
983a581555
8 files changed
+716
-214
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 expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Switch to the map view, then open the map fullscreen.
|
||||
await page.getByRole('button', { name: 'Map', exact: true }).click();
|
||||
// Switch to the map view with the floating button (the toolbar's switch is
|
||||
// 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' });
|
||||
await expect(openFs).toBeVisible({ timeout: 15_000 });
|
||||
await openFs.click();
|
||||
@@ -516,6 +517,78 @@ test('results map fullscreen falls back to an overlay on iOS', async ({ page })
|
||||
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 }) => {
|
||||
// Two same-phase (pure primary) schools so both stay on one tab.
|
||||
const [urn0, urn1] = await twoPrimaryUrns(page);
|
||||
|
||||
Reference in new issue
Block a user