PWA: vis dyplenket tilfluktsrom nederst i lista med badge (hybrid)

Speiler Android-fiksen i 1fb9f14/337e9b9. Når en dyplenke eller
markørtap velger et tilfluktsrom utenfor de N nærmeste, ble det tidligere
unshift-et inn på indeks 0 og framstod som nærmest — uten forklaring.

Nå:
- app.ts: push i stedet for unshift på begge steder, valgt indeks = siste;
  ny modultilstand outsideNearestRomnr og renderShelterList()-helper
- shelter-list.ts: updateList() tar outsideNearestRomnr, tegner badge på
  den raden, prefikser badge-teksten i aria-label (samme rekkefølge som
  contentDescription i ShelterListAdapter.kt) og scrollIntoView() til
  valgt rad (auto i stedet for smooth ved prefers-reduced-motion)
- main.css: .shelter-item-badge med #BF360C (= warning_bg på Android,
  ~5.6:1 mot hvit → WCAG 2.2 AA SC 1.4.3)
- i18n en/nb/nn: shelter_outside_nearest_badge

Verifisert i Chromium (Playwright): posisjon Oslo, dyplenke /shelter/1681
(Vardø) → tre nærmeste først, fjerde rad med badge, selected og
aria-label "Selected (outside nearest). Kaigaten 8 …, 1487.1 km …".

Forgejo: #17

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FrDGDmN2WAXvNPS85rWJGK
This commit is contained in:
Ole-Morten Duesund 2026-08-17 12:48:41 +02:00
commit 6a50d86266
7 changed files with 98 additions and 10 deletions

View file

@ -28,6 +28,11 @@ const NEAREST_COUNT = 3;
let allShelters: Shelter[] = [];
let nearestShelters: ShelterWithDistance[] = [];
let selectedShelterIndex = 0;
// romnr of a user-selected shelter that has been appended to the list because
// it is outside the geographic top-N (deep link, marker tap). null when the
// selection is a genuine top-N entry. Drives the "outside nearest" badge in
// the list — mirrors ShelterListItem.isOutsideNearest in the Android app.
let outsideNearestRomnr: number | null = null;
let currentLocation: LatLon | null = null;
let deviceHeading = 0;
let isCompassMode = false;
@ -248,6 +253,15 @@ function startLocationUpdates(): void {
);
}
/** Re-render the bottom-sheet list from current module state. */
function renderShelterList(): void {
shelterList.updateList(
nearestShelters,
selectedShelterIndex,
outsideNearestRomnr,
);
}
function updateNearestShelters(location: LatLon): void {
if (allShelters.length === 0) return;
@ -268,10 +282,14 @@ function updateNearestShelters(location: LatLon): void {
);
if (inList >= 0) {
selectedShelterIndex = inList;
outsideNearestRomnr = null;
} else {
const shelter = allShelters.find((s) => s.romnr === selectedRomnr);
if (shelter) {
nearestShelters.unshift({
// Append (not prepend) so the list keeps its sorted-by-distance
// invariant; the badge explains why this row is here. Same hybrid
// approach as MainActivity.rebuildShelterList() on Android.
nearestShelters.push({
shelter,
distanceMeters: distanceMeters(
location.latitude, location.longitude,
@ -282,20 +300,23 @@ function updateNearestShelters(location: LatLon): void {
shelter.latitude, shelter.longitude,
),
});
selectedShelterIndex = 0;
selectedShelterIndex = nearestShelters.length - 1;
outsideNearestRomnr = shelter.romnr;
} else {
// Selected shelter no longer exists in the dataset (e.g. DSB
// decommissioned it). Fall back to nearest.
selectedRomnr = null;
userSelectedShelter = false;
selectedShelterIndex = 0;
outsideNearestRomnr = null;
}
}
} else {
selectedShelterIndex = 0;
outsideNearestRomnr = null;
}
shelterList.updateList(nearestShelters, selectedShelterIndex);
renderShelterList();
updateSelectedShelter(false);
statusBar.setStatus(t('status_shelters_loaded', allShelters.length));
@ -337,7 +358,7 @@ function updateSelectedShelter(isUserAction: boolean): void {
compassView.setNorthAngle(-deviceHeading);
// Update shelter list selection
shelterList.updateList(nearestShelters, selectedShelterIndex);
renderShelterList();
// Update map: highlight selected and optionally fit view
if (isUserAction) {
@ -503,13 +524,15 @@ function selectShelterByData(shelter: Shelter): void {
)
: NaN;
nearestShelters.unshift({
// Append rather than prepend — see updateNearestShelters().
nearestShelters.push({
shelter,
distanceMeters: dist,
bearingDegrees: bearing,
});
selectedShelterIndex = 0;
shelterList.updateList(nearestShelters, selectedShelterIndex);
selectedShelterIndex = nearestShelters.length - 1;
outsideNearestRomnr = shelter.romnr;
renderShelterList();
}
updateSelectedShelter(true);

View file

@ -21,6 +21,7 @@ export const en: Record<string, string> = {
// Shelter info
shelter_capacity: '%d places',
shelter_room_nr: 'Room %d',
shelter_outside_nearest_badge: 'Selected (outside nearest)',
nearest_shelter: 'Nearest shelter',
no_shelters: 'No shelter data available',

View file

@ -18,6 +18,7 @@ export const nb: Record<string, string> = {
shelter_capacity: '%d plasser',
shelter_room_nr: 'Rom %d',
shelter_outside_nearest_badge: 'Valgt utenfor nærområdet',
nearest_shelter: 'Nærmeste tilfluktsrom',
no_shelters: 'Ingen tilfluktsromdata tilgjengelig',

View file

@ -18,6 +18,7 @@ export const nn: Record<string, string> = {
shelter_capacity: '%d plassar',
shelter_room_nr: 'Rom %d',
shelter_outside_nearest_badge: 'Vald utanfor nærområdet',
nearest_shelter: 'Næraste tilfluktsrom',
no_shelters: 'Ingen tilfluktsromdata tilgjengeleg',

View file

@ -355,6 +355,24 @@ html, body {
margin-top: 2px;
}
/*
* "Selected (outside nearest)" badge on a row appended below the top-N.
* #BF360C = warning_bg in the Android colors.xml: ~5.6:1 against white,
* clearing WCAG 2.2 AA SC 1.4.3 (4.5:1) for this small bold text. The plain
* shelter orange (#FF6B35) only reaches ~2.8:1 and must not be used here.
*/
.shelter-item-badge {
align-self: flex-start;
background: #BF360C;
color: #FFFFFF;
font-size: 11px;
font-weight: 700;
line-height: 1.4;
padding: 1px 8px;
border-radius: 4px;
margin-bottom: 4px;
}
/* --- Loading overlay --- */
#loading-overlay {
display: none;

View file

@ -20,10 +20,19 @@ export function initShelterList(
onSelect = onShelterSelect;
}
/** Render the list of nearest shelters using safe DOM methods. */
/**
* Render the list of nearest shelters using safe DOM methods.
*
* @param outsideNearestRomnr romnr of a row that was appended because the
* user selected it (deep link / marker tap) even though it is outside the
* geographic top-N. That row gets a visible badge and a screen-reader
* prefix so the list's sort-by-distance invariant is explained rather than
* silently broken. null when every row is a genuine top-N entry.
*/
export function updateList(
shelters: ShelterWithDistance[],
currentSelectedIndex: number,
outsideNearestRomnr: number | null = null,
): void {
if (!container) return;
selectedIndex = currentSelectedIndex;
@ -46,7 +55,26 @@ export function updateList(
t('shelter_room_nr', swd.shelter.romnr),
].join(' \u00B7 ');
item.setAttribute('aria-label', `${swd.shelter.adresse}, ${details}`);
const isOutsideNearest =
outsideNearestRomnr !== null && swd.shelter.romnr === outsideNearestRomnr;
// Prefix (not suffix) the badge text so screen-reader users hear the
// caveat before the address — same order as contentDescription in
// ShelterListAdapter.kt on Android.
const baseLabel = `${swd.shelter.adresse}, ${details}`;
item.setAttribute(
'aria-label',
isOutsideNearest
? `${t('shelter_outside_nearest_badge')}. ${baseLabel}`
: baseLabel,
);
if (isOutsideNearest) {
const badge = document.createElement('span');
badge.className = 'shelter-item-badge';
badge.textContent = t('shelter_outside_nearest_badge');
item.appendChild(badge);
}
const addressSpan = document.createElement('span');
addressSpan.className = 'shelter-item-address';
@ -64,4 +92,20 @@ export function updateList(
});
container!.appendChild(item);
});
// Keep the selected row visible — matters when the selection was appended
// below the fold, or when the user scrolled the list away from it.
const selectedEl = container.children[selectedIndex] as HTMLElement | undefined;
selectedEl?.scrollIntoView?.({
block: 'nearest',
behavior: prefersReducedMotion() ? 'auto' : 'smooth',
});
}
function prefersReducedMotion(): boolean {
return (
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
}