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
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:
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', () => {
|
||||
it('summarises the search and unfolds on tap', () => {
|
||||
render(<FilterBar filters={filters} />);
|
||||
@@ -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(<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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<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.
|
||||
@@ -423,7 +452,13 @@ export function FilterBar({
|
||||
{/* 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 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 && (
|
||||
<SelectShell>
|
||||
<select
|
||||
|
||||
Reference in new issue
Block a user