Add files via upload
This commit is contained in:
1 parent
c6e2fed82c
commit
7020ad99fc
7 files changed
+540
-93
No files matched your search
+64
-54
@@ -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 & Bahn</small></span>
|
<span><strong data-i18n="welcome.routeTitle">Route planen</strong><small data-i18n="welcome.routeText">Von A nach B mit Bus & 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 & 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 & 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 & 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 & 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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) => ({ '&':'&', '<':'<', '>':'>', "'":''', '"':'"' }[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
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user