"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(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(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( '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) => ( } selected={istAktiv(eintrag)} variant={eingeklappt ? "icon-only" : "icon-and-text"} > {eintrag.label} ); 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 (
{/* Abdeckung hinter der ausgefahrenen Leiste: Ein Klick daneben schließt. */} {mobilOffen ? ( ); }