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>
116 lines
4.1 KiB
TypeScript
116 lines
4.1 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";
|
||
|
||
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>
|
||
);
|
||
}
|