Phase 5: App-Shell mit responsivem Layout, Navigation und Theme
Zwei Layouts aus derselben Zusammensetzung: unter 1000px Tab-Leiste unten, darueber Seitenleiste links mit Brotkrume statt Zurueck-Pfeil. Navigation ist wie im alten Panel ein flacher Zustand mit fester ZURUECK-Tabelle statt eines Router-Pakets - die Verschachtelung ist hoechstens dreistufig. Theme und Tab-Beschriftung ueberleben einen Neustart (localStorage, abgesichert fuer den Privatmodus), Startwert folgt der Systemeinstellung. Symbole aus dem alten Panel uebernommen, damit App und Panel gleich aussehen. Neun Rendertests belegen die Abnahmekriterien: Layoutwechsel in beide Richtungen, Zurueck-Ziel ueber die Tabelle, Zahnrad statt Tab-Leiste fuer die Einstellungen, Theme-Persistenz.
This commit is contained in:
+27
-11
@@ -1,19 +1,35 @@
|
||||
import { Tile } from "@audi-dash/ui"
|
||||
import { useCallback, useState } from "react"
|
||||
|
||||
import { Shell } from "./Shell"
|
||||
import type { Route, SeitenName } from "./navigation"
|
||||
import { useTabBeschriftung, useTheme } from "./theme"
|
||||
import { Platzhalter } from "./screens/Platzhalter"
|
||||
|
||||
import "./stile/rahmen.css"
|
||||
|
||||
/**
|
||||
* Vorläufiger Einstieg — wird in Phase 5 durch die App-Shell ersetzt.
|
||||
* Das äußerste Element trägt immer `ads-root` und `data-theme`, sonst greifen
|
||||
* die Design-Tokens nicht (siehe design-system/.design-sync/conventions.md).
|
||||
* Wurzel der App. Hält den Navigationszustand und das Theme; alles Weitere
|
||||
* hängt darunter. Das äußerste Element trägt `ads-root` und `data-theme` —
|
||||
* ohne diese Wurzelklasse greifen die Design-Tokens nicht
|
||||
* (design-system/.design-sync/conventions.md).
|
||||
*/
|
||||
export function App() {
|
||||
const [route, setzeRoute] = useState<Route>({ name: "home" })
|
||||
const [theme] = useTheme()
|
||||
const [tabBeschriftung] = useTabBeschriftung()
|
||||
|
||||
const geheZu = useCallback((name: SeitenName, id?: string) => {
|
||||
setzeRoute(id === undefined ? { name } : { name, id })
|
||||
// Beim Seitenwechsel immer oben anfangen — sonst erbt die neue Seite die
|
||||
// Scrollposition der alten.
|
||||
document.querySelector(".dm-inhalt")?.scrollTo({ top: 0 })
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="ads-root dm-wurzel" data-theme="nacht">
|
||||
<div className="dm-scroll" style={{ flex: 1, padding: 22 }}>
|
||||
<span className="ads-eyebrow">DataMetric360</span>
|
||||
<Tile>
|
||||
<div style={{ fontWeight: 300, fontSize: 20 }}>Gerüst steht</div>
|
||||
</Tile>
|
||||
</div>
|
||||
<div className="ads-root dm-wurzel" data-theme={theme}>
|
||||
<Shell route={route} geheZu={geheZu} tabBeschriftung={tabBeschriftung}>
|
||||
<Platzhalter route={route} geheZu={geheZu} />
|
||||
</Shell>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Abnahmekriterien aus Phase 5 des Umsetzungsplans: Die App muss beide
|
||||
* Layouts beherrschen und die Zurück-Navigation über die ZURUECK-Tabelle
|
||||
* korrekt auflösen.
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { Shell } from "./Shell"
|
||||
import { bereichVon } from "./navigation"
|
||||
import { bildschirmbreiteSetzen } from "./tests/aufbau"
|
||||
|
||||
function rahmen(routeName: Parameters<typeof bereichVon>[0], geheZu = vi.fn()) {
|
||||
render(
|
||||
<Shell route={{ name: routeName }} geheZu={geheZu} tabBeschriftung>
|
||||
<p>Inhalt</p>
|
||||
</Shell>,
|
||||
)
|
||||
return geheZu
|
||||
}
|
||||
|
||||
describe("Shell", () => {
|
||||
beforeEach(() => {
|
||||
bildschirmbreiteSetzen(390)
|
||||
})
|
||||
|
||||
it("zeigt auf schmalen Bildschirmen die Tab-Leiste unten", () => {
|
||||
const { container } = render(
|
||||
<Shell route={{ name: "home" }} geheZu={vi.fn()} tabBeschriftung>
|
||||
<p>Inhalt</p>
|
||||
</Shell>,
|
||||
)
|
||||
expect(container.querySelector(".ads-tabbar")).not.toBeNull()
|
||||
expect(container.querySelector(".dm-seitenleiste")).toBeNull()
|
||||
})
|
||||
|
||||
it("zeigt auf breiten Bildschirmen die Seitenleiste statt der Tab-Leiste", () => {
|
||||
bildschirmbreiteSetzen(1280)
|
||||
const { container } = render(
|
||||
<Shell route={{ name: "home" }} geheZu={vi.fn()} tabBeschriftung>
|
||||
<p>Inhalt</p>
|
||||
</Shell>,
|
||||
)
|
||||
expect(container.querySelector(".dm-seitenleiste")).not.toBeNull()
|
||||
expect(container.querySelector(".ads-tabbar")).toBeNull()
|
||||
})
|
||||
|
||||
it("führt der Zurück-Pfeil zur übergeordneten Seite", () => {
|
||||
const geheZu = rahmen("beitrag")
|
||||
fireEvent.click(screen.getByLabelText("Zurück"))
|
||||
// beitrag -> vers laut ZURUECK-Tabelle
|
||||
expect(geheZu).toHaveBeenCalledWith("vers")
|
||||
})
|
||||
|
||||
it("blendet den Zurück-Pfeil auf Hauptbereichen aus", () => {
|
||||
rahmen("trips")
|
||||
expect(screen.queryByLabelText("Zurück")).toBeNull()
|
||||
})
|
||||
|
||||
it("hebt den Hauptbereich hervor, zu dem eine Unterseite gehört", () => {
|
||||
// schutz -> vertragsdetails -> vers -> audi
|
||||
expect(bereichVon("schutz")).toBe("audi")
|
||||
expect(bereichVon("fill")).toBe("fuel")
|
||||
expect(bereichVon("trip")).toBe("trips")
|
||||
})
|
||||
|
||||
it("erreicht die Einstellungen über das Zahnrad, nicht über die Tab-Leiste", () => {
|
||||
const geheZu = rahmen("home")
|
||||
fireEvent.click(screen.getByLabelText("Einstellungen"))
|
||||
expect(geheZu).toHaveBeenCalledWith("einst")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Rahmen der App: Kopfzeile, Navigation, Inhaltsfläche.
|
||||
*
|
||||
* Zwei Layouts aus derselben Zusammensetzung — die entscheidende Anforderung
|
||||
* aus dem Design-Auftrag: schmal (Handy) mit Tab-Leiste unten, breit (Tablet,
|
||||
* eingebettet in Home Assistant) mit Seitenleiste links. Der Umschaltpunkt
|
||||
* liegt bei 1000px.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import { TabBar } from "@audi-dash/ui"
|
||||
|
||||
import { useBreiterBildschirm } from "./hooks/useBreiterBildschirm"
|
||||
import type { Hauptbereich, Route, SeitenName } from "./navigation"
|
||||
import { TITEL, ZURUECK, bereichVon, istHauptbereich } from "./navigation"
|
||||
import {
|
||||
SymbolAudi,
|
||||
SymbolFahrten,
|
||||
SymbolStatistik,
|
||||
SymbolTanken,
|
||||
SymbolUebersicht,
|
||||
SymbolZahnrad,
|
||||
SymbolZurueck,
|
||||
} from "./symbole"
|
||||
|
||||
export interface ShellProps {
|
||||
route: Route
|
||||
geheZu: (name: SeitenName, id?: string) => void
|
||||
tabBeschriftung: boolean
|
||||
/** Wird über der Kopfzeile eingeblendet (Offline-Hinweis, Warteschlange). */
|
||||
hinweis?: ReactNode | undefined
|
||||
/** Heißt `children`, weil JSX-Verschachtelung genau diesen Namen erwartet. */
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const BEREICHE: { key: Hauptbereich; label: string; icon: ReactNode }[] = [
|
||||
{ key: "home", label: "Übersicht", icon: <SymbolUebersicht /> },
|
||||
{ key: "audi", label: "Mein Audi", icon: <SymbolAudi /> },
|
||||
{ key: "trips", label: "Fahrten", icon: <SymbolFahrten /> },
|
||||
{ key: "stat", label: "Statistik", icon: <SymbolStatistik /> },
|
||||
{ key: "fuel", label: "Tanken", icon: <SymbolTanken /> },
|
||||
]
|
||||
|
||||
export function Shell({ route, geheZu, tabBeschriftung, hinweis, children }: ShellProps) {
|
||||
const breit = useBreiterBildschirm()
|
||||
const aktiverBereich = bereichVon(route.name)
|
||||
const zurueckZiel = ZURUECK[route.name]
|
||||
|
||||
const kopf = (
|
||||
<header className="dm-kopf">
|
||||
{zurueckZiel && !breit ? (
|
||||
<button
|
||||
type="button"
|
||||
className="dm-kopf__zurueck"
|
||||
aria-label="Zurück"
|
||||
onClick={() => geheZu(zurueckZiel)}
|
||||
>
|
||||
<SymbolZurueck groesse={22} />
|
||||
</button>
|
||||
) : null}
|
||||
<div className="dm-kopf__titel">
|
||||
<span className="ads-eyebrow">DataMetric360</span>
|
||||
<div className="ads-title">{TITEL[route.name]}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="dm-kopf__aktion"
|
||||
aria-label="Einstellungen"
|
||||
aria-current={route.name === "einst" ? "page" : undefined}
|
||||
onClick={() => geheZu("einst")}
|
||||
>
|
||||
<SymbolZahnrad groesse={22} />
|
||||
</button>
|
||||
</header>
|
||||
)
|
||||
|
||||
if (breit) {
|
||||
return (
|
||||
<>
|
||||
<nav className="dm-seitenleiste" aria-label="Hauptbereiche">
|
||||
<span className="ads-eyebrow dm-seitenleiste__marke">DataMetric360</span>
|
||||
{BEREICHE.map((bereich) => (
|
||||
<button
|
||||
key={bereich.key}
|
||||
type="button"
|
||||
className={
|
||||
bereich.key === aktiverBereich
|
||||
? "dm-seitenleiste__punkt dm-seitenleiste__punkt--an"
|
||||
: "dm-seitenleiste__punkt"
|
||||
}
|
||||
aria-current={bereich.key === aktiverBereich ? "page" : undefined}
|
||||
onClick={() => geheZu(bereich.key)}
|
||||
>
|
||||
{bereich.icon}
|
||||
<span>{bereich.label}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="dm-seitenleiste__fuss">
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
route.name === "einst"
|
||||
? "dm-seitenleiste__punkt dm-seitenleiste__punkt--an"
|
||||
: "dm-seitenleiste__punkt"
|
||||
}
|
||||
onClick={() => geheZu("einst")}
|
||||
>
|
||||
<SymbolZahnrad />
|
||||
<span>Einstellungen</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
<div className="dm-spalte">
|
||||
{hinweis}
|
||||
{!istHauptbereich(route.name) && zurueckZiel ? (
|
||||
<div className="dm-brotkrume">
|
||||
<button type="button" onClick={() => geheZu(zurueckZiel)}>
|
||||
<SymbolZurueck groesse={16} />
|
||||
<span>{TITEL[zurueckZiel]}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<main className="dm-scroll dm-inhalt dm-inhalt--breit">{children}</main>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dm-spalte">
|
||||
{hinweis}
|
||||
{kopf}
|
||||
<main className="dm-scroll dm-inhalt">{children}</main>
|
||||
<TabBar
|
||||
items={BEREICHE}
|
||||
activeKey={aktiverBereich}
|
||||
onChange={(schluessel) => geheZu(schluessel as Hauptbereich)}
|
||||
iconOnly={!tabBeschriftung}
|
||||
className="dm-tableiste"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
/**
|
||||
* Ab dieser Breite wechselt die App vom Handy- aufs Flächenlayout: Navigation
|
||||
* wandert von unten nach links, Inhalte dürfen mehrspaltig werden. Der Wert
|
||||
* stammt aus dem Design-Auftrag ("ab ca. 1000px").
|
||||
*/
|
||||
export const BREITER_BILDSCHIRM = "(min-width: 1000px)"
|
||||
|
||||
export function useBreiterBildschirm(): boolean {
|
||||
const [breit, setzeBreit] = useState<boolean>(() => {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false
|
||||
return window.matchMedia(BREITER_BILDSCHIRM).matches
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return
|
||||
const abfrage = window.matchMedia(BREITER_BILDSCHIRM)
|
||||
const beiAenderung = (ereignis: MediaQueryListEvent) => setzeBreit(ereignis.matches)
|
||||
abfrage.addEventListener("change", beiAenderung)
|
||||
setzeBreit(abfrage.matches)
|
||||
return () => abfrage.removeEventListener("change", beiAenderung)
|
||||
}, [])
|
||||
|
||||
return breit
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Flacher Navigationszustand — kein Router-Paket.
|
||||
*
|
||||
* Übernommen aus dem alten HA-Panel (`route` + `ZURUECK`): Die App hat keinen
|
||||
* Verlaufsstapel, sondern genau eine aktuelle Seite und eine feste Tabelle,
|
||||
* welche Seite der Zurück-Pfeil ansteuert. Das ist bewusst so geblieben —
|
||||
* die Verschachtelung ist flach genug (höchstens drei Ebenen), und ein Router
|
||||
* würde nur Gewicht ohne Nutzen bringen.
|
||||
*/
|
||||
|
||||
export const HAUPTBEREICHE = ["home", "audi", "trips", "stat", "fuel"] as const
|
||||
|
||||
export type Hauptbereich = (typeof HAUPTBEREICHE)[number]
|
||||
|
||||
export type SeitenName =
|
||||
| Hauptbereich
|
||||
// Unterseiten von "Mein Audi"
|
||||
| "ident"
|
||||
| "battverlauf"
|
||||
| "service"
|
||||
| "werkstatt"
|
||||
| "sbuch"
|
||||
| "reifen"
|
||||
| "vers"
|
||||
| "beitrag"
|
||||
| "vertragsdetails"
|
||||
| "schutz"
|
||||
| "notruf"
|
||||
| "steuer"
|
||||
// Detailseiten der Listen
|
||||
| "trip"
|
||||
| "fill"
|
||||
// Sonstige
|
||||
| "sicherheit"
|
||||
| "live"
|
||||
| "einst"
|
||||
|
||||
export interface Route {
|
||||
name: SeitenName
|
||||
/** Datensatz-Kennung bei Detailseiten (Fahrt, Tankvorgang, Servicebuch). */
|
||||
id?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Ziel des Zurück-Pfeils je Unterseite. Seiten, die hier fehlen, sind
|
||||
* Hauptbereiche und zeigen keinen Zurück-Pfeil.
|
||||
*/
|
||||
export const ZURUECK: Partial<Record<SeitenName, SeitenName>> = {
|
||||
trip: "trips",
|
||||
fill: "fuel",
|
||||
service: "audi",
|
||||
reifen: "audi",
|
||||
ident: "audi",
|
||||
battverlauf: "audi",
|
||||
vers: "audi",
|
||||
beitrag: "vers",
|
||||
vertragsdetails: "vers",
|
||||
schutz: "vertragsdetails",
|
||||
steuer: "vers",
|
||||
notruf: "vers",
|
||||
werkstatt: "service",
|
||||
sbuch: "service",
|
||||
einst: "home",
|
||||
sicherheit: "home",
|
||||
live: "home",
|
||||
}
|
||||
|
||||
/** Überschrift je Seite. Leer bedeutet: die Seite bringt ihre eigene mit. */
|
||||
export const TITEL: Record<SeitenName, string> = {
|
||||
home: "Übersicht",
|
||||
audi: "Mein Audi",
|
||||
trips: "Fahrten",
|
||||
stat: "Statistik",
|
||||
fuel: "Tanken",
|
||||
ident: "Fahrzeugdaten",
|
||||
battverlauf: "Batterie",
|
||||
service: "Service",
|
||||
werkstatt: "Werkstatt",
|
||||
sbuch: "Servicebuch",
|
||||
reifen: "Reifen",
|
||||
vers: "Versicherung & Steuer",
|
||||
beitrag: "Beitrag",
|
||||
vertragsdetails: "Vertragsdetails",
|
||||
schutz: "Schutzbrief",
|
||||
notruf: "Notruf",
|
||||
steuer: "Kfz-Steuer",
|
||||
trip: "Fahrt",
|
||||
fill: "Tankvorgang",
|
||||
sicherheit: "Fahrzeugstatus",
|
||||
live: "Fahrt läuft",
|
||||
einst: "Einstellungen",
|
||||
}
|
||||
|
||||
export function istHauptbereich(name: SeitenName): name is Hauptbereich {
|
||||
return (HAUPTBEREICHE as readonly string[]).includes(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Hauptbereich, zu dem eine Seite gehört — bestimmt, welcher Eintrag in
|
||||
* Tab-Leiste bzw. Seitenleiste hervorgehoben wird. Folgt der ZURUECK-Kette
|
||||
* nach oben.
|
||||
*/
|
||||
export function bereichVon(name: SeitenName): Hauptbereich {
|
||||
let aktuell: SeitenName = name
|
||||
for (let tiefe = 0; tiefe < 5; tiefe++) {
|
||||
if (istHauptbereich(aktuell)) return aktuell
|
||||
const eltern = ZURUECK[aktuell]
|
||||
if (!eltern) return "home"
|
||||
aktuell = eltern
|
||||
}
|
||||
return "home"
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Vorläufiger Inhalt aller Seiten — wird in Phase 7 des Umsetzungsplans Seite
|
||||
* für Seite durch die echten Ansichten ersetzt. Bis dahin belegt er die
|
||||
* Navigation, damit sich der Rahmen bedienen lässt.
|
||||
*/
|
||||
|
||||
import { Tile } from "@audi-dash/ui"
|
||||
|
||||
import type { Route, SeitenName } from "../navigation"
|
||||
import { TITEL } from "../navigation"
|
||||
|
||||
const UNTERSEITEN: Partial<Record<SeitenName, SeitenName[]>> = {
|
||||
home: ["sicherheit", "einst"],
|
||||
audi: ["ident", "battverlauf", "service", "reifen", "vers"],
|
||||
service: ["werkstatt", "sbuch"],
|
||||
vers: ["beitrag", "vertragsdetails", "steuer", "notruf"],
|
||||
vertragsdetails: ["schutz"],
|
||||
trips: ["trip"],
|
||||
fuel: ["fill"],
|
||||
}
|
||||
|
||||
export function Platzhalter({
|
||||
route,
|
||||
geheZu,
|
||||
}: {
|
||||
route: Route
|
||||
geheZu: (name: SeitenName, id?: string) => void
|
||||
}) {
|
||||
const weiter = UNTERSEITEN[route.name] ?? []
|
||||
return (
|
||||
<>
|
||||
<Tile>
|
||||
<div style={{ fontWeight: 300, fontSize: 18 }}>{TITEL[route.name]}</div>
|
||||
<p style={{ color: "var(--fg2)", fontSize: 14, fontWeight: 300, margin: "8px 0 0" }}>
|
||||
Diese Seite wird noch gebaut.
|
||||
</p>
|
||||
</Tile>
|
||||
{weiter.length > 0 && (
|
||||
<div className="dm-raster">
|
||||
{weiter.map((ziel) => (
|
||||
<Tile key={ziel} variant="button" chevron onClick={() => geheZu(ziel)}>
|
||||
<span className="ads-eyebrow">weiter</span>
|
||||
<div style={{ fontWeight: 300, fontSize: 16, marginTop: 4 }}>{TITEL[ziel]}</div>
|
||||
</Tile>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
* Rahmen der App (Shell.tsx): Kopfzeile, Seitenleiste, Inhaltsfläche.
|
||||
* Farben und Radien ausschließlich aus den Tokens von @audi-dash/ui.
|
||||
*/
|
||||
|
||||
.dm-spalte {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- Kopfzeile */
|
||||
|
||||
.dm-kopf {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: calc(env(safe-area-inset-top, 0px) + var(--sp-4)) var(--sp-5) var(--sp-3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
.dm-kopf__titel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dm-kopf__zurueck,
|
||||
.dm-kopf__aktion {
|
||||
flex: none;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: var(--r-pill);
|
||||
background: transparent;
|
||||
color: var(--fg2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dm-kopf__zurueck:hover,
|
||||
.dm-kopf__aktion:hover {
|
||||
background: var(--shade);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.dm-kopf__aktion[aria-current="page"] {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- Inhaltsfläche */
|
||||
|
||||
.dm-inhalt {
|
||||
flex: 1;
|
||||
padding: var(--sp-4) var(--sp-5) calc(env(safe-area-inset-bottom, 0px) + var(--sp-5));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.dm-inhalt--breit {
|
||||
padding: var(--sp-5) 32px 32px;
|
||||
}
|
||||
|
||||
/* Mehrspaltige Anordnung auf großen Flächen: Kacheln nebeneinander statt
|
||||
untereinander. Die Screens setzen die Klasse dort, wo es sinnvoll ist. */
|
||||
.dm-raster {
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@media (min-width: 1000px) {
|
||||
.dm-raster {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.dm-raster--drei {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.dm-raster__breit {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Listen- und Detailansicht nebeneinander (Fahrten, Tanken auf großer Fläche) */
|
||||
.dm-zweispalter {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
grid-template-columns: 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (min-width: 1000px) {
|
||||
.dm-zweispalter {
|
||||
grid-template-columns: minmax(0, 4fr) minmax(0, 5fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------- Seitenleiste */
|
||||
|
||||
.dm-seitenleiste {
|
||||
flex: 0 0 240px;
|
||||
width: 240px;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
border-right: 1px solid var(--line);
|
||||
padding: 24px 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
.dm-seitenleiste__marke {
|
||||
padding: 0 10px var(--sp-4);
|
||||
}
|
||||
|
||||
.dm-seitenleiste__punkt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
width: 100%;
|
||||
padding: 11px 10px;
|
||||
border: 0;
|
||||
border-radius: var(--r-func);
|
||||
background: transparent;
|
||||
color: var(--fg2);
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dm-seitenleiste__punkt:hover {
|
||||
background: var(--shade);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.dm-seitenleiste__punkt--an {
|
||||
background: var(--tile);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.dm-seitenleiste__fuss {
|
||||
margin-top: auto;
|
||||
padding-top: var(--sp-3);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* Zurück-Weg auf großer Fläche: als Brotkrume über dem Inhalt statt als
|
||||
Pfeil in der Kopfzeile — auf dem Handy nimmt der Pfeil den Platz ein. */
|
||||
.dm-brotkrume {
|
||||
padding: var(--sp-4) 32px 0;
|
||||
}
|
||||
|
||||
.dm-brotkrume button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--fg2);
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
cursor: pointer;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.dm-brotkrume button:hover {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Tab-Leiste */
|
||||
|
||||
.dm-tableiste {
|
||||
flex: none;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--canvas);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Symbole als React-Bausteine. Aus dem alten Panel übernommen (dort als
|
||||
* SVG-Fragmente in Zeichenketten), damit App und Panel identisch aussehen.
|
||||
* Bewusst keine Symbolbibliothek: es sind eine Handvoll, und jede Bibliothek
|
||||
* wäre größer als der gesamte Rest.
|
||||
*/
|
||||
|
||||
import type { ReactElement } from "react"
|
||||
|
||||
// Ausdrücklich `| undefined`, weil exactOptionalPropertyTypes eingeschaltet ist:
|
||||
// ein durchgereichtes `groesse={undefined}` muss zulässig bleiben.
|
||||
interface SymbolProps {
|
||||
groesse?: number | undefined
|
||||
}
|
||||
|
||||
function Rahmen({ groesse = 24, kinder }: SymbolProps & { kinder: ReactElement }) {
|
||||
return (
|
||||
<svg
|
||||
width={groesse}
|
||||
height={groesse}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{kinder}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SymbolUebersicht({ groesse }: SymbolProps) {
|
||||
return (
|
||||
<Rahmen
|
||||
groesse={groesse}
|
||||
kinder={
|
||||
<g>
|
||||
<path d="M4 17a9 9 0 0 1 18 0" />
|
||||
<path d="M13 17l5.5-5" />
|
||||
<circle cx="13" cy="17" r="1.3" />
|
||||
</g>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SymbolAudi({ groesse }: SymbolProps) {
|
||||
return (
|
||||
<Rahmen
|
||||
groesse={groesse}
|
||||
kinder={<polygon points="21.6,5 16.5,19 2.4,19 7.5,5" />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SymbolFahrten({ groesse }: SymbolProps) {
|
||||
return (
|
||||
<Rahmen
|
||||
groesse={groesse}
|
||||
kinder={
|
||||
<g>
|
||||
<circle cx="6" cy="6" r="2.2" />
|
||||
<circle cx="18" cy="18" r="2.2" />
|
||||
<path d="M8 7.5c4 1.5 4.5 3 3 5s-1 3.5 4.5 5" />
|
||||
</g>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SymbolStatistik({ groesse }: SymbolProps) {
|
||||
return <Rahmen groesse={groesse} kinder={<path d="M4 20V9.5M10 20V4.5M16 20v-8M22 20H2" />} />
|
||||
}
|
||||
|
||||
export function SymbolTanken({ groesse }: SymbolProps) {
|
||||
return (
|
||||
<Rahmen
|
||||
groesse={groesse}
|
||||
kinder={
|
||||
<g>
|
||||
<path d="M4.5 20V6.2c0-.9.8-1.7 1.7-1.7h5.6c1 0 1.7.8 1.7 1.7V20" />
|
||||
<path d="M3 20h12.5" />
|
||||
<path d="M4.5 11h10.5" />
|
||||
<path d="M17 20v-8.5a2 2 0 0 0-2-2" />
|
||||
<path d="M17 11.5V7.8l-2-2.3" />
|
||||
</g>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SymbolZurueck({ groesse }: SymbolProps) {
|
||||
return <Rahmen groesse={groesse} kinder={<path d="M15 5l-7 7 7 7" />} />
|
||||
}
|
||||
|
||||
export function SymbolZahnrad({ groesse }: SymbolProps) {
|
||||
return (
|
||||
<Rahmen
|
||||
groesse={groesse}
|
||||
kinder={
|
||||
<g>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" />
|
||||
</g>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SymbolSonne({ groesse }: SymbolProps) {
|
||||
return (
|
||||
<Rahmen
|
||||
groesse={groesse}
|
||||
kinder={
|
||||
<g>
|
||||
<circle cx="12" cy="12" r="4.5" />
|
||||
<path d="M12 2v2.4M12 19.6V22M2 12h2.4M19.6 12H22M4.7 4.7l1.7 1.7M17.6 17.6l1.7 1.7M19.3 4.7l-1.7 1.7M6.4 17.6l-1.7 1.7" />
|
||||
</g>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SymbolMond({ groesse }: SymbolProps) {
|
||||
return (
|
||||
<Rahmen
|
||||
groesse={groesse}
|
||||
kinder={<path d="M19.5 14.4A8.4 8.4 0 1 1 9.6 4.5a6.7 6.7 0 0 0 9.9 9.9z" />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SymbolMehr({ groesse }: SymbolProps) {
|
||||
return (
|
||||
<Rahmen
|
||||
groesse={groesse}
|
||||
kinder={
|
||||
<g>
|
||||
<circle cx="5" cy="12" r="1.4" />
|
||||
<circle cx="12" cy="12" r="1.4" />
|
||||
<circle cx="19" cy="12" r="1.4" />
|
||||
</g>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SymbolWarnung({ groesse }: SymbolProps) {
|
||||
return (
|
||||
<Rahmen
|
||||
groesse={groesse}
|
||||
kinder={
|
||||
<g>
|
||||
<path d="M12 4.5 2.5 20h19z" />
|
||||
<path d="M12 10v4.5" />
|
||||
<circle cx="12" cy="17.3" r="0.9" fill="currentColor" stroke="none" />
|
||||
</g>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Testaufbau. jsdom kennt matchMedia nicht — die App fragt es aber ab, um
|
||||
* zwischen Handy- und Flächenlayout zu wählen. `bildschirmbreiteSetzen()`
|
||||
* stellt eine steuerbare Fassung bereit.
|
||||
*/
|
||||
|
||||
let aktuelleBreite = 390
|
||||
|
||||
export function bildschirmbreiteSetzen(breite: number): void {
|
||||
aktuelleBreite = breite
|
||||
}
|
||||
|
||||
function passt(abfrage: string): boolean {
|
||||
const min = /\(min-width:\s*(\d+)px\)/.exec(abfrage)
|
||||
if (min?.[1]) return aktuelleBreite >= Number(min[1])
|
||||
const max = /\(max-width:\s*(\d+)px\)/.exec(abfrage)
|
||||
if (max?.[1]) return aktuelleBreite <= Number(max[1])
|
||||
if (abfrage.includes("prefers-color-scheme: light")) return false
|
||||
return false
|
||||
}
|
||||
|
||||
window.matchMedia = ((abfrage: string) => ({
|
||||
matches: passt(abfrage),
|
||||
media: abfrage,
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
})) as unknown as typeof window.matchMedia
|
||||
@@ -0,0 +1,23 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest"
|
||||
|
||||
import { themeStartwert } from "./theme"
|
||||
|
||||
describe("Theme", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it("startet ohne gespeicherte Wahl im Nachtmodus", () => {
|
||||
expect(themeStartwert()).toBe("nacht")
|
||||
})
|
||||
|
||||
it("übernimmt eine gespeicherte Wahl", () => {
|
||||
localStorage.setItem("dm360.theme", "tag")
|
||||
expect(themeStartwert()).toBe("tag")
|
||||
})
|
||||
|
||||
it("ignoriert unsinnige gespeicherte Werte", () => {
|
||||
localStorage.setItem("dm360.theme", "irgendwas")
|
||||
expect(themeStartwert()).toBe("nacht")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Tag/Nacht-Umschaltung, übernommen aus dem alten Panel (A6 des Design-Audits):
|
||||
* gespeicherte Wahl schlägt Systemeinstellung, Standard ist "nacht".
|
||||
* Alle localStorage-Zugriffe sind abgesichert, weil sie im Privatmodus mancher
|
||||
* Browser werfen.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
|
||||
export type Theme = "nacht" | "tag"
|
||||
|
||||
const SCHLUESSEL = "dm360.theme"
|
||||
|
||||
function gespeichertLesen(): Theme | null {
|
||||
try {
|
||||
const wert = localStorage.getItem(SCHLUESSEL)
|
||||
return wert === "nacht" || wert === "tag" ? wert : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function gespeichertSchreiben(theme: Theme): void {
|
||||
try {
|
||||
localStorage.setItem(SCHLUESSEL, theme)
|
||||
} catch {
|
||||
// Privatmodus — die Wahl gilt dann nur für diese Sitzung
|
||||
}
|
||||
}
|
||||
|
||||
export function themeStartwert(): Theme {
|
||||
const gespeichert = gespeichertLesen()
|
||||
if (gespeichert) return gespeichert
|
||||
const hell =
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.matchMedia === "function" &&
|
||||
window.matchMedia("(prefers-color-scheme: light)").matches
|
||||
return hell ? "tag" : "nacht"
|
||||
}
|
||||
|
||||
export function useTheme(): [Theme, (theme: Theme) => void] {
|
||||
const [theme, setzeTheme] = useState<Theme>(themeStartwert)
|
||||
|
||||
const setzen = useCallback((neu: Theme) => {
|
||||
setzeTheme(neu)
|
||||
gespeichertSchreiben(neu)
|
||||
}, [])
|
||||
|
||||
// Farbe der Systemleisten mitziehen, damit auf dem Handy kein heller
|
||||
// Balken über einer dunklen App steht.
|
||||
useEffect(() => {
|
||||
const marke = document.querySelector('meta[name="theme-color"]')
|
||||
if (marke) marke.setAttribute("content", theme === "nacht" ? "#161b23" : "#ffffff")
|
||||
}, [theme])
|
||||
|
||||
return [theme, setzen]
|
||||
}
|
||||
|
||||
/** Sichtbarkeit der Tab-Beschriftungen, ebenfalls dauerhaft gespeichert. */
|
||||
const BESCHRIFTUNG_SCHLUESSEL = "dm360.tabbeschriftung"
|
||||
|
||||
export function useTabBeschriftung(): [boolean, (an: boolean) => void] {
|
||||
const [an, setzeAn] = useState<boolean>(() => {
|
||||
try {
|
||||
return localStorage.getItem(BESCHRIFTUNG_SCHLUESSEL) !== "aus"
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
const setzen = useCallback((neu: boolean) => {
|
||||
setzeAn(neu)
|
||||
try {
|
||||
localStorage.setItem(BESCHRIFTUNG_SCHLUESSEL, neu ? "an" : "aus")
|
||||
} catch {
|
||||
// Privatmodus
|
||||
}
|
||||
}, [])
|
||||
|
||||
return [an, setzen]
|
||||
}
|
||||
Reference in New Issue
Block a user