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