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/DEVLOG.md b/DEVLOG.md index 6a9fd56..7ab7076 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -64,6 +64,9 @@ Keine Änderungen ermittelbar. ## 2026-08-27 17:28 – 17:29 (1m) **Beschreibung:** Claude Code Session **Projekt:** code +## 2026-08-28 21:44 – 21:44 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** nexarch ### Commits Keine Commits in dieser Session. @@ -127,5 +130,32 @@ Keine Commits in dieser Session. - internal/config/config.go | 29 +++++++++++++++++++++++++++++ - internal/db/db.go | 11 +++++++++++ - migrations/0001_tenant_registry.sql | 10 ++++++++++ +- 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 ++++++++++++++++++ + +--- +## 2026-08-28 21:51 – 21:57 (5m) +**Beschreibung:** Claude Code Session +**Projekt:** nexarch + +### Commits +- 3c226da SHL-01: fix — vitest jsdom-environment + jest-dom-Setup (3 Dialog-Tests schlugen ohne DOM fehl) + +### Geänderte Dateien +- web/shl/package.json | 2 ++ +- web/shl/vitest.config.ts | 8 ++++++++ +- web/shl/vitest.setup.ts | 1 + --- 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