Add files via upload

This commit is contained in:
Serverlele30 authored and GitHub committed 2026-08-31 17:13:04 +02:00
1 parent c6e2fed82c
commit 7020ad99fc
7 files changed
+541 -94

No files matched your search

+192 -21
View File
@@ -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 = `<li class="search-result-heading">${escapeHtml(I18N.t('search.recentSearches'))}</li>`;
results.innerHTML = `<li class="search-result-heading" role="presentation">${escapeHtml(I18N.t('search.recentSearches'))}</li>`;
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 = `<li class="search-result search-result--empty">${escapeHtml(I18N.t('search.noResults'))}</li>`;
container.innerHTML = `<li class="search-result search-result--empty" role="presentation">${escapeHtml(I18N.t('search.noResults'))}</li>`;
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' ? '<span class="search-result__badge">DB · bundesweit</span>' : '';
li.innerHTML = `<span class="search-result__icon">${resultIcon(loc)}</span> ${loc.name}${badge}`;
li.innerHTML = `<span class="search-result__icon" aria-hidden="true">${resultIcon(loc)}</span> ${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 = `<div class="board-empty board-loading"><span class="spinner" aria-hidden="true"></span> ${escapeHtml(I18N.t('departures.loading'))}</div>`;
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 = `<div class="board-error">⚠ ${err.message}</div>`;
board.innerHTML = `
<div class="board-error">
⚠ ${escapeHtml(err.message)}
<button id="board-retry" class="text-button" type="button">${escapeHtml(I18N.t('common.retry'))}</button>
</div>
`;
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 = `<div class="board-empty">${escapeHtml(I18N.t('journey.searching'))}</div>`;
resultsEl.innerHTML = `<div class="board-empty board-loading"><span class="spinner" aria-hidden="true"></span> ${escapeHtml(I18N.t('journey.searching'))}</div>`;
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 = `<div class="board-error">⚠ ${err.message}</div>`;
resultsEl.innerHTML = `
<div class="board-error">
⚠ ${escapeHtml(err.message)}
<button id="journey-retry" class="text-button" type="button">${escapeHtml(I18N.t('common.retry'))}</button>
</div>
`;
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;
+23 -1
View File
@@ -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));
});
}
+28 -5
View File
@@ -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 };
})();
})();
+27 -3
View File
@@ -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 = `
<span class="trip-alarm-banner__icon">🔔</span>
<span class="trip-alarm-banner__icon" aria-hidden="true">🔔</span>
<span id="trip-alarm-banner__text" class="trip-alarm-banner__text"></span>
<button id="trip-alarm-banner__cancel" type="button">Alarm beenden</button>
`;
@@ -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 = `
<div class="trip-alarm-overlay__card">
<div class="trip-alarm-overlay__icon">⏰</div>
<h2>Aufwachen!</h2>
<div class="trip-alarm-overlay__icon" aria-hidden="true">⏰</div>
<h2 id="trip-alarm-overlay__heading">Aufwachen!</h2>
<p id="trip-alarm-overlay__text"></p>
<button id="trip-alarm-overlay__dismiss" type="button">Alarm beenden</button>
</div>
`;
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() {
+45 -3
View File
@@ -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 <body> 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) => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', "'":'&#39;', '"':'&quot;' }[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
? `<button class="trip-stopover__alarm-btn ${isActiveAlarm ? 'is-active' : ''}" data-alarm-index="${index}" type="button" title="${isActiveAlarm ? 'Fahrtalarm entfernen' : 'Fahrtalarm für diese Haltestelle setzen'}">${isActiveAlarm ? '🔔' : '🔕'}</button>`
? `<button class="trip-stopover__alarm-btn ${isActiveAlarm ? 'is-active' : ''}" data-alarm-index="${index}" type="button" aria-label="${escapeHtml(alarmLabel)}" aria-pressed="${isActiveAlarm}" title="${escapeHtml(alarmLabel)}"><span aria-hidden="true">${isActiveAlarm ? '🔔' : '🔕'}</span></button>`
: '';
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 = '<div class="trip-modal__loading"><span class="spinner"></span>Lade Fahrt-Details…</div>';
// 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 });