all is well
This commit is contained in:
parent
eac1bd0d13
commit
2f149503bb
53 changed files with 1543 additions and 354 deletions
|
|
@ -206,7 +206,6 @@ export function useHexagonSelection({
|
|||
const params = new URLSearchParams({
|
||||
h3,
|
||||
resolution: res.toString(),
|
||||
limit: '100',
|
||||
offset: offset.toString(),
|
||||
});
|
||||
|
||||
|
|
@ -250,7 +249,6 @@ export function useHexagonSelection({
|
|||
try {
|
||||
const params = new URLSearchParams({
|
||||
postcode,
|
||||
limit: '100',
|
||||
offset: offset.toString(),
|
||||
});
|
||||
if (focusAddress && offset === 0) {
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ export function useLocationSearch(mode?: string) {
|
|||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
try {
|
||||
const params = new URLSearchParams({ q: trimmed, limit: '20' });
|
||||
const params = new URLSearchParams({ q: trimmed });
|
||||
if (mode) params.set('mode', mode);
|
||||
const res = await fetch(
|
||||
`/api/places?${params}`,
|
||||
|
|
|
|||
59
frontend/src/hooks/useNearbyStations.ts
Normal file
59
frontend/src/hooks/useNearbyStations.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { NearbyStation, GeoPoint } from '../lib/nearby-stations';
|
||||
import {
|
||||
STATION_CATEGORIES,
|
||||
selectNearbyStations,
|
||||
stationSearchBounds,
|
||||
} from '../lib/nearby-stations';
|
||||
import type { POIResponse } from '../types';
|
||||
import { apiUrl, assertOk, authHeaders, logNonAbortError } from '../lib/api';
|
||||
|
||||
function boundsParam(bounds: ReturnType<typeof stationSearchBounds>): string {
|
||||
return `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
||||
}
|
||||
|
||||
export function useNearbyStations(origin: GeoPoint | null) {
|
||||
const [stations, setStations] = useState<NearbyStation[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const lat = origin?.lat;
|
||||
const lon = origin?.lon;
|
||||
|
||||
useEffect(() => {
|
||||
if (lat == null || lon == null) {
|
||||
setStations([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setStations([]);
|
||||
setLoading(true);
|
||||
|
||||
const originPoint = { lat, lon };
|
||||
const bounds = stationSearchBounds(originPoint);
|
||||
const params = new URLSearchParams({
|
||||
bounds: boundsParam(bounds),
|
||||
categories: STATION_CATEGORIES.join(','),
|
||||
});
|
||||
|
||||
fetch(apiUrl('pois', params), authHeaders({ signal: controller.signal }))
|
||||
.then((response) => {
|
||||
assertOk(response, 'nearby stations');
|
||||
return response.json() as Promise<POIResponse>;
|
||||
})
|
||||
.then((json) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setStations(selectNearbyStations(json.pois ?? [], originPoint));
|
||||
}
|
||||
})
|
||||
.catch((error) => logNonAbortError('Failed to fetch nearby stations', error))
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [lat, lon]);
|
||||
|
||||
return { stations, loading };
|
||||
}
|
||||
|
|
@ -44,7 +44,7 @@ function getPoiIconUrlForPoi(poi: POI): string {
|
|||
}
|
||||
|
||||
function isBundledPoiIcon(url: string): boolean {
|
||||
return url.startsWith('/assets/poi-icons/');
|
||||
return url.startsWith('/assets/poi-icons/') || url.startsWith('data:image/svg+xml');
|
||||
}
|
||||
|
||||
function hasBundledPoiLogo(poi: POI): boolean {
|
||||
|
|
|
|||
80
frontend/src/hooks/useRetainedScrollTop.test.tsx
Normal file
80
frontend/src/hooks/useRetainedScrollTop.test.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { cleanup, fireEvent, render } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import type { MutableRefObject } from 'react';
|
||||
|
||||
import { useRetainedScrollTop } from './useRetainedScrollTop';
|
||||
|
||||
function ScrollPane({
|
||||
restoreKey,
|
||||
savedScrollTopRef,
|
||||
suspendSave,
|
||||
}: {
|
||||
restoreKey: string;
|
||||
savedScrollTopRef: MutableRefObject<number>;
|
||||
suspendSave: boolean;
|
||||
}) {
|
||||
const { scrollRef, onScroll } = useRetainedScrollTop<HTMLDivElement>({
|
||||
restoreKey,
|
||||
scrollTopRef: savedScrollTopRef,
|
||||
suspendSave,
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} onScroll={onScroll} data-testid="pane">
|
||||
<div style={{ height: 2000 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useRetainedScrollTop', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('keeps the saved scroll offset while replacement content is loading', () => {
|
||||
const savedScrollTopRef = { current: 0 };
|
||||
const view = render(
|
||||
<ScrollPane
|
||||
restoreKey="area:a"
|
||||
savedScrollTopRef={savedScrollTopRef}
|
||||
suspendSave={false}
|
||||
/>
|
||||
);
|
||||
const pane = view.getByTestId('pane');
|
||||
|
||||
pane.scrollTop = 360;
|
||||
fireEvent.scroll(pane);
|
||||
expect(savedScrollTopRef.current).toBe(360);
|
||||
|
||||
view.rerender(
|
||||
<ScrollPane restoreKey="area:b" savedScrollTopRef={savedScrollTopRef} suspendSave />
|
||||
);
|
||||
|
||||
pane.scrollTop = 0;
|
||||
fireEvent.scroll(pane);
|
||||
expect(savedScrollTopRef.current).toBe(360);
|
||||
|
||||
view.rerender(
|
||||
<ScrollPane
|
||||
restoreKey="area:b"
|
||||
savedScrollTopRef={savedScrollTopRef}
|
||||
suspendSave={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(pane.scrollTop).toBe(360);
|
||||
});
|
||||
|
||||
it('restores the saved offset when a pane remounts', () => {
|
||||
const savedScrollTopRef = { current: 220 };
|
||||
const view = render(
|
||||
<ScrollPane
|
||||
restoreKey="area:a"
|
||||
savedScrollTopRef={savedScrollTopRef}
|
||||
suspendSave={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(view.getByTestId('pane').scrollTop).toBe(220);
|
||||
});
|
||||
});
|
||||
63
frontend/src/hooks/useRetainedScrollTop.ts
Normal file
63
frontend/src/hooks/useRetainedScrollTop.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { useCallback, useLayoutEffect, useRef, type MutableRefObject, type UIEvent } from 'react';
|
||||
|
||||
interface UseRetainedScrollTopOptions {
|
||||
restoreKey: string | null;
|
||||
scrollTopRef?: MutableRefObject<number>;
|
||||
suspendSave?: boolean;
|
||||
}
|
||||
|
||||
export function useRetainedScrollTop<T extends HTMLElement>({
|
||||
restoreKey,
|
||||
scrollTopRef,
|
||||
suspendSave = false,
|
||||
}: UseRetainedScrollTopOptions) {
|
||||
const fallbackScrollTopRef = useRef(0);
|
||||
const savedScrollTopRef = scrollTopRef ?? fallbackScrollTopRef;
|
||||
const nodeRef = useRef<T | null>(null);
|
||||
const previousRestoreKeyRef = useRef(restoreKey);
|
||||
const pendingRestoreTopRef = useRef<number | null>(null);
|
||||
const suspendSaveRef = useRef(suspendSave);
|
||||
|
||||
suspendSaveRef.current = suspendSave;
|
||||
|
||||
const scrollRef = useCallback(
|
||||
(node: T | null) => {
|
||||
const previousNode = nodeRef.current;
|
||||
if (!node && previousNode && !suspendSaveRef.current) {
|
||||
savedScrollTopRef.current = previousNode.scrollTop;
|
||||
}
|
||||
|
||||
nodeRef.current = node;
|
||||
if (node) {
|
||||
node.scrollTop = pendingRestoreTopRef.current ?? savedScrollTopRef.current;
|
||||
}
|
||||
},
|
||||
[savedScrollTopRef]
|
||||
);
|
||||
|
||||
const onScroll = useCallback(
|
||||
(event: UIEvent<T>) => {
|
||||
if (suspendSaveRef.current) return;
|
||||
savedScrollTopRef.current = event.currentTarget.scrollTop;
|
||||
},
|
||||
[savedScrollTopRef]
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (previousRestoreKeyRef.current !== restoreKey) {
|
||||
previousRestoreKeyRef.current = restoreKey;
|
||||
pendingRestoreTopRef.current = savedScrollTopRef.current;
|
||||
}
|
||||
|
||||
const node = nodeRef.current;
|
||||
const pendingRestoreTop = pendingRestoreTopRef.current;
|
||||
if (!node || pendingRestoreTop == null) return;
|
||||
|
||||
node.scrollTop = pendingRestoreTop;
|
||||
if (!suspendSave) {
|
||||
pendingRestoreTopRef.current = null;
|
||||
}
|
||||
}, [restoreKey, savedScrollTopRef, suspendSave]);
|
||||
|
||||
return { scrollRef, onScroll };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue