Files
Dashboard/features/dashboard/components/layout/AppShell.tsx
T
dejanandClaude Opus 5 7bbbd00ed2 Fokusring nur noch bei Tastaturbedienung zeigen
Nach dem Oeffnen der Schublade lag ein blauer Rahmen um "Dashboard", nach dem
Schliessen um den Hamburger — bis man woanders hinklickte. Der Ring hat einen
praktischen Grund, aber nur fuer die Tastatur: Er zeigt, wo man gerade steht.
Nach einem Mausklick weiss man das ohnehin.

Ursache war unser eigener Fokus-Sprung, nicht die CSS-Regel. Die benutzt
laengst :focus-visible. Setzt aber ein Skript den Fokus — Fokusfalle beim
Oeffnen, Rueckgabe an den Ausloeser beim Schliessen —, wertet Chrome das wie
Tastaturbedienung und zeigt den Ring trotzdem.

Drei Aenderungen:

- Beim Oeffnen bekommt der Dialograhmen den Fokus statt des ersten Links. Das
  ist ohnehin das uebliche Vorgehen fuer einen Dialog; die Sprachausgabe liest
  den Dialog an, der erste Tabulatorschritt landet auf dem ersten Link.

- Elemente mit tabindex="-1" bekommen keinen Ring mehr. Sie sind aus der
  Tabulatorreihenfolge heraus und werden nur per Skript fokussiert — ein Ring
  um den ganzen Schubladenrahmen waere dort Laerm.

- AppShell schreibt die zuletzt benutzte Eingabeart als data-eingabe an das
  Wurzelelement. Solange sie "zeiger" ist, entfaellt der Ring an Knoepfen und
  Links. Eingabefelder sind ausgenommen, dort gehoert er zum Zustand "hier
  wird gerade geschrieben". Der erste Tastendruck schaltet ihn ueberall zurueck.

Dabei ist eine Luecke in der Fokusfalle aufgefallen und behoben: Mit dem Fokus
auf dem Rahmen lief Umschalt+Tab aus dem Dialog heraus, weil die Falle nur den
ersten Link als Umkehrpunkt kannte. Der Rahmen steht im Dokument vor seinen
Kindern und gehoert dazu — sonst waere die Zusage von aria-modal gebrochen.

Nachgemessen statt begutachtet, alle Faelle:
  Maus offen        → Rahmen, kein Ring
  Maus zu           → Hamburger, kein Ring
  Tastatur offen    → Rahmen, kein Ring
  Tastatur erstes   → "Dashboard", RING
  Tastatur rueckwaerts → "Komponenten", RING (Falle haelt)

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

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>
);
}