feat(ui): consolidate search/filter area into cleaner 2-row layout
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m4s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s

Before: 7 visual rows (search, radius row, phase row, advanced toggle,
teal location banner, results count, filter chips).
After: 2 rows in card + 1 unified results toolbar.

- FilterBar: merge radius, phase, and Advanced toggle into a single
  .controlsRow below the search bar; removed orphaned stacked rows
- HomeView: remove separate teal location banner; merge location info
  into results heading ("16 schools within 1.0 miles of SW196AR");
  move List/Map toggle inline with sort in one results header row
- Remove "Near postcode (Xmi)" chip (now redundant with heading)
- Sort select hidden in map view (sorting is meaningless there)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-29 20:46:38 +01:00
parent 9c9528b51b
commit 5615458223
4 changed files with 132 additions and 139 deletions

View File

@@ -134,6 +134,19 @@
.filterSelect {
min-width: 100%;
}
.controlsRow {
gap: 0.5rem;
}
.controlsRow .advancedToggle {
margin-left: 0;
}
.controlSelect {
flex: 1;
min-width: 140px;
}
}
.radiusWrapper {
@@ -159,33 +172,44 @@
cursor: pointer;
}
/* ── Phase filter (always visible) ──────────────────── */
.phaseFilterRow {
/* ── Controls row (radius + phase + advanced toggle) ─── */
.controlsRow {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border-color, #e5dfd5);
}
.phaseSelect {
padding: 0.5rem 0.875rem;
font-size: 0.9rem;
.controlsRow .advancedToggle {
margin-left: auto;
}
.radiusControl {
display: flex;
align-items: center;
gap: 0.375rem;
flex-shrink: 0;
}
.controlSelect {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 6px;
background: var(--bg-card, white);
color: var(--text-primary, #1a1612);
cursor: pointer;
outline: none;
color: var(--text-primary, #1a1612);
min-width: 160px;
}
.phaseSelect:focus {
.controlSelect:focus {
border-color: var(--accent-coral, #e07256);
}
/* ── Advanced filters toggle ─────────────────────────── */
.advancedSection {
margin-top: 0.75rem;
border-top: 1px solid var(--border-color, #e5dfd5);
padding-top: 0.5rem;
}
.advancedToggle {
display: inline-flex;

View File

@@ -127,34 +127,34 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
{isPending ? <div className={styles.spinner}></div> : 'Search'}
</button>
</div>
{currentPostcode && (
<div className={styles.radiusWrapper}>
<label className={styles.radiusLabel}>Within:</label>
<select
value={currentRadius}
onChange={e => updateURL({ radius: e.target.value })}
className={styles.radiusSelect}
disabled={isPending}
>
<option value="0.5">0.5 miles</option>
<option value="1">1 mile</option>
<option value="3">3 miles</option>
<option value="5">5 miles</option>
<option value="10">10 miles</option>
</select>
</div>
)}
</form>
{!isHero && (
<>
{/* Phase filter — always visible on results page */}
{phaseOptions.length > 0 && (
<div className={styles.phaseFilterRow}>
<div className={styles.controlsRow}>
{currentPostcode && (
<div className={styles.radiusControl}>
<label className={styles.radiusLabel}>Within:</label>
<select
value={currentRadius}
onChange={e => updateURL({ radius: e.target.value })}
className={styles.controlSelect}
disabled={isPending}
>
<option value="0.5">0.5 miles</option>
<option value="1">1 mile</option>
<option value="3">3 miles</option>
<option value="5">5 miles</option>
<option value="10">10 miles</option>
</select>
</div>
)}
{phaseOptions.length > 0 && (
<select
value={currentPhase}
onChange={(e) => handleFilterChange('phase', e.target.value)}
className={styles.phaseSelect}
className={styles.controlSelect}
disabled={isPending}
>
<option value="">All Phases</option>
@@ -162,18 +162,23 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
<option key={p} value={p.toLowerCase()}>{p}</option>
))}
</select>
</div>
)}
)}
<div className={styles.advancedSection}>
<button
type="button"
className={styles.advancedToggle}
onClick={() => setFiltersOpen(v => !v)}
>
Advanced filters{hasActiveDropdownFilters ? ` (${activeDropdownFilters.length})` : ''}
<span className={filtersOpen ? styles.chevronUp : styles.chevronDown} />
</button>
<button
type="button"
className={styles.advancedToggle}
onClick={() => setFiltersOpen(v => !v)}
>
Advanced{hasActiveDropdownFilters ? ` (${activeDropdownFilters.length})` : ''}
<span className={filtersOpen ? styles.chevronUp : styles.chevronDown} />
</button>
{hasActiveFilters && (
<button onClick={handleClearFilters} className={`btn btn-tertiary ${styles.clearButton}`} type="button" disabled={isPending}>
Clear
</button>
)}
</div>
{filtersOpen && (
<div className={styles.filters}>
@@ -243,15 +248,8 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
)}
</>
)}
{hasActiveFilters && (
<button onClick={handleClearFilters} className={`btn btn-tertiary ${styles.clearButton}`} type="button" disabled={isPending}>
Clear Filters
</button>
)}
</div>
)}
</div>
</>
)}
</div>

View File

@@ -33,33 +33,6 @@
}
}
.locationBannerWrapper {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.locationBanner {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--accent-teal-bg);
border: 1px solid rgba(45, 125, 125, 0.25);
border-radius: 8px;
font-size: 0.875rem;
color: var(--accent-teal, #2d7d7d);
font-weight: 500;
}
.locationIcon {
font-size: 1.25rem;
color: var(--accent-teal, #2d7d7d);
}
/* View Toggle */
.viewToggle {
display: flex;
@@ -308,16 +281,14 @@
}
@media (max-width: 768px) {
.locationBannerWrapper {
.resultsHeader {
flex-direction: column;
align-items: stretch;
margin-bottom: 0.75rem;
align-items: flex-start;
}
.locationBanner {
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
border-radius: 6px;
.resultsHeaderActions {
width: 100%;
justify-content: space-between;
}
.viewToggle {
@@ -509,6 +480,13 @@
padding: 0 0 1rem;
}
.resultsHeaderActions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-shrink: 0;
}
.sortSelect {
padding: 0.375rem 0.75rem;
border: 1px solid var(--border-color, #e0ddd8);

View File

@@ -126,46 +126,6 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
</div>
)}
{/* Location Info Banner with View Toggle */}
{isLocationSearch && initialSchools.location_info && (
<div className={styles.locationBannerWrapper}>
<div className={styles.locationBanner}>
<span>
Showing schools within {(initialSchools.location_info.radius / 1.60934).toFixed(1)} miles of{' '}
<strong>{initialSchools.location_info.postcode}</strong>
</span>
</div>
{initialSchools.schools.length > 0 && (
<div className={styles.viewToggle}>
<button
className={`${styles.viewToggleBtn} ${resultsView === 'list' ? styles.active : ''}`}
onClick={() => setResultsView('list')}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16">
<line x1="8" y1="6" x2="21" y2="6"/>
<line x1="8" y1="12" x2="21" y2="12"/>
<line x1="8" y1="18" x2="21" y2="18"/>
<line x1="3" y1="6" x2="3.01" y2="6"/>
<line x1="3" y1="12" x2="3.01" y2="12"/>
<line x1="3" y1="18" x2="3.01" y2="18"/>
</svg>
List
</button>
<button
className={`${styles.viewToggleBtn} ${resultsView === 'map' ? styles.active : ''}`}
onClick={() => setResultsView('map')}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
Map
</button>
</div>
)}
</div>
)}
{/* Results Section */}
<section className={`${styles.results} ${resultsView === 'map' && isLocationSearch ? styles.mapViewResults : ''}`}>
{!hasSearch && initialSchools.schools.length > 0 && (
@@ -177,21 +137,55 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
</div>
)}
{hasSearch && resultsView === 'list' && (
{hasSearch && (
<div className={styles.resultsHeader}>
<h2 aria-live="polite" aria-atomic="true">
{initialSchools.total.toLocaleString()} school
{initialSchools.total !== 1 ? 's' : ''} found
{isLocationSearch && initialSchools.location_info
? `${initialSchools.total.toLocaleString()} school${initialSchools.total !== 1 ? 's' : ''} within ${(initialSchools.location_info.radius / 1.60934).toFixed(1)} miles of ${initialSchools.location_info.postcode}`
: `${initialSchools.total.toLocaleString()} school${initialSchools.total !== 1 ? 's' : ''} found`
}
</h2>
<select value={sortOrder} onChange={e => setSortOrder(e.target.value)} className={styles.sortSelect}>
<option value="default">Sort: Relevance</option>
{!isSecondaryView && <option value="rwm_desc">Highest R, W &amp; M %</option>}
{!isSecondaryView && <option value="rwm_asc">Lowest R, W &amp; M %</option>}
{isSecondaryView && <option value="att8_desc">Highest Attainment 8</option>}
{isSecondaryView && <option value="att8_asc">Lowest Attainment 8</option>}
{isLocationSearch && <option value="distance">Nearest first</option>}
<option value="name_asc">Name AZ</option>
</select>
<div className={styles.resultsHeaderActions}>
{isLocationSearch && initialSchools.schools.length > 0 && (
<div className={styles.viewToggle}>
<button
className={`${styles.viewToggleBtn} ${resultsView === 'list' ? styles.active : ''}`}
onClick={() => setResultsView('list')}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16">
<line x1="8" y1="6" x2="21" y2="6"/>
<line x1="8" y1="12" x2="21" y2="12"/>
<line x1="8" y1="18" x2="21" y2="18"/>
<line x1="3" y1="6" x2="3.01" y2="6"/>
<line x1="3" y1="12" x2="3.01" y2="12"/>
<line x1="3" y1="18" x2="3.01" y2="18"/>
</svg>
List
</button>
<button
className={`${styles.viewToggleBtn} ${resultsView === 'map' ? styles.active : ''}`}
onClick={() => setResultsView('map')}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
Map
</button>
</div>
)}
{resultsView === 'list' && (
<select value={sortOrder} onChange={e => setSortOrder(e.target.value)} className={styles.sortSelect}>
<option value="default">Sort: Relevance</option>
{!isSecondaryView && <option value="rwm_desc">Highest R, W &amp; M %</option>}
{!isSecondaryView && <option value="rwm_asc">Lowest R, W &amp; M %</option>}
{isSecondaryView && <option value="att8_desc">Highest Attainment 8</option>}
{isSecondaryView && <option value="att8_asc">Lowest Attainment 8</option>}
{isLocationSearch && <option value="distance">Nearest first</option>}
<option value="name_asc">Name AZ</option>
</select>
)}
</div>
</div>
)}
@@ -200,7 +194,6 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
{searchParams.get('search') && <span className={styles.filterChip}>Search: {searchParams.get('search')}<a href="/" className={styles.chipRemove} onClick={e => { e.preventDefault(); }}>×</a></span>}
{searchParams.get('local_authority') && <span className={styles.filterChip}>{searchParams.get('local_authority')}</span>}
{searchParams.get('school_type') && <span className={styles.filterChip}>{searchParams.get('school_type')}</span>}
{searchParams.get('postcode') && <span className={styles.filterChip}>Near {searchParams.get('postcode')} ({parseFloat(searchParams.get('radius') || '1')} mi)</span>}
</div>
)}