ui: harden a11y, plurals, NaN guards, map viewport persistence
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" / "<n>/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 <noreply@anthropic.com>
This commit is contained in:
parent
ebc58cd8a1
commit
fad834c20b
6 changed files with 58 additions and 38 deletions
|
|
@ -375,20 +375,25 @@ export function FilterPanel({ onSubmit, currentMetric, isLoading, listingCount,
|
||||||
{ value: FurnishType.FURNISHED, label: 'Furnished' },
|
{ value: FurnishType.FURNISHED, label: 'Furnished' },
|
||||||
{ value: FurnishType.PART_FURNISHED, label: 'Part' },
|
{ value: FurnishType.PART_FURNISHED, label: 'Part' },
|
||||||
{ value: FurnishType.UNFURNISHED, label: 'Unfurn.' },
|
{ value: FurnishType.UNFURNISHED, label: 'Unfurn.' },
|
||||||
].map((option) => (
|
].map((option) => {
|
||||||
<button
|
const isSelected = selectedFurnishTypes.includes(option.value);
|
||||||
key={option.value}
|
return (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => toggleFurnishType(option.value)}
|
key={option.value}
|
||||||
className={`px-2 py-1 text-xs rounded-md border transition-colors ${
|
type="button"
|
||||||
selectedFurnishTypes.includes(option.value)
|
onClick={() => toggleFurnishType(option.value)}
|
||||||
? 'bg-primary text-primary-foreground border-primary'
|
aria-pressed={isSelected}
|
||||||
: 'bg-background hover:bg-muted border-input'
|
data-state={isSelected ? 'on' : 'off'}
|
||||||
}`}
|
className={`px-2 py-1 text-xs rounded-md border transition-colors ${
|
||||||
>
|
isSelected
|
||||||
{option.label}
|
? 'bg-primary text-primary-foreground border-primary'
|
||||||
</button>
|
: 'bg-background hover:bg-muted border-input'
|
||||||
))}
|
}`}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -134,23 +134,31 @@ export function ListView({ listingData, onPropertyClick, highlightedPropertyUrl,
|
||||||
{/* Sort controls */}
|
{/* Sort controls */}
|
||||||
<div className="flex items-center gap-1 p-2 border-b overflow-x-auto">
|
<div className="flex items-center gap-1 p-2 border-b overflow-x-auto">
|
||||||
<span className="text-xs text-muted-foreground mr-1 shrink-0">Sort:</span>
|
<span className="text-xs text-muted-foreground mr-1 shrink-0">Sort:</span>
|
||||||
{sortOptions.map((option) => (
|
{sortOptions.map((option) => {
|
||||||
<Button
|
const isActive = sortConfig.field === option.field;
|
||||||
key={option.field}
|
const ariaSort: 'ascending' | 'descending' | 'none' = isActive
|
||||||
variant={sortConfig.field === option.field ? 'secondary' : 'ghost'}
|
? (sortConfig.order === 'asc' ? 'ascending' : 'descending')
|
||||||
size="sm"
|
: 'none';
|
||||||
className="h-7 px-2 text-xs shrink-0"
|
return (
|
||||||
onClick={() => handleSort(option.field)}
|
<Button
|
||||||
>
|
key={option.field}
|
||||||
{option.label}
|
variant={isActive ? 'secondary' : 'ghost'}
|
||||||
<SortIcon field={option.field} />
|
size="sm"
|
||||||
</Button>
|
className="h-7 px-2 text-xs shrink-0"
|
||||||
))}
|
onClick={() => handleSort(option.field)}
|
||||||
|
aria-pressed={isActive}
|
||||||
|
aria-sort={ariaSort}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
<SortIcon field={option.field} />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Listing count */}
|
{/* Listing count */}
|
||||||
<div className="px-3 py-2 text-sm text-muted-foreground border-b">
|
<div className="px-3 py-2 text-sm text-muted-foreground border-b tabular-nums">
|
||||||
Showing {sortedFeatures.length.toLocaleString()} properties
|
Showing {sortedFeatures.length.toLocaleString('en-GB')} {sortedFeatures.length === 1 ? 'property' : 'properties'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Property list */}
|
{/* Property list */}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ export function Map(props: MapProps) {
|
||||||
const updateTimeoutRef = useRef<number | null>(null);
|
const updateTimeoutRef = useRef<number | null>(null);
|
||||||
const isMapLoadedRef = useRef<boolean>(false);
|
const isMapLoadedRef = useRef<boolean>(false);
|
||||||
const lastDataLengthRef = useRef<number>(0);
|
const lastDataLengthRef = useRef<number>(0);
|
||||||
|
const hasFittedOnceRef = useRef<boolean>(false);
|
||||||
const poiMarkersRef = useRef<mapboxgl.Marker[]>([]);
|
const poiMarkersRef = useRef<mapboxgl.Marker[]>([]);
|
||||||
const isPickingPOIRef = useRef(props.isPickingPOI ?? false);
|
const isPickingPOIRef = useRef(props.isPickingPOI ?? false);
|
||||||
const onPoiLocationPickRef = useRef(props.onPoiLocationPick);
|
const onPoiLocationPickRef = useRef(props.onPoiLocationPick);
|
||||||
|
|
@ -103,8 +104,10 @@ export function Map(props: MapProps) {
|
||||||
|
|
||||||
heatmap.update();
|
heatmap.update();
|
||||||
|
|
||||||
// Fit bounds only on first load or significant data change
|
// Fit bounds ONLY on the very first load with data; preserve user pan/zoom forever after
|
||||||
if (lastDataLengthRef.current === 0 && data.features.length > 0) {
|
// (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({
|
const boundsResult = await heatmap.computeBounds({
|
||||||
clipMin: PERCENTILE_CONFIG.BOUNDS_CLIP_MIN,
|
clipMin: PERCENTILE_CONFIG.BOUNDS_CLIP_MIN,
|
||||||
clipMax: PERCENTILE_CONFIG.BOUNDS_CLIP_MAX,
|
clipMax: PERCENTILE_CONFIG.BOUNDS_CLIP_MAX,
|
||||||
|
|
@ -133,6 +136,7 @@ export function Map(props: MapProps) {
|
||||||
mapRef.current.on('load', function () {
|
mapRef.current.on('load', function () {
|
||||||
isMapLoadedRef.current = true;
|
isMapLoadedRef.current = true;
|
||||||
lastDataLengthRef.current = 0;
|
lastDataLengthRef.current = 0;
|
||||||
|
hasFittedOnceRef.current = false;
|
||||||
updateHeatmap();
|
updateHeatmap();
|
||||||
});
|
});
|
||||||
mapRef.current.on('click', function (e: mapboxgl.MapMouseEvent) {
|
mapRef.current.on('click', function (e: mapboxgl.MapMouseEvent) {
|
||||||
|
|
|
||||||
|
|
@ -78,23 +78,23 @@ export function StatsBar({
|
||||||
<div className="flex items-center gap-4 text-muted-foreground">
|
<div className="flex items-center gap-4 text-muted-foreground">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<MapPin className="h-4 w-4" />
|
<MapPin className="h-4 w-4" />
|
||||||
<span className="font-medium text-foreground">{stats.count.toLocaleString()}</span>
|
<span className="font-medium text-foreground tabular-nums">{stats.count.toLocaleString('en-GB')}</span>
|
||||||
<span className="hidden sm:inline">listings</span>
|
<span className="hidden sm:inline">{stats.count === 1 ? 'listing' : 'listings'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{stats.avgPrice > 0 && (
|
{stats.avgPrice > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="hidden md:flex items-center gap-1.5">
|
<div className="hidden md:flex items-center gap-1.5">
|
||||||
<PoundSterling className="h-4 w-4" />
|
<PoundSterling className="h-4 w-4" />
|
||||||
<span>Avg: <span className="font-medium text-foreground">{formatCurrency(stats.avgPrice)}</span></span>
|
<span>Avg price <span className="font-medium text-foreground tabular-nums">{formatCurrency(stats.avgPrice)}</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden lg:flex items-center gap-1.5">
|
<div className="hidden lg:flex items-center gap-1.5">
|
||||||
<BarChart3 className="h-4 w-4" />
|
<BarChart3 className="h-4 w-4" />
|
||||||
<span>Avg £/m²: <span className="font-medium text-foreground">{formatCurrency(stats.avgPricePerSqm)}</span></span>
|
<span><span className="font-medium text-foreground tabular-nums">{formatCurrency(stats.avgPricePerSqm)}</span>/m²</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden lg:flex items-center gap-1.5">
|
<div className="hidden lg:flex items-center gap-1.5">
|
||||||
<Maximize2 className="h-4 w-4" />
|
<Maximize2 className="h-4 w-4" />
|
||||||
<span>Avg: <span className="font-medium text-foreground">{Math.round(stats.avgSize)} m²</span></span>
|
<span>Size <span className="font-medium text-foreground tabular-nums">{Math.round(stats.avgSize)} m²</span></span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,8 @@ export function StreamingProgressBar({ progress, isLoading, onCancel }: Streamin
|
||||||
<div
|
<div
|
||||||
className="h-full bg-primary transition-all duration-300 ease-out rounded-full"
|
className="h-full bg-primary transition-all duration-300 ease-out rounded-full"
|
||||||
style={{
|
style={{
|
||||||
width: progress.total
|
width: progress.total && progress.total > 0
|
||||||
? `${Math.min((progress.count / progress.total) * 100, 100)}%`
|
? `${Math.min(Math.max((progress.count / progress.total) * 100, 0), 100)}%`
|
||||||
: '100%',
|
: '100%',
|
||||||
animation: progress.total ? undefined : 'pulse 1.5s ease-in-out infinite',
|
animation: progress.total ? undefined : 'pulse 1.5s ease-in-out infinite',
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,10 @@ export function TaskIndicator({
|
||||||
const activeTask = activeTaskId ? tasks[activeTaskId] : undefined;
|
const activeTask = activeTaskId ? tasks[activeTaskId] : undefined;
|
||||||
const taskStatus = activeTask ? (activeTask.status as TaskStatus) : null;
|
const taskStatus = activeTask ? (activeTask.status as TaskStatus) : null;
|
||||||
const taskResult = activeTask?.phase ? taskStateToResult(activeTask) : 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 processed = activeTask?.processed ?? null;
|
||||||
const total = activeTask?.total ?? null;
|
const total = activeTask?.total ?? null;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue