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', () => {
|
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();
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user