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
+540 -93

No files matched your search

+64 -54
View File
@@ -17,6 +17,8 @@
<link rel="stylesheet" href="styles.css" /> <link rel="stylesheet" href="styles.css" />
</head> </head>
<body> <body>
<a class="skip-link" href="#main-content" data-i18n="a11y.skipLink">Zum Inhalt springen</a>
<header class="app-header"> <header class="app-header">
<button class="brand" data-tab="departures" aria-label="Zur Startseite"> <button class="brand" data-tab="departures" aria-label="Zur Startseite">
<span class="brand__mark">ÖPNV</span><span class="brand__mark brand__mark--accent">NAVI</span> <span class="brand__mark">ÖPNV</span><span class="brand__mark brand__mark--accent">NAVI</span>
@@ -24,34 +26,36 @@
<div class="search-box search-box--global"> <div class="search-box search-box--global">
<span class="search-box__icon" aria-hidden="true">⌕</span> <span class="search-box__icon" aria-hidden="true">⌕</span>
<input type="text" id="station-search" placeholder="Haltestelle, Ort oder Ziel suchen…" data-i18n-placeholder="search.placeholder" autocomplete="off" aria-label="Haltestelle, Ort oder Ziel suchen" data-i18n-aria-label="search.placeholder" /> <input type="text" id="station-search" placeholder="Haltestelle, Ort oder Ziel suchen…" data-i18n-placeholder="search.placeholder" autocomplete="off" role="combobox" aria-expanded="false" aria-controls="search-results" aria-autocomplete="list" aria-label="Haltestelle, Ort oder Ziel suchen" data-i18n-aria-label="search.placeholder" />
<button id="search-clear" class="search-clear" type="button" aria-label="Suche leeren" data-i18n-aria-label="search.clear" hidden>×</button> <button id="search-clear" class="search-clear" type="button" aria-label="Suche leeren" data-i18n-aria-label="search.clear" hidden>×</button>
<ul id="search-results" class="search-results" hidden></ul> <ul id="search-results" class="search-results" role="listbox" aria-label="Suchergebnisse" data-i18n-aria-label="search.resultsLabel" hidden></ul>
</div> </div>
<button id="header-favorites-btn" class="header-icon-btn" data-tab="favorites" type="button" title="Favoriten" data-i18n-title="nav.favorites" aria-label="Favoriten" data-i18n-aria-label="nav.favorites">★</button> <button id="header-favorites-btn" class="header-icon-btn" data-tab="favorites" type="button" title="Favoriten" data-i18n-title="nav.favorites" aria-label="Favoriten" data-i18n-aria-label="nav.favorites">★</button>
</header> </header>
<nav class="tab-nav" aria-label="Hauptnavigation"> <nav class="tab-nav" aria-label="Hauptnavigation">
<button class="tab-button is-active" data-tab="departures"><span class="tab-button__icon">🚏</span> <span data-i18n="nav.departures">Abfahrten</span></button> <div role="tablist" aria-label="Hauptnavigation" class="tab-nav__list">
<button class="tab-button" data-tab="journey"><span class="tab-button__icon">🧭</span> <span data-i18n="nav.route">Route</span></button> <button class="tab-button is-active" role="tab" aria-selected="true" aria-controls="panel-departures" id="tab-departures" tabindex="0" data-tab="departures"><span class="tab-button__icon" aria-hidden="true">🚏</span> <span data-i18n="nav.departures">Abfahrten</span></button>
<button class="tab-button" data-tab="map"><span class="tab-button__icon">🗺</span> <span data-i18n="nav.map">Karte</span></button> <button class="tab-button" role="tab" aria-selected="false" aria-controls="panel-journey" id="tab-journey" tabindex="-1" data-tab="journey"><span class="tab-button__icon" aria-hidden="true">🧭</span> <span data-i18n="nav.route">Route</span></button>
<button class="tab-button" data-tab="more"><span class="tab-button__icon">☰</span> <span data-i18n="nav.more">Mehr</span></button> <button class="tab-button" role="tab" aria-selected="false" aria-controls="panel-map" id="tab-map" tabindex="-1" data-tab="map"><span class="tab-button__icon" aria-hidden="true">🗺</span> <span data-i18n="nav.map">Karte</span></button>
<button class="tab-button" role="tab" aria-selected="false" aria-controls="panel-more" id="tab-more" tabindex="-1" data-tab="more"><span class="tab-button__icon" aria-hidden="true">☰</span> <span data-i18n="nav.more">Mehr</span></button>
</div>
</nav> </nav>
<main class="app-main"> <main class="app-main" id="main-content">
<section class="tab-panel" data-tab-panel="departures"> <section class="tab-panel" data-tab-panel="departures" id="panel-departures" role="tabpanel" aria-labelledby="tab-departures mobile-tab-departures" tabindex="-1">
<div class="welcome-panel" id="welcome-panel"> <div class="welcome-panel" id="welcome-panel">
<div class="welcome-panel__eyebrow">LIVE · VBB</div> <div class="welcome-panel__eyebrow">LIVE · VBB</div>
<h1 data-i18n="welcome.title">Was fährt jetzt?</h1> <h1 data-i18n="welcome.title">Was fährt jetzt?</h1>
<p data-i18n="welcome.text">Suche eine Haltestelle oder nutze deinen Standort, um die nächsten Abfahrten zu sehen.</p> <p data-i18n="welcome.text">Suche eine Haltestelle oder nutze deinen Standort, um die nächsten Abfahrten zu sehen.</p>
<div class="welcome-actions"> <div class="welcome-actions">
<button id="welcome-nearby" class="action-card action-card--primary" type="button"> <button id="welcome-nearby" class="action-card action-card--primary" type="button">
<span class="action-card__icon">📍</span> <span class="action-card__icon" aria-hidden="true">📍</span>
<span><strong data-i18n="welcome.nearbyTitle">In meiner Nähe</strong><small data-i18n="welcome.nearbyText">Haltestellen im Umkreis finden</small></span> <span><strong data-i18n="welcome.nearbyTitle">In meiner Nähe</strong><small data-i18n="welcome.nearbyText">Haltestellen im Umkreis finden</small></span>
</button> </button>
<button id="welcome-route" class="action-card" type="button"> <button id="welcome-route" class="action-card" type="button">
<span class="action-card__icon">🧭</span> <span class="action-card__icon" aria-hidden="true">🧭</span>
<span><strong data-i18n="welcome.routeTitle">Route planen</strong><small data-i18n="welcome.routeText">Von A nach B mit Bus &amp; Bahn</small></span> <span><strong data-i18n="welcome.routeTitle">Route planen</strong><small data-i18n="welcome.routeText">Von A nach B mit Bus &amp; Bahn</small></span>
</button> </button>
</div> </div>
@@ -73,58 +77,58 @@
</div> </div>
<button id="favorite-toggle" class="favorite-toggle" hidden>☆ <span data-i18n="favorites.favorite">Favorit</span></button> <button id="favorite-toggle" class="favorite-toggle" hidden>☆ <span data-i18n="favorites.favorite">Favorit</span></button>
</div> </div>
<div id="board-status" class="board-status" aria-live="polite"><span class="status-dot"></span> <span data-i18n="departures.refreshInfo">Aktualisierung alle 30 Sekunden</span></div> <div id="board-status" class="board-status" aria-live="polite"><span class="status-dot" aria-hidden="true"></span> <span data-i18n="departures.refreshInfo">Aktualisierung alle 30 Sekunden</span></div>
<div id="departures-mode-filter" class="mode-filter"></div> <div id="departures-mode-filter" class="mode-filter"></div>
<div id="departure-board" class="departure-board" role="table" aria-label="Abfahrtstafel"></div> <div id="departure-board" class="departure-board" role="list" aria-label="Abfahrtstafel"></div>
<div id="board-empty-state" class="board-empty-state" data-i18n="departures.emptyState">Suche oben eine Haltestelle, um Live-Abfahrten zu sehen.</div> <div id="board-empty-state" class="board-empty-state" data-i18n="departures.emptyState">Suche oben eine Haltestelle, um Live-Abfahrten zu sehen.</div>
<div id="elevator-status" class="elevator-status" hidden></div> <div id="elevator-status" class="elevator-status" aria-live="polite" hidden></div>
<div id="station-disruption" class="station-disruption" hidden></div> <div id="station-disruption" class="station-disruption" aria-live="polite" hidden></div>
</div> </div>
</section> </section>
<section class="tab-panel" data-tab-panel="map" hidden> <section class="tab-panel" data-tab-panel="map" id="panel-map" role="tabpanel" aria-labelledby="tab-map mobile-tab-map" tabindex="-1" hidden>
<div class="panel-heading"><div><span class="section-heading__eyebrow" data-i18n="common.live">LIVE</span><h1 data-i18n="map.title">Karte</h1><p data-i18n="map.text">Fahrzeuge, Routen und deine aktuelle Fahrt live verfolgen.</p></div></div> <div class="panel-heading"><div><span class="section-heading__eyebrow" data-i18n="common.live">LIVE</span><h1 data-i18n="map.title">Karte</h1><p data-i18n="map.text">Fahrzeuge, Routen und deine aktuelle Fahrt live verfolgen.</p></div></div>
<div class="map-controls"> <div class="map-controls">
<div id="map-mode-filter" class="mode-filter"></div> <div id="map-mode-filter" class="mode-filter"></div>
<button id="map-locate" class="map-locate-btn" type="button">📍 <span data-i18n="map.locate">Mein Standort</span></button> <button id="map-locate" class="map-locate-btn" type="button">📍 <span data-i18n="map.locate">Mein Standort</span></button>
</div> </div>
<div id="map-isolation-banner" class="map-isolation-banner"> <div id="map-isolation-banner" class="map-isolation-banner" aria-live="polite">
<span><span data-i18n="map.following">Verfolge Linie</span> <span id="map-isolation-line" class="map-isolation-banner__line"></span> <span data-i18n="map.live">live</span></span> <span><span data-i18n="map.following">Verfolge Linie</span> <span id="map-isolation-line" class="map-isolation-banner__line"></span> <span data-i18n="map.live">live</span></span>
<button id="map-isolation-reset" type="button" data-i18n="map.showAllLines">Alle Linien anzeigen</button> <button id="map-isolation-reset" type="button" data-i18n="map.showAllLines">Alle Linien anzeigen</button>
</div> </div>
<div id="live-map" class="live-map"></div> <div id="live-map" class="live-map" role="application" aria-label="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." data-i18n-aria-label="map.a11yDescription"></div>
</section> </section>
<section class="tab-panel" data-tab-panel="journey" hidden> <section class="tab-panel" data-tab-panel="journey" id="panel-journey" role="tabpanel" aria-labelledby="tab-journey mobile-tab-journey" tabindex="-1" hidden>
<div class="panel-heading"><div><span class="section-heading__eyebrow" data-i18n="journey.eyebrow">VERBINDUNG SUCHEN</span><h1 data-i18n="journey.title">Wohin möchtest du?</h1><p data-i18n="journey.text">Starte mit deinem Standort oder suche einen Ausgangspunkt.</p></div></div> <div class="panel-heading"><div><span class="section-heading__eyebrow" data-i18n="journey.eyebrow">VERBINDUNG SUCHEN</span><h1 data-i18n="journey.title">Wohin möchtest du?</h1><p data-i18n="journey.text">Starte mit deinem Standort oder suche einen Ausgangspunkt.</p></div></div>
<div class="journey-card-form"> <div class="journey-card-form">
<div class="journey-input-row"> <div class="journey-input-row">
<div class="journey-input-marker journey-input-marker--start">●</div> <div class="journey-input-marker journey-input-marker--start" aria-hidden="true">●</div>
<div class="search-box"> <div class="search-box">
<input type="text" id="journey-from" placeholder="Mein Standort oder Start…" data-i18n-placeholder="journey.fromPlaceholder" autocomplete="off" /> <input type="text" id="journey-from" placeholder="Mein Standort oder Start…" data-i18n-placeholder="journey.fromPlaceholder" aria-label="Start der Route" data-i18n-aria-label="journey.fromPlaceholder" role="combobox" aria-expanded="false" aria-controls="journey-from-results" aria-autocomplete="list" autocomplete="off" />
<ul id="journey-from-results" class="search-results" hidden></ul> <ul id="journey-from-results" class="search-results" role="listbox" aria-label="Vorschläge für Start" hidden></ul>
</div> </div>
<button id="journey-use-location" class="location-btn" title="Meinen Standort verwenden" data-i18n-title="journey.useLocation" aria-label="Meinen Standort verwenden" data-i18n-aria-label="journey.useLocation">📍</button> <button id="journey-use-location" class="location-btn" title="Meinen Standort verwenden" data-i18n-title="journey.useLocation" aria-label="Meinen Standort verwenden" data-i18n-aria-label="journey.useLocation">📍</button>
</div> </div>
<div class="journey-connector"></div> <div class="journey-connector" aria-hidden="true"></div>
<div class="journey-input-row"> <div class="journey-input-row">
<div class="journey-input-marker journey-input-marker--end">●</div> <div class="journey-input-marker journey-input-marker--end" aria-hidden="true">●</div>
<div class="search-box"> <div class="search-box">
<input type="text" id="journey-to" placeholder="Wohin?" data-i18n-placeholder="journey.toPlaceholder" autocomplete="off" /> <input type="text" id="journey-to" placeholder="Wohin?" data-i18n-placeholder="journey.toPlaceholder" aria-label="Ziel der Route" data-i18n-aria-label="journey.toPlaceholder" role="combobox" aria-expanded="false" aria-controls="journey-to-results" aria-autocomplete="list" autocomplete="off" />
<ul id="journey-to-results" class="search-results" hidden></ul> <ul id="journey-to-results" class="search-results" role="listbox" aria-label="Vorschläge für Ziel" hidden></ul>
</div> </div>
</div> </div>
</div> </div>
<div class="journey-when"> <div class="journey-when" role="group" aria-label="Zeitpunkt der Fahrt">
<button class="when-choice is-active" data-when="now" type="button">● <span data-i18n="journey.now">Jetzt</span></button> <button class="when-choice is-active" data-when="now" type="button" aria-pressed="true">● <span data-i18n="journey.now">Jetzt</span></button>
<button class="when-choice" data-when="departure" type="button" data-i18n="journey.departureAt">Abfahrt um</button> <button class="when-choice" data-when="departure" type="button" aria-pressed="false" data-i18n="journey.departureAt">Abfahrt um</button>
<button class="when-choice" data-when="arrival" type="button" data-i18n="journey.arrivalAt">Ankunft um</button> <button class="when-choice" data-when="arrival" type="button" aria-pressed="false" data-i18n="journey.arrivalAt">Ankunft um</button>
<input type="datetime-local" id="journey-when" class="journey-datetime" hidden /> <input type="datetime-local" id="journey-when" class="journey-datetime" aria-label="Datum und Uhrzeit" hidden />
</div> </div>
<label class="wheelchair-toggle"> <label class="wheelchair-toggle">
<input type="checkbox" id="journey-wheelchair" /> <input type="checkbox" id="journey-wheelchair" />
<span>♿ <span data-i18n="journey.wheelchair">Nur rollstuhlgerechte Verbindungen</span></span> <span><span aria-hidden="true">♿</span> <span data-i18n="journey.wheelchair">Nur rollstuhlgerechte Verbindungen</span></span>
</label> </label>
<div class="journey-options-row"> <div class="journey-options-row">
<div class="transfer-slack-field"> <div class="transfer-slack-field">
@@ -138,13 +142,13 @@
<option value="15" data-i18n="journey.transferSlack15">15 min (sicher)</option> <option value="15" data-i18n="journey.transferSlack15">15 min (sicher)</option>
</select> </select>
</div> </div>
<button id="journey-submit" class="journey-submit" type="button"><span data-i18n="journey.submit">Verbindungen suchen</span> <span>→</span></button> <button id="journey-submit" class="journey-submit" type="button"><span data-i18n="journey.submit">Verbindungen suchen</span> <span aria-hidden="true">→</span></button>
</div> </div>
<p class="journey-source-note" data-i18n="journey.sourceNote">🛰 Routenplanung über unsere selbst gehostete, offene Instanz (VBB-Region)</p> <p class="journey-source-note" data-i18n="journey.sourceNote">🛰 Routenplanung über unsere selbst gehostete, offene Instanz (VBB-Region)</p>
<div id="journey-share-row" class="journey-share-row" hidden> <div id="journey-share-row" class="journey-share-row" hidden>
<button id="journey-share-btn" class="text-button" type="button">🔗 <span data-i18n="journey.share">Route teilen</span></button> <button id="journey-share-btn" class="text-button" type="button"><span aria-hidden="true">🔗</span> <span data-i18n="journey.share">Route teilen</span></button>
</div> </div>
<div id="journey-results" class="journey-results"></div> <div id="journey-results" class="journey-results" aria-live="polite"></div>
</section> </section>
<section class="tab-panel" data-tab-panel="nearby" hidden> <section class="tab-panel" data-tab-panel="nearby" hidden>
@@ -158,12 +162,16 @@
<div><span class="section-heading__eyebrow" data-i18n="favorites.eyebrow">SCHNELLZUGRIFF</span><h1 data-i18n="favorites.title">Meine Haltestellen</h1><p data-i18n="favorites.text">Deine gespeicherten Haltestellen mit Live-Abfahrten und Störungshinweisen.</p></div> <div><span class="section-heading__eyebrow" data-i18n="favorites.eyebrow">SCHNELLZUGRIFF</span><h1 data-i18n="favorites.title">Meine Haltestellen</h1><p data-i18n="favorites.text">Deine gespeicherten Haltestellen mit Live-Abfahrten und Störungshinweisen.</p></div>
</div> </div>
<div id="favorites-home" class="home-section"> <div id="favorites-home" class="home-section">
<div class="section-heading"> <div id="notify-callout" class="notify-callout" hidden>
<div></div> <div class="notify-callout__icon" aria-hidden="true">🔔</div>
<button id="notify-toggle" class="text-button" type="button" hidden>🔔 <span data-i18n="favorites.enableNotify">Störungsbenachrichtigungen aktivieren</span></button> <div class="notify-callout__text">
<strong data-i18n="favorites.enableNotifyTitle">Störungen sofort mitbekommen</strong>
<small data-i18n="favorites.enableNotifyText">Auch wenn die App gerade nicht offen ist.</small>
</div>
<button id="notify-toggle" class="notify-callout__btn" type="button" data-i18n="favorites.enableNotifyShort">Aktivieren</button>
</div> </div>
<p id="favorites-empty-hint" class="favorites-empty-hint" data-i18n="favorites.emptyHint">Noch keine Favoriten - tippe bei einer Haltestelle auf ☆, um sie hier anzupinnen.</p> <p id="favorites-empty-hint" class="favorites-empty-hint" data-i18n="favorites.emptyHint">Noch keine Favoriten - tippe bei einer Haltestelle auf ☆, um sie hier anzupinnen.</p>
<div id="favorite-cards" class="favorite-cards"></div> <div id="favorite-cards" class="favorite-cards" role="list"></div>
</div> </div>
</section> </section>
@@ -172,39 +180,39 @@
<div class="disruption-section"> <div class="disruption-section">
<div class="section-heading"><div><h2 data-i18n="disruptions.nearbyTitle">In deiner Nähe</h2></div></div> <div class="section-heading"><div><h2 data-i18n="disruptions.nearbyTitle">In deiner Nähe</h2></div></div>
<button id="disruptions-locate" class="location-btn location-btn--wide">📍 <span data-i18n="disruptions.useLocation">Standort verwenden</span></button> <button id="disruptions-locate" class="location-btn location-btn--wide">📍 <span data-i18n="disruptions.useLocation">Standort verwenden</span></button>
<div id="nearby-disruptions-list" class="remarks-list"><div class="board-empty-state" data-i18n="disruptions.nearbyEmpty">Nutze deinen Standort für ortsbezogene Störungen.</div></div> <div id="nearby-disruptions-list" class="remarks-list" aria-live="polite"><div class="board-empty-state" data-i18n="disruptions.nearbyEmpty">Nutze deinen Standort für ortsbezogene Störungen.</div></div>
</div> </div>
<div class="disruption-section"> <div class="disruption-section">
<div class="section-heading"><div><h2 data-i18n="disruptions.networkTitle">Netzweit</h2></div></div> <div class="section-heading"><div><h2 data-i18n="disruptions.networkTitle">Netzweit</h2></div></div>
<div id="remarks-list" class="remarks-list"><div class="board-empty-state" data-i18n="disruptions.loading">Störungsmeldungen werden geladen…</div></div> <div id="remarks-list" class="remarks-list" aria-live="polite"><div class="board-empty-state" data-i18n="disruptions.loading">Störungsmeldungen werden geladen…</div></div>
</div> </div>
</section> </section>
<section class="tab-panel" data-tab-panel="fares" hidden> <section class="tab-panel" data-tab-panel="fares" hidden>
<div class="panel-heading"><div><span class="section-heading__eyebrow" data-i18n="fares.eyebrow">PREISE</span><h1 data-i18n="fares.title">Tickets</h1><p data-i18n="fares.text">Übliche VBB-Fahrpreise auf einen Blick.</p></div></div> <div class="panel-heading"><div><span class="section-heading__eyebrow" data-i18n="fares.eyebrow">PREISE</span><h1 data-i18n="fares.title">Tickets</h1><p data-i18n="fares.text">Übliche VBB-Fahrpreise auf einen Blick.</p></div></div>
<div class="disclaimer-banner">⚠ <span data-i18n="fares.disclaimer">Angaben ohne Gewähr – Preise können sich ändern.</span> <span id="fares-stand"></span></div> <div class="disclaimer-banner"><span aria-hidden="true">⚠</span> <span data-i18n="fares.disclaimer">Angaben ohne Gewähr – Preise können sich ändern.</span> <span id="fares-stand"></span></div>
<div id="fares-content"><div class="board-empty-state" data-i18n="fares.loading">Lade Fahrpreise…</div></div> <div id="fares-content"><div class="board-empty-state" data-i18n="fares.loading">Lade Fahrpreise…</div></div>
<div class="section-heading"><div><h2 data-i18n="fares.buyTitle">Tickets kaufen</h2></div></div> <div class="section-heading"><div><h2 data-i18n="fares.buyTitle">Tickets kaufen</h2></div></div>
<div id="fares-companies" class="company-links"></div> <div id="fares-companies" class="company-links"></div>
</section> </section>
<section class="tab-panel" data-tab-panel="more" hidden> <section class="tab-panel" data-tab-panel="more" id="panel-more" role="tabpanel" aria-labelledby="tab-more mobile-tab-more" tabindex="-1" hidden>
<div class="panel-heading"><div><span class="section-heading__eyebrow" data-i18n="more.eyebrow">WEITERE FUNKTIONEN</span><h1 data-i18n="nav.more">Mehr</h1><p data-i18n="more.text">Alles, was nicht bei jeder Fahrt gebraucht wird.</p></div></div> <div class="panel-heading"><div><span class="section-heading__eyebrow" data-i18n="more.eyebrow">WEITERE FUNKTIONEN</span><h1 data-i18n="nav.more">Mehr</h1><p data-i18n="more.text">Alles, was nicht bei jeder Fahrt gebraucht wird.</p></div></div>
<div class="more-grid"> <div class="more-grid">
<button class="more-card" data-open-tab="favorites" type="button"><span>★</span><strong data-i18n="favorites.title">Favoriten</strong><small data-i18n="more.favoritesText">Deine gespeicherten Haltestellen</small></button> <button class="more-card" data-open-tab="favorites" type="button"><span aria-hidden="true">★</span><strong data-i18n="favorites.title">Favoriten</strong><small data-i18n="more.favoritesText">Deine gespeicherten Haltestellen</small></button>
<button class="more-card" data-open-tab="nearby" type="button"><span>📍</span><strong data-i18n="nearby.title">In der Nähe</strong><small data-i18n="more.nearbyText">Haltestellen in deiner Umgebung</small></button> <button class="more-card" data-open-tab="nearby" type="button"><span aria-hidden="true">📍</span><strong data-i18n="nearby.title">In der Nähe</strong><small data-i18n="more.nearbyText">Haltestellen in deiner Umgebung</small></button>
<button class="more-card" data-open-tab="disruptions" type="button"><span>⚠</span><strong data-i18n="disruptions.title">Störungen</strong><small data-i18n="more.disruptionsText">Ortsbezogen &amp; netzweit</small></button> <button class="more-card" data-open-tab="disruptions" type="button"><span aria-hidden="true">⚠</span><strong data-i18n="disruptions.title">Störungen</strong><small data-i18n="more.disruptionsText">Ortsbezogen &amp; netzweit</small></button>
<button class="more-card" data-open-tab="fares" type="button"><span>🎫</span><strong data-i18n="fares.title">Tickets</strong><small data-i18n="more.faresText">Preise &amp; wo kaufen</small></button> <button class="more-card" data-open-tab="fares" type="button"><span aria-hidden="true">🎫</span><strong data-i18n="fares.title">Tickets</strong><small data-i18n="more.faresText">Preise &amp; wo kaufen</small></button>
</div> </div>
<div class="recent-section"> <div class="recent-section">
<div class="section-heading"><div><span class="section-heading__eyebrow" data-i18n="more.historyEyebrow">VERLAUF</span><h2 data-i18n="more.historyTitle">Zuletzt gesucht</h2></div><button id="clear-recent" class="text-button" type="button" data-i18n="common.clear">Leeren</button></div> <div class="section-heading"><div><span class="section-heading__eyebrow" data-i18n="more.historyEyebrow">VERLAUF</span><h2 data-i18n="more.historyTitle">Zuletzt gesucht</h2></div><button id="clear-recent" class="text-button" type="button" data-i18n="common.clear">Leeren</button></div>
<div id="recent-searches" class="recent-searches"></div> <div id="recent-searches" class="recent-searches" role="list"></div>
</div> </div>
<div class="recent-section"> <div class="recent-section">
<div class="section-heading"><div><span class="section-heading__eyebrow" data-i18n="more.langEyebrow">SPRACHE / LANGUAGE</span><h2 data-i18n="more.langTitle">Sprache</h2></div></div> <div class="section-heading"><div><span class="section-heading__eyebrow" data-i18n="more.langEyebrow">SPRACHE / LANGUAGE</span><h2 data-i18n="more.langTitle">Sprache</h2></div></div>
<div class="lang-switch"> <div class="lang-switch" role="group" aria-label="Sprache / Language">
<button id="lang-de" class="lang-switch__btn" type="button" data-lang="de">Deutsch</button> <button id="lang-de" class="lang-switch__btn" type="button" data-lang="de" aria-pressed="true">Deutsch</button>
<button id="lang-en" class="lang-switch__btn" type="button" data-lang="en">English</button> <button id="lang-en" class="lang-switch__btn" type="button" data-lang="en" aria-pressed="false">English</button>
</div> </div>
</div> </div>
</section> </section>
@@ -213,10 +221,12 @@
<div class="global-disclaimer" data-i18n="common.footer">ℹ Live-Daten von VBB · Angaben ohne Gewähr</div> <div class="global-disclaimer" data-i18n="common.footer">ℹ Live-Daten von VBB · Angaben ohne Gewähr</div>
<nav class="mobile-nav" aria-label="Hauptnavigation mobil"> <nav class="mobile-nav" aria-label="Hauptnavigation mobil">
<button class="mobile-nav__item is-active" data-tab="departures"><span>🚏</span><small data-i18n="nav.departures">Abfahrten</small></button> <div role="tablist" aria-label="Hauptnavigation mobil" class="mobile-nav__list">
<button class="mobile-nav__item" data-tab="journey"><span>🧭</span><small data-i18n="nav.route">Route</small></button> <button class="mobile-nav__item is-active" role="tab" aria-selected="true" aria-controls="panel-departures" id="mobile-tab-departures" tabindex="0" data-tab="departures"><span aria-hidden="true">🚏</span><small data-i18n="nav.departures">Abfahrten</small></button>
<button class="mobile-nav__item" data-tab="map"><span>🗺</span><small data-i18n="nav.map">Karte</small></button> <button class="mobile-nav__item" role="tab" aria-selected="false" aria-controls="panel-journey" id="mobile-tab-journey" tabindex="-1" data-tab="journey"><span aria-hidden="true">🧭</span><small data-i18n="nav.route">Route</small></button>
<button class="mobile-nav__item" data-tab="more"><span>☰</span><small data-i18n="nav.more">Mehr</small></button> <button class="mobile-nav__item" role="tab" aria-selected="false" aria-controls="panel-map" id="mobile-tab-map" tabindex="-1" data-tab="map"><span aria-hidden="true">🗺</span><small data-i18n="nav.map">Karte</small></button>
<button class="mobile-nav__item" role="tab" aria-selected="false" aria-controls="panel-more" id="mobile-tab-more" tabindex="-1" data-tab="more"><span aria-hidden="true">☰</span><small data-i18n="nav.more">Mehr</small></button>
</div>
</nav> </nav>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
+192 -21
View File
@@ -125,6 +125,7 @@ const App = (() => {
function toggleFavorite(station) { function toggleFavorite(station) {
const favorites = loadFavorites(); const favorites = loadFavorites();
const idx = favorites.findIndex((f) => f.id === station.id); const idx = favorites.findIndex((f) => f.id === station.id);
const wasAdded = idx < 0;
if (idx >= 0) { if (idx >= 0) {
favorites.splice(idx, 1); favorites.splice(idx, 1);
} else { } else {
@@ -135,6 +136,7 @@ const App = (() => {
renderRecentSearches(); renderRecentSearches();
updateFavoriteButton(); updateFavoriteButton();
if (window.Push) Push.syncFavorites(favorites); if (window.Push) Push.syncFavorites(favorites);
showToast(wasAdded ? `★ ${station.name} zu Favoriten hinzugefügt` : `☆ ${station.name} aus Favoriten entfernt`, 'success');
} }
function renderFavoriteChips() { function renderFavoriteChips() {
@@ -314,23 +316,24 @@ const App = (() => {
function updateNotifyToggleVisibility(hasFavorites) { function updateNotifyToggleVisibility(hasFavorites) {
const btn = document.getElementById('notify-toggle'); const btn = document.getElementById('notify-toggle');
if (!btn) return; const callout = document.getElementById('notify-callout');
if (!btn || !callout) return;
if (!hasFavorites || !window.Notification) { if (!hasFavorites || !window.Notification) {
btn.hidden = true; callout.hidden = true;
return; return;
} }
btn.hidden = false; callout.hidden = false;
callout.classList.remove('notify-callout--active', 'notify-callout--blocked');
if (Notification.permission === 'granted') { if (Notification.permission === 'granted') {
btn.textContent = `🔔 ${I18N.t('favorites.notifyActive')}`; btn.textContent = I18N.t('favorites.notifyActiveShort');
btn.classList.add('is-active'); callout.classList.add('notify-callout--active');
btn.disabled = true; btn.disabled = true;
} else if (Notification.permission === 'denied') { } else if (Notification.permission === 'denied') {
btn.textContent = `🔕 ${I18N.t('favorites.notifyBlocked')}`; btn.textContent = I18N.t('favorites.notifyBlockedShort');
btn.classList.remove('is-active'); callout.classList.add('notify-callout--blocked');
btn.disabled = true; btn.disabled = true;
} else { } else {
btn.textContent = `🔔 ${I18N.t('favorites.enableNotify')}`; btn.textContent = I18N.t('favorites.enableNotifyShort');
btn.classList.remove('is-active');
btn.disabled = false; btn.disabled = false;
} }
} }
@@ -389,10 +392,12 @@ const App = (() => {
} }
let searchDebounce = null; let searchDebounce = null;
let searchRequestId = 0;
function initSearch() { function initSearch() {
const input = document.getElementById('station-search'); const input = document.getElementById('station-search');
const results = document.getElementById('search-results'); const results = document.getElementById('search-results');
wireSearchKeyboardNav(input, results);
// Leeres Suchfeld antippen: zuletzt gesuchte Haltestellen als Vorschläge // Leeres Suchfeld antippen: zuletzt gesuchte Haltestellen als Vorschläge
// zeigen, statt dass man erst tippen muss, um überhaupt etwas zu sehen. // zeigen, statt dass man erst tippen muss, um überhaupt etwas zu sehen.
@@ -403,7 +408,7 @@ const App = (() => {
results.innerHTML = ''; results.innerHTML = '';
return; 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) => { renderSearchResults(results, recents, (station) => {
selectStation(station); selectStation(station);
input.value = ''; input.value = '';
@@ -426,15 +431,22 @@ const App = (() => {
return; return;
} }
const requestId = ++searchRequestId;
searchDebounce = setTimeout(async () => { searchDebounce = setTimeout(async () => {
try { try {
const stations = await API.searchAll(query); 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) => { renderSearchResults(results, stations, (station) => {
selectStation(station); selectStation(station);
input.value = ''; input.value = '';
results.hidden = true; results.hidden = true;
}); });
} catch (err) { } catch (err) {
if (requestId !== searchRequestId) return;
console.error('Suchfehler:', err.message); console.error('Suchfehler:', err.message);
} }
}, 250); }, 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 // Ein Symbol pro Ergebnis-Herkunft: VBB-Haltestelle (Nahverkehr), echter
// DB-Bahnhof (bundesweit), oder Adresse/POI (nur im Routenplaner relevant). // DB-Bahnhof (bundesweit), oder Adresse/POI (nur im Routenplaner relevant).
function resultIcon(loc) { function resultIcon(loc) {
@@ -463,7 +547,7 @@ const App = (() => {
if (!append) container.innerHTML = ''; if (!append) container.innerHTML = '';
if (locations.length === 0) { 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; container.hidden = false;
return; return;
} }
@@ -471,8 +555,10 @@ const App = (() => {
locations.forEach((loc) => { locations.forEach((loc) => {
const li = document.createElement('li'); const li = document.createElement('li');
li.className = 'search-result'; 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>' : ''; 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)); li.addEventListener('click', () => onSelect(loc));
container.appendChild(li); container.appendChild(li);
}); });
@@ -486,11 +572,26 @@ const App = (() => {
saveRecentSearch(station); saveRecentSearch(station);
activateTab('departures'); activateTab('departures');
document.getElementById('board-station-name').textContent = station.name; document.getElementById('board-station-name').textContent = station.name;
document.title = `${station.name} · ÖPNV Navi`;
document.getElementById('board-empty-state').hidden = true; document.getElementById('board-empty-state').hidden = true;
document.getElementById('welcome-panel').hidden = true; document.getElementById('welcome-panel').hidden = true;
document.getElementById('departures-view').hidden = false; document.getElementById('departures-view').hidden = false;
updateFavoriteButton(); 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, // Störungsanzeige der zuvor gewählten Haltestelle sofort verstecken,
// damit nicht kurz die alten Störungen der letzten Station aufblitzen, // damit nicht kurz die alten Störungen der letzten Station aufblitzen,
// bevor die neuen Abfahrten geladen sind. // bevor die neuen Abfahrten geladen sind.
@@ -521,7 +622,14 @@ const App = (() => {
renderDepartureBoard(data.departures); renderDepartureBoard(data.departures);
renderStationDisruptions(data.disruptions); renderStationDisruptions(data.disruptions);
} catch (err) { } 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(); 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; return row;
@@ -660,6 +773,9 @@ const App = (() => {
const lineCell = row.querySelector('[data-field="line"]'); const lineCell = row.querySelector('[data-field="line"]');
lineCell.style.setProperty('--line-color', dep.color || '#8E99A6'); lineCell.style.setProperty('--line-color', dep.color || '#8E99A6');
lineCell.style.setProperty('--line-text-color', dep.textColor || '#fff'); 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(lineCell, (dep.line || '?').toUpperCase());
SplitFlap.render(row.querySelector('[data-field="direction"]'), (dep.direction || '').toUpperCase()); SplitFlap.render(row.querySelector('[data-field="direction"]'), (dep.direction || '').toUpperCase());
@@ -719,18 +835,58 @@ const App = (() => {
function initTabs() { function initTabs() {
document.querySelectorAll('[data-tab]').forEach((tab) => { 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) => { 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) => { 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') { if (tabName === 'map') {
LiveMap.startPolling(); LiveMap.startPolling();
} else { } else {
@@ -973,7 +1129,11 @@ const App = (() => {
// Setzt "Jetzt/Abfahrt um/Ankunft um" programmatisch (Klick-Handler UND // Setzt "Jetzt/Abfahrt um/Ankunft um" programmatisch (Klick-Handler UND
// Wiederherstellung eines geteilten Links nutzen dieselbe Logik). // Wiederherstellung eines geteilten Links nutzen dieselbe Logik).
function setWhenChoice(mode, whenValue) { 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'; journeyArrivalMode = mode === 'arrival';
const when = document.getElementById('journey-when'); const when = document.getElementById('journey-when');
when.hidden = mode === 'now'; when.hidden = mode === 'now';
@@ -997,7 +1157,7 @@ const App = (() => {
return; 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; if (shareRow) shareRow.hidden = true;
const whenInput = document.getElementById('journey-when').value; const whenInput = document.getElementById('journey-when').value;
@@ -1019,7 +1179,14 @@ const App = (() => {
renderJourneys(journeys, resultsEl); renderJourneys(journeys, resultsEl);
if (shareRow) shareRow.hidden = false; if (shareRow) shareRow.hidden = false;
} catch (err) { } 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 { try {
await navigator.clipboard.writeText(url); await navigator.clipboard.writeText(url);
showToast('Link kopiert!', 'info'); showToast('Link kopiert!', 'success');
} catch (err) { } catch (err) {
console.error('Clipboard-Fehler:', err.message); console.error('Clipboard-Fehler:', err.message);
window.prompt('Link kopieren:', url); window.prompt('Link kopieren:', url);
@@ -1123,6 +1290,8 @@ const App = (() => {
const input = document.getElementById(inputId); const input = document.getElementById(inputId);
const results = document.getElementById(`${inputId}-results`); const results = document.getElementById(`${inputId}-results`);
let debounce = null; let debounce = null;
let requestId = 0;
wireSearchKeyboardNav(input, results);
input.addEventListener('input', () => { input.addEventListener('input', () => {
clearTimeout(debounce); clearTimeout(debounce);
@@ -1131,9 +1300,11 @@ const App = (() => {
results.hidden = true; results.hidden = true;
return; return;
} }
const thisRequestId = ++requestId;
debounce = setTimeout(async () => { debounce = setTimeout(async () => {
try { try {
const { locations } = await API.searchLocations(query); const { locations } = await API.searchLocations(query);
if (thisRequestId !== requestId) return; // veraltete Antwort, verwerfen
renderSearchResults(results, locations, (loc) => { renderSearchResults(results, locations, (loc) => {
onSelect({ id: loc.id || undefined, lat: loc.latitude, lon: loc.longitude, name: loc.name, kind: loc.kind }); onSelect({ id: loc.id || undefined, lat: loc.latitude, lon: loc.longitude, name: loc.name, kind: loc.kind });
input.value = loc.name; input.value = loc.name;
+23 -1
View File
@@ -28,6 +28,7 @@ const I18N = (() => {
'common.live': 'LIVE', 'common.live': 'LIVE',
'common.current': 'AKTUELL', 'common.current': 'AKTUELL',
'common.clear': 'Leeren', 'common.clear': 'Leeren',
'common.retry': 'Erneut versuchen',
'common.footer': 'ℹ Live-Daten von VBB · Angaben ohne Gewähr', 'common.footer': 'ℹ Live-Daten von VBB · Angaben ohne Gewähr',
'welcome.title': 'Was fährt jetzt?', 'welcome.title': 'Was fährt jetzt?',
'welcome.text': 'Suche eine Haltestelle oder nutze deinen Standort, um die nächsten Abfahrten zu sehen.', '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.noStation': 'Keine Haltestelle ausgewählt',
'departures.refreshInfo': 'Aktualisierung alle 30 Sekunden', 'departures.refreshInfo': 'Aktualisierung alle 30 Sekunden',
'departures.emptyState': 'Suche oben eine Haltestelle, um Live-Abfahrten zu sehen.', 'departures.emptyState': 'Suche oben eine Haltestelle, um Live-Abfahrten zu sehen.',
'departures.loading': 'Lade Abfahrten…',
'departures.line': 'Linie', 'departures.line': 'Linie',
'departures.direction': 'Ziel', 'departures.direction': 'Ziel',
'departures.platform': 'Gleis', 'departures.platform': 'Gleis',
@@ -59,8 +61,13 @@ const I18N = (() => {
'favorites.title': 'Meine Haltestellen', 'favorites.title': 'Meine Haltestellen',
'favorites.text': 'Deine gespeicherten Haltestellen mit Live-Abfahrten und Störungshinweisen.', 'favorites.text': 'Deine gespeicherten Haltestellen mit Live-Abfahrten und Störungshinweisen.',
'favorites.enableNotify': 'Störungsbenachrichtigungen aktivieren', '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.notifyActive': 'Benachrichtigungen aktiv',
'favorites.notifyActiveShort': '✓ Aktiv',
'favorites.notifyBlocked': 'Benachrichtigungen blockiert', 'favorites.notifyBlocked': 'Benachrichtigungen blockiert',
'favorites.notifyBlockedShort': 'Blockiert',
'favorites.emptyHint': 'Noch keine Favoriten - tippe bei einer Haltestelle auf ☆, um sie hier anzupinnen.', 'favorites.emptyHint': 'Noch keine Favoriten - tippe bei einer Haltestelle auf ☆, um sie hier anzupinnen.',
'favorites.loadingNext': 'Lade nächste Abfahrt…', 'favorites.loadingNext': 'Lade nächste Abfahrt…',
'favorites.noDeparture': 'Keine Abfahrt gefunden', 'favorites.noDeparture': 'Keine Abfahrt gefunden',
@@ -117,8 +124,11 @@ const I18N = (() => {
'more.langTitle': 'Sprache', 'more.langTitle': 'Sprache',
'search.noResults': 'Keine Treffer', 'search.noResults': 'Keine Treffer',
'search.recentSearches': 'Zuletzt gesucht', 'search.recentSearches': 'Zuletzt gesucht',
'search.resultsLabel': 'Suchergebnisse',
'recent.empty': 'Noch keine Suchen.', 'recent.empty': 'Noch keine Suchen.',
'recent.remove': '{name} aus Verlauf entfernen', '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: { en: {
'search.placeholder': 'Search for a stop, place or destination…', 'search.placeholder': 'Search for a stop, place or destination…',
@@ -133,6 +143,7 @@ const I18N = (() => {
'common.live': 'LIVE', 'common.live': 'LIVE',
'common.current': 'CURRENT', 'common.current': 'CURRENT',
'common.clear': 'Clear', 'common.clear': 'Clear',
'common.retry': 'Try again',
'common.footer': 'ℹ Live data from VBB · No guarantee of accuracy', 'common.footer': 'ℹ Live data from VBB · No guarantee of accuracy',
'welcome.title': 'What\u2019s leaving now?', 'welcome.title': 'What\u2019s leaving now?',
'welcome.text': 'Search for a stop or use your location to see the next departures.', '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.noStation': 'No stop selected',
'departures.refreshInfo': 'Updates every 30 seconds', 'departures.refreshInfo': 'Updates every 30 seconds',
'departures.emptyState': 'Search for a stop above to see live departures.', 'departures.emptyState': 'Search for a stop above to see live departures.',
'departures.loading': 'Loading departures…',
'departures.line': 'Line', 'departures.line': 'Line',
'departures.direction': 'Towards', 'departures.direction': 'Towards',
'departures.platform': 'Platform', 'departures.platform': 'Platform',
@@ -164,8 +176,13 @@ const I18N = (() => {
'favorites.title': 'My Stops', 'favorites.title': 'My Stops',
'favorites.text': 'Your saved stops with live departures and disruption notices.', 'favorites.text': 'Your saved stops with live departures and disruption notices.',
'favorites.enableNotify': 'Enable disruption notifications', '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.notifyActive': 'Notifications active',
'favorites.notifyActiveShort': '✓ Active',
'favorites.notifyBlocked': 'Notifications blocked', 'favorites.notifyBlocked': 'Notifications blocked',
'favorites.notifyBlockedShort': 'Blocked',
'favorites.emptyHint': 'No favorites yet - tap ☆ at a stop to pin it here.', 'favorites.emptyHint': 'No favorites yet - tap ☆ at a stop to pin it here.',
'favorites.loadingNext': 'Loading next departure…', 'favorites.loadingNext': 'Loading next departure…',
'favorites.noDeparture': 'No departure found', 'favorites.noDeparture': 'No departure found',
@@ -222,8 +239,11 @@ const I18N = (() => {
'more.langTitle': 'Language', 'more.langTitle': 'Language',
'search.noResults': 'No results', 'search.noResults': 'No results',
'search.recentSearches': 'Recently searched', 'search.recentSearches': 'Recently searched',
'search.resultsLabel': 'Search results',
'recent.empty': 'No searches yet.', 'recent.empty': 'No searches yet.',
'recent.remove': 'Remove {name} from history', '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); el.title = t(el.dataset.i18nTitle);
}); });
document.querySelectorAll('.lang-switch__btn').forEach((btn) => { 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));
}); });
} }
+27 -4
View File
@@ -1,6 +1,13 @@
// Simuliert das Umklappen einer mechanischen Bahnhofs-Anzeigetafel (Solari-Board). // Simuliert das Umklappen einer mechanischen Bahnhofs-Anzeigetafel (Solari-Board).
// Jedes Zeichen "rattert" kurz durch ein paar Zufallszeichen, bevor es sich auf // 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. // 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 SplitFlap = (() => {
const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 :+-.äöüÄÖÜß'; const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 :+-.äöüÄÖÜß';
@@ -13,20 +20,36 @@ const SplitFlap = (() => {
} }
function render(el, text) { 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 chars = text.split('');
const existing = el.querySelectorAll('.flap-char'); const existing = visual.querySelectorAll('.flap-char');
if (existing.length !== chars.length) { if (existing.length !== chars.length) {
el.innerHTML = ''; visual.innerHTML = '';
chars.forEach(() => { chars.forEach(() => {
const span = document.createElement('span'); const span = document.createElement('span');
span.className = 'flap-char'; span.className = 'flap-char';
span.textContent = ' '; span.textContent = ' ';
el.appendChild(span); visual.appendChild(span);
}); });
} }
const spans = el.querySelectorAll('.flap-char'); const spans = visual.querySelectorAll('.flap-char');
chars.forEach((targetChar, i) => { chars.forEach((targetChar, i) => {
const span = spans[i]; const span = spans[i];
+27 -3
View File
@@ -198,8 +198,10 @@ const TripAlarm = (() => {
banner = document.createElement('div'); banner = document.createElement('div');
banner.id = 'trip-alarm-banner'; banner.id = 'trip-alarm-banner';
banner.className = 'trip-alarm-banner'; banner.className = 'trip-alarm-banner';
banner.setAttribute('role', 'status');
banner.setAttribute('aria-live', 'polite');
banner.innerHTML = ` 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> <span id="trip-alarm-banner__text" class="trip-alarm-banner__text"></span>
<button id="trip-alarm-banner__cancel" type="button">Alarm beenden</button> <button id="trip-alarm-banner__cancel" type="button">Alarm beenden</button>
`; `;
@@ -262,16 +264,35 @@ const TripAlarm = (() => {
overlay.id = 'trip-alarm-overlay'; overlay.id = 'trip-alarm-overlay';
overlay.className = 'trip-alarm-overlay'; overlay.className = 'trip-alarm-overlay';
overlay.hidden = true; 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 = ` overlay.innerHTML = `
<div class="trip-alarm-overlay__card"> <div class="trip-alarm-overlay__card">
<div class="trip-alarm-overlay__icon">⏰</div> <div class="trip-alarm-overlay__icon" aria-hidden="true">⏰</div>
<h2>Aufwachen!</h2> <h2 id="trip-alarm-overlay__heading">Aufwachen!</h2>
<p id="trip-alarm-overlay__text"></p> <p id="trip-alarm-overlay__text"></p>
<button id="trip-alarm-overlay__dismiss" type="button">Alarm beenden</button> <button id="trip-alarm-overlay__dismiss" type="button">Alarm beenden</button>
</div> </div>
`; `;
document.body.appendChild(overlay); document.body.appendChild(overlay);
overlay.querySelector('#trip-alarm-overlay__dismiss').addEventListener('click', clear); 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; return overlay;
} }
@@ -282,6 +303,9 @@ const TripAlarm = (() => {
? `Gleich da: ${stopName}` ? `Gleich da: ${stopName}`
: `Noch ${stopsRemaining} Station${stopsRemaining === 1 ? '' : 'en'} bis ${stopName}`; : `Noch ${stopsRemaining} Station${stopsRemaining === 1 ? '' : 'en'} bis ${stopName}`;
overlay.hidden = false; 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() { function hideOverlay() {
+45 -3
View File
@@ -6,9 +6,11 @@
const TripModal = (() => { const TripModal = (() => {
const ALARM_OFFSET_KEY = 'oepnv-navi:db-alarm-offset'; 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 backdrop = null;
let miniMap = null; let miniMap = null;
let lastFocusedElement = null;
// Zustand der aktuell geöffneten Fahrt, damit wir nach dem Setzen/Entfernen // Zustand der aktuell geöffneten Fahrt, damit wir nach dem Setzen/Entfernen
// eines Alarms den Haltestellen-Teil neu rendern können, ohne die Fahrt // eines Alarms den Haltestellen-Teil neu rendern können, ohne die Fahrt
@@ -46,7 +48,27 @@ const TripModal = (() => {
if (e.target === backdrop) close(); if (e.target === backdrop) close();
}); });
document.addEventListener('keydown', (e) => { 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 - // Delegierter Klick-Handler für die Alarm-Glocken an den Haltestellen -
@@ -69,6 +91,13 @@ const TripModal = (() => {
miniMap.remove(); miniMap.remove();
miniMap = null; 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) { function formatTime(iso) {
@@ -76,6 +105,10 @@ const TripModal = (() => {
return new Date(iso).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); 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 // Verspätungsstatistik pro Linie - Datenbasis wächst erst über Zeit, auf
// einem frisch aufgesetzten Server gibt es also erstmal nichts anzuzeigen. // einem frisch aufgesetzten Server gibt es also erstmal nichts anzuzeigen.
async function loadLineStats(line) { async function loadLineStats(line) {
@@ -152,8 +185,9 @@ const TripModal = (() => {
// Halte Sinn - nicht für bereits vergangene. // Halte Sinn - nicht für bereits vergangene.
const canAlarm = !s.cancelled && index >= currentIndex; const canAlarm = !s.cancelled && index >= currentIndex;
const isActiveAlarm = canAlarm && TripAlarm.isActiveFor(currentTripId, index); const isActiveAlarm = canAlarm && TripAlarm.isActiveFor(currentTripId, index);
const alarmLabel = isActiveAlarm ? 'Fahrtalarm entfernen' : `Fahrtalarm für ${s.name || 'diese Haltestelle'} setzen`;
const alarmHtml = canAlarm 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 ` return `
@@ -206,6 +240,7 @@ const TripModal = (() => {
async function open(tripId) { async function open(tripId) {
if (!tripId) return; if (!tripId) return;
lastFocusedElement = document.activeElement;
const el = ensureBackdrop(); const el = ensureBackdrop();
el.hidden = false; el.hidden = false;
currentTripId = tripId; currentTripId = tripId;
@@ -215,10 +250,17 @@ const TripModal = (() => {
if (statsEl) { statsEl.hidden = true; } if (statsEl) { statsEl.hidden = true; }
document.getElementById('trip-modal-line').textContent = '…'; 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-meta').textContent = '';
document.getElementById('trip-modal-body').innerHTML = '<div class="trip-modal__loading"><span class="spinner"></span>Lade Fahrt-Details…</div>'; 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; let trip;
try { try {
trip = await API.getTrip(tripId, { polyline: true }); trip = await API.getTrip(tripId, { polyline: true });
+162 -7
View File
@@ -50,6 +50,39 @@ button {
outline-offset: 2px; 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) { @media (prefers-reduced-motion: reduce) {
* { * {
animation-duration: 0.01ms !important; animation-duration: 0.01ms !important;
@@ -135,8 +168,11 @@ button {
} }
.search-result:hover, .search-result:hover,
.search-result:focus { .search-result:focus,
.search-result--active {
background: var(--panel); background: var(--panel);
outline: 2px solid var(--signal-amber);
outline-offset: -2px;
} }
.search-result--empty { .search-result--empty {
@@ -182,17 +218,68 @@ button {
margin: 0; 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 Tabs
============================================================ */ ============================================================ */
.tab-nav { .tab-nav {
display: flex;
gap: 4px;
padding: 0 20px; padding: 0 20px;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
} }
.tab-nav__list {
display: flex;
gap: 4px;
}
.tab-button { .tab-button {
background: none; background: none;
border: none; border: none;
@@ -248,6 +335,15 @@ button {
border-color: var(--signal-amber); 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-state,
.board-empty, .board-empty,
.board-error { .board-error {
@@ -263,6 +359,27 @@ button {
color: var(--signal-red); 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 { .departure-board {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -276,11 +393,32 @@ button {
align-items: center; align-items: center;
background: var(--panel); background: var(--panel);
border: 1px solid var(--line); border: 1px solid var(--line);
border-left: 4px solid var(--line-color, var(--line));
border-radius: var(--radius); border-radius: var(--radius);
padding: 14px 16px; padding: 14px 16px 14px 13px;
font-family: var(--font-display); 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 { .flap-row--delayed .flap-cell--time {
color: var(--signal-red); 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; 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: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__icon { font-size: 23px; }
.action-card strong, .action-card small { display: block; } .action-card strong, .action-card small { display: block; }
.action-card small { color: var(--ink-muted); margin-top: 3px; font-size: 12px; } .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.is-visible { opacity: 1; transform: translateY(0); }
.toast--warning { border-left-color: var(--signal-red); } .toast--warning { border-left-color: var(--signal-red); }
.toast--info { border-left-color: var(--signal-amber); } .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 { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 8px; }
.nearby-preview__item { .nearby-preview__item {
display: flex; justify-content: space-between; align-items: center; gap: 10px; text-align: left; 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); } .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; } .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--time { font-size: 17px; }
.flap-cell--direction { font-size: 15px; font-weight: 600; } .flap-cell--direction { font-size: 15px; font-weight: 600; }
.flap-cell--platform { font-size: 14px; } .flap-cell--platform { font-size: 14px; }
@@ -1765,13 +1917,14 @@ button:active { transform: translateY(1px); }
.journey-options-row .mode-filter { margin-bottom: 8px; } .journey-options-row .mode-filter { margin-bottom: 8px; }
.transfer-slack-field select { width: 100%; } .transfer-slack-field select { width: 100%; }
.mobile-nav { .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 /* Muss über der (jetzt display-breiten) Live-Karte liegen - Leaflet nutzt
für seine eigenen Steuerelemente (Zoom-Buttons etc.) intern z-index- für seine eigenen Steuerelemente (Zoom-Buttons etc.) intern z-index-
Werte bis ~1000, das HUD/die Navigation soll aber immer sichtbar und Werte bis ~1000, das HUD/die Navigation soll aber immer sichtbar und
bedienbar bleiben, unabhängig von der Karte. */ 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)); 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 { 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 span { font-size: 18px; line-height: 1.1; }
.mobile-nav__item small { font-size: 10px; } .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-row { grid-template-columns: 58px minmax(0,1fr) 72px; gap: 12px; }
.flap-field--platform { display: none; } .flap-field--platform { display: none; }
.flap-field--time { grid-column: 3; } .flap-field--time { grid-column: 3; }
.notify-callout { flex-wrap: wrap; }
.notify-callout__btn { width: 100%; }
} }
.journey-source-note { .journey-source-note {