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:
Paul Nothaft
2026-08-11 10:55:10 +02:00
parent 2fe3576682
commit 5c0c2d1898
14 changed files with 1616 additions and 12 deletions
+144
View File
@@ -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>
)
}