From 00665592f6238efcd12d5d71ec0ff355a473aed7 Mon Sep 17 00:00:00 2001 From: sysops Date: Fri, 28 Aug 2026 21:41:56 +0200 Subject: [PATCH] SHL-01: ui-shell-design-system-zentral (tokens, theming, i18n-rahmen, basis-komponenten) --- .gitignore | 2 + web/shl/README.md | 45 +++++++++++++ web/shl/__tests__/Dialog.test.tsx | 38 +++++++++++ web/shl/__tests__/tokens.test.ts | 39 ++++++++++++ web/shl/components/Dialog.tsx | 88 ++++++++++++++++++++++++++ web/shl/components/FormElements.tsx | 98 +++++++++++++++++++++++++++++ web/shl/components/Shell.tsx | 62 ++++++++++++++++++ web/shl/components/Table.tsx | 83 ++++++++++++++++++++++++ web/shl/components/Toast.tsx | 67 ++++++++++++++++++++ web/shl/i18n/i18n.tsx | 75 ++++++++++++++++++++++ web/shl/index.ts | 25 ++++++++ web/shl/package.json | 23 +++++++ web/shl/theme/ThemeProvider.tsx | 81 ++++++++++++++++++++++++ web/shl/tokens/tokens.ts | 96 ++++++++++++++++++++++++++++ web/shl/tsconfig.json | 18 ++++++ 15 files changed, 840 insertions(+) create mode 100644 web/shl/README.md create mode 100644 web/shl/__tests__/Dialog.test.tsx create mode 100644 web/shl/__tests__/tokens.test.ts create mode 100644 web/shl/components/Dialog.tsx create mode 100644 web/shl/components/FormElements.tsx create mode 100644 web/shl/components/Shell.tsx create mode 100644 web/shl/components/Table.tsx create mode 100644 web/shl/components/Toast.tsx create mode 100644 web/shl/i18n/i18n.tsx create mode 100644 web/shl/index.ts create mode 100644 web/shl/package.json create mode 100644 web/shl/theme/ThemeProvider.tsx create mode 100644 web/shl/tokens/tokens.ts create mode 100644 web/shl/tsconfig.json diff --git a/.gitignore b/.gitignore index ac00d6f..a5e59d5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ *.log .env +web/*/node_modules/ +web/*/.next/ diff --git a/web/shl/README.md b/web/shl/README.md new file mode 100644 index 0000000..86f7609 --- /dev/null +++ b/web/shl/README.md @@ -0,0 +1,45 @@ +# @nexarch/shl — UI-Shell & Design-System (Core SHL-01) + +Gemeinsames Paket für alle NEXARCH-Modul-Frontends (Core, DMS, Mail, Archive, Workflow, AI, Connect). +Ein Modul-Frontend importiert ausschließlich über `index.ts`, kopiert keine Komponenten oder Tokens lokal. + +## Enthält + +- **Design-Tokens** (`tokens/tokens.ts`) — Farbe (Hell/Dunkel), Abstand, Typografie. Kontrastwerte gegen WCAG 2.1 AA geprüft (siehe `__tests__/tokens.test.ts`). +- **Theming** (`theme/ThemeProvider.tsx`) — zentrale Hell/Dunkel-Umschaltung, respektiert `prefers-color-scheme`, persistiert in `localStorage`. +- **i18n-Rahmen** (`i18n/i18n.tsx`) — Umschaltmechanismus Deutsch/Englisch. Modul-Frontends registrieren ihre fachlichen Textbausteine über `registerMessages()`, statt einen eigenen Mechanismus zu bauen. +- **Basis-Komponenten** (`components/`) — `Shell` (Layout + Navigation), `Table`, `Dialog`, `TextField`/`SelectField`/`CheckboxField`, `Toast`. Alle mit WCAG-2.1-AA-Grundlage (Tastaturbedienung, ARIA-Attribute, Fokus-Management). + +## Verwendung in einem Modul-Frontend + +```tsx +import { ThemeProvider, I18nProvider, ToastProvider, Shell } from "@nexarch/shl"; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + + {children} + + + + + ); +} +``` + +## Bekannter offener Punkt + +Die vier bereits gebauten Core-Frontends (`TEN-05`, `LIC-04`, `AUD-04`, `OPS-02`) sind vor diesem Paket entstanden und binden es noch nicht ein — Retrofit ist der nächste Schritt, siehe `nexarch-state.json`. + +## Tests + +Ausführung auf dem Test-Host (nicht lokal, siehe Projekt-Testinfrastruktur): + +```bash +npm install +npm test +npm run typecheck +``` diff --git a/web/shl/__tests__/Dialog.test.tsx b/web/shl/__tests__/Dialog.test.tsx new file mode 100644 index 0000000..cde82cb --- /dev/null +++ b/web/shl/__tests__/Dialog.test.tsx @@ -0,0 +1,38 @@ +// Prüfung: Tastaturbedienung der Basis-Komponenten funktioniert (SHL-01 Prüfung 2). +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { Dialog } from "../components/Dialog"; +import { I18nProvider } from "../i18n/i18n"; + +function renderDialog(onClose: () => void) { + return render( + + + + + + , + ); +} + +describe("Dialog: Tastaturbedienung", () => { + it("schließt sich bei ESC", () => { + const onClose = vi.fn(); + renderDialog(onClose); + fireEvent.keyDown(document, { key: "Escape" }); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("setzt den Fokus beim Öffnen auf das erste fokussierbare Element", () => { + renderDialog(vi.fn()); + const closeButton = screen.getByRole("button", { name: /schließen/i }); + expect(document.activeElement).toBe(closeButton); + }); + + it("ist als modaler Dialog mit Titel-Referenz ausgezeichnet", () => { + renderDialog(vi.fn()); + const dialog = screen.getByRole("dialog"); + expect(dialog).toHaveAttribute("aria-modal", "true"); + expect(dialog).toHaveAttribute("aria-labelledby", "test-title"); + }); +}); diff --git a/web/shl/__tests__/tokens.test.ts b/web/shl/__tests__/tokens.test.ts new file mode 100644 index 0000000..91e186f --- /dev/null +++ b/web/shl/__tests__/tokens.test.ts @@ -0,0 +1,39 @@ +// Prüfung: Kontrastwerte erfüllen mindestens AA (SHL-01 Prüfung 3 / Akzeptanzkriterium 4). +import { describe, expect, it } from "vitest"; +import { colorTokens } from "../tokens/tokens"; + +// WCAG-2.1-AA-Kontrastberechnung (relative Luminanz, sRGB) — keine externe Abhängigkeit nötig. +function relLuminance(hex: string): number { + const rgb = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255); + const [r, g, b] = rgb.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +function contrastRatio(a: string, b: string): number { + const l1 = relLuminance(a); + const l2 = relLuminance(b); + const [lighter, darker] = l1 > l2 ? [l1, l2] : [l2, l1]; + return (lighter + 0.05) / (darker + 0.05); +} + +describe("Design-Tokens: WCAG 2.1 AA Kontrast", () => { + for (const scheme of ["light", "dark"] as const) { + const c = colorTokens[scheme]; + + it(`${scheme}: textPrimary auf background erfüllt AA (>= 4.5:1)`, () => { + expect(contrastRatio(c.textPrimary, c.background)).toBeGreaterThanOrEqual(4.5); + }); + + it(`${scheme}: textSecondary auf surface erfüllt AA (>= 4.5:1)`, () => { + expect(contrastRatio(c.textSecondary, c.surface)).toBeGreaterThanOrEqual(4.5); + }); + + it(`${scheme}: accentContrast auf accent erfüllt AA (>= 4.5:1)`, () => { + expect(contrastRatio(c.accentContrast, c.accent)).toBeGreaterThanOrEqual(4.5); + }); + + it(`${scheme}: dangerContrast auf danger erfüllt AA (>= 4.5:1)`, () => { + expect(contrastRatio(c.dangerContrast, c.danger)).toBeGreaterThanOrEqual(4.5); + }); + } +}); diff --git a/web/shl/components/Dialog.tsx b/web/shl/components/Dialog.tsx new file mode 100644 index 0000000..4815b31 --- /dev/null +++ b/web/shl/components/Dialog.tsx @@ -0,0 +1,88 @@ +"use client"; + +// Dialog-Basis-Komponente — SHL-01. WCAG 2.1 AA: Fokus-Falle, ESC schließt, Tastaturbedienung vollständig. + +import { useEffect, useRef } from "react"; +import type { ReactNode } from "react"; +import { useI18n } from "../i18n/i18n"; + +const FOCUSABLE_SELECTOR = + 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +export interface DialogProps { + open: boolean; + onClose: () => void; + titleId: string; + title: string; + children: ReactNode; +} + +export function Dialog({ open, onClose, titleId, title, children }: DialogProps) { + const { t } = useI18n(); + const dialogRef = useRef(null); + const previouslyFocused = useRef(null); + + useEffect(() => { + if (!open) return; + previouslyFocused.current = document.activeElement as HTMLElement | null; + + const node = dialogRef.current; + const focusables = node?.querySelectorAll(FOCUSABLE_SELECTOR); + focusables?.[0]?.focus(); + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + onClose(); + return; + } + if (event.key !== "Tab" || !node) return; + + const items = Array.from(node.querySelectorAll(FOCUSABLE_SELECTOR)); + if (items.length === 0) return; + const first = items[0]; + const last = items[items.length - 1]; + + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + previouslyFocused.current?.focus(); + }; + }, [open, onClose]); + + if (!open) return null; + + return ( +
{ + if (event.target === event.currentTarget) onClose(); + }} + > +
+
+

{title}

+ +
+
{children}
+
+
+ ); +} diff --git a/web/shl/components/FormElements.tsx b/web/shl/components/FormElements.tsx new file mode 100644 index 0000000..12c312a --- /dev/null +++ b/web/shl/components/FormElements.tsx @@ -0,0 +1,98 @@ +// Formularelemente-Basis-Komponenten — SHL-01. WCAG: jedes Feld hat verknüpftes