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:
parent
f9f8ac3d60
commit
6ba35add2f
17 changed files with 240 additions and 36 deletions
|
|
@ -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">↻</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">🧭</button>
|
||||
<!-- Reset view button -->
|
||||
<button id="reset-view-btn" aria-label="Reset view">⌖</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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue