Files
nexarch/web/shl/components/Shell.tsx
patrickandClaude Sonnet 5 4a36f07fee
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
SHL-01/UX-01: Table/Form-Styles, fehlende ModuleNavShell-Einbindung, Hydration-Fix
P0-Fund aus Fable5-Review: shl-table/shl-field* hatten keine CSS-Regeln
(baseComponentStyles in tokens.ts ergaenzt, ueber Shell.tsx eingebunden).
dms-browser und portal hatten ModuleNavShell trotz Registry-Eintrag nicht
eingebunden, jetzt nachgezogen. Hartcodierte Hex-Farben in status-page/
tenant-admin/lic-admin/audit-log durch Tokens ersetzt (dangerSubtle neu).

ModuleNavShell.resolveHref las window.location.hostname beim ersten
Render -> SSR/Client-Mismatch (Hydration-Fehler in React 19). Fix: Href
bleibt beim ersten Render unveraendert, Hostname-Tausch erst in
useEffect nach dem Mount. Verschachteltes <main> in portal/app/page.tsx
entfernt (Shell.tsx rendert bereits eins).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-19 02:50:44 +02:00

145 lines
5.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
// Layout-Shell mit Navigation — SHL-01 Akzeptanzkriterium 1.
// Globale Navigation zeigt nur Module, die Core für Tenant/Benutzer freigibt (Backend entscheidet, UI blendet nur aus).
//
// UX-01: unter 768px klappt die Navigation hinter einem Hamburger-Button
// weg (Akzeptanzkriterium 1: Kernabläufe auf dem Telefon vollständig
// durchführbar — eine dauerhaft sichtbare, mehrzeilig umbrechende Leiste
// wäre das nicht), `max-width:100%`/`overflow-x:hidden` auf der Shell
// verhindert horizontales Scrollen (Akzeptanzkriterium 2), alle
// interaktiven Elemente sind mindestens 44×44px groß
// (Akzeptanzkriterium 3, WCAG-Empfehlung für Touch-Ziele).
//
// UX-05 Akzeptanzkriterium 2: sichtbare Fokusmarkierung global über
// :focus-visible statt je Komponente einzeln (Shell umschließt alle
// Modul-Inhalte).
import { useState } from "react";
import type { ReactNode } from "react";
import { useI18n } from "../i18n/i18n";
import { useTheme } from "../theme/ThemeProvider";
import { baseComponentStyles } from "../tokens/tokens";
export interface ModuleLink {
key: string;
label: string;
href: string;
active?: boolean;
}
export interface ShellProps {
modules: ModuleLink[];
tenantLabel: string;
children: ReactNode;
}
const responsiveStyles = `
.shl-shell { max-width: 100%; overflow-x: hidden; box-sizing: border-box; }
.shl-shell *:focus-visible {
outline: 3px solid var(--shl-color-focus-ring, #1D4ED8);
outline-offset: 2px;
}
.shl-shell *, .shl-shell *::before, .shl-shell *::after { box-sizing: inherit; }
.shl-shell-header {
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px;
padding: 10px 20px;
background: var(--shl-color-surface-raised, #fff);
border-bottom: 1px solid var(--shl-color-border, #d7dbe0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
position: sticky; top: 0; z-index: 10;
}
.shl-shell-header nav ul { display: flex; gap: 4px; list-style: none; margin: 0; padding: 0; flex-wrap: wrap; }
.shl-shell-header nav a {
min-height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0 14px;
border-radius: 8px; font-weight: 500; text-decoration: none;
color: var(--shl-color-text-secondary, #4b5563);
transition: background-color 0.15s ease, color 0.15s ease;
}
.shl-shell-header nav a:hover { background: var(--shl-color-accent-subtle, #eef3ff); }
.shl-shell-header nav a[aria-current="page"] {
background: var(--shl-color-accent-subtle, #eef3ff);
color: var(--shl-color-accent, #1d4ed8);
font-weight: 600;
}
.shl-shell-header button,
.shl-nav-toggle {
min-height: 44px; min-width: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0 12px;
border-radius: 8px;
}
.shl-nav-toggle { display: none; background: none; border: 1px solid var(--shl-color-border, #d7dbe0); font-size: 1.25rem; }
.shl-shell-context { display: flex; align-items: center; gap: 12px; }
.shl-tenant-context {
font-size: 0.85rem; font-weight: 500; color: var(--shl-color-text-secondary, #4b5563);
padding: 4px 10px; border-radius: 999px; background: var(--shl-color-surface, #f5f6f8);
}
.shl-shell-context button {
background: none; border: 1px solid var(--shl-color-border, #d7dbe0);
}
@media (max-width: 768px) {
.shl-nav-toggle { display: inline-flex; }
.shl-shell-header nav { display: none; width: 100%; order: 3; }
.shl-shell-header nav.shl-nav-open { display: block; }
.shl-shell-header nav ul { flex-direction: column; }
}
`;
export function Shell({ modules, tenantLabel, children }: ShellProps) {
const { scheme, toggle } = useTheme();
const { t } = useI18n();
const [navOpen, setNavOpen] = useState(false);
return (
<div className="shl-shell">
<style>{responsiveStyles}</style>
<style>{baseComponentStyles}</style>
<a className="shl-skip-link" href="#shl-main-content">
{t("shl.shell.skipToContent", "Zum Inhalt springen")}
</a>
<header className="shl-shell-header">
<button
type="button"
className="shl-nav-toggle"
data-testid="shl-nav-toggle"
aria-expanded={navOpen}
aria-controls="shl-module-nav"
onClick={() => setNavOpen((open) => !open)}
aria-label={t("shl.shell.toggleNav", "Navigation umschalten")}
>
☰
</button>
<nav
id="shl-module-nav"
aria-label={t("shl.shell.moduleNav", "Modul-Navigation")}
className={navOpen ? "shl-nav-open" : undefined}
>
<ul>
{modules.map((mod) => (
<li key={mod.key}>
<a href={mod.href} aria-current={mod.active ? "page" : undefined} onClick={() => setNavOpen(false)}>
{mod.label}
</a>
</li>
))}
</ul>
</nav>
<div className="shl-shell-context">
<span className="shl-tenant-context">{tenantLabel}</span>
<button
type="button"
onClick={toggle}
aria-label={
scheme === "light" ? t("shl.theme.toggleToDark") : t("shl.theme.toggleToLight")
}
>
{scheme === "light" ? "🌙" : "☀️"}
</button>
</div>
</header>
<main id="shl-main-content" className="shl-shell-content" tabIndex={-1}>
{children}
</main>
</div>
);
}