From 367a07c15d9f042ca70c5d6fc4240fea150f8e4f Mon Sep 17 00:00:00 2001 From: Tudor Date: Wed, 30 Sep 2026 20:47:57 +0100 Subject: [PATCH] fix(search): address review on the results toolbar - Refold the phone search, and resync its text, when the query changes without a submit (back/forward, "Use my location"). The summary and the unfolded input could otherwise describe two different searches. - Cap the "More filters" panel at every width, not only on phones, so an open panel scrolls inside the pinned toolbar on a short screen. - Drop the phone filter row's right-edge fade once it is scrolled to the end or does not overflow (the MOBILE.md pattern), and give focus rings room inside the scroll clip. Co-Authored-By: Claude Opus 5.5 --- .../components/ResultsToolbar.test.tsx | 24 ++++++++++++ nextjs-app/components/FilterBar.module.css | 28 ++++++++------ nextjs-app/components/FilterBar.tsx | 37 ++++++++++++++++++- 3 files changed, 76 insertions(+), 13 deletions(-) diff --git a/nextjs-app/__tests__/components/ResultsToolbar.test.tsx b/nextjs-app/__tests__/components/ResultsToolbar.test.tsx index 85ceb14..4b50b2a 100644 --- a/nextjs-app/__tests__/components/ResultsToolbar.test.tsx +++ b/nextjs-app/__tests__/components/ResultsToolbar.test.tsx @@ -102,6 +102,14 @@ describe('the toolbar filters', () => { }); }); +describe('the phone filter row', () => { + it('drops its "more this way" fade when nothing is left to scroll', () => { + render(); + // 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(); @@ -119,6 +127,22 @@ describe('the folded search', () => { 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(); + 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(); + + 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(); expect(screen.queryByRole('button', { name: /Edit search/ })).not.toBeInTheDocument(); diff --git a/nextjs-app/components/FilterBar.module.css b/nextjs-app/components/FilterBar.module.css index 45af2ec..0df34a8 100644 --- a/nextjs-app/components/FilterBar.module.css +++ b/nextjs-app/components/FilterBar.module.css @@ -359,13 +359,18 @@ 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 { flex-basis: 100%; display: flex; gap: 0.625rem; flex-wrap: wrap; - margin-top: 0.25rem; - padding-top: 0.75rem; + max-height: 50dvh; + overflow-y: auto; + margin: 0.25rem -3px 0; + padding: 0.75rem 3px 3px; border-top: 1px solid var(--border); } @@ -568,16 +573,22 @@ /* 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). */ + 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-inline: -1rem; - padding-inline: 1rem; + 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; } @@ -589,11 +600,4 @@ .controlsRow .advancedToggle { order: -1; } - - /* An open panel scrolls inside the pinned toolbar instead of pushing the - results off a short screen. */ - .filters { - max-height: 50dvh; - overflow-y: auto; - } } diff --git a/nextjs-app/components/FilterBar.tsx b/nextjs-app/components/FilterBar.tsx index 310292a..20f7dbf 100644 --- a/nextjs-app/components/FilterBar.tsx +++ b/nextjs-app/components/FilterBar.tsx @@ -154,6 +154,35 @@ export function FilterBar({ 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(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. @@ -423,7 +452,13 @@ export function FilterBar({ {/* Every control here is a real