From fad834c20ba5437a20335beab62ca724b012a025 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sun, 10 May 2026 18:52:49 +0000 Subject: [PATCH] ui: harden a11y, plurals, NaN guards, map viewport persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surgical UX/a11y fixes layered on top of the team's UI redesign, keeping the teal palette and FilterBar architecture intact. - TaskIndicator: NaN-safe progressPercentage (Number.isFinite + clamp 0..100). No more "NaN%" when a task posts undefined progress. - StreamingProgressBar: same NaN guard on the inline width calc. - StatsBar: pluralize listings ("1 listing" / "30 listings"), drop the duplicated "Avg:" label (now "Avg price" / "/m²" / "Size"), tabular-nums on every numeric. - FilterPanel furnishing pills: aria-pressed + data-state for screen readers and tests. - ListView sort buttons: aria-pressed + aria-sort (ascending/descending/none); listing count pluralizes ("1 property" / "N properties") with tabular-nums. - Map: only fitBounds the FIRST time data loads (hasFittedOnceRef). The previous lastDataLengthRef-based gate refit when results went N → 0 → M, blowing away the user's pan/zoom. Verified: tsc --noEmit clean, 125/125 vitest specs pass. Co-Authored-By: Claude Opus 4.7 --- frontend/src/components/FilterPanel.tsx | 33 +++++++++-------- frontend/src/components/ListView.tsx | 36 +++++++++++-------- frontend/src/components/Map.tsx | 8 +++-- frontend/src/components/StatsBar.tsx | 10 +++--- .../src/components/StreamingProgressBar.tsx | 4 +-- frontend/src/components/TaskIndicator.tsx | 5 ++- 6 files changed, 58 insertions(+), 38 deletions(-) diff --git a/frontend/src/components/FilterPanel.tsx b/frontend/src/components/FilterPanel.tsx index 111f992..b7d922f 100644 --- a/frontend/src/components/FilterPanel.tsx +++ b/frontend/src/components/FilterPanel.tsx @@ -375,20 +375,25 @@ export function FilterPanel({ onSubmit, currentMetric, isLoading, listingCount, { value: FurnishType.FURNISHED, label: 'Furnished' }, { value: FurnishType.PART_FURNISHED, label: 'Part' }, { value: FurnishType.UNFURNISHED, label: 'Unfurn.' }, - ].map((option) => ( - - ))} + ].map((option) => { + const isSelected = selectedFurnishTypes.includes(option.value); + return ( + + ); + })} )} diff --git a/frontend/src/components/ListView.tsx b/frontend/src/components/ListView.tsx index 344fbff..bc2c29c 100644 --- a/frontend/src/components/ListView.tsx +++ b/frontend/src/components/ListView.tsx @@ -134,23 +134,31 @@ export function ListView({ listingData, onPropertyClick, highlightedPropertyUrl, {/* Sort controls */}
Sort: - {sortOptions.map((option) => ( - - ))} + {sortOptions.map((option) => { + const isActive = sortConfig.field === option.field; + const ariaSort: 'ascending' | 'descending' | 'none' = isActive + ? (sortConfig.order === 'asc' ? 'ascending' : 'descending') + : 'none'; + return ( + + ); + })}
{/* Listing count */} -
- Showing {sortedFeatures.length.toLocaleString()} properties +
+ Showing {sortedFeatures.length.toLocaleString('en-GB')} {sortedFeatures.length === 1 ? 'property' : 'properties'}
{/* Property list */} diff --git a/frontend/src/components/Map.tsx b/frontend/src/components/Map.tsx index 5afec72..3cb2adb 100644 --- a/frontend/src/components/Map.tsx +++ b/frontend/src/components/Map.tsx @@ -42,6 +42,7 @@ export function Map(props: MapProps) { const updateTimeoutRef = useRef(null); const isMapLoadedRef = useRef(false); const lastDataLengthRef = useRef(0); + const hasFittedOnceRef = useRef(false); const poiMarkersRef = useRef([]); const isPickingPOIRef = useRef(props.isPickingPOI ?? false); const onPoiLocationPickRef = useRef(props.onPoiLocationPick); @@ -103,8 +104,10 @@ export function Map(props: MapProps) { heatmap.update(); - // Fit bounds only on first load or significant data change - if (lastDataLengthRef.current === 0 && data.features.length > 0) { + // Fit bounds ONLY on the very first load with data; preserve user pan/zoom forever after + // (without this, going from N → 0 → M results re-fits because lastDataLengthRef resets to 0) + if (!hasFittedOnceRef.current && data.features.length > 0) { + hasFittedOnceRef.current = true; const boundsResult = await heatmap.computeBounds({ clipMin: PERCENTILE_CONFIG.BOUNDS_CLIP_MIN, clipMax: PERCENTILE_CONFIG.BOUNDS_CLIP_MAX, @@ -133,6 +136,7 @@ export function Map(props: MapProps) { mapRef.current.on('load', function () { isMapLoadedRef.current = true; lastDataLengthRef.current = 0; + hasFittedOnceRef.current = false; updateHeatmap(); }); mapRef.current.on('click', function (e: mapboxgl.MapMouseEvent) { diff --git a/frontend/src/components/StatsBar.tsx b/frontend/src/components/StatsBar.tsx index 1c14211..e74f848 100644 --- a/frontend/src/components/StatsBar.tsx +++ b/frontend/src/components/StatsBar.tsx @@ -78,23 +78,23 @@ export function StatsBar({
- {stats.count.toLocaleString()} - listings + {stats.count.toLocaleString('en-GB')} + {stats.count === 1 ? 'listing' : 'listings'}
{stats.avgPrice > 0 && ( <>
- Avg: {formatCurrency(stats.avgPrice)} + Avg price {formatCurrency(stats.avgPrice)}
- Avg £/m²: {formatCurrency(stats.avgPricePerSqm)} + {formatCurrency(stats.avgPricePerSqm)}/m²
- Avg: {Math.round(stats.avgSize)} m² + Size {Math.round(stats.avgSize)} m²
)} diff --git a/frontend/src/components/StreamingProgressBar.tsx b/frontend/src/components/StreamingProgressBar.tsx index 74da47c..d3ac4ef 100644 --- a/frontend/src/components/StreamingProgressBar.tsx +++ b/frontend/src/components/StreamingProgressBar.tsx @@ -33,8 +33,8 @@ export function StreamingProgressBar({ progress, isLoading, onCancel }: Streamin
0 + ? `${Math.min(Math.max((progress.count / progress.total) * 100, 0), 100)}%` : '100%', animation: progress.total ? undefined : 'pulse 1.5s ease-in-out infinite', }} diff --git a/frontend/src/components/TaskIndicator.tsx b/frontend/src/components/TaskIndicator.tsx index b5afead..0f6f960 100644 --- a/frontend/src/components/TaskIndicator.tsx +++ b/frontend/src/components/TaskIndicator.tsx @@ -63,7 +63,10 @@ export function TaskIndicator({ const activeTask = activeTaskId ? tasks[activeTaskId] : undefined; const taskStatus = activeTask ? (activeTask.status as TaskStatus) : null; const taskResult = activeTask?.phase ? taskStateToResult(activeTask) : null; - const progressPercentage = (activeTask?.progress ?? 0) * 100; + const rawProgress = activeTask?.progress ?? 0; + const progressPercentage = Number.isFinite(rawProgress) + ? Math.min(Math.max(rawProgress * 100, 0), 100) + : 0; const processed = activeTask?.processed ?? null; const total = activeTask?.total ?? null;