+
+
+
+
-
-
+
-
+
-
-
-
+
+
+
-
-
+
+
+
diff --git a/pwa/src/app.ts b/pwa/src/app.ts
index 4bfd68c..5f4d6a9 100644
--- a/pwa/src/app.ts
+++ b/pwa/src/app.ts
@@ -36,6 +36,7 @@ let firstLocationFix = true;
let userSelectedShelter = false;
export async function init(): Promise {
+ applyA11yLabels();
setupMap();
setupCompass();
setupShelterList();
@@ -43,6 +44,16 @@ export async function init(): Promise {
await loadData();
}
+/** Set localized aria-labels on landmark elements. */
+function applyA11yLabels(): void {
+ document.getElementById('map-container')?.setAttribute('aria-label', t('a11y_map'));
+ document.getElementById('compass-container')?.setAttribute('aria-label', t('a11y_compass'));
+ document.getElementById('bottom-sheet')?.setAttribute('aria-label', t('a11y_shelter_info'));
+ document.getElementById('shelter-list')?.setAttribute('aria-label', t('a11y_nearest_shelters'));
+ document.getElementById('refresh-btn')?.setAttribute('aria-label', t('action_refresh'));
+ document.getElementById('toggle-fab')?.setAttribute('aria-label', t('action_toggle_view'));
+}
+
function setupMap(): void {
const container = document.getElementById('map-container')!;
mapView.initMap(container, (shelter: Shelter) => {
@@ -268,6 +279,7 @@ function updateSelectedShelter(isUserAction: boolean): void {
document.getElementById('compass-address')!.textContent =
selected.shelter.adresse;
compassView.setDirection(selected.bearingDegrees - deviceHeading);
+ compassView.setNorthAngle(-deviceHeading);
// Update shelter list selection
shelterList.updateList(nearestShelters, selectedShelterIndex);
@@ -290,6 +302,7 @@ function onHeadingUpdate(heading: number): void {
const angle = selected.bearingDegrees - heading;
compassView.setDirection(angle);
+ compassView.setNorthAngle(-heading);
updateMiniArrow(angle);
}
diff --git a/pwa/src/i18n/en.ts b/pwa/src/i18n/en.ts
index 0f3fc1f..506e718 100644
--- a/pwa/src/i18n/en.ts
+++ b/pwa/src/i18n/en.ts
@@ -49,4 +49,8 @@ export const en: Record = {
// Accessibility
direction_arrow_description: 'Direction to shelter, %s away',
+ a11y_map: 'Map',
+ a11y_compass: 'Compass',
+ a11y_shelter_info: 'Shelter info',
+ a11y_nearest_shelters: 'Nearest shelters',
};
diff --git a/pwa/src/i18n/i18n.ts b/pwa/src/i18n/i18n.ts
index 129c53d..8d0376b 100644
--- a/pwa/src/i18n/i18n.ts
+++ b/pwa/src/i18n/i18n.ts
@@ -11,21 +11,22 @@ const locales: Record> = { en, nb, nn };
let currentLocale = 'en';
-/** Detect and set locale from browser preferences. */
+/** Detect and set locale from browser preferences, update document lang. */
export function initLocale(): void {
const langs = navigator.languages ?? [navigator.language];
for (const lang of langs) {
const code = lang.toLowerCase().split('-')[0];
if (code in locales) {
currentLocale = code;
- return;
+ break;
}
// nb and nn both start with "n" — also match "no" as Bokmål
if (code === 'no') {
currentLocale = 'nb';
- return;
+ break;
}
}
+ document.documentElement.lang = currentLocale;
}
/** Get current locale code. */
diff --git a/pwa/src/i18n/nb.ts b/pwa/src/i18n/nb.ts
index 9090128..8506849 100644
--- a/pwa/src/i18n/nb.ts
+++ b/pwa/src/i18n/nb.ts
@@ -44,4 +44,8 @@ export const nb: Record = {
// Tilgjengelighet
direction_arrow_description: 'Retning til tilfluktsrom, %s unna',
+ a11y_map: 'Kart',
+ a11y_compass: 'Kompass',
+ a11y_shelter_info: 'Tilfluktsrominfo',
+ a11y_nearest_shelters: 'Nærmeste tilfluktsrom',
};
diff --git a/pwa/src/i18n/nn.ts b/pwa/src/i18n/nn.ts
index 39e0a5d..2ecd5e2 100644
--- a/pwa/src/i18n/nn.ts
+++ b/pwa/src/i18n/nn.ts
@@ -44,4 +44,8 @@ export const nn: Record = {
// Tilgjenge
direction_arrow_description: 'Retning til tilfluktsrom, %s unna',
+ a11y_map: 'Kart',
+ a11y_compass: 'Kompass',
+ a11y_shelter_info: 'Tilfluktsrominfo',
+ a11y_nearest_shelters: 'Nærmaste tilfluktsrom',
};
diff --git a/pwa/src/styles/main.css b/pwa/src/styles/main.css
index fe3d9ff..5106319 100644
--- a/pwa/src/styles/main.css
+++ b/pwa/src/styles/main.css
@@ -20,6 +20,21 @@ html, body {
-webkit-text-size-adjust: 100%;
}
+/* --- Focus indicators for screen reader / switch access --- */
+:focus-visible {
+ outline: 2px solid #FF6B35;
+ outline-offset: 2px;
+}
+
+/* --- Respect reduced motion preference --- */
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
+}
+
/* --- App shell layout --- */
#app {
display: flex;
@@ -263,6 +278,18 @@ html, body {
.shelter-item.selected {
border-color: #FF6B35;
background: rgba(255, 107, 53, 0.1);
+ padding-left: 20px;
+ position: relative;
+}
+
+.shelter-item.selected::before {
+ content: '\25B6';
+ position: absolute;
+ left: 6px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: #FF6B35;
+ font-size: 10px;
}
.shelter-item-address {
diff --git a/pwa/src/ui/compass-view.ts b/pwa/src/ui/compass-view.ts
index accbbee..5638dd9 100644
--- a/pwa/src/ui/compass-view.ts
+++ b/pwa/src/ui/compass-view.ts
@@ -3,15 +3,20 @@
* Ported from DirectionArrowView.kt — same 7-point arrow polygon.
*
* Arrow rotation = shelterBearing - deviceHeading
+ *
+ * Also draws a discrete north indicator on the perimeter so users can
+ * validate compass calibration against a known direction.
*/
const ARROW_COLOR = '#FF6B35';
const OUTLINE_COLOR = '#FFFFFF';
const OUTLINE_WIDTH = 4;
+const NORTH_COLOR = 'rgba(207, 216, 220, 0.6)'; // text_secondary at ~60%
let canvas: HTMLCanvasElement | null = null;
let ctx: CanvasRenderingContext2D | null = null;
let currentAngle = 0;
+let northAngle: number | null = null;
let animFrameId = 0;
/** Initialize the compass canvas inside the given container element. */
@@ -20,6 +25,7 @@ export function initCompass(container: HTMLElement): void {
canvas.id = 'compass-canvas';
canvas.style.width = '100%';
canvas.style.height = '100%';
+ canvas.setAttribute('aria-hidden', 'true');
container.prepend(canvas);
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
@@ -43,6 +49,14 @@ export function setDirection(degrees: number): void {
animFrameId = requestAnimationFrame(draw);
}
+/**
+ * Set the angle to north in the view's coordinate space.
+ * Typically -deviceHeading. Set to null to hide.
+ */
+export function setNorthAngle(degrees: number): void {
+ northAngle = degrees;
+}
+
function draw(): void {
if (!canvas) return;
ctx = canvas.getContext('2d');
@@ -55,6 +69,12 @@ function draw(): void {
const size = Math.min(w, h) * 0.4;
ctx.clearRect(0, 0, w, h);
+
+ // Draw north indicator behind the main arrow
+ if (northAngle !== null) {
+ drawNorthIndicator(ctx, cx, cy, size);
+ }
+
ctx.save();
ctx.translate(cx, cy);
ctx.rotate((currentAngle * Math.PI) / 180);
@@ -79,6 +99,42 @@ function draw(): void {
ctx.restore();
}
+/** Small triangle and "N" label on the perimeter, pointing inward. */
+function drawNorthIndicator(
+ c: CanvasRenderingContext2D,
+ cx: number,
+ cy: number,
+ arrowSize: number,
+): void {
+ if (northAngle === null) return;
+
+ const radius = arrowSize * 1.35;
+ const tickSize = arrowSize * 0.1;
+ const rad = (northAngle * Math.PI) / 180;
+
+ c.save();
+ c.translate(cx, cy);
+ c.rotate(rad);
+
+ // Small triangle
+ c.beginPath();
+ c.moveTo(0, -radius);
+ c.lineTo(-tickSize, -radius - tickSize * 1.8);
+ c.lineTo(tickSize, -radius - tickSize * 1.8);
+ c.closePath();
+ c.fillStyle = NORTH_COLOR;
+ c.fill();
+
+ // "N" label
+ c.font = `${arrowSize * 0.18}px -apple-system, sans-serif`;
+ c.fillStyle = NORTH_COLOR;
+ c.textAlign = 'center';
+ c.textBaseline = 'bottom';
+ c.fillText('N', 0, -radius - tickSize * 2.2);
+
+ c.restore();
+}
+
/** Clean up compass resources. */
export function destroyCompass(): void {
window.removeEventListener('resize', resizeCanvas);
diff --git a/pwa/src/ui/loading-overlay.ts b/pwa/src/ui/loading-overlay.ts
index 4db5908..f6dc948 100644
--- a/pwa/src/ui/loading-overlay.ts
+++ b/pwa/src/ui/loading-overlay.ts
@@ -1,8 +1,14 @@
/**
* Loading overlay: spinner + message + OK/Skip buttons.
* Same flow as Android: prompt before map caching, user can skip.
+ *
+ * Accessibility: the overlay is a modal dialog (role="dialog", aria-modal).
+ * Focus is moved into the dialog when shown and restored when hidden.
*/
+/** Element that had focus before the overlay opened. */
+let previousFocus: HTMLElement | null = null;
+
/** Show the loading overlay with a message and optional spinner. */
export function showLoading(message: string, showSpinner = true): void {
const overlay = document.getElementById('loading-overlay')!;
@@ -10,10 +16,13 @@ export function showLoading(message: string, showSpinner = true): void {
const spinner = document.getElementById('loading-spinner')!;
const buttonRow = document.getElementById('loading-button-row')!;
+ previousFocus = document.activeElement as HTMLElement | null;
text.textContent = message;
+ overlay.setAttribute('aria-label', message);
spinner.style.display = showSpinner ? 'block' : 'none';
buttonRow.style.display = 'none';
overlay.style.display = 'flex';
+ text.focus();
}
/** Show the cache prompt (OK / Skip buttons, no spinner). */
@@ -29,7 +38,9 @@ export function showCachePrompt(
const okBtn = document.getElementById('loading-ok-btn')!;
const skipBtn = document.getElementById('loading-skip-btn')!;
+ previousFocus = document.activeElement as HTMLElement | null;
text.textContent = message;
+ overlay.setAttribute('aria-label', message);
spinner.style.display = 'none';
buttonRow.style.display = 'flex';
overlay.style.display = 'flex';
@@ -42,6 +53,8 @@ export function showCachePrompt(
hideLoading();
onSkip();
};
+
+ okBtn.focus();
}
/** Update loading text (e.g. progress). */
@@ -50,8 +63,10 @@ export function updateLoadingText(message: string): void {
if (text) text.textContent = message;
}
-/** Hide the loading overlay. */
+/** Hide the loading overlay and restore focus. */
export function hideLoading(): void {
const overlay = document.getElementById('loading-overlay');
if (overlay) overlay.style.display = 'none';
+ previousFocus?.focus();
+ previousFocus = null;
}
diff --git a/pwa/src/ui/shelter-list.ts b/pwa/src/ui/shelter-list.ts
index c0d6f8d..ce9f0cf 100644
--- a/pwa/src/ui/shelter-list.ts
+++ b/pwa/src/ui/shelter-list.ts
@@ -34,8 +34,19 @@ export function updateList(
}
shelters.forEach((swd, i) => {
+ const isSelected = i === selectedIndex;
const item = document.createElement('button');
- item.className = `shelter-item${i === selectedIndex ? ' selected' : ''}`;
+ item.className = `shelter-item${isSelected ? ' selected' : ''}`;
+ item.role = 'listitem';
+ if (isSelected) item.setAttribute('aria-current', 'true');
+
+ const details = [
+ formatDistance(swd.distanceMeters),
+ t('shelter_capacity', swd.shelter.plasser),
+ t('shelter_room_nr', swd.shelter.romnr),
+ ].join(' \u00B7 ');
+
+ item.setAttribute('aria-label', `${swd.shelter.adresse}, ${details}`);
const addressSpan = document.createElement('span');
addressSpan.className = 'shelter-item-address';
@@ -43,11 +54,7 @@ export function updateList(
const detailsSpan = document.createElement('span');
detailsSpan.className = 'shelter-item-details';
- detailsSpan.textContent = [
- formatDistance(swd.distanceMeters),
- t('shelter_capacity', swd.shelter.plasser),
- t('shelter_room_nr', swd.shelter.romnr),
- ].join(' \u00B7 ');
+ detailsSpan.textContent = details;
item.appendChild(addressSpan);
item.appendChild(detailsSpan);