feat: add UI conformance and launch context
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { FileText, GitBranch, Inbox, Search, ShieldCheck } from "lucide-react";
|
||||
import { useLocation } from "react-router";
|
||||
import ActionToolbar, { ToolbarGroup } from "../src/components/ActionToolbar";
|
||||
import Button from "../src/components/Button";
|
||||
import Card from "../src/components/Card";
|
||||
import ContentGrid, { FormGrid } from "../src/components/ContentGrid";
|
||||
import ContentSection from "../src/components/ContentSection";
|
||||
import CountBadge from "../src/components/CountBadge";
|
||||
import DefinitionNodeIcon from "../src/components/DefinitionNodeIcon";
|
||||
import DefinitionPalette, { DefinitionPaletteGroup, DefinitionPaletteItem } from "../src/components/DefinitionPalette";
|
||||
import DescriptionList, { DescriptionItem } from "../src/components/DescriptionList";
|
||||
import Dialog from "../src/components/Dialog";
|
||||
import { DialogForm, DialogSection } from "../src/components/DialogAnatomy";
|
||||
import FilterBar from "../src/components/FilterBar";
|
||||
import FloatingStatus from "../src/components/FloatingStatus";
|
||||
import FormSection from "../src/components/FormSection";
|
||||
import MetricCard from "../src/components/MetricCard";
|
||||
import MetricGrid from "../src/components/MetricGrid";
|
||||
import PageLayout from "../src/components/PageLayout";
|
||||
import SelectionList, { SelectionListItem, SelectionListItemContent } from "../src/components/SelectionList";
|
||||
import StatePanel from "../src/components/StatePanel";
|
||||
import WorkspaceFrame from "../src/components/WorkspaceFrame";
|
||||
import WorkspaceLayout from "../src/components/WorkspaceLayout";
|
||||
import BreadcrumbBar from "../src/layout/BreadcrumbBar";
|
||||
import { useGuardedNavigate } from "../src/components/UnsavedChangesGuard";
|
||||
import {
|
||||
createQuickAccessLaunchContext,
|
||||
quickAccessLaunchState
|
||||
} from "../src/platform/launchContext";
|
||||
import type { AuthInfo } from "../src/types";
|
||||
|
||||
export default function ConformanceApp() {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<main className="conformance-root" data-conformance-id="shared-ui-lab">
|
||||
<PageLayout
|
||||
mode="embedded"
|
||||
title="Zentrale GovOPlaN-Oberflächen"
|
||||
description="Diese Prüfseite rendert reale gemeinsame Komponenten mit langen deutschen Beschriftungen, Zuständen und responsiven Zusammensetzungen."
|
||||
actions={<Button variant="primary" data-testid="open-dialog" onClick={() => setDialogOpen(true)}>Prüfdialog öffnen</Button>}
|
||||
>
|
||||
<section className="conformance-section" aria-labelledby="actions-heading">
|
||||
<h2 id="actions-heading">Aktionen, Filter und Status</h2>
|
||||
<ActionToolbar surface="subtle" justify="between" aria-label="Vorgangsaktionen">
|
||||
<ToolbarGroup>
|
||||
<Button variant="primary">Änderungen speichern</Button>
|
||||
<Button>Vorschau öffnen</Button>
|
||||
<Button variant="ghost">Verwerfen</Button>
|
||||
</ToolbarGroup>
|
||||
<Button variant="danger" disabledReason="Nur die federführende Stelle darf diesen Vorgang endgültig löschen.">Löschen</Button>
|
||||
</ActionToolbar>
|
||||
<FilterBar surface="control" aria-label="Vorgangsliste filtern">
|
||||
<label>Suche<input type="search" placeholder="Aktenzeichen oder verantwortliche Stelle" /></label>
|
||||
<label>Status<select defaultValue="open"><option value="open">Offen</option><option value="done">Abgeschlossen</option></select></label>
|
||||
<Button><Search size={16} aria-hidden="true" /> Anwenden</Button>
|
||||
<CountBadge aria-label="27 Treffer">27</CountBadge>
|
||||
</FilterBar>
|
||||
<ContentGrid columns={3} collapseAt="standard" spacing="block">
|
||||
<StatePanel title="Keine Einträge" description="Für den gewählten Zeitraum liegen noch keine Einträge vor." surface="dashed" size="compact" icon={<Inbox size={22} />} />
|
||||
<StatePanel title="Berechtigung erforderlich" description="Die zuständige Administration kann den fehlenden Zugriff prüfen." tone="warning" surface="subtle" size="compact" icon={<ShieldCheck size={22} />} />
|
||||
<StatePanel title="Verbindung unterbrochen" description="Gespeicherte Daten bleiben erhalten. Erneut versuchen, sobald die Verbindung verfügbar ist." tone="danger" surface="subtle" size="compact" actions={<Button>Erneut versuchen</Button>} />
|
||||
</ContentGrid>
|
||||
</section>
|
||||
|
||||
<section className="conformance-section" aria-labelledby="metrics-heading">
|
||||
<h2 id="metrics-heading">Kennzahlen und Eigenschaften</h2>
|
||||
<MetricGrid columns={4} collapseAt="standard">
|
||||
<MetricCard label="Offene Aufgaben" value="18" detail="3 heute fällig" />
|
||||
<MetricCard label="Fristgerecht" value="94 %" tone="good" detail="Letzte 30 Tage" />
|
||||
<MetricCard label="Klärung erforderlich" value="4" tone="warning" density="compact" />
|
||||
<MetricCard label="Fehlgeschlagen" value="1" tone="danger" surface="subtle" />
|
||||
</MetricGrid>
|
||||
<ContentSection density="default" surface="subtle">
|
||||
<h3>Nachvollziehbare Entscheidung</h3>
|
||||
<DescriptionList columns={3} collapseAt="standard">
|
||||
<DescriptionItem term="Aktenzeichen">A-2026-004218</DescriptionItem>
|
||||
<DescriptionItem term="Verantwortliche Stelle">Fachbereich Öffentlicher Raum und nachhaltige Mobilität</DescriptionItem>
|
||||
<DescriptionItem term="Letzte Änderung">18. August 2026, 14:42 Uhr</DescriptionItem>
|
||||
</DescriptionList>
|
||||
</ContentSection>
|
||||
</section>
|
||||
|
||||
<section className="conformance-section" aria-labelledby="workspace-heading">
|
||||
<h2 id="workspace-heading">Listen- und Arbeitsbereich</h2>
|
||||
<WorkspaceFrame className="conformance-workspace" label="Vorgangsauswahl">
|
||||
<WorkspaceLayout
|
||||
variant="split"
|
||||
primarySize="compact"
|
||||
surface="contained"
|
||||
primaryLabel="Vorgänge"
|
||||
contentLabel="Ausgewählter Vorgang"
|
||||
primary={
|
||||
<SelectionList label="Vorgänge" variant="navigation">
|
||||
<SelectionListItem selected>
|
||||
<SelectionListItemContent leading={<FileText size={18} />} title="Anwohnerparkausweis" description="A-2026-004218 · Prüfung läuft" />
|
||||
</SelectionListItem>
|
||||
<SelectionListItem selected={false}>
|
||||
<SelectionListItemContent leading={<FileText size={18} />} title="Sondernutzung öffentlicher Fläche" description="A-2026-004219 · Rückfrage offen" />
|
||||
</SelectionListItem>
|
||||
</SelectionList>
|
||||
}
|
||||
>
|
||||
<Card title="Anwohnerparkausweis" actions={<Button variant="primary">Bearbeiten</Button>}>
|
||||
<p>Die Identität wurde geprüft. Ein aktueller Wohnsitznachweis muss noch bestätigt werden.</p>
|
||||
<FormSection title="Nächster Arbeitsschritt" description="Die Entscheidung bleibt nachvollziehbar und kann vor Abschluss korrigiert werden." variant="panel">
|
||||
<FormGrid columns={2}>
|
||||
<label>Zuständigkeit<input defaultValue="Bürgerdienste Mitte" /></label>
|
||||
<label>Bearbeitungsfrist<input type="date" defaultValue="2026-08-28" /></label>
|
||||
</FormGrid>
|
||||
</FormSection>
|
||||
</Card>
|
||||
</WorkspaceLayout>
|
||||
</WorkspaceFrame>
|
||||
</section>
|
||||
|
||||
<section className="conformance-section" aria-labelledby="definition-heading">
|
||||
<h2 id="definition-heading">Definitionsarbeitsbereich</h2>
|
||||
<div className="conformance-definition">
|
||||
<DefinitionPalette label="Bausteine" description="Mit Tastatur oder Schaltfläche einfügen">
|
||||
<DefinitionPaletteGroup label="Verarbeitung">
|
||||
<DefinitionPaletteItem icon={<DefinitionNodeIcon><GitBranch size={16} /></DefinitionNodeIcon>} label="Bedingung prüfen" />
|
||||
<DefinitionPaletteItem icon={<DefinitionNodeIcon><ShieldCheck size={16} /></DefinitionNodeIcon>} label="Freigabe anfordern" />
|
||||
</DefinitionPaletteGroup>
|
||||
</DefinitionPalette>
|
||||
<ContentSection className="conformance-canvas" density="default" spacing="none">
|
||||
<StatePanel title="Definition auswählen" description="Wählen Sie links einen Baustein oder öffnen Sie eine vorhandene Definition." size="fill" />
|
||||
<FloatingStatus>Automatische Prüfung läuft …</FloatingStatus>
|
||||
</ContentSection>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<LaunchContextScenario />
|
||||
</PageLayout>
|
||||
|
||||
<Dialog
|
||||
open={dialogOpen}
|
||||
title="Konsequenz vor Ausführung prüfen"
|
||||
description="Die Änderung betrifft nachgelagerte Aufgaben und wird mit Verantwortlichkeit und Zeitpunkt protokolliert."
|
||||
size="default"
|
||||
onClose={() => setDialogOpen(false)}
|
||||
footer={<><Button onClick={() => setDialogOpen(false)}>Abbrechen</Button><Button variant="primary" onClick={() => setDialogOpen(false)}>Änderung bestätigen</Button></>}
|
||||
>
|
||||
<DialogForm onSubmit={(event) => event.preventDefault()}>
|
||||
<DialogSection title="Begründung">
|
||||
<label>Begründung<textarea defaultValue="Die vorliegenden Nachweise wurden vollständig geprüft." /></label>
|
||||
</DialogSection>
|
||||
</DialogForm>
|
||||
</Dialog>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function LaunchContextScenario() {
|
||||
const location = useLocation();
|
||||
const navigate = useGuardedNavigate();
|
||||
const launchContext = useMemo(() => createQuickAccessLaunchContext({
|
||||
pathname: location.pathname,
|
||||
search: location.search,
|
||||
hash: location.hash,
|
||||
historyIndex: browserHistoryIndex(),
|
||||
auth: CONFORMANCE_AUTH,
|
||||
activeObject: {
|
||||
ownerModule: "cases",
|
||||
kind: "case",
|
||||
objectId: "case-1",
|
||||
tenantId: "tenant-1",
|
||||
label: "RPP-2026-0001 · Anwohnerparkausweis",
|
||||
version: "4",
|
||||
path: "/cases/case-1"
|
||||
},
|
||||
temporalContext: { validityMode: "current", validAt: null, recordedAt: null }
|
||||
}), [location.hash, location.pathname, location.search]);
|
||||
|
||||
return (
|
||||
<section className="conformance-section" aria-labelledby="launch-heading">
|
||||
<h2 id="launch-heading">Start- und Rücksprungkontext</h2>
|
||||
<p>Ein begrenzter Objektverweis öffnet das vollständige Werkzeug und erhält einen eindeutigen Rücksprung zum Vorgang.</p>
|
||||
<BreadcrumbBar
|
||||
pathname={location.pathname === "/" ? "/cases/case-1" : location.pathname}
|
||||
locationState={location.state}
|
||||
/>
|
||||
<ActionToolbar surface="subtle">
|
||||
<Button
|
||||
data-testid="launch-full-page"
|
||||
onClick={() => navigate("/files", { state: quickAccessLaunchState(launchContext) })}
|
||||
>
|
||||
Vollständige Dateiansicht öffnen
|
||||
</Button>
|
||||
</ActionToolbar>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const CONFORMANCE_AUTH = {
|
||||
user: { id: "user-1", account_id: "account-1", email: "case@example.test" },
|
||||
tenant: { id: "tenant-1", slug: "reference", name: "Referenzkommune" },
|
||||
scopes: [],
|
||||
roles: [],
|
||||
groups: [],
|
||||
profile_loaded: true,
|
||||
roles_loaded: true,
|
||||
groups_loaded: true
|
||||
} satisfies AuthInfo;
|
||||
|
||||
function browserHistoryIndex(): number | null {
|
||||
const value = (window.history.state as { idx?: unknown } | null)?.idx;
|
||||
return typeof value === "number" && Number.isInteger(value) ? value : null;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
:root {
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 320px; background: var(--bg); }
|
||||
button, input, select, textarea { font: inherit; }
|
||||
|
||||
.conformance-root { min-height: 100vh; }
|
||||
.conformance-section { display: grid; gap: 14px; padding-block: 20px; border-top: var(--border-line); }
|
||||
.conformance-section:first-child { border-top: 0; }
|
||||
.conformance-section > h2, .conformance-section h3 { margin: 0; color: var(--text-strong); }
|
||||
.conformance-section p { margin-block: 0 12px; }
|
||||
.conformance-section label { display: grid; gap: 5px; color: var(--text-label); font-size: 13px; font-weight: 700; }
|
||||
.conformance-workspace { height: 430px; }
|
||||
.conformance-definition { display: grid; grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); min-height: 330px; gap: 12px; }
|
||||
.conformance-canvas { position: relative; min-height: 330px; overflow: hidden; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.conformance-definition { grid-template-columns: 1fr; }
|
||||
.conformance-workspace { height: auto; min-height: 560px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.conformance-root *, .conformance-root *::before, .conformance-root *::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: .01ms !important;
|
||||
animation-duration: .01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>GovOPlaN WebUI Conformance Lab</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router";
|
||||
import ConformanceApp from "./ConformanceApp";
|
||||
import { UnsavedChangesProvider } from "../src/components/UnsavedChangesGuard";
|
||||
import "../src/styles/tokens.css";
|
||||
import "../src/styles/layout.css";
|
||||
import "../src/styles/forms.css";
|
||||
import "../src/styles/tables.css";
|
||||
import "../src/styles/badges.css";
|
||||
import "../src/styles/components.css";
|
||||
import "../src/styles/dialogs.css";
|
||||
import "./conformance.css";
|
||||
|
||||
const theme = new URLSearchParams(window.location.search).get("theme");
|
||||
if (theme === "dark" || theme === "light") document.documentElement.dataset.theme = theme;
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<UnsavedChangesProvider>
|
||||
<ConformanceApp />
|
||||
</UnsavedChangesProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 230 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 228 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 211 KiB |
@@ -0,0 +1,75 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const axePath = require.resolve("axe-core/axe.min.js");
|
||||
|
||||
async function expectNoAccessibilityViolations(page: import("@playwright/test").Page) {
|
||||
await page.addScriptTag({ path: axePath });
|
||||
const violations = await page.evaluate(async () => {
|
||||
const axe = (window as typeof window & { axe: { run: (options: unknown) => Promise<{ violations: Array<{ id: string; impact?: string | null; help: string; nodes: Array<{ target: unknown; failureSummary?: string }> }> }> } }).axe;
|
||||
const result = await axe.run({ runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21aa"] } });
|
||||
return result.violations.map(({ id, impact, help, nodes }) => ({
|
||||
id,
|
||||
impact,
|
||||
help,
|
||||
nodes: nodes.map((node: { target: unknown; failureSummary?: string }) => ({ target: node.target, failureSummary: node.failureSummary }))
|
||||
}));
|
||||
});
|
||||
expect(violations).toEqual([]);
|
||||
}
|
||||
|
||||
test("shared components remain accessible and keyboard operable", async ({ page }) => {
|
||||
await page.goto("/?theme=light");
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Zentrale GovOPlaN-Oberflächen" })).toBeVisible();
|
||||
await expectNoAccessibilityViolations(page);
|
||||
|
||||
const opener = page.getByTestId("open-dialog");
|
||||
await opener.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByRole("dialog", { name: "Konsequenz vor Ausführung prüfen" })).toBeVisible();
|
||||
await expectNoAccessibilityViolations(page);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog")).toBeHidden();
|
||||
await expect(opener).toBeFocused();
|
||||
});
|
||||
|
||||
test("full-page tool launch preserves a bounded return context", async ({ page }) => {
|
||||
await page.goto("/?theme=light");
|
||||
await page.getByTestId("launch-full-page").click();
|
||||
await expect(page).toHaveURL(/\/files$/);
|
||||
const returnButton = page.getByRole("button", { name: /RPP-2026-0001.*Anwohnerparkausweis/ });
|
||||
await expect(returnButton).toBeVisible();
|
||||
await returnButton.click();
|
||||
await expect(page).toHaveURL(/\/?\?theme=light$/);
|
||||
});
|
||||
|
||||
test("light and dark desktop geometry remains stable", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
await page.goto("/?theme=light");
|
||||
await expect(page.locator("[data-conformance-id='shared-ui-lab']")).toHaveScreenshot("shared-ui-light-desktop.png", { animations: "disabled", maxDiffPixelRatio: 0.005 });
|
||||
|
||||
await page.goto("/?theme=dark");
|
||||
await expect(page.locator("[data-conformance-id='shared-ui-lab']")).toHaveScreenshot("shared-ui-dark-desktop.png", { animations: "disabled", maxDiffPixelRatio: 0.005 });
|
||||
});
|
||||
|
||||
test("narrow layout preserves task order without horizontal overflow", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/?theme=light");
|
||||
await expect(page.getByText("Bedingung prüfen")).toBeVisible();
|
||||
const overflowing = await page.evaluate(() => Array.from(document.querySelectorAll<HTMLElement>("body *"))
|
||||
.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
element: `${element.tagName.toLowerCase()}.${Array.from(element.classList).join(".")}`,
|
||||
left: Math.round(rect.left),
|
||||
right: Math.round(rect.right),
|
||||
scrollWidth: element.scrollWidth,
|
||||
clientWidth: element.clientWidth
|
||||
};
|
||||
})
|
||||
.filter(({ left, right }) => left < -1 || right > window.innerWidth + 1)
|
||||
.slice(0, 20));
|
||||
expect(overflowing).toEqual([]);
|
||||
await expect(page.locator("[data-conformance-id='shared-ui-lab']")).toHaveScreenshot("shared-ui-light-narrow.png", { animations: "disabled", maxDiffPixelRatio: 0.005 });
|
||||
});
|
||||
Reference in New Issue
Block a user