Files
nexarch/web/shl/components/Shell.tsx
T
patrickandClaude Sonnet 5 358908e171 UX-05: Barrierefreiheit nach WCAG 2.1 AA
Fuehrt automatisierte axe-core-Pruefung ein (jest-axe, __tests__/a11y.test.tsx
in dms-browser und pdf-viewer) und behebt die dabei real gefundenen
Verstoesse: leere Tabellenkopfzellen im Papierkorb (empty-table-header),
sowie verschachtelte interaktive Elemente und fehlende Eingabefeld-
Bezeichnung in TagInput (auf ARIA-Combobox-Muster umgestellt).

Globale sichtbare Fokusmarkierung (:focus-visible) ueber
web/shl/tokens/tokens.ts::focusVisibleStyles, eingebunden im Root-Layout
beider Apps -- Shell.tsx selbst wird von keiner der beiden Apps gerendert,
daher reicht die Ergaenzung dort allein nicht aus.

Neuer Test fuer die Fokusfalle in Dialog.tsx (Tab-Zyklus), bestehende
Implementierung aus SHL-01 bestaetigt statt nur angenommen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 08:05:00 +02:00

116 lines
4.1 KiB
TypeScript
Raw 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";
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: 8px 16px; }
.shl-shell-header nav ul { display: flex; gap: 8px; list-style: none; margin: 0; padding: 0; flex-wrap: wrap; }
.shl-shell-header nav a,
.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;
}
.shl-nav-toggle { display: none; background: none; border: 1px solid var(--shl-color-border, #d7dbe0); font-size: 1.25rem; }
@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>
<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>
);
}