Legg til TalkBack-støtte og nordindikator på kompasset

Tilgjengelegheit (Android + PWA):
- Semantiske landemerke (header, main, aside, role=dialog)
- aria-live-regionar for statusoppdateringar og lasteoverlegg
- Fokusindikatorar (:focus-visible) og prefers-reduced-motion
- Auka trykkmål til 48dp (infoknapp, oppdater, del, widget)
- contentDescription på kart, kompass og framdriftsindikator
- aria-current og role=listitem på tilfluktsromliste
- Fokusfangst og fokusgjenoppretting i lasteoverlegg
- Ikkje-farge-indikator (▶) for valt tilfluktsrom
- Dynamisk lang-attributt basert på oppdaga språk
- Lokaliserte aria-label (en/nb/nn)

Nordindikator:
- DirectionArrowView teiknar diskret «N»-markør på omkrinsen
- Roterer uavhengig av hovudpila for kompasskalibrering
- Berre på stor kompassvisning, ikkje minipila

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-23 14:02:32 +01:00
commit 6ba35add2f
17 changed files with 240 additions and 36 deletions

View file

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
@ -16,47 +16,47 @@
<body>
<div id="app">
<!-- Status bar -->
<div id="status-bar">
<span id="status-text"></span>
<header id="status-bar" role="banner">
<span id="status-text" aria-live="polite"></span>
<button id="refresh-btn" aria-label="Refresh data">&#x21bb;</button>
</div>
</header>
<!-- Main content: map or compass -->
<div id="main-content">
<div id="map-container"></div>
<div id="compass-container">
<main id="main-content">
<div id="map-container" role="application" aria-label="Map"></div>
<div id="compass-container" role="img" aria-label="Compass">
<span id="compass-address"></span>
<span id="compass-distance"></span>
<span id="compass-distance" aria-live="polite"></span>
</div>
<!-- Toggle map/compass FAB -->
<button id="toggle-fab" aria-label="Toggle map/compass view">&#x1F9ED;</button>
<!-- Reset view button -->
<button id="reset-view-btn" aria-label="Reset view">&#x2316;</button>
</div>
</main>
<!-- No-cache warning banner -->
<div id="no-cache-banner">
<div id="no-cache-banner" role="alert">
<span id="no-cache-text"></span>
<button id="cache-retry-btn"></button>
</div>
<!-- Bottom sheet with shelter info -->
<div id="bottom-sheet">
<div id="selected-shelter">
<aside id="bottom-sheet" aria-label="Shelter info">
<div id="selected-shelter" aria-live="polite">
<canvas id="mini-arrow" width="96" height="96" role="img" aria-label="Direction to shelter"></canvas>
<div id="selected-shelter-info">
<div id="selected-shelter-address"></div>
<div id="selected-shelter-details"></div>
</div>
</div>
<div id="shelter-list"></div>
</div>
<div id="shelter-list" role="list" aria-label="Nearest shelters"></div>
</aside>
</div>
<!-- Loading overlay -->
<div id="loading-overlay">
<div id="loading-spinner"></div>
<div id="loading-text"></div>
<div id="loading-overlay" role="dialog" aria-modal="true" aria-label="Loading">
<div id="loading-spinner" aria-hidden="true"></div>
<div id="loading-text" aria-live="assertive" tabindex="-1"></div>
<div id="loading-button-row">
<button id="loading-skip-btn"></button>
<button id="loading-ok-btn"></button>

View file

@ -36,6 +36,7 @@ let firstLocationFix = true;
let userSelectedShelter = false;
export async function init(): Promise<void> {
applyA11yLabels();
setupMap();
setupCompass();
setupShelterList();
@ -43,6 +44,16 @@ export async function init(): Promise<void> {
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);
}

View file

@ -49,4 +49,8 @@ export const en: Record<string, string> = {
// 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',
};

View file

@ -11,21 +11,22 @@ const locales: Record<string, Record<string, string>> = { 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. */

View file

@ -44,4 +44,8 @@ export const nb: Record<string, string> = {
// 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',
};

View file

@ -44,4 +44,8 @@ export const nn: Record<string, string> = {
// 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',
};

View file

@ -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 {

View file

@ -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);

View file

@ -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;
}

View file

@ -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);