Files
Dashboard/features/dashboard/components/layout/AppShell.tsx
T
dejanandClaude Opus 5 4cb0b5c799 feat(responsive): Breakpoints für 375–1920 px
Der Prototyp war auf Schreibtischbreiten gebaut. Bei 375 px lief die Seite um
306 px seitlich über, und dem Inhalt blieben 135 px, weil die Seitenleiste ihre
vollen 240 px behielt.

Seitenleiste (an HiveGrid abgeglichen, nicht erfunden):
- Unterhalb 768 px faehrt sie als Schublade ueber den Inhalt. Derselbe
  Breakpoint, den die Software als MOBILE_BREAKPOINT fuehrt.
- Hamburger links in der Kopfzeile, FeatherMenu, 32x32, md:hidden, vor dem
  Breadcrumb — wie SubframeAppShell ihn rendert.
- Abdeckung dahinter, Escape schliesst, Fokus wandert beim Oeffnen auf den
  ersten Eintrag und kehrt beim Schliessen zum Ausloeser zurueck.
- Fokusfalle mit Tab und Umschalt+Tab: Der Rahmen traegt role=dialog und
  aria-modal, ohne Falle waere das gegenueber einem Screenreader unwahr.
- Der Einklapp-Knopf ist unterhalb md ausgeblendet. Er ragt bauartbedingt
  17 px nach rechts heraus und blieb sonst als angeschnittener, wirkungsloser
  Rest bei x = -16 am Bildrand stehen — der gemeldete Fehler.

Inhalte: Raster einspaltig ab 375 px, Seitenkopf mit Aktionen unter dem Titel
und Icon-only, Zeitraum-Segment und Reiterstreifen waagerecht scrollend statt
umbrechend (ein Segment ueber zwei Zeilen liest sich als zwei Steuerelemente),
Filter-Panel auf volle Zeilenbreite, Tabellen mit eigenem Scrollbereich.

Zwei abgeschnittene Fehler mitbehoben, die der Prueferer nicht sieht:
- KPI-Label lief bei 1280 px um 5 px aus seinem Kasten; bricht jetzt um. Die
  Karte wird dadurch eine Zeile hoeher — der Preis fuer eine vollstaendig
  lesbare Beschriftung.
- Randmarken der Auslastungsachse ragten je rund 10 px ueber die Spur. In sehr
  schmalen Karten entfaellt die Achse ganz; dort bleibt nur rund 40 px Spur,
  auf der selbst zwei Marken aneinanderstossen.

verify-layout deckt jetzt 375-1920 px ab (vorher ab 1024) und meldet zusaetzlich
Seitenueberlauf: ein waagerechter Scrollbalken auf Dokumentebene ist immer ein
Fehler, nie ein Kompromiss. 7 Seiten x 9 Breiten gruen.

Ab 1024 px ist der abgenommene Stand unveraendert — numerisch geprueft ueber die
Bounding-Boxen aller Karten, Raster, Tabellen und Kopfelemente.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPeZNwSeU7qG3RRCwaxEZX
2026-08-29 21:46:51 +02:00

270 lines
12 KiB
TypeScript

"use client";
import React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { FeatherBell, FeatherHelpCircle, FeatherMenu, FeatherSearch } from "@subframe/core";
import { SidebarWithSections } from "@/ui/components/SidebarWithSections";
import { NAV_GRUPPEN, NAV_SEKUNDAER, ANALYSE_SEITEN, type NavEintrag } from "./navigation";
import { FeatherIcon } from "./FeatherIcon";
import { useIstSchmal } from "../../hooks/useIstSchmal";
import { DATENSATZ_INFO } from "../../data/seed";
import { formatDatum, formatZahl } from "../../utils/formatters";
import { HEUTE } from "../../utils/dateUtils";
/**
* Rahmen der Anwendung — Seitenleiste nach dem Vorbild von HiveGrid Cloud.
*
* Bewusst dieselbe Komponente wie dort (`~/ui/components/SidebarWithSections`)
* mit denselben Gruppen und derselben Reihenfolge. Wer den Prototyp neben der
* Software öffnet, soll dieselbe Navigation sehen; die Übergabe wird dadurch
* eine Frage des Einsetzens und nicht des Übersetzens.
*
* Jeder Eintrag führt auf eine echte Seite. „Dashboard" landet auf einer
* Platzhalterseite, weil es ein eigenes Feature ist und hier nicht gestaltet
* wird — aber ein Menüpunkt, der nur einen Hinweis auslöst, lässt offen, ob
* etwas fehlt oder etwas kaputt ist.
*
* ────────────────────────────────────────────────────────────────────────────
* BEIM EINBAU: Diese Datei wird NICHT portiert.
*
* Sie existiert, damit der Prototyp alleine lauffähig ist. In HiveGrid Cloud
* übernehmen `SubframeAppShell` und `AppSidebar`. Verbindlich ist aus dieser
* Datei allein die **Struktur**: zwei Einträge in der Seitenleiste, Dashboard
* und Analytics — mehr nicht, weil alles Übrige dort schon steht.
*
* Globale Regeln (Schrift, Größen, Abstände, Farben, Control-Höhen) kommen
* ausschließlich aus HiveGrid und werden von hier aus nicht überschrieben.
* Weicht ein Wert hier von dort ab, ist der Wert hier falsch.
* Rangfolge: docs/ZIELSYSTEM.md Abschnitt 0.
* ────────────────────────────────────────────────────────────────────────────
*/
export function AppShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const [eingeklappt, setEingeklappt] = React.useState(false);
// Unterhalb `md` fährt die Leiste über den Inhalt statt ihn zu verdrängen.
// Bei 375 px blieben sonst 135 px für den Inhalt, und die Seite lief um über
// 300 px seitlich über. Dasselbe Vorgehen wie in der Software.
const istSchmal = useIstSchmal();
const ausloeser = React.useRef<HTMLButtonElement>(null);
// Beim Seitenwechsel schließen — sonst bleibt die Leiste über dem Ziel
// liegen. Der Pfad wandert dafür in den State: Zustand beim Rendern
// anzupassen ist hier richtig, ein Effekt würde einen zweiten Durchlauf
// auslösen und die Leiste kurz über der neuen Seite zeigen.
const [schublade, setSchublade] = React.useState({ offen: false, pfad: pathname });
if (schublade.pfad !== pathname) setSchublade({ offen: false, pfad: pathname });
const mobilOffen = schublade.offen;
const leisteRef = React.useRef<HTMLDivElement>(null);
/** Schließen und den Fokus dorthin zurückgeben, wo er hergekommen ist. */
const schliesseSchublade = React.useCallback(() => {
setSchublade((aktuell) => ({ ...aktuell, offen: false }));
window.requestAnimationFrame(() => ausloeser.current?.focus());
}, []);
/**
* Fokusfalle der offenen Schublade.
*
* Nicht optional: Der Rahmen trägt `role="dialog"` und `aria-modal`, und
* damit ist einem Screenreader zugesagt, dass nichts dahinter erreichbar ist.
* Ohne Falle wandert der Tabulator hinter die Abdeckung in eine Seite, die
* optisch verdeckt ist — die Zusage wäre eine Unwahrheit.
*
* Übernommen aus `SubframeAppShell` im Zielsystem: Fokus auf das erste
* bedienbare Element, Tab und Umschalt+Tab laufen zyklisch, Escape schließt
* und gibt den Fokus an den Auslöser zurück.
*/
React.useEffect(() => {
if (!mobilOffen) return;
const leiste = leisteRef.current;
if (!leiste) return;
const bedienbar = () =>
Array.from(
leiste.querySelectorAll<HTMLElement>(
'a[href],button:not([disabled]),input:not([disabled]),[tabindex]:not([tabindex="-1"])'
)
).filter((el) => el.getClientRects().length > 0);
window.requestAnimationFrame(() => bedienbar()[0]?.focus());
const taste = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
schliesseSchublade();
return;
}
if (event.key !== "Tab") return;
const elemente = bedienbar();
if (!elemente.length) return;
const erstes = elemente[0];
const letztes = elemente[elemente.length - 1];
const aktiv = document.activeElement;
if (event.shiftKey && (aktiv === erstes || !leiste.contains(aktiv))) {
event.preventDefault();
letztes.focus();
} else if (!event.shiftKey && aktiv === letztes) {
event.preventDefault();
erstes.focus();
}
};
document.addEventListener("keydown", taste);
return () => document.removeEventListener("keydown", taste);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mobilOffen]);
const istAktiv = (eintrag: NavEintrag) =>
eintrag.ziel.href === "/analytics"
? pathname.startsWith("/analytics")
: pathname === eintrag.ziel.href;
const renderEintrag = (eintrag: NavEintrag) => (
<Link
key={eintrag.id}
href={eintrag.ziel.href}
title={eingeklappt ? eintrag.label : undefined}
className="w-full no-underline"
>
<SidebarWithSections.NavItem
icon={<FeatherIcon name={eintrag.icon} />}
selected={istAktiv(eintrag)}
variant={eingeklappt ? "icon-only" : "icon-and-text"}
>
{eintrag.label}
</SidebarWithSections.NavItem>
</Link>
);
const aktiveAnalyse = ANALYSE_SEITEN.find((s) =>
s.href === "/analytics" ? pathname === "/analytics" : pathname.startsWith(s.href)
);
const bereich = pathname.startsWith("/komponenten")
? "Komponenten"
: pathname.startsWith("/dashboard")
? "Dashboard"
: "Analytics";
return (
<div className="flex min-h-screen w-full bg-neutral-50">
{/* Abdeckung hinter der ausgefahrenen Leiste: Ein Klick daneben schließt. */}
{mobilOffen ? (
<div
className="fixed inset-0 z-40 bg-neutral-900/40 md:hidden"
onClick={schliesseSchublade}
aria-hidden="true"
/>
) : null}
{/* z-50 ist nötig, nicht dekorativ: `position: sticky` erzeugt einen
eigenen Stapelkontext. Ohne z-Wert am Rahmen bleibt das z-index des
Einklapp-Knopfes darin gefangen, und die Inhaltsspalte — ein späteres
Geschwisterelement — malt über den Teil, der aus der Leiste
herausragt. Die Software setzt an derselben Stelle ebenfalls z-50. */}
<div
ref={leisteRef}
data-shell-sidebar
className={`dv-no-print z-50 h-screen flex-none max-md:fixed max-md:inset-y-0 max-md:left-0 max-md:transition-transform md:sticky md:top-0 ${
mobilOffen ? "max-md:translate-x-0" : "max-md:-translate-x-full"
}`}
role={istSchmal ? "dialog" : undefined}
aria-modal={istSchmal && mobilOffen ? true : undefined}
aria-label={istSchmal ? "Navigation" : undefined}
aria-hidden={istSchmal && !mobilOffen ? true : undefined}
inert={istSchmal && !mobilOffen}
>
<SidebarWithSections
variant={eingeklappt ? "closed" : "open"}
onToggleCollapse={() => setEingeklappt((wert) => !wert)}
toggleLabel={eingeklappt ? "Seitenleiste ausklappen" : "Seitenleiste einklappen"}
>
<div className="flex h-full min-h-0 w-full flex-col">
<div className="flex min-h-0 w-full grow flex-col gap-1 overflow-x-hidden overflow-y-auto">
{NAV_GRUPPEN.map((gruppe) => (
<SidebarWithSections.NavSection
key={gruppe.id}
label={gruppe.titel}
showLabel={!eingeklappt && !!gruppe.titel}
>
{gruppe.eintraege.map(renderEintrag)}
</SidebarWithSections.NavSection>
))}
</div>
<div className="sticky bottom-0 z-10 mt-auto flex w-full flex-none flex-col gap-1 bg-surface-neutral-secondary pt-2">
<SidebarWithSections.NavDivider />
{NAV_SEKUNDAER.map(renderEintrag)}
{/* Einzeilig statt als Kasten: Die Seitenleiste soll die der
Software spiegeln, und dort steht kein Demo-Hinweis. Die
Information bleibt, nimmt aber keinen Navigationsplatz weg. */}
{!eingeklappt ? (
<p className="px-3 pb-2 pt-1 text-[11px] leading-tight text-text-neutral-tertiary">
Demo-Daten · {formatZahl(DATENSATZ_INFO.auftraege)} Aufträge ·{" "}
{formatDatum(HEUTE.toISOString())}
</p>
) : null}
</div>
</div>
</SidebarWithSections>
</div>
<div className="flex min-w-0 grow flex-col">
<header className="dv-no-print sticky top-0 z-30 flex h-14 flex-none items-center gap-4 border-b border-solid border-border-default bg-white/95 px-6 backdrop-blur">
<button
ref={ausloeser}
type="button"
onClick={() =>
mobilOffen ? schliesseSchublade() : setSchublade({ offen: true, pfad: pathname })
}
aria-label="Navigation"
aria-expanded={mobilOffen}
className="inline-flex h-8 w-8 flex-none items-center justify-center rounded-md text-text-neutral-secondary hover:bg-neutral-100 md:hidden"
>
<FeatherMenu className="text-[18px]" />
</button>
<div className="flex min-w-0 items-center gap-2 text-[13px]">
<span className="text-text-neutral-tertiary">{bereich}</span>
{aktiveAnalyse ? (
<>
<span className="text-text-neutral-subtle">/</span>
<span className="truncate font-semibold text-text-neutral-primary">
{aktiveAnalyse.label}
</span>
</>
) : null}
</div>
<div className="ml-auto flex items-center gap-2">
<div className="hidden h-9 items-center gap-2 rounded-md border border-solid border-border-default px-3 text-[13px] text-text-neutral-subtle lg:flex">
<FeatherSearch className="text-[15px]" />
<span>Suchen</span>
<kbd className="ml-4 rounded border border-solid border-border-default bg-neutral-50 px-1.5 py-0.5 text-[11px] font-medium text-text-neutral-tertiary">
⌘K
</kbd>
</div>
<button
className="flex h-9 w-9 items-center justify-center rounded-md text-text-neutral-tertiary hover:bg-neutral-100"
aria-label="Benachrichtigungen"
>
<FeatherBell className="text-[17px]" />
</button>
<button
className="flex h-9 w-9 items-center justify-center rounded-md text-text-neutral-tertiary hover:bg-neutral-100"
aria-label="Hilfe"
>
<FeatherHelpCircle className="text-[17px]" />
</button>
<span className="ml-1 flex h-8 w-8 items-center justify-center rounded-full bg-neutral-800 text-[12px] font-semibold text-white">
MK
</span>
</div>
</header>
<main className="min-w-0 grow">{children}</main>
</div>
</div>
);
}