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
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>
145 lines
5.4 KiB
TypeScript
145 lines
5.4 KiB
TypeScript
"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>
|
||
);
|
||
}
|