467 Stellen. Nicht durch einen anderen Strich ersetzt, sondern durch das
Satzzeichen, das an der jeweiligen Stelle richtig ist: Doppelpunkt, wo eine
Erlaeuterung folgt; Komma, wo der Satz weiterlaeuft; Punkt, wo zwei
Hauptsaetze aneinanderstiessen. Ein Halbgeviertstrich waere nur derselbe
Griff mit kuerzerem Strich gewesen.
Regelbasiert ersetzt und danach nachgeprueft, weil eine Regel allein nicht
reicht. Gefunden und einzeln korrigiert:
- Eingeschobene Saetze mit Strichpaar. Nur der oeffnende Strich passte ins
Muster, der schliessende blieb als "—," stehen. Drei Stellen, jetzt mit
Klammern oder als eigener Satz.
- Doppelte Doppelpunkte, wo beide Striche eines Paars zu Doppelpunkten wurden
("Globale Regeln: Schrift, Groessen, Abstaende: kommen aus HiveGrid").
Sieben Stellen in Doku und Oberflaeche, jetzt in Klammern.
- Pfade, denen ein Komma statt eines Doppelpunkts folgte.
- Ausgerichtete Kommentartabellen, in denen der Strich eine Spalte war.
Nicht angefasst: drei Geviertstriche, die keine Prosa sind, sondern
Tabellenplatzhalter fuer "kein Wert" (Trichter, Auftraege, Kunden). Dort ist
der Strich die Angabe selbst; ihn zu entfernen liesse leere Zellen zurueck.
tsc, lint, build, verify:carbon und verify:layout laufen sauber.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPeZNwSeU7qG3RRCwaxEZX
315 lines
14 KiB
TypeScript
315 lines
14 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: 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<HTMLElement>(
|
|
'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) => (
|
|
<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
|
|
tabIndex={istSchmal && mobilOffen ? -1 : undefined}
|
|
className={`dv-no-print z-50 outline-none 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>
|
|
);
|
|
}
|