LIVE · VBB
Was fährt jetzt?
Suche eine Haltestelle oder nutze deinen Standort, um die nächsten Abfahrten zu sehen.
Aktualisierung alle 30 Sekunden
+ Aktualisierung alle 30 Sekunden
-
+
Suche oben eine Haltestelle, um Live-Abfahrten zu sehen.
-
-
+
+
LIVE
Karte
Fahrzeuge, Routen und deine aktuelle Fahrt live verfolgen.
+
+
-
Verfolge Linie live
Alle Linien anzeigen
-
+
- VERBINDUNG SUCHEN
Wohin möchtest du?
Starte mit deinem Standort oder suche einen Ausgangspunkt.
- 📍
-
+
●
+ ●
-
-
-
●
+ ●
-
-
- ● Jetzt
- Abfahrt um
- Ankunft um
-
+
Verbindungen suchen →
+ Verbindungen suchen →
@@ -158,12 +162,16 @@
+ ● Jetzt
+ Abfahrt um
+ Ankunft um
+
@@ -138,13 +142,13 @@
- 🛰 Routenplanung über unsere selbst gehostete, offene Instanz (VBB-Region)
- 🔗 Route teilen
+ 🔗 Route teilen
-
+
SCHNELLZUGRIFF
Meine Haltestellen
Deine gespeicherten Haltestellen mit Live-Abfahrten und Störungshinweisen.
-
📍 Standort verwenden
-
+
-
+
-
-
+
Leeren
-
+
- ${escapeHtml(I18N.t('search.recentSearches'))} `;
+ results.innerHTML = `${escapeHtml(I18N.t('search.recentSearches'))} `;
renderSearchResults(results, recents, (station) => {
selectStation(station);
input.value = '';
@@ -426,15 +431,22 @@ const App = (() => {
return;
}
+ const requestId = ++searchRequestId;
searchDebounce = setTimeout(async () => {
try {
const stations = await API.searchAll(query);
+ // Wettlauf-Schutz: falls währenddessen weitergetippt und eine neuere
+ // Suche gestartet wurde, deren (schnellere) Antwort schon
+ // angekommen ist, wird diese ältere, jetzt überholte Antwort
+ // verworfen statt die neueren Ergebnisse zu überschreiben.
+ if (requestId !== searchRequestId) return;
renderSearchResults(results, stations, (station) => {
selectStation(station);
input.value = '';
results.hidden = true;
});
} catch (err) {
+ if (requestId !== searchRequestId) return;
console.error('Suchfehler:', err.message);
}
}, 250);
@@ -450,6 +462,78 @@ const App = (() => {
});
}
+ // Macht ein Such-Ergebnis-Dropdown per Tastatur bedienbar (Pfeiltasten
+ // rauf/runter, Enter zum Auswählen, Escape zum Schließen) - vorher war das
+ // nur per Maus/Touch nutzbar, was Tastatur- und Screenreader-Nutzer
+ // komplett von der Haltestellensuche ausgeschlossen hat. Nutzt das
+ // "aria-activedescendant"-Muster: der echte Tastaturfokus bleibt im
+ // Eingabefeld, nur die optische/semantische Markierung wandert durch die
+ // Liste - Screenreader kündigen die jeweils markierte Option trotzdem an.
+ function wireSearchKeyboardNav(input, container) {
+ let activeIndex = -1;
+
+ function getOptions() {
+ return Array.from(container.querySelectorAll('[role="option"]'));
+ }
+
+ function setActive(index) {
+ const options = getOptions();
+ options.forEach((el) => {
+ el.classList.remove('search-result--active');
+ el.setAttribute('aria-selected', 'false');
+ });
+ if (index < 0 || index >= options.length) {
+ activeIndex = -1;
+ input.removeAttribute('aria-activedescendant');
+ return;
+ }
+ activeIndex = index;
+ const el = options[index];
+ el.classList.add('search-result--active');
+ el.setAttribute('aria-selected', 'true');
+ if (!el.id) el.id = `search-option-${Math.random().toString(36).slice(2, 9)}`;
+ input.setAttribute('aria-activedescendant', el.id);
+ el.scrollIntoView({ block: 'nearest' });
+ }
+
+ input.addEventListener('keydown', (e) => {
+ if (container.hidden) return;
+ const options = getOptions();
+ if (!options.length) return;
+
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ setActive(activeIndex < options.length - 1 ? activeIndex + 1 : 0);
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ setActive(activeIndex > 0 ? activeIndex - 1 : options.length - 1);
+ } else if (e.key === 'Enter') {
+ if (activeIndex >= 0 && options[activeIndex]) {
+ e.preventDefault();
+ options[activeIndex].click();
+ }
+ } else if (e.key === 'Escape') {
+ container.hidden = true;
+ setActive(-1);
+ }
+ });
+
+ // Hält aria-expanded synchron (egal von wo der Dropdown ein-/ausgeblendet
+ // wird) und setzt die Markierung zurück, sobald sich der Inhalt ändert
+ // (neue Suchergebnisse) - so muss nicht an jeder einzelnen Stelle im Code,
+ // die results.hidden setzt, manuell dran gedacht werden.
+ const observer = new MutationObserver((mutations) => {
+ mutations.forEach((m) => {
+ if (m.type === 'attributes' && m.attributeName === 'hidden') {
+ input.setAttribute('aria-expanded', String(!container.hidden));
+ if (container.hidden) setActive(-1);
+ }
+ if (m.type === 'childList') setActive(-1);
+ });
+ });
+ observer.observe(container, { childList: true, attributes: true, attributeFilter: ['hidden'] });
+ }
+
// Ein Symbol pro Ergebnis-Herkunft: VBB-Haltestelle (Nahverkehr), echter
// DB-Bahnhof (bundesweit), oder Adresse/POI (nur im Routenplaner relevant).
function resultIcon(loc) {
@@ -463,7 +547,7 @@ const App = (() => {
if (!append) container.innerHTML = '';
if (locations.length === 0) {
- container.innerHTML = `${escapeHtml(I18N.t('search.noResults'))} `;
+ container.innerHTML = `${escapeHtml(I18N.t('search.noResults'))} `;
container.hidden = false;
return;
}
@@ -471,8 +555,10 @@ const App = (() => {
locations.forEach((loc) => {
const li = document.createElement('li');
li.className = 'search-result';
+ li.setAttribute('role', 'option');
+ li.setAttribute('aria-selected', 'false');
const badge = loc.source === 'db' ? 'DB · bundesweit' : '';
- li.innerHTML = `${resultIcon(loc)} ${loc.name}${badge}`;
+ li.innerHTML = `${resultIcon(loc)} ${escapeHtml(loc.name)}${badge}`;
li.addEventListener('click', () => onSelect(loc));
container.appendChild(li);
});
@@ -486,11 +572,26 @@ const App = (() => {
saveRecentSearch(station);
activateTab('departures');
document.getElementById('board-station-name').textContent = station.name;
+ document.title = `${station.name} · ÖPNV Navi`;
document.getElementById('board-empty-state').hidden = true;
document.getElementById('welcome-panel').hidden = true;
document.getElementById('departures-view').hidden = false;
updateFavoriteButton();
+ // "In der Nähe"-Vorschau der Startseite ausblenden, sobald eine konkrete
+ // Haltestelle gewählt wurde - sonst bleibt sie über der Abfahrtstafel
+ // stehen, obwohl man längst weitergeklickt hat.
+ const nearbyHomeEl = document.getElementById('nearby-home');
+ if (nearbyHomeEl) nearbyHomeEl.hidden = true;
+
+ // Abfahrtstafel sofort leeren statt die Zeilen der vorherigen Haltestelle
+ // stehen zu lassen, bis die neuen Daten da sind - bei langsamer Verbindung
+ // sah man sonst kurz "S41 → Ringbahn" unter dem bereits neuen
+ // Stationsnamen, was verwirrend war.
+ const board = document.getElementById('departure-board');
+ if (board) board.innerHTML = `Alarm beenden
`;
@@ -262,16 +264,35 @@ const TripAlarm = (() => {
overlay.id = 'trip-alarm-overlay';
overlay.className = 'trip-alarm-overlay';
overlay.hidden = true;
+ overlay.setAttribute('role', 'alertdialog');
+ overlay.setAttribute('aria-modal', 'true');
+ overlay.setAttribute('aria-labelledby', 'trip-alarm-overlay__heading');
+ overlay.setAttribute('aria-describedby', 'trip-alarm-overlay__text');
overlay.innerHTML = `
${isActiveAlarm ? '🔔' : '🔕'} `
+ ? `${isActiveAlarm ? '🔔' : '🔕'} `
: '';
return `
@@ -206,6 +240,7 @@ const TripModal = (() => {
async function open(tripId) {
if (!tripId) return;
+ lastFocusedElement = document.activeElement;
const el = ensureBackdrop();
el.hidden = false;
currentTripId = tripId;
@@ -215,10 +250,17 @@ const TripModal = (() => {
if (statsEl) { statsEl.hidden = true; }
document.getElementById('trip-modal-line').textContent = '…';
- document.getElementById('trip-modal-line').style.background = '#8E99A6'; document.getElementById('trip-modal-direction').textContent = '';
+ document.getElementById('trip-modal-line').style.background = '#8E99A6';
+ document.getElementById('trip-modal-direction').textContent = '';
document.getElementById('trip-modal-meta').textContent = '';
document.getElementById('trip-modal-body').innerHTML = '
-
- 🔔 Störungsbenachrichtigungen aktivieren
+
@@ -172,39 +180,39 @@
+ Aktivieren
🔔
+
+ Störungen sofort mitbekommen
+ Auch wenn die App gerade nicht offen ist.
+
+ Noch keine Favoriten - tippe bei einer Haltestelle auf ☆, um sie hier anzupinnen.
- +In deiner Nähe
Nutze deinen Standort für ortsbezogene Störungen.
Nutze deinen Standort für ortsbezogene Störungen.
Netzweit
Störungsmeldungen werden geladen…
Störungsmeldungen werden geladen…
PREISE
Tickets
Übliche VBB-Fahrpreise auf einen Blick.
⚠ Angaben ohne Gewähr – Preise können sich ändern.
+ ⚠ Angaben ohne Gewähr – Preise können sich ändern.
Lade Fahrpreise…
Tickets kaufen
WEITERE FUNKTIONEN
Mehr
Alles, was nicht bei jeder Fahrt gebraucht wird.
- ★FavoritenDeine gespeicherten Haltestellen
- 📍In der NäheHaltestellen in deiner Umgebung
- ⚠StörungenOrtsbezogen & netzweit
- 🎫TicketsPreise & wo kaufen
+ ★FavoritenDeine gespeicherten Haltestellen
+ 📍In der NäheHaltestellen in deiner Umgebung
+ ⚠StörungenOrtsbezogen & netzweit
+ 🎫TicketsPreise & wo kaufen
VERLAUF
Zuletzt gesucht
SPRACHE / LANGUAGE
Sprache
- Deutsch
- English
+
@@ -213,10 +221,12 @@
+ Deutsch
+ English
ℹ Live-Daten von VBB · Angaben ohne Gewähr
diff --git a/js/app.js b/js/app.js
index 5f9972e..20bb1ac 100644
--- a/js/app.js
+++ b/js/app.js
@@ -125,6 +125,7 @@ const App = (() => {
function toggleFavorite(station) {
const favorites = loadFavorites();
const idx = favorites.findIndex((f) => f.id === station.id);
+ const wasAdded = idx < 0;
if (idx >= 0) {
favorites.splice(idx, 1);
} else {
@@ -135,6 +136,7 @@ const App = (() => {
renderRecentSearches();
updateFavoriteButton();
if (window.Push) Push.syncFavorites(favorites);
+ showToast(wasAdded ? `★ ${station.name} zu Favoriten hinzugefügt` : `☆ ${station.name} aus Favoriten entfernt`, 'success');
}
function renderFavoriteChips() {
@@ -314,23 +316,24 @@ const App = (() => {
function updateNotifyToggleVisibility(hasFavorites) {
const btn = document.getElementById('notify-toggle');
- if (!btn) return;
+ const callout = document.getElementById('notify-callout');
+ if (!btn || !callout) return;
if (!hasFavorites || !window.Notification) {
- btn.hidden = true;
+ callout.hidden = true;
return;
}
- btn.hidden = false;
+ callout.hidden = false;
+ callout.classList.remove('notify-callout--active', 'notify-callout--blocked');
if (Notification.permission === 'granted') {
- btn.textContent = `🔔 ${I18N.t('favorites.notifyActive')}`;
- btn.classList.add('is-active');
+ btn.textContent = I18N.t('favorites.notifyActiveShort');
+ callout.classList.add('notify-callout--active');
btn.disabled = true;
} else if (Notification.permission === 'denied') {
- btn.textContent = `🔕 ${I18N.t('favorites.notifyBlocked')}`;
- btn.classList.remove('is-active');
+ btn.textContent = I18N.t('favorites.notifyBlockedShort');
+ callout.classList.add('notify-callout--blocked');
btn.disabled = true;
} else {
- btn.textContent = `🔔 ${I18N.t('favorites.enableNotify')}`;
- btn.classList.remove('is-active');
+ btn.textContent = I18N.t('favorites.enableNotifyShort');
btn.disabled = false;
}
}
@@ -389,10 +392,12 @@ const App = (() => {
}
let searchDebounce = null;
+ let searchRequestId = 0;
function initSearch() {
const input = document.getElementById('station-search');
const results = document.getElementById('search-results');
+ wireSearchKeyboardNav(input, results);
// Leeres Suchfeld antippen: zuletzt gesuchte Haltestellen als Vorschläge
// zeigen, statt dass man erst tippen muss, um überhaupt etwas zu sehen.
@@ -403,7 +408,7 @@ const App = (() => {
results.innerHTML = '';
return;
}
- results.innerHTML = ` ${escapeHtml(I18N.t('departures.loading'))}
`;
+ lastRenderedSource = null;
+
// Störungsanzeige der zuvor gewählten Haltestelle sofort verstecken,
// damit nicht kurz die alten Störungen der letzten Station aufblitzen,
// bevor die neuen Abfahrten geladen sind.
@@ -521,7 +622,14 @@ const App = (() => {
renderDepartureBoard(data.departures);
renderStationDisruptions(data.disruptions);
} catch (err) {
- board.innerHTML = `⚠ ${err.message}
`;
+ board.innerHTML = `
+
+ ⚠ ${escapeHtml(err.message)}
+ ${escapeHtml(I18N.t('common.retry'))}
+
+ `;
+ const retryBtn = document.getElementById('board-retry');
+ if (retryBtn) retryBtn.addEventListener('click', () => loadDepartures());
}
}
@@ -636,6 +744,11 @@ const App = (() => {
openModal();
}
});
+ } else {
+ // Nicht klickbare Zeile (DB-Haltestelle) - trotzdem ein sinnvolles
+ // Listenelement innerhalb von #departure-board (role="list"), statt
+ // eines rollen-losen div, das für Screenreader wie "nichts" aussieht.
+ row.setAttribute('role', 'listitem');
}
return row;
@@ -660,6 +773,9 @@ const App = (() => {
const lineCell = row.querySelector('[data-field="line"]');
lineCell.style.setProperty('--line-color', dep.color || '#8E99A6');
lineCell.style.setProperty('--line-text-color', dep.textColor || '#fff');
+ // Auch auf der Zeile selbst setzen, für den farbigen Rand links (schnelles
+ // Scannen der Tafel nach Linie, ohne jede Zeile einzeln lesen zu müssen).
+ row.style.setProperty('--line-color', dep.color || '#8E99A6');
SplitFlap.render(lineCell, (dep.line || '?').toUpperCase());
SplitFlap.render(row.querySelector('[data-field="direction"]'), (dep.direction || '').toUpperCase());
@@ -719,18 +835,58 @@ const App = (() => {
function initTabs() {
document.querySelectorAll('[data-tab]').forEach((tab) => {
- tab.addEventListener('click', () => activateTab(tab.dataset.tab));
+ tab.addEventListener('click', () => activateTab(tab.dataset.tab, { focusPanel: true }));
+ });
+
+ // Pfeiltasten-Navigation innerhalb einer Tab-Leiste (Standard-ARIA-Tabs-
+ // Verhalten: Pfeil links/rechts wechselt zwischen den Tabs, Pos1/Ende
+ // springt an Anfang/Ende) - wichtig für Tastatur-/Screenreader-Nutzer,
+ // die sonst durch jeden Tab-Button einzeln tabben müssten.
+ [
+ Array.from(document.querySelectorAll('.tab-nav [role="tab"]')),
+ Array.from(document.querySelectorAll('.mobile-nav [role="tab"]')),
+ ].forEach((tabs) => {
+ tabs.forEach((tab, index) => {
+ tab.addEventListener('keydown', (e) => {
+ let targetIndex = null;
+ if (e.key === 'ArrowRight') targetIndex = (index + 1) % tabs.length;
+ else if (e.key === 'ArrowLeft') targetIndex = (index - 1 + tabs.length) % tabs.length;
+ else if (e.key === 'Home') targetIndex = 0;
+ else if (e.key === 'End') targetIndex = tabs.length - 1;
+ if (targetIndex === null) return;
+ e.preventDefault();
+ tabs[targetIndex].focus();
+ activateTab(tabs[targetIndex].dataset.tab);
+ });
+ });
});
}
- function activateTab(tabName) {
+ function activateTab(tabName, { focusPanel = false } = {}) {
document.querySelectorAll('.tab-button, .mobile-nav__item').forEach((btn) => {
- btn.classList.toggle('is-active', btn.dataset.tab === tabName);
+ const active = btn.dataset.tab === tabName;
+ btn.classList.toggle('is-active', active);
+ btn.setAttribute('aria-selected', String(active));
+ btn.tabIndex = active ? 0 : -1;
});
+
+ let activePanel = null;
document.querySelectorAll('.tab-panel').forEach((panel) => {
- panel.hidden = panel.dataset.tabPanel !== tabName;
+ const active = panel.dataset.tabPanel === tabName;
+ panel.hidden = !active;
+ if (active) activePanel = panel;
});
+ // Bewegt den Fokus zum neu sichtbaren Panel, wenn der Tabwechsel eine
+ // bewusste Navigationshandlung war (Klick/Tastatur auf einen Tab-Button) -
+ // Screenreader-Nutzer landen so direkt im neuen Inhalt, statt weiter auf
+ // dem (jetzt unsichtbaren) Tab-Button zu stehen. Beim initialen Laden der
+ // Seite (restoreLastStop() etc.) wird das bewusst NICHT gemacht, damit der
+ // Fokus nicht ungefragt vom Seitenanfang wegspringt.
+ if (focusPanel && activePanel && activePanel.hasAttribute('tabindex')) {
+ activePanel.focus({ preventScroll: true });
+ }
+
if (tabName === 'map') {
LiveMap.startPolling();
} else {
@@ -973,7 +1129,11 @@ const App = (() => {
// Setzt "Jetzt/Abfahrt um/Ankunft um" programmatisch (Klick-Handler UND
// Wiederherstellung eines geteilten Links nutzen dieselbe Logik).
function setWhenChoice(mode, whenValue) {
- document.querySelectorAll('.when-choice').forEach((item) => item.classList.toggle('is-active', item.dataset.when === mode));
+ document.querySelectorAll('.when-choice').forEach((item) => {
+ const active = item.dataset.when === mode;
+ item.classList.toggle('is-active', active);
+ item.setAttribute('aria-pressed', String(active));
+ });
journeyArrivalMode = mode === 'arrival';
const when = document.getElementById('journey-when');
when.hidden = mode === 'now';
@@ -997,7 +1157,7 @@ const App = (() => {
return;
}
- resultsEl.innerHTML = `${escapeHtml(I18N.t('journey.searching'))}
`;
+ resultsEl.innerHTML = ` ${escapeHtml(I18N.t('journey.searching'))}
`;
if (shareRow) shareRow.hidden = true;
const whenInput = document.getElementById('journey-when').value;
@@ -1019,7 +1179,14 @@ const App = (() => {
renderJourneys(journeys, resultsEl);
if (shareRow) shareRow.hidden = false;
} catch (err) {
- resultsEl.innerHTML = `⚠ ${err.message}
`;
+ resultsEl.innerHTML = `
+
+ ⚠ ${escapeHtml(err.message)}
+ ${escapeHtml(I18N.t('common.retry'))}
+
+ `;
+ const retryBtn = document.getElementById('journey-retry');
+ if (retryBtn) retryBtn.addEventListener('click', () => runJourneySearch());
}
}
@@ -1081,7 +1248,7 @@ const App = (() => {
try {
await navigator.clipboard.writeText(url);
- showToast('Link kopiert!', 'info');
+ showToast('Link kopiert!', 'success');
} catch (err) {
console.error('Clipboard-Fehler:', err.message);
window.prompt('Link kopieren:', url);
@@ -1123,6 +1290,8 @@ const App = (() => {
const input = document.getElementById(inputId);
const results = document.getElementById(`${inputId}-results`);
let debounce = null;
+ let requestId = 0;
+ wireSearchKeyboardNav(input, results);
input.addEventListener('input', () => {
clearTimeout(debounce);
@@ -1131,9 +1300,11 @@ const App = (() => {
results.hidden = true;
return;
}
+ const thisRequestId = ++requestId;
debounce = setTimeout(async () => {
try {
const { locations } = await API.searchLocations(query);
+ if (thisRequestId !== requestId) return; // veraltete Antwort, verwerfen
renderSearchResults(results, locations, (loc) => {
onSelect({ id: loc.id || undefined, lat: loc.latitude, lon: loc.longitude, name: loc.name, kind: loc.kind });
input.value = loc.name;
diff --git a/js/i18n.js b/js/i18n.js
index aee7056..84a631f 100644
--- a/js/i18n.js
+++ b/js/i18n.js
@@ -28,6 +28,7 @@ const I18N = (() => {
'common.live': 'LIVE',
'common.current': 'AKTUELL',
'common.clear': 'Leeren',
+ 'common.retry': 'Erneut versuchen',
'common.footer': 'ℹ Live-Daten von VBB · Angaben ohne Gewähr',
'welcome.title': 'Was fährt jetzt?',
'welcome.text': 'Suche eine Haltestelle oder nutze deinen Standort, um die nächsten Abfahrten zu sehen.',
@@ -45,6 +46,7 @@ const I18N = (() => {
'departures.noStation': 'Keine Haltestelle ausgewählt',
'departures.refreshInfo': 'Aktualisierung alle 30 Sekunden',
'departures.emptyState': 'Suche oben eine Haltestelle, um Live-Abfahrten zu sehen.',
+ 'departures.loading': 'Lade Abfahrten…',
'departures.line': 'Linie',
'departures.direction': 'Ziel',
'departures.platform': 'Gleis',
@@ -59,8 +61,13 @@ const I18N = (() => {
'favorites.title': 'Meine Haltestellen',
'favorites.text': 'Deine gespeicherten Haltestellen mit Live-Abfahrten und Störungshinweisen.',
'favorites.enableNotify': 'Störungsbenachrichtigungen aktivieren',
+ 'favorites.enableNotifyTitle': 'Störungen sofort mitbekommen',
+ 'favorites.enableNotifyText': 'Auch wenn die App gerade nicht offen ist.',
+ 'favorites.enableNotifyShort': 'Aktivieren',
'favorites.notifyActive': 'Benachrichtigungen aktiv',
+ 'favorites.notifyActiveShort': '✓ Aktiv',
'favorites.notifyBlocked': 'Benachrichtigungen blockiert',
+ 'favorites.notifyBlockedShort': 'Blockiert',
'favorites.emptyHint': 'Noch keine Favoriten - tippe bei einer Haltestelle auf ☆, um sie hier anzupinnen.',
'favorites.loadingNext': 'Lade nächste Abfahrt…',
'favorites.noDeparture': 'Keine Abfahrt gefunden',
@@ -117,8 +124,11 @@ const I18N = (() => {
'more.langTitle': 'Sprache',
'search.noResults': 'Keine Treffer',
'search.recentSearches': 'Zuletzt gesucht',
+ 'search.resultsLabel': 'Suchergebnisse',
'recent.empty': 'Noch keine Suchen.',
'recent.remove': '{name} aus Verlauf entfernen',
+ 'a11y.skipLink': 'Zum Inhalt springen',
+ 'map.a11yDescription': 'Live-Karte mit Fahrzeugpositionen. Für Screenreader-Nutzer eingeschränkt zugänglich - Details zu einzelnen Fahrten sind über die Abfahrtstafel und den Routenplaner verfügbar.',
},
en: {
'search.placeholder': 'Search for a stop, place or destination…',
@@ -133,6 +143,7 @@ const I18N = (() => {
'common.live': 'LIVE',
'common.current': 'CURRENT',
'common.clear': 'Clear',
+ 'common.retry': 'Try again',
'common.footer': 'ℹ Live data from VBB · No guarantee of accuracy',
'welcome.title': 'What\u2019s leaving now?',
'welcome.text': 'Search for a stop or use your location to see the next departures.',
@@ -150,6 +161,7 @@ const I18N = (() => {
'departures.noStation': 'No stop selected',
'departures.refreshInfo': 'Updates every 30 seconds',
'departures.emptyState': 'Search for a stop above to see live departures.',
+ 'departures.loading': 'Loading departures…',
'departures.line': 'Line',
'departures.direction': 'Towards',
'departures.platform': 'Platform',
@@ -164,8 +176,13 @@ const I18N = (() => {
'favorites.title': 'My Stops',
'favorites.text': 'Your saved stops with live departures and disruption notices.',
'favorites.enableNotify': 'Enable disruption notifications',
+ 'favorites.enableNotifyTitle': 'Hear about disruptions right away',
+ 'favorites.enableNotifyText': 'Even when the app isn\u2019t open.',
+ 'favorites.enableNotifyShort': 'Enable',
'favorites.notifyActive': 'Notifications active',
+ 'favorites.notifyActiveShort': '✓ Active',
'favorites.notifyBlocked': 'Notifications blocked',
+ 'favorites.notifyBlockedShort': 'Blocked',
'favorites.emptyHint': 'No favorites yet - tap ☆ at a stop to pin it here.',
'favorites.loadingNext': 'Loading next departure…',
'favorites.noDeparture': 'No departure found',
@@ -222,8 +239,11 @@ const I18N = (() => {
'more.langTitle': 'Language',
'search.noResults': 'No results',
'search.recentSearches': 'Recently searched',
+ 'search.resultsLabel': 'Search results',
'recent.empty': 'No searches yet.',
'recent.remove': 'Remove {name} from history',
+ 'a11y.skipLink': 'Skip to content',
+ 'map.a11yDescription': 'Live map with vehicle positions. Limited accessibility for screen reader users - trip details are available via the departure board and journey planner.',
},
};
@@ -263,7 +283,9 @@ const I18N = (() => {
el.title = t(el.dataset.i18nTitle);
});
document.querySelectorAll('.lang-switch__btn').forEach((btn) => {
- btn.classList.toggle('is-active', btn.dataset.lang === lang);
+ const active = btn.dataset.lang === lang;
+ btn.classList.toggle('is-active', active);
+ btn.setAttribute('aria-pressed', String(active));
});
}
diff --git a/js/splitflap.js b/js/splitflap.js
index 08cdef5..2a33db6 100644
--- a/js/splitflap.js
+++ b/js/splitflap.js
@@ -1,6 +1,13 @@
// Simuliert das Umklappen einer mechanischen Bahnhofs-Anzeigetafel (Solari-Board).
// Jedes Zeichen "rattert" kurz durch ein paar Zufallszeichen, bevor es sich auf
// den Zielwert setzt - versetzt pro Zeichen, damit es wie eine echte Tafel wirkt.
+//
+// Für Screenreader ist dieses Zeichensalat-Geflacker während der Animation
+// nutzlos bis irreführend. Deshalb wird der sichtbare, animierte Teil in einen
+// eigenen Container mit aria-hidden="true" verschoben, und daneben liegt ein
+// unsichtbarer (.sr-only), aber für Screenreader normal lesbarer Textknoten
+// mit dem fertigen Zielwert - der aktualisiert sich sofort, ohne die
+// Ratter-Zwischenschritte mitzumachen.
const SplitFlap = (() => {
const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 :+-.äöüÄÖÜß';
@@ -13,20 +20,36 @@ const SplitFlap = (() => {
}
function render(el, text) {
+ let srText = el.querySelector(':scope > .flap-cell__sr');
+ if (!srText) {
+ srText = document.createElement('span');
+ srText.className = 'flap-cell__sr sr-only';
+ el.appendChild(srText);
+ }
+ srText.textContent = text;
+
+ let visual = el.querySelector(':scope > .flap-cell__visual');
+ if (!visual) {
+ visual = document.createElement('span');
+ visual.className = 'flap-cell__visual';
+ visual.setAttribute('aria-hidden', 'true');
+ el.appendChild(visual);
+ }
+
const chars = text.split('');
- const existing = el.querySelectorAll('.flap-char');
+ const existing = visual.querySelectorAll('.flap-char');
if (existing.length !== chars.length) {
- el.innerHTML = '';
+ visual.innerHTML = '';
chars.forEach(() => {
const span = document.createElement('span');
span.className = 'flap-char';
span.textContent = ' ';
- el.appendChild(span);
+ visual.appendChild(span);
});
}
- const spans = el.querySelectorAll('.flap-char');
+ const spans = visual.querySelectorAll('.flap-char');
chars.forEach((targetChar, i) => {
const span = spans[i];
@@ -50,4 +73,4 @@ const SplitFlap = (() => {
}
return { render };
-})();
\ No newline at end of file
+})();
diff --git a/js/tripalarm.js b/js/tripalarm.js
index c7f6c25..d164190 100644
--- a/js/tripalarm.js
+++ b/js/tripalarm.js
@@ -198,8 +198,10 @@ const TripAlarm = (() => {
banner = document.createElement('div');
banner.id = 'trip-alarm-banner';
banner.className = 'trip-alarm-banner';
+ banner.setAttribute('role', 'status');
+ banner.setAttribute('aria-live', 'polite');
banner.innerHTML = `
- 🔔
+ 🔔
- Alarm beenden
`;
document.body.appendChild(overlay);
overlay.querySelector('#trip-alarm-overlay__dismiss').addEventListener('click', clear);
+
+ // Nur ein interaktives Element im Overlay - "Falle" ist hier trivial:
+ // Tab/Shift+Tab bleibt einfach immer auf dem einen Button, Escape löst
+ // dieselbe Aktion wie der Button aus.
+ overlay.addEventListener('keydown', (e) => {
+ if (overlay.hidden) return;
+ const dismissBtn = document.getElementById('trip-alarm-overlay__dismiss');
+ if (e.key === 'Tab') {
+ e.preventDefault();
+ dismissBtn.focus();
+ } else if (e.key === 'Escape') {
+ clear();
+ }
+ });
+
return overlay;
}
@@ -282,6 +303,9 @@ const TripAlarm = (() => {
? `Gleich da: ${stopName}`
: `Noch ${stopsRemaining} Station${stopsRemaining === 1 ? '' : 'en'} bis ${stopName}`;
overlay.hidden = false;
+ // Fokus sofort auf den einzigen Button - der Alarm unterbricht bewusst
+ // alles andere, das muss auch für Screenreader-Nutzer sofort ankommen.
+ document.getElementById('trip-alarm-overlay__dismiss').focus();
}
function hideOverlay() {
diff --git a/js/tripmodal.js b/js/tripmodal.js
index afcfb20..35be08e 100644
--- a/js/tripmodal.js
+++ b/js/tripmodal.js
@@ -6,9 +6,11 @@
const TripModal = (() => {
const ALARM_OFFSET_KEY = 'oepnv-navi:db-alarm-offset';
+ const FOCUSABLE_SELECTOR = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
let backdrop = null;
let miniMap = null;
+ let lastFocusedElement = null;
// Zustand der aktuell geöffneten Fahrt, damit wir nach dem Setzen/Entfernen
// eines Alarms den Haltestellen-Teil neu rendern können, ohne die Fahrt
@@ -46,7 +48,27 @@ const TripModal = (() => {
if (e.target === backdrop) close();
});
document.addEventListener('keydown', (e) => {
- if (e.key === 'Escape' && !backdrop.hidden) close();
+ if (backdrop.hidden) return;
+ if (e.key === 'Escape') {
+ close();
+ return;
+ }
+ // Fokus-Falle: Tab darf das Dialog nicht verlassen, solange es offen
+ // ist - sonst würde ein Tastatur-/Screenreader-Nutzer "hinter" den
+ // Dialog auf die (für ihn unsichtbar gewordene) Seite dahinter tabben.
+ if (e.key === 'Tab') {
+ const focusable = Array.from(backdrop.querySelectorAll(FOCUSABLE_SELECTOR)).filter((el) => el.offsetParent !== null);
+ if (focusable.length === 0) return;
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ if (e.shiftKey && document.activeElement === first) {
+ e.preventDefault();
+ last.focus();
+ } else if (!e.shiftKey && document.activeElement === last) {
+ e.preventDefault();
+ first.focus();
+ }
+ }
});
// Delegierter Klick-Handler für die Alarm-Glocken an den Haltestellen -
@@ -69,6 +91,13 @@ const TripModal = (() => {
miniMap.remove();
miniMap = null;
}
+ // Fokus zurück zur auslösenden Abfahrtszeile/dem Link geben, statt ihn
+ // einfach verloren gehen zu lassen (würde sonst auf zurückfallen -
+ // Tastatur-/Screenreader-Nutzer wüssten dann nicht mehr, wo sie sind).
+ if (lastFocusedElement && document.body.contains(lastFocusedElement)) {
+ lastFocusedElement.focus();
+ }
+ lastFocusedElement = null;
}
function formatTime(iso) {
@@ -76,6 +105,10 @@ const TripModal = (() => {
return new Date(iso).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
+ function escapeHtml(value) {
+ return String(value ?? '').replace(/[&<>'"]/g, (char) => ({ '&':'&', '<':'<', '>':'>', "'":''', '"':'"' }[char]));
+ }
+
// Verspätungsstatistik pro Linie - Datenbasis wächst erst über Zeit, auf
// einem frisch aufgesetzten Server gibt es also erstmal nichts anzuzeigen.
async function loadLineStats(line) {
@@ -152,8 +185,9 @@ const TripModal = (() => {
// Halte Sinn - nicht für bereits vergangene.
const canAlarm = !s.cancelled && index >= currentIndex;
const isActiveAlarm = canAlarm && TripAlarm.isActiveFor(currentTripId, index);
+ const alarmLabel = isActiveAlarm ? 'Fahrtalarm entfernen' : `Fahrtalarm für ${s.name || 'diese Haltestelle'} setzen`;
const alarmHtml = canAlarm
- ? `⏰
- Aufwachen!
+⏰
+ Aufwachen!
Lade Fahrt-Details…
';
+ // Fokus sofort in den Dialog holen (auf den Schließen-Button), damit
+ // Screenreader-Nutzer merken, dass sich ein Dialog geöffnet hat, statt
+ // weiter "unsichtbar" auf der jetzt verdeckten Seite zu stehen.
+ const closeBtn = el.querySelector('.trip-modal__close');
+ if (closeBtn) closeBtn.focus();
+
let trip;
try {
trip = await API.getTrip(tripId, { polyline: true });
diff --git a/styles.css b/styles.css
index 9f1969d..67ca708 100644
--- a/styles.css
+++ b/styles.css
@@ -50,6 +50,39 @@ button {
outline-offset: 2px;
}
+/* Visuell versteckt, aber für Screenreader normal vorlesbar - Standardmuster
+ für z.B. die Klartext-Alternative zur Flip-Animation der Abfahrtstafel. */
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+/* "Zum Inhalt springen" - für Tastatur-/Screenreader-Nutzer, die sonst bei
+ jedem Seitenaufruf erst durch Header+Suche+Navigation tabben müssten, bevor
+ sie beim eigentlichen Inhalt sind. Unsichtbar, bis es per Tab fokussiert wird. */
+.skip-link {
+ position: absolute;
+ top: -60px;
+ left: 12px;
+ z-index: 2000;
+ background: var(--signal-amber);
+ color: #17110a;
+ font-weight: 600;
+ padding: 10px 16px;
+ border-radius: 8px;
+ transition: top 0.15s ease;
+}
+.skip-link:focus {
+ top: 12px;
+}
+
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
@@ -135,8 +168,11 @@ button {
}
.search-result:hover,
-.search-result:focus {
+.search-result:focus,
+.search-result--active {
background: var(--panel);
+ outline: 2px solid var(--signal-amber);
+ outline-offset: -2px;
}
.search-result--empty {
@@ -182,17 +218,68 @@ button {
margin: 0;
}
+/* Störungsbenachrichtigungen-Karte auf dem Favoriten-Tab - bewusst als
+ eigenständige, einladende Karte statt eines unauffälligen Text-Links,
+ da das eine der nützlichsten Funktionen der App ist. */
+.notify-callout {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ background: rgba(245,166,35,.08);
+ border: 1px solid rgba(245,166,35,.35);
+ border-radius: var(--radius);
+ padding: 14px 16px;
+ margin-bottom: 18px;
+}
+.notify-callout__icon { font-size: 26px; flex-shrink: 0; }
+.notify-callout__text { flex: 1; min-width: 0; }
+.notify-callout__text strong { display: block; font-size: 14px; }
+.notify-callout__text small { display: block; color: var(--ink-muted); font-size: 12px; margin-top: 2px; }
+.notify-callout__btn {
+ flex-shrink: 0;
+ background: var(--signal-amber);
+ color: #17110a;
+ font-weight: 600;
+ border: 0;
+ border-radius: 999px;
+ padding: 10px 18px;
+ font-size: 13px;
+}
+.notify-callout__btn:hover { filter: brightness(1.08); }
+.notify-callout__btn:disabled { cursor: default; }
+
+.notify-callout--active {
+ background: rgba(61,220,132,.08);
+ border-color: rgba(61,220,132,.35);
+}
+.notify-callout--active .notify-callout__btn {
+ background: var(--signal-green);
+ color: #06301b;
+}
+
+.notify-callout--blocked {
+ background: rgba(255,93,108,.08);
+ border-color: rgba(255,93,108,.35);
+}
+.notify-callout--blocked .notify-callout__btn {
+ background: var(--panel-raised);
+ color: var(--ink-muted);
+}
+
/* ============================================================
Tabs
============================================================ */
.tab-nav {
- display: flex;
- gap: 4px;
padding: 0 20px;
border-bottom: 1px solid var(--line);
}
+.tab-nav__list {
+ display: flex;
+ gap: 4px;
+}
+
.tab-button {
background: none;
border: none;
@@ -248,6 +335,15 @@ button {
border-color: var(--signal-amber);
}
+@keyframes favorite-pop {
+ 0% { transform: scale(1); }
+ 40% { transform: scale(1.25); }
+ 100% { transform: scale(1); }
+}
+.favorite-toggle.is-active {
+ animation: favorite-pop .3s ease;
+}
+
.board-empty-state,
.board-empty,
.board-error {
@@ -263,6 +359,27 @@ button {
color: var(--signal-red);
}
+.board-loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+}
+
+.board-error {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 10px;
+}
+.board-error .text-button {
+ border: 1px solid var(--signal-red);
+ border-radius: 999px;
+ padding: 6px 16px;
+ color: var(--signal-red);
+}
+.board-error .text-button:hover { background: rgba(255,93,108,.1); }
+
.departure-board {
display: flex;
flex-direction: column;
@@ -276,11 +393,32 @@ button {
align-items: center;
background: var(--panel);
border: 1px solid var(--line);
+ border-left: 4px solid var(--line-color, var(--line));
border-radius: var(--radius);
- padding: 14px 16px;
+ padding: 14px 16px 14px 13px;
font-family: var(--font-display);
+ animation: flap-row-in .25s ease both;
+ animation-delay: calc(var(--row-index, 0) * 25ms);
}
+@keyframes flap-row-in {
+ from { opacity: 0; transform: translateY(4px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+/* Gleicher dezenter Eintritts-Effekt für andere Karten-Listen (Favoriten,
+ Routenergebnisse) - per nth-child gestaffelt, da diese keine --row-index
+ Variable aus JS mitbekommen. */
+.favorite-card,
+.journey-card {
+ animation: flap-row-in .25s ease both;
+}
+.favorite-cards > :nth-child(1), .journey-results > :nth-child(1) { animation-delay: 0ms; }
+.favorite-cards > :nth-child(2), .journey-results > :nth-child(2) { animation-delay: 40ms; }
+.favorite-cards > :nth-child(3), .journey-results > :nth-child(3) { animation-delay: 80ms; }
+.favorite-cards > :nth-child(4), .journey-results > :nth-child(4) { animation-delay: 120ms; }
+.favorite-cards > :nth-child(5), .journey-results > :nth-child(5) { animation-delay: 160ms; }
+
.flap-row--delayed .flap-cell--time {
color: var(--signal-red);
}
@@ -911,7 +1049,20 @@ button:active { transform: translateY(1px); }
background: var(--panel); color: var(--ink); border: 1px solid var(--line); border-radius: 10px; padding: 16px;
}
.action-card:hover { border-color: var(--signal-amber); background: var(--panel-raised); }
-.action-card--primary { border-color: rgba(245,166,35,.45); }
+
+/* Dezenter "Lift"-Effekt beim Hover für Karten-Buttons - macht sie klickbarer
+ greifbar, ohne das Layout zu beeinflussen (nur Transform+Schatten). */
+.action-card,
+.more-card,
+.favorite-card {
+ transition: border-color .15s ease, background .15s ease, transform .15s ease, box-shadow .15s ease;
+}
+.action-card:hover,
+.more-card:hover,
+.favorite-card:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 8px 20px rgba(0,0,0,.28);
+}.action-card--primary { border-color: rgba(245,166,35,.45); }
.action-card__icon { font-size: 23px; }
.action-card strong, .action-card small { display: block; }
.action-card small { color: var(--ink-muted); margin-top: 3px; font-size: 12px; }
@@ -982,6 +1133,7 @@ button:active { transform: translateY(1px); }
.toast.is-visible { opacity: 1; transform: translateY(0); }
.toast--warning { border-left-color: var(--signal-red); }
.toast--info { border-left-color: var(--signal-amber); }
+.toast--success { border-left-color: var(--signal-green); }
.nearby-preview { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 8px; }
.nearby-preview__item {
display: flex; justify-content: space-between; align-items: center; gap: 10px; text-align: left;
@@ -998,7 +1150,7 @@ button:active { transform: translateY(1px); }
.status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--signal-green); box-shadow: 0 0 0 3px rgba(61,220,132,.12); }
.favorite-toggle { padding: 8px 12px; }
-.flap-row { grid-template-columns: 82px minmax(0,1fr) 82px 112px; padding: 14px 16px; }
+.flap-row { grid-template-columns: 82px minmax(0,1fr) 82px 112px; padding: 14px 16px 14px 13px; }
.flap-cell--time { font-size: 17px; }
.flap-cell--direction { font-size: 15px; font-weight: 600; }
.flap-cell--platform { font-size: 14px; }
@@ -1765,13 +1917,14 @@ button:active { transform: translateY(1px); }
.journey-options-row .mode-filter { margin-bottom: 8px; }
.transfer-slack-field select { width: 100%; }
.mobile-nav {
- display: grid; grid-template-columns: repeat(4, 1fr); position: fixed; bottom: 0; left: 0; right: 0;
+ display: block; position: fixed; bottom: 0; left: 0; right: 0;
/* Muss über der (jetzt display-breiten) Live-Karte liegen - Leaflet nutzt
für seine eigenen Steuerelemente (Zoom-Buttons etc.) intern z-index-
Werte bis ~1000, das HUD/die Navigation soll aber immer sichtbar und
bedienbar bleiben, unabhängig von der Karte. */
z-index: 1100; background: rgba(23,30,38,.97); border-top: 1px solid var(--line); padding: 5px 4px calc(5px + env(safe-area-inset-bottom));
}
+ .mobile-nav__list { display: grid; grid-template-columns: repeat(4, 1fr); }
.mobile-nav__item { border: 0; background: transparent; color: var(--ink-muted); padding: 5px 2px; display: flex; flex-direction: column; align-items: center; gap: 2px; }
.mobile-nav__item span { font-size: 18px; line-height: 1.1; }
.mobile-nav__item small { font-size: 10px; }
@@ -1783,6 +1936,8 @@ button:active { transform: translateY(1px); }
.flap-row { grid-template-columns: 58px minmax(0,1fr) 72px; gap: 12px; }
.flap-field--platform { display: none; }
.flap-field--time { grid-column: 3; }
+ .notify-callout { flex-wrap: wrap; }
+ .notify-callout__btn { width: 100%; }
}
.journey-source-note {