fix(search): address review on the results toolbar
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 9s
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 1m17s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 21s

- 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 <noreply@anthropic.com>
This commit is contained in:
TudorandClaude Opus 5.5 committed 2026-09-30 20:47:57 +01:00
1 parent 983a581555
commit 367a07c15d
3 files changed
+76 -13

No files matched your search

@@ -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(<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', () => { describe('the folded search', () => {
it('summarises the search and unfolds on tap', () => { it('summarises the search and unfolds on tap', () => {
render(<FilterBar filters={filters} />); render(<FilterBar filters={filters} />);
@@ -119,6 +127,22 @@ describe('the folded search', () => {
expect(screen.getByRole('button', { name: /Edit search/ })).toBeInTheDocument(); 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', () => { it('never appears in the hero, or before anything has been searched', () => {
const { unmount } = render(<FilterBar filters={filters} isHero />); const { unmount } = render(<FilterBar filters={filters} isHero />);
expect(screen.queryByRole('button', { name: /Edit search/ })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /Edit search/ })).not.toBeInTheDocument();
+16 -12
View File
@@ -359,13 +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%; flex-basis: 100%;
display: flex; display: flex;
gap: 0.625rem; gap: 0.625rem;
flex-wrap: wrap; flex-wrap: wrap;
margin-top: 0.25rem; 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);
} }
@@ -568,16 +573,22 @@
/* Bleeds to the screen edge so a chip scrolls out from under it, rather than /* 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 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 { .controlsRow {
flex-wrap: nowrap; flex-wrap: nowrap;
overflow-x: auto; overflow-x: auto;
margin-inline: -1rem; margin: -4px -1rem;
padding-inline: 1rem; padding: 4px 1rem;
scrollbar-width: none; scrollbar-width: none;
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
} }
.controlsAtEnd {
mask-image: none;
}
.controlsRow::-webkit-scrollbar { .controlsRow::-webkit-scrollbar {
display: none; display: none;
} }
@@ -589,11 +600,4 @@
.controlsRow .advancedToggle { .controlsRow .advancedToggle {
order: -1; 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;
}
} }
+36 -1
View File
@@ -154,6 +154,35 @@ export function FilterBar({
const currentQuery = currentPostcode || currentSearch; const currentQuery = currentPostcode || currentSearch;
const canFold = !isHero && !!currentQuery; const canFold = !isHero && !!currentQuery;
const [searchOpen, setSearchOpen] = useState(false); 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 = () => { const openSearch = () => {
setSearchOpen(true); setSearchOpen(true);
// After the form is displayed, or focus() lands on a hidden input. // 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 <select> or <button>, drawn as a {/* Every control here is a real <select> or <button>, drawn as a
pill. On phones the row scrolls sideways rather than wrapping, so pill. On phones the row scrolls sideways rather than wrapping, so
the pinned toolbar stays two lines tall. */} the pinned toolbar stays two lines tall. */}
<div className={styles.controlsRow} role="group" aria-label="Filters"> <div
ref={controlsRowRef}
className={`${styles.controlsRow}${controlsAtEnd ? ` ${styles.controlsAtEnd}` : ""}`}
role="group"
aria-label="Filters"
onScroll={updateControlsAtEnd}
>
{currentPostcode && ( {currentPostcode && (
<SelectShell> <SelectShell>
<select <select