"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: Tab und Umschalt+Tab * laufen zyklisch, Escape schließt und gibt den Fokus an den Auslöser * zurück. * * Beim Öffnen bekommt der **Rahmen** den Fokus, nicht der erste Link. Das * ist das übliche Vorgehen für einen Dialog und hat hier einen sichtbaren * Grund: Ein per Skript fokussierter Link zeigt in Chrome seinen Fokusring, * auch wenn die Schublade mit der Maus geöffnet wurde, dann liegt ein * blauer Rahmen um „Dashboard", ohne dass jemand dorthin getippt hat. Der * Rahmen selbst trägt `tabIndex={-1}` und keinen Ring; die Sprachausgabe * liest trotzdem den Dialog an, und der erste Tabulatorschritt landet auf * dem ersten Link. */ 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(() => leiste.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; // `aktiv === leiste` gehört dazu: Direkt nach dem Öffnen liegt der Fokus // auf dem Rahmen selbst. Er steht im Dokument vor seinen Kindern, also // muss Umschalt+Tab von dort ans Ende springen, sonst verlässt der // Tabulator den Dialog rückwärts, und die Zusage von `aria-modal` wäre // gebrochen. if (event.shiftKey && (aktiv === erstes || aktiv === leiste || !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]); /** * Zuletzt benutzte Eingabeart am Wurzelelement vermerken. * * `:focus-visible` allein reicht nicht: Wenn ein Skript den Fokus setzt, * beim Schließen der Schublade zurück auf den Hamburger: behandelt Chrome * das wie Tastaturbedienung und zeigt den Ring. Nach einem Mausklick steht * er dann sichtbar da, bis man woanders hinklickt. * * Die Regel dazu steht in `app/globals.css`. Sie unterdrückt den Ring nur, * solange die letzte Eingabe ein Zeiger war, und nur an Knöpfen und Links, * Eingabefelder behalten ihn immer, dort gehört er zum Zustand „hier wird * gerade geschrieben". Der erste Tastendruck schaltet ihn sofort zurück. */ React.useEffect(() => { const wurzel = document.documentElement; const zeiger = () => { wurzel.dataset.eingabe = "zeiger"; }; const tastatur = (event: KeyboardEvent) => { if (event.metaKey || event.altKey || event.ctrlKey) return; wurzel.dataset.eingabe = "tastatur"; }; window.addEventListener("pointerdown", zeiger, true); window.addEventListener("keydown", tastatur, true); return () => { window.removeEventListener("pointerdown", zeiger, true); window.removeEventListener("keydown", tastatur, true); }; }, []); 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 ? ( ); }