Add files via upload
This commit is contained in:
1 parent
c6e2fed82c
commit
7020ad99fc
7 files changed
+541
-94
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) => ({ '&':'&', '<':'<', '>':'>', "'":''', '"':'"' }[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 });
|
||||
|
||||
Reference in new issue
Block a user