From fce9d10192d802b14b0abbc38628214c88fea150 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Nick=20Rosenl=C3=B6cher?=
Date: Fri, 16 Oct 2026 20:08:36 +0200
Subject: [PATCH] =?UTF-8?q?feat(t5):=20ein=20Tabs-Baustein=20f=C3=BCr=20Li?=
=?UTF-8?q?sten,=20Einstellungen=20und=20Statistik=20(T5a=20A8)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- ui/tabs.tsx nur noch Variante segment: Leiste auf --muted, aktiver Tab
mit Kartenfläche, fetter Schrift und 3-px-Balken in --primary-ink;
44 px bei Touch, kompakt mit Maus; inaktive Schrift ≥ 6,27:1.
- ActiveHistoryTabs als Vorlage auf Tabs für 10 Listen (Pfeiltasten
schalten direkt, Daten sind geladen), mit toolbar und heading.
- Einstellungen (Automatisierung, Benutzer & Rollen) und Statistik mit
manueller Aktivierung: Pfeil bewegt den Fokus, Enter/Leertaste wählt;
?tab= über router.replace. TabSwitcher entfernt.
- Menüeinträge (Dropdown, Select) mit innerem Fokusring ≥ 3:1.
- Aufgaben: der Prioritätsfilter lädt die Liste jetzt neu (queryKey).
- Wächter: keine eigenen tablist/tab/tabpanel außerhalb ui/tabs.tsx.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../src/app/(app)/apiaries/page.tsx | 64 +++---
.../src/app/(app)/colonies/page.tsx | 78 ++++---
.../src/app/(app)/honey-batches/page.tsx | 58 ++---
.../src/app/(app)/medicines/page.tsx | 53 ++---
.../src/app/(app)/products/page.tsx | 50 +++--
.../src/app/(app)/queens/page.tsx | 76 ++++---
.../app/(app)/settings/automation/page.tsx | 50 +++--
.../automation/task-templates-tab.tsx | 137 ++++++------
.../app/(app)/settings/settings-tabs.spec.tsx | 74 +++++++
.../app/(app)/settings/users-roles/page.tsx | 27 ++-
.../(app)/settings/users-roles/roles-tab.tsx | 199 +++++++++---------
.../src/app/(app)/statistics/page.spec.tsx | 8 +
.../src/app/(app)/storage/page.tsx | 43 ++--
.../src/app/(app)/tasks/page.spec.tsx | 53 +++++
ApiCore-Frontend/src/app/(app)/tasks/page.tsx | 196 +++++++++--------
.../components/active-history-tabs.spec.tsx | 115 ++++++++++
.../src/components/active-history-tabs.tsx | 84 +++++---
.../src/components/tab-switcher.spec.tsx | 21 --
.../src/components/tab-switcher.tsx | 34 ---
.../src/components/ui/dropdown-menu.tsx | 8 +-
.../src/components/ui/menu-items.spec.tsx | 67 ++++++
ApiCore-Frontend/src/components/ui/select.tsx | 2 +-
.../src/components/ui/tabs.spec.tsx | 102 +++++++++
ApiCore-Frontend/src/components/ui/tabs.tsx | 79 +++----
.../src/styles/baukasten-guard.spec.ts | 33 +++
ApiCore-Frontend/src/styles/contrast.spec.ts | 32 +++
26 files changed, 1137 insertions(+), 606 deletions(-)
create mode 100644 ApiCore-Frontend/src/app/(app)/settings/settings-tabs.spec.tsx
create mode 100644 ApiCore-Frontend/src/app/(app)/tasks/page.spec.tsx
create mode 100644 ApiCore-Frontend/src/components/active-history-tabs.spec.tsx
delete mode 100644 ApiCore-Frontend/src/components/tab-switcher.spec.tsx
delete mode 100644 ApiCore-Frontend/src/components/tab-switcher.tsx
create mode 100644 ApiCore-Frontend/src/components/ui/menu-items.spec.tsx
create mode 100644 ApiCore-Frontend/src/components/ui/tabs.spec.tsx
diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/page.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/page.tsx
index ed67e50..1701293 100644
--- a/ApiCore-Frontend/src/app/(app)/apiaries/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/apiaries/page.tsx
@@ -35,7 +35,25 @@ export default function ApiariesPage() {
);
const activeApiaries = byLocation.filter((apiary) => apiary.status === "ACTIVE");
const historyApiaries = byLocation.filter((apiary) => apiary.status !== "ACTIVE");
- const visibleApiaries = tab === "active" ? activeApiaries : historyApiaries;
+
+ const listOf = (bucket: EntityLifecycleTab) => {
+ const apiaries = bucket === "active" ? activeApiaries : historyApiaries;
+ return (
+
+ {() =>
+ apiaries.length === 0 ? (
+ setLocation("")}
+ />
+ ) : (
+
+ )
+ }
+
+ );
+ };
return (
@@ -63,35 +81,23 @@ export default function ApiariesPage() {
}
/>
-
-
-
setLocation(event.target.value)}
- />
-
-
-
- {() =>
- visibleApiaries.length === 0 ? (
- setLocation("")}
- />
- ) : (
-
- )
+ setLocation(event.target.value)}
+ />
}
-
+ >
+ {{ active: listOf("active"), history: listOf("history") }}
+
);
}
diff --git a/ApiCore-Frontend/src/app/(app)/colonies/page.tsx b/ApiCore-Frontend/src/app/(app)/colonies/page.tsx
index c84dae5..2074a56 100644
--- a/ApiCore-Frontend/src/app/(app)/colonies/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/colonies/page.tsx
@@ -48,10 +48,30 @@ export default function ColoniesPage() {
const colonies = coloniesQuery.data ?? [];
const activeColonies = colonies.filter((colony) => colony.deactivatedAt === null);
const historyColonies = colonies.filter((colony) => colony.deactivatedAt !== null);
- const bucketColonies = tab === "active" ? activeColonies : historyColonies;
- const visibleColonies = status ? bucketColonies.filter((colony) => colony.status === status) : bucketColonies;
const statusOptions = tab === "active" ? LIVING_COLONY_STATUSES : ENDED_COLONY_STATUSES;
+ const listOf = (bucket: EntityLifecycleTab) => {
+ const bucketColonies = bucket === "active" ? activeColonies : historyColonies;
+ const visibleColonies = status
+ ? bucketColonies.filter((colony) => colony.status === status)
+ : bucketColonies;
+ return (
+
+ {() =>
+ visibleColonies.length === 0 ? (
+ 0}
+ onResetFilter={() => setStatus("")}
+ />
+ ) : (
+
+ )
+ }
+
+ );
+ };
+
return (
-
-
{
- setTab(next);
- setStatus("");
- }}
- activeCount={activeColonies.length}
- historyCount={historyColonies.length}
- />
-
-
-
-
- {() =>
- visibleColonies.length === 0 ? (
- 0}
- onResetFilter={() => setStatus("")}
- />
- ) : (
-
- )
+ {
+ setTab(next);
+ setStatus("");
+ }}
+ activeCount={activeColonies.length}
+ historyCount={historyColonies.length}
+ toolbar={
+
}
-
+ >
+ {{ active: listOf("active"), history: listOf("history") }}
+
);
}
diff --git a/ApiCore-Frontend/src/app/(app)/honey-batches/page.tsx b/ApiCore-Frontend/src/app/(app)/honey-batches/page.tsx
index 2585d63..06e4a0a 100644
--- a/ApiCore-Frontend/src/app/(app)/honey-batches/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/honey-batches/page.tsx
@@ -42,7 +42,34 @@ export default function HoneyBatchesPage() {
const honeyBatches = honeyBatchesQuery.data ?? [];
const activeBatches = honeyBatches.filter((batch) => !HISTORY_STATUSES.includes(batch.status));
const historyBatches = honeyBatches.filter((batch) => HISTORY_STATUSES.includes(batch.status));
- const visibleBatches = tab === "active" ? activeBatches : historyBatches;
+
+ const listOf = (bucket: EntityLifecycleTab) => {
+ const batches = bucket === "active" ? activeBatches : historyBatches;
+ return (
+
+ {() =>
+ batches.length === 0 ? (
+ bucket === "active" ? (
+ }
+ />
+ ) : (
+
+ )
+ ) : (
+
+ )
+ }
+
+ );
+ };
return (
@@ -74,33 +101,12 @@ export default function HoneyBatchesPage() {
-
-
- {() =>
- visibleBatches.length === 0 ? (
- tab === "active" ? (
- }
- />
- ) : (
-
- )
- ) : (
-
- )
- }
-
+ >
+ {{ active: listOf("active"), history: listOf("history") }}
+
);
}
diff --git a/ApiCore-Frontend/src/app/(app)/medicines/page.tsx b/ApiCore-Frontend/src/app/(app)/medicines/page.tsx
index 0bb68c4..acbf69f 100644
--- a/ApiCore-Frontend/src/app/(app)/medicines/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/medicines/page.tsx
@@ -35,6 +35,22 @@ export default function MedicinesPage() {
? `${visible.length} ${tab === "active" ? (visible.length === 1 ? "aktives Mittel" : "aktive Mittel") : visible.length === 1 ? "deaktiviertes Mittel" : "deaktivierte Mittel"}`
: "";
+ const listOf = (bucket: EntityLifecycleTab) => {
+ const shown = bucket === "active" ? active : inactive;
+ if (shown.length > 0) return ;
+ return (
+
+ );
+ };
+
if (!canRead) return ;
return (
@@ -47,35 +63,24 @@ export default function MedicinesPage() {
/>
{canCreate ? : null}
- {medicines.length > 0 ? (
-
- ) : null}
-
-
- {medicines.length > 0 ? countText : ""}
-
-
{() =>
medicines.length === 0 ? (
- ) : visible.length === 0 ? (
-
) : (
-
+
+ {countText}
+
+ }
+ >
+ {{ active: listOf("active"), history: listOf("history") }}
+
)
}
diff --git a/ApiCore-Frontend/src/app/(app)/products/page.tsx b/ApiCore-Frontend/src/app/(app)/products/page.tsx
index 5f968bd..436933b 100644
--- a/ApiCore-Frontend/src/app/(app)/products/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/products/page.tsx
@@ -27,32 +27,14 @@ export default function ProductsPage() {
const products = productsQuery.data ?? [];
const activeProducts = products.filter((product) => product.status === "ACTIVE");
const historyProducts = products.filter((product) => product.status !== "ACTIVE");
- const visibleProducts = tab === "active" ? activeProducts : historyProducts;
-
- return (
-
- }>
- + Neues Produkt
-
- }
- />
-
-
+ const listOf = (bucket: EntityLifecycleTab) => {
+ const visibleProducts = bucket === "active" ? activeProducts : historyProducts;
+ return (
{() =>
visibleProducts.length === 0 ? (
- tab === "active" ? (
+ bucket === "active" ? (
+ );
+ };
+
+ return (
+
+ }>
+ + Neues Produkt
+
+ }
+ />
+
+
+ {{ active: listOf("active"), history: listOf("history") }}
+
);
}
diff --git a/ApiCore-Frontend/src/app/(app)/queens/page.tsx b/ApiCore-Frontend/src/app/(app)/queens/page.tsx
index c8c7d81..00d8b23 100644
--- a/ApiCore-Frontend/src/app/(app)/queens/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/queens/page.tsx
@@ -28,8 +28,26 @@ export default function QueensPage() {
const queens = queensQuery.data ?? [];
const activeQueens = queens.filter((queen) => queen.deactivatedAt === null);
const historyQueens = queens.filter((queen) => queen.deactivatedAt !== null);
- const bucketQueens = tab === "active" ? activeQueens : historyQueens;
- const visibleQueens = status ? bucketQueens.filter((queen) => queen.status === status) : bucketQueens;
+
+ const listOf = (bucket: EntityLifecycleTab) => {
+ const bucketQueens = bucket === "active" ? activeQueens : historyQueens;
+ const visibleQueens = status ? bucketQueens.filter((queen) => queen.status === status) : bucketQueens;
+ return (
+
+ {() =>
+ visibleQueens.length === 0 ? (
+ 0}
+ onResetFilter={() => setStatus("")}
+ />
+ ) : (
+
+ )
+ }
+
+ );
+ };
return (
@@ -44,40 +62,28 @@ export default function QueensPage() {
}
/>
-
-
{
- setTab(next);
- setStatus("");
- }}
- activeCount={activeQueens.length}
- historyCount={historyQueens.length}
- />
-
-
-
-
- {() =>
- visibleQueens.length === 0 ? (
- 0}
- onResetFilter={() => setStatus("")}
- />
- ) : (
-
- )
+ {
+ setTab(next);
+ setStatus("");
+ }}
+ activeCount={activeQueens.length}
+ historyCount={historyQueens.length}
+ toolbar={
+
}
-
+ >
+ {{ active: listOf("active"), history: listOf("history") }}
+
);
}
diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/page.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/page.tsx
index cec1c69..7130391 100644
--- a/ApiCore-Frontend/src/app/(app)/settings/automation/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/settings/automation/page.tsx
@@ -1,9 +1,9 @@
"use client";
-import { Suspense } from "react";
+import { Suspense, type ComponentType } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { PageHeader } from "@/components/page-header";
-import { TabSwitcher } from "@/components/tab-switcher";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { WebhooksTab } from "./webhooks-tab";
import { TaskTemplatesTab } from "./task-templates-tab";
import { LabelTemplatesTab } from "./label-templates-tab";
@@ -22,17 +22,17 @@ type Tab =
| "api-keys";
// Everyday settings first, connections to other systems last.
-const TABS: { value: Tab; label: string }[] = [
- { value: "rules", label: "Vorschläge" },
- { value: "task-templates", label: "Wiederkehrende Aufgaben" },
- { value: "label-templates", label: "Etikettenvorlagen" },
- { value: "social-media", label: "Stammdaten & Web" },
- { value: "calendar-sync", label: "Kalender-Sync" },
- { value: "api-keys", label: "API-Schlüssel" },
- { value: "webhooks", label: "Webhooks" },
+const TABS: { value: Tab; label: string; Content: ComponentType }[] = [
+ { value: "rules", label: "Vorschläge", Content: AutomationRulesTab },
+ { value: "task-templates", label: "Wiederkehrende Aufgaben", Content: TaskTemplatesTab },
+ { value: "label-templates", label: "Etikettenvorlagen", Content: LabelTemplatesTab },
+ { value: "social-media", label: "Stammdaten & Web", Content: SocialMediaTab },
+ { value: "calendar-sync", label: "Kalender-Sync", Content: CalendarSyncTab },
+ { value: "api-keys", label: "API-Schlüssel", Content: ApiKeysTab },
+ { value: "webhooks", label: "Webhooks", Content: WebhooksTab },
];
-function isTab(value: string | null): value is Tab {
+function isTab(value: unknown): value is Tab {
return TABS.some((tab) => tab.value === value);
}
@@ -54,15 +54,25 @@ function AutomationPageContent() {
description="Was ApiCore dir abnimmt – und wie es mit anderen Systemen verbunden ist."
/>
-
-
- {tab === "webhooks" ? : null}
- {tab === "task-templates" ? : null}
- {tab === "label-templates" ? : null}
- {tab === "social-media" ? : null}
- {tab === "rules" ? : null}
- {tab === "calendar-sync" ? : null}
- {tab === "api-keys" ? : null}
+ {
+ if (isTab(next)) setTab(next);
+ }}
+ >
+
+ {TABS.map((entry) => (
+
+ {entry.label}
+
+ ))}
+
+ {TABS.map(({ value, Content }) => (
+
+
+
+ ))}
+
);
}
diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/task-templates-tab.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/task-templates-tab.tsx
index 1ae5b50..14ddb83 100644
--- a/ApiCore-Frontend/src/app/(app)/settings/automation/task-templates-tab.tsx
+++ b/ApiCore-Frontend/src/app/(app)/settings/automation/task-templates-tab.tsx
@@ -48,74 +48,81 @@ export function TaskTemplatesTab() {
const activeTemplates = (templates ?? []).filter((template) => template.active);
const historyTemplates = (templates ?? []).filter((template) => !template.active);
- const visibleTemplates = tab === "active" ? activeTemplates : historyTemplates;
+
+ const listOf = (bucket: EntityLifecycleTab) => {
+ const visibleTemplates = bucket === "active" ? activeTemplates : historyTemplates;
+ return (
+
+ {isLoading ?
Lädt…
: null}
+ {isError ? (
+
+ Vorlagen konnten nicht geladen werden.
+
+ ) : null}
+
+ {templates && visibleTemplates.length === 0 ? (
+
+ ) : null}
+
+ {visibleTemplates.length > 0 ? (
+
+ {visibleTemplates.map((template) => (
+
+ {template.active ? "Aktiv" : "Inaktiv"}
+
+ }
+ fields={[
+ { label: "Wiederholung", value: describeRecurrence(template) },
+ { label: "Nächste Fälligkeit", value: formatDate(template.nextDueDate) },
+ ]}
+ actions={
+ <>
+
+ deactivateMutation.mutate(template.id)}
+ />
+ >
+ }
+ />
+ ))}
+
+ ) : null}
+
+ );
+ };
return (
-
-
- {isLoading ?
Lädt…
: null}
- {isError ? (
-
- Vorlagen konnten nicht geladen werden.
-
- ) : null}
-
- {templates && visibleTemplates.length === 0 ? (
-
- ) : null}
-
- {visibleTemplates.length > 0 ? (
-
- {visibleTemplates.map((template) => (
-
- {template.active ? "Aktiv" : "Inaktiv"}
-
- }
- fields={[
- { label: "Wiederholung", value: describeRecurrence(template) },
- { label: "Nächste Fälligkeit", value: formatDate(template.nextDueDate) },
- ]}
- actions={
- <>
-
- deactivateMutation.mutate(template.id)}
- />
- >
- }
- />
- ))}
-
- ) : null}
+
}
+ toolbarAlign="end"
+ >
+ {{ active: listOf("active"), history: listOf("history") }}
+
);
}
diff --git a/ApiCore-Frontend/src/app/(app)/settings/settings-tabs.spec.tsx b/ApiCore-Frontend/src/app/(app)/settings/settings-tabs.spec.tsx
new file mode 100644
index 0000000..c6b48ae
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/settings/settings-tabs.spec.tsx
@@ -0,0 +1,74 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import AutomationPage from "./automation/page";
+import UsersRolesPage from "./users-roles/page";
+
+const replace = vi.fn();
+let search = "";
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ replace }),
+ useSearchParams: () => new URLSearchParams(search),
+}));
+
+// The tab contents fetch their own data; here only the switch between them matters.
+vi.mock("./automation/webhooks-tab", () => ({ WebhooksTab: () => Inhalt Webhooks
}));
+vi.mock("./automation/task-templates-tab", () => ({ TaskTemplatesTab: () => Inhalt Vorlagen
}));
+vi.mock("./automation/label-templates-tab", () => ({ LabelTemplatesTab: () => Inhalt Etiketten
}));
+vi.mock("./automation/social-media-tab", () => ({ SocialMediaTab: () => Inhalt Stammdaten
}));
+vi.mock("./automation/automation-rules-tab", () => ({ AutomationRulesTab: () => Inhalt Vorschläge
}));
+vi.mock("./automation/calendar-sync-tab", () => ({ CalendarSyncTab: () => Inhalt Kalender
}));
+vi.mock("./automation/api-keys-tab", () => ({ ApiKeysTab: () => Inhalt API
}));
+vi.mock("./users-roles/users-tab", () => ({ UsersTab: () => Inhalt Benutzer
}));
+vi.mock("./users-roles/roles-tab", () => ({ RolesTab: () => Inhalt Rollen
}));
+
+describe("both settings pages use Tabs directly", () => {
+ beforeEach(() => {
+ replace.mockReset();
+ search = "";
+ });
+
+ it("Automatisierung: ?tab= picks the tab, the panel is labelled by it and a switch keeps the URL form", async () => {
+ search = "tab=api-keys";
+ const user = userEvent.setup();
+ render();
+
+ const selected = screen.getByRole("tab", { name: "API-Schlüssel" });
+ expect(selected).toHaveAttribute("aria-selected", "true");
+ expect(screen.getByRole("tabpanel", { name: "API-Schlüssel" })).toHaveTextContent("Inhalt API");
+ expect(screen.getByRole("tablist")).toHaveAttribute("data-variant", "segment");
+
+ await user.click(screen.getByRole("tab", { name: "Webhooks" }));
+ expect(replace).toHaveBeenLastCalledWith("/settings/automation?tab=webhooks");
+ });
+
+ it("Automatisierung: an unknown ?tab= falls back to Vorschläge", () => {
+ search = "tab=unknown";
+ render();
+ expect(screen.getByRole("tab", { name: "Vorschläge" })).toHaveAttribute("aria-selected", "true");
+ expect(screen.getByRole("tabpanel")).toHaveTextContent("Inhalt Vorschläge");
+ });
+
+ it("Benutzer & Rollen: an arrow only moves the focus, Enter switches the tab through the URL", async () => {
+ const user = userEvent.setup();
+ render();
+
+ expect(screen.getByRole("tabpanel", { name: "Benutzer" })).toHaveTextContent("Inhalt Benutzer");
+ await user.click(screen.getByRole("tab", { name: "Benutzer" }));
+ replace.mockReset();
+ await user.keyboard("{ArrowRight}");
+ expect(screen.getByRole("tab", { name: "Rollen & Rechte" })).toHaveFocus();
+ expect(replace).not.toHaveBeenCalled();
+
+ await user.keyboard("{Enter}");
+ expect(replace).toHaveBeenLastCalledWith("/settings/users-roles?tab=roles");
+ });
+
+ it("Benutzer & Rollen: ?tab=roles shows the roles", () => {
+ search = "tab=roles";
+ render();
+ expect(screen.getByRole("tab", { name: "Rollen & Rechte" })).toHaveAttribute("aria-selected", "true");
+ expect(screen.getByRole("tabpanel")).toHaveTextContent("Inhalt Rollen");
+ });
+});
diff --git a/ApiCore-Frontend/src/app/(app)/settings/users-roles/page.tsx b/ApiCore-Frontend/src/app/(app)/settings/users-roles/page.tsx
index d5fa2f5..72f5509 100644
--- a/ApiCore-Frontend/src/app/(app)/settings/users-roles/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/settings/users-roles/page.tsx
@@ -3,7 +3,7 @@
import { Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { PageHeader } from "@/components/page-header";
-import { TabSwitcher } from "@/components/tab-switcher";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { UsersTab } from "./users-tab";
import { RolesTab } from "./roles-tab";
@@ -14,7 +14,7 @@ const TABS: { value: Tab; label: string }[] = [
{ value: "roles", label: "Rollen & Rechte" },
];
-function isTab(value: string | null): value is Tab {
+function isTab(value: unknown): value is Tab {
return value === "users" || value === "roles";
}
@@ -36,9 +36,26 @@ function UsersRolesPageContent() {
description="Zugänge, Rollen und Berechtigungen verwalten"
/>
-
-
- {tab === "users" ? : }
+ {
+ if (isTab(next)) setTab(next);
+ }}
+ >
+
+ {TABS.map((entry) => (
+
+ {entry.label}
+
+ ))}
+
+
+
+
+
+
+
+
);
}
diff --git a/ApiCore-Frontend/src/app/(app)/settings/users-roles/roles-tab.tsx b/ApiCore-Frontend/src/app/(app)/settings/users-roles/roles-tab.tsx
index facce96..d01e48d 100644
--- a/ApiCore-Frontend/src/app/(app)/settings/users-roles/roles-tab.tsx
+++ b/ApiCore-Frontend/src/app/(app)/settings/users-roles/roles-tab.tsx
@@ -54,112 +54,119 @@ export function RolesTab() {
const activeRoles = (roles ?? []).filter((role) => role.status === "ACTIVE");
const historyRoles = (roles ?? []).filter((role) => role.status === "INACTIVE");
- const visibleRoles = tab === "active" ? activeRoles : historyRoles;
const catalogLength = catalog?.length ?? 0;
- return (
-
-
+ const listOf = (bucket: EntityLifecycleTab) => {
+ const visibleRoles = bucket === "active" ? activeRoles : historyRoles;
+ return (
+
+ {isLoading ?
Lädt…
: null}
+ {isError ? (
+
+ Rollen konnten nicht geladen werden.
+
+ ) : null}
- {isLoading ?
Lädt…
: null}
- {isError ? (
-
- Rollen konnten nicht geladen werden.
-
- ) : null}
+ {roles && visibleRoles.length === 0 ? (
+
+ ) : null}
- {roles && visibleRoles.length === 0 ? (
-
- ) : null}
-
- {visibleRoles.length > 0 ? (
-
- {visibleRoles.map((role) => (
-
System-Rolle
- ) : (
-
- {role.status === "ACTIVE" ? "Aktiv" : "Inaktiv"}
-
- )
- }
- fields={[
- {
- label: "Rechte",
- value: `${role.permissionKeys.length}/${catalogLength}`,
- },
- { label: "Benutzer", value: role.userCount },
- ]}
- actions={
- role.isSystem ? (
-
- Systemrollen können nicht bearbeitet werden.
-
- ) : (
- <>
-
- {role.status === "ACTIVE" ? (
- 0 || deactivateMutation.isPending}
- title="Rolle deaktivieren?"
- description={
- role.userCount > 0
- ? `„${role.name}“ ist noch ${role.userCount} Benutzer(n) zugewiesen. Bitte zuerst umverteilen.`
- : `„${role.name}“ kann keinem Benutzer mehr neu zugewiesen werden. Kann jederzeit reaktiviert werden.`
- }
- confirmLabel="Deaktivieren"
- isPending={deactivateMutation.isPending}
- onConfirm={() => deactivateMutation.mutate(role.id)}
- />
- ) : (
+ {visibleRoles.length > 0 ? (
+
+ {visibleRoles.map((role) => (
+
System-Rolle
+ ) : (
+
+ {role.status === "ACTIVE" ? "Aktiv" : "Inaktiv"}
+
+ )
+ }
+ fields={[
+ {
+ label: "Rechte",
+ value: `${role.permissionKeys.length}/${catalogLength}`,
+ },
+ { label: "Benutzer", value: role.userCount },
+ ]}
+ actions={
+ role.isSystem ? (
+
+ Systemrollen können nicht bearbeitet werden.
+
+ ) : (
+ <>
- )}
- >
- )
- }
- />
- ))}
-
- ) : null}
+ {role.status === "ACTIVE" ? (
+ 0 || deactivateMutation.isPending}
+ title="Rolle deaktivieren?"
+ description={
+ role.userCount > 0
+ ? `„${role.name}“ ist noch ${role.userCount} Benutzer(n) zugewiesen. Bitte zuerst umverteilen.`
+ : `„${role.name}“ kann keinem Benutzer mehr neu zugewiesen werden. Kann jederzeit reaktiviert werden.`
+ }
+ confirmLabel="Deaktivieren"
+ isPending={deactivateMutation.isPending}
+ onConfirm={() => deactivateMutation.mutate(role.id)}
+ />
+ ) : (
+
+ )}
+ >
+ )
+ }
+ />
+ ))}
+
+ ) : null}
+
+ );
+ };
+
+ return (
+
+
}
+ toolbarAlign="end"
+ >
+ {{ active: listOf("active"), history: listOf("history") }}
+
setEditingRole(null)} />
diff --git a/ApiCore-Frontend/src/app/(app)/statistics/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/statistics/page.spec.tsx
index 308e11d..bb4cc3b 100644
--- a/ApiCore-Frontend/src/app/(app)/statistics/page.spec.tsx
+++ b/ApiCore-Frontend/src/app/(app)/statistics/page.spec.tsx
@@ -55,4 +55,12 @@ describe("StatisticsPage", () => {
expect(fetchColonyStats).toHaveBeenCalledTimes(2);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
+
+ it("the statistics page uses the segment variant", () => {
+ vi.mocked(fetchColonyStats).mockReturnValue(new Promise(() => {}));
+ renderWithClient(
);
+ const list = screen.getByRole("tablist", { hidden: true });
+ expect(list).toHaveAttribute("data-variant", "segment");
+ expect(screen.getByRole("tab", { name: "Völker", hidden: true })).toHaveAttribute("aria-selected", "true");
+ });
});
diff --git a/ApiCore-Frontend/src/app/(app)/storage/page.tsx b/ApiCore-Frontend/src/app/(app)/storage/page.tsx
index 65d53bd..69f745b 100644
--- a/ApiCore-Frontend/src/app/(app)/storage/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/storage/page.tsx
@@ -35,7 +35,21 @@ export default function StoragePage() {
const containers = containersQuery.data ?? [];
const activeContainers = containers.filter((container) => container.status !== "OUT_OF_SERVICE");
const historyContainers = containers.filter((container) => container.status === "OUT_OF_SERVICE");
- const visibleContainers = containerTab === "active" ? activeContainers : historyContainers;
+
+ const containersOf = (bucket: EntityLifecycleTab) => {
+ const visibleContainers = bucket === "active" ? activeContainers : historyContainers;
+ return (
+
+ {() =>
+ visibleContainers.length === 0 ? (
+
+ ) : (
+
+ )
+ }
+
+ );
+ };
return (
@@ -47,24 +61,15 @@ export default function StoragePage() {
actions={}
/>
{hasPermission("veterinary-medicines:read") ? : null}
-
-
- {() =>
- visibleContainers.length === 0 ? (
-
- ) : (
-
- )
- }
-
+ Lagerbehälter}
+ value={containerTab}
+ onValueChange={setContainerTab}
+ activeCount={activeContainers.length}
+ historyCount={historyContainers.length}
+ >
+ {{ active: containersOf("active"), history: containersOf("history") }}
+
diff --git a/ApiCore-Frontend/src/app/(app)/tasks/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/tasks/page.spec.tsx
new file mode 100644
index 0000000..e2c3ba8
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/tasks/page.spec.tsx
@@ -0,0 +1,53 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import TasksPage from "./page";
+import { fetchTasks } from "@/lib/tasks/api";
+
+vi.mock("next/link", () => ({
+ default: ({ href, children, ...rest }: { href: string; children: React.ReactNode }) => (
+
+ {children}
+
+ ),
+}));
+
+vi.mock("@/lib/tasks/api", () => ({
+ fetchTasks: vi.fn(),
+ completeTask: vi.fn(),
+}));
+
+vi.mock("./task-edit-dialog", () => ({ TaskEditDialog: () => null }));
+
+function renderPage() {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ render(
+
+
+ ,
+ );
+ return client;
+}
+
+describe("TasksPage", () => {
+ beforeEach(() => {
+ vi.mocked(fetchTasks).mockReset();
+ vi.mocked(fetchTasks).mockResolvedValue([]);
+ });
+
+ it("refetches with the chosen priority when the filter changes", async () => {
+ const user = userEvent.setup();
+ const client = renderPage();
+ await waitFor(() => expect(fetchTasks).toHaveBeenCalledWith({ priority: undefined }));
+
+ await user.click(screen.getByRole("combobox", { name: "Nach Priorität filtern" }));
+ await user.click(await screen.findByRole("option", { name: "Hoch" }));
+
+ await waitFor(() => expect(fetchTasks).toHaveBeenLastCalledWith({ priority: "HIGH" }));
+ // The key still starts with "tasks", so every invalidateQueries({ queryKey: ["tasks"] }) hits it.
+ const keys = client.getQueryCache().getAll().map((query) => query.queryKey);
+ expect(keys.every((key) => key[0] === "tasks")).toBe(true);
+ expect(keys.length).toBeGreaterThanOrEqual(2);
+ });
+});
diff --git a/ApiCore-Frontend/src/app/(app)/tasks/page.tsx b/ApiCore-Frontend/src/app/(app)/tasks/page.tsx
index a9347d3..3a73066 100644
--- a/ApiCore-Frontend/src/app/(app)/tasks/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/tasks/page.tsx
@@ -27,7 +27,9 @@ export default function TasksPage() {
const [onlyOverdue, setOnlyOverdue] = useState(false);
const { data: tasks, isLoading, isError } = useQuery({
- queryKey: ["tasks"],
+ // The filter belongs to the key, or a new priority never refetches. The key keeps the
+ // prefix "tasks", so every invalidateQueries({ queryKey: ["tasks"] }) still reaches it.
+ queryKey: ["tasks", "list", { priority: priority || null }],
queryFn: () => fetchTasks({ priority: priority || undefined }),
});
@@ -44,11 +46,79 @@ export default function TasksPage() {
const [now] = useState(() => Date.now());
const activeTasks = (tasks ?? []).filter((task) => !HISTORY_STATUSES.includes(task.status));
const historyTasks = (tasks ?? []).filter((task) => HISTORY_STATUSES.includes(task.status));
- const bucketTasks = tab === "active" ? activeTasks : historyTasks;
- const visibleTasks = bucketTasks.filter((task) => {
- if (!onlyOverdue) return true;
- return task.dueDate !== null && new Date(task.dueDate).getTime() < now;
- });
+
+ const listOf = (bucket: EntityLifecycleTab) => {
+ const bucketTasks = bucket === "active" ? activeTasks : historyTasks;
+ const visibleTasks = bucketTasks.filter((task) => {
+ if (!onlyOverdue) return true;
+ return task.dueDate !== null && new Date(task.dueDate).getTime() < now;
+ });
+ return (
+
+ {isLoading ?
Lädt…
: null}
+ {isError ? (
+
+ Aufgaben konnten nicht geladen werden.
+
+ ) : null}
+
+ {tasks && visibleTasks.length === 0 ? (
+
+ ) : null}
+
+ {visibleTasks.length > 0 ? (
+
+ {visibleTasks.map((task) => (
+
+
+
+
+ }
+ fields={[
+ { label: "Priorität", value: TASK_PRIORITY_LABELS[task.priority] },
+ {
+ label: "Fällig",
+ value: task.dueDate ? new Date(task.dueDate).toLocaleDateString("de-DE") : "—",
+ },
+ ]}
+ actions={
+ <>
+
+ {task.status !== "DONE" && task.status !== "CANCELLED" ? (
+
+ ) : null}
+ >
+ }
+ />
+ ))}
+
+ ) : null}
+
+ );
+ };
return (
@@ -63,93 +133,35 @@ export default function TasksPage() {
}
/>
-
-
-
- {tab === "active" ? (
-
- ) : null}
-
-
- {isLoading ? Lädt…
: null}
- {isError ? (
-
- Aufgaben konnten nicht geladen werden.
-
- ) : null}
-
- {tasks && visibleTasks.length === 0 ? (
-
- ) : null}
-
- {visibleTasks.length > 0 ? (
-
- {visibleTasks.map((task) => (
-
-
-
-
- }
- fields={[
- { label: "Priorität", value: TASK_PRIORITY_LABELS[task.priority] },
- {
- label: "Fällig",
- value: task.dueDate ? new Date(task.dueDate).toLocaleDateString("de-DE") : "—",
- },
- ]}
- actions={
- <>
-
- {task.status !== "DONE" && task.status !== "CANCELLED" ? (
-
- ) : null}
- >
- }
+
+
- ))}
-
- ) : null}
+ {tab === "active" ? (
+
+ ) : null}
+ >
+ }
+ >
+ {{ active: listOf("active"), history: listOf("history") }}
+
);
}
diff --git a/ApiCore-Frontend/src/components/active-history-tabs.spec.tsx b/ApiCore-Frontend/src/components/active-history-tabs.spec.tsx
new file mode 100644
index 0000000..bd7280a
--- /dev/null
+++ b/ApiCore-Frontend/src/components/active-history-tabs.spec.tsx
@@ -0,0 +1,115 @@
+import { useState } from "react";
+import { render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { ActiveHistoryTabs, type EntityLifecycleTab } from "./active-history-tabs";
+
+function Harness({ onValueChange }: { onValueChange?: (value: EntityLifecycleTab) => void }) {
+ const [value, setValue] = useState("active");
+ return (
+ {
+ setValue(next);
+ onValueChange?.(next);
+ }}
+ activeCount={1}
+ historyCount={0}
+ toolbar={}
+ >
+ {{ active: Liste aktiv
, history: Liste Historie
}}
+
+ );
+}
+
+describe("ActiveHistoryTabs (spec 10.4)", () => {
+ it("ActiveHistoryTabs shows the counts and renders the matching panel", async () => {
+ const user = userEvent.setup();
+ const onValueChange = vi.fn();
+ render();
+
+ const active = screen.getByRole("tab", { name: "Aktiv (1)" });
+ const history = screen.getByRole("tab", { name: "Historie (0)" });
+ expect(active).toHaveAttribute("aria-selected", "true");
+ expect(screen.getByRole("tabpanel", { name: "Aktiv (1)" })).toHaveTextContent("Liste aktiv");
+ expect(screen.queryByText("Liste Historie")).not.toBeInTheDocument();
+
+ await user.click(history);
+ expect(onValueChange).toHaveBeenLastCalledWith("history");
+ expect(history).toHaveAttribute("aria-selected", "true");
+ expect(screen.getByRole("tabpanel", { name: "Historie (0)" })).toHaveTextContent("Liste Historie");
+ expect(screen.queryByText("Liste aktiv")).not.toBeInTheDocument();
+
+ await user.keyboard("{ArrowLeft}");
+ expect(onValueChange).toHaveBeenLastCalledWith("active");
+ expect(screen.getByRole("tabpanel")).toHaveTextContent("Liste aktiv");
+ });
+
+ it("leaves the counts out while they are unknown", () => {
+ render(
+ {}}>
+ {{ active: null, history: null }}
+ ,
+ );
+ expect(screen.getByRole("tab", { name: "Aktiv" })).toBeInTheDocument();
+ expect(screen.getByRole("tab", { name: "Historie" })).toBeInTheDocument();
+ });
+
+ it("puts the toolbar next to the tabs, outside the panels", () => {
+ render();
+ const filter = screen.getByRole("button", { name: "Filter" });
+ expect(within(screen.getByRole("tabpanel")).queryByRole("button", { name: "Filter" })).toBeNull();
+ expect(screen.getByRole("tablist").parentElement).toContainElement(filter);
+ });
+
+ it("arrow keys switch at once here, because both panels filter data that is already loaded", async () => {
+ const user = userEvent.setup();
+ render();
+ await user.click(screen.getByRole("tab", { name: "Aktiv (1)" }));
+ await user.keyboard("{ArrowRight}");
+ expect(screen.getByRole("tab", { name: "Historie (0)" })).toHaveAttribute("aria-selected", "true");
+ expect(screen.getByRole("tabpanel")).toHaveTextContent("Liste Historie");
+ });
+
+ it("toolbarAlign end pushes the toolbar to the far right of the row", () => {
+ render(
+ {}}
+ toolbar={}
+ toolbarAlign="end"
+ >
+ {{ active: null, history: null }}
+ ,
+ );
+ const wrapper = screen.getByRole("button", { name: "Neue Rolle" }).parentElement as HTMLElement;
+ expect(wrapper.className.split(/\s+/)).toContain("ml-auto");
+ expect(screen.getByRole("tablist").parentElement).toContainElement(wrapper);
+ });
+
+ it("a heading opens the row before the tabs, the tabs sit on the right", () => {
+ render(
+ {}}
+ heading={Lagerbehälter
}
+ >
+ {{ active: null, history: null }}
+ ,
+ );
+ const heading = screen.getByRole("heading", { name: "Lagerbehälter" });
+ const list = screen.getByRole("tablist");
+ expect(heading.compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ expect(list.parentElement).toContainElement(heading);
+ expect(list.className.split(/\s+/)).toContain("ml-auto");
+ });
+
+ it("the chips are 44 px high on touch and compact only with a fine pointer", () => {
+ render();
+ for (const tab of screen.getAllByRole("tab")) {
+ const classes = tab.className.split(/\s+/);
+ expect(classes).toContain("h-11");
+ expect(classes).toContain("pointer-fine:h-9");
+ }
+ });
+});
diff --git a/ApiCore-Frontend/src/components/active-history-tabs.tsx b/ApiCore-Frontend/src/components/active-history-tabs.tsx
index 3160343..3512b7b 100644
--- a/ApiCore-Frontend/src/components/active-history-tabs.tsx
+++ b/ApiCore-Frontend/src/components/active-history-tabs.tsx
@@ -1,53 +1,69 @@
+import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
export type EntityLifecycleTab = "active" | "history";
-interface ActiveHistoryTabsProps {
+export interface ActiveHistoryTabsProps {
value: EntityLifecycleTab;
- onChange: (value: EntityLifecycleTab) => void;
+ onValueChange: (value: EntityLifecycleTab) => void;
activeCount?: number;
historyCount?: number;
+ /** A section heading that opens the row; the tabs then sit on its right. */
+ heading?: ReactNode;
+ /** Controls next to the tabs (a filter, a create button), outside the panels. */
+ toolbar?: ReactNode;
+ /** `end` pushes the toolbar to the far right of the row (a create button). */
+ toolbarAlign?: "start" | "end";
+ /** The list of each panel; only the selected one is mounted. */
+ children: { active: ReactNode; history: ReactNode };
}
+const withCount = (label: string, count: number | undefined) =>
+ count === undefined ? label : `${label} (${count})`;
+
+const isLifecycleTab = (value: unknown): value is EntityLifecycleTab =>
+ value === "active" || value === "history";
+
+/**
+ * Template on Tabs (spec 10.4): „Aktiv“ and „Historie“, each panel holds its list. Both panels
+ * filter data the page has already loaded, so the arrow keys switch at once (activateOnFocus).
+ */
export function ActiveHistoryTabs({
value,
- onChange,
+ onValueChange,
activeCount,
historyCount,
+ heading,
+ toolbar,
+ toolbarAlign = "start",
+ children,
}: ActiveHistoryTabsProps) {
return (
- {
+ if (isLifecycleTab(next)) onValueChange(next);
+ }}
>
-
+ {children.active}
+ {children.history}
+
);
}
diff --git a/ApiCore-Frontend/src/components/tab-switcher.spec.tsx b/ApiCore-Frontend/src/components/tab-switcher.spec.tsx
deleted file mode 100644
index e238e87..0000000
--- a/ApiCore-Frontend/src/components/tab-switcher.spec.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { render, screen } from "@testing-library/react";
-import { TabSwitcher } from "./tab-switcher";
-
-describe("TabSwitcher", () => {
- it("makes every tab at least 44 px high for a thumb", () => {
- render(
- {}}
- />,
- );
- for (const tab of screen.getAllByRole("tab")) {
- expect(tab).toHaveClass("min-h-11", "pointer-fine:min-h-9");
- }
- });
-});
diff --git a/ApiCore-Frontend/src/components/tab-switcher.tsx b/ApiCore-Frontend/src/components/tab-switcher.tsx
deleted file mode 100644
index 7004d1c..0000000
--- a/ApiCore-Frontend/src/components/tab-switcher.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import { cn } from "@/lib/utils";
-
-interface TabSwitcherProps {
- tabs: { value: T; label: string }[];
- value: T;
- onChange: (value: T) => void;
-}
-
-export function TabSwitcher({ tabs, value, onChange }: TabSwitcherProps) {
- return (
-
- {tabs.map((tab) => (
- onChange(tab.value)}
- className={cn(
- "min-h-11 pointer-fine:min-h-9 rounded-lg px-4 py-1.5 text-sm font-medium transition-colors motion-reduce:transition-none",
- value === tab.value
- ? "bg-card text-foreground shadow-[var(--shadow-soft)]"
- : "text-muted-foreground hover:text-foreground",
- )}
- >
- {tab.label}
-
- ))}
-
- );
-}
diff --git a/ApiCore-Frontend/src/components/ui/dropdown-menu.tsx b/ApiCore-Frontend/src/components/ui/dropdown-menu.tsx
index 781fa79..7a58ebf 100644
--- a/ApiCore-Frontend/src/components/ui/dropdown-menu.tsx
+++ b/ApiCore-Frontend/src/components/ui/dropdown-menu.tsx
@@ -88,7 +88,7 @@ function DropdownMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
- "group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
+ "group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:inset-ring-2 focus:inset-ring-ring focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
@@ -113,7 +113,7 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
- "flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ "flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:inset-ring-2 focus:inset-ring-ring focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
@@ -159,7 +159,7 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
- "relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ "relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:inset-ring-2 focus:inset-ring-ring focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
@@ -201,7 +201,7 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
- "relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ "relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:inset-ring-2 focus:inset-ring-ring focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
diff --git a/ApiCore-Frontend/src/components/ui/menu-items.spec.tsx b/ApiCore-Frontend/src/components/ui/menu-items.spec.tsx
new file mode 100644
index 0000000..760bdf0
--- /dev/null
+++ b/ApiCore-Frontend/src/components/ui/menu-items.spec.tsx
@@ -0,0 +1,67 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "./dropdown-menu";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
+
+/**
+ * Menu and select items keep `outline-hidden` (the item itself is the moving highlight), so the
+ * accent fill alone was the focus cue, and it stays below 3:1 on the popover. Each item now
+ * draws a 2 px inset ring in --ring while focused (≥ 3:1 in all four sets, contrast.spec.ts).
+ */
+const FOCUS_CUE = ["focus:inset-ring-2", "focus:inset-ring-ring"];
+
+function expectFocusCue(element: HTMLElement) {
+ const classes = element.className.split(/\s+/);
+ for (const token of FOCUS_CUE) expect(classes, token).toContain(token);
+}
+
+describe("menu items show a focus cue of at least 3:1 (task A8)", () => {
+ it("every dropdown item kind draws the inset ring while focused", () => {
+ render(
+
+ Mehr
+
+ Exportieren
+ Löschen
+ Mit Details
+
+ Liste
+
+
+ Weitere
+
+
+ ,
+ );
+ expectFocusCue(screen.getByRole("menuitem", { name: "Exportieren" }));
+ expectFocusCue(screen.getByRole("menuitem", { name: "Löschen" }));
+ expectFocusCue(screen.getByRole("menuitemcheckbox", { name: "Mit Details" }));
+ expectFocusCue(screen.getByRole("menuitemradio", { name: "Liste" }));
+ expectFocusCue(screen.getByRole("menuitem", { name: "Weitere" }));
+ });
+
+ it("a select option draws the inset ring while focused", () => {
+ render(
+ ,
+ );
+ for (const option of screen.getAllByRole("option")) expectFocusCue(option);
+ });
+});
diff --git a/ApiCore-Frontend/src/components/ui/select.tsx b/ApiCore-Frontend/src/components/ui/select.tsx
index 7d2d3eb..d6c959c 100644
--- a/ApiCore-Frontend/src/components/ui/select.tsx
+++ b/ApiCore-Frontend/src/components/ui/select.tsx
@@ -117,7 +117,7 @@ function SelectItem({
element.className.split(/\s+/).includes(token);
+
+function Harness({ initial = "a" }: { initial?: string }) {
+ const [value, setValue] = useState(initial);
+ return (
+ setValue(String(next))}>
+
+ Völker
+ Königinnen
+ Ernte
+
+ Panel Völker
+ Panel Königinnen
+ Panel Ernte
+
+ );
+}
+
+describe("Tabs (spec 10.4)", () => {
+ it("arrow keys move the focus, Enter or Space switch the tab, and the panel is labelled by it", async () => {
+ const user = userEvent.setup();
+ render();
+ const first = screen.getByRole("tab", { name: "Völker" });
+ await user.click(first);
+ await user.keyboard("{ArrowRight}");
+
+ // Manual activation (APG): the panels fetch on mount, so an arrow only moves the focus.
+ const second = screen.getByRole("tab", { name: "Königinnen" });
+ expect(second).toHaveFocus();
+ expect(second).toHaveAttribute("aria-selected", "false");
+ expect(screen.getByRole("tabpanel")).toHaveTextContent("Panel Völker");
+
+ await user.keyboard("{Enter}");
+ expect(second).toHaveAttribute("aria-selected", "true");
+ const panel = screen.getByRole("tabpanel");
+ expect(panel).toHaveTextContent("Panel Königinnen");
+ expect(panel).toHaveAccessibleName("Königinnen");
+ expect(second).toHaveAttribute("aria-controls", panel.id);
+
+ // Focus wraps around at the end (one tab stop, arrows inside the list); Space selects too.
+ await user.keyboard("{ArrowRight}{ArrowRight}");
+ expect(first).toHaveFocus();
+ await user.keyboard(" ");
+ expect(first).toHaveAttribute("aria-selected", "true");
+ expect(screen.getByRole("tabpanel")).toHaveTextContent("Panel Völker");
+ });
+
+ it("the active tab has the surface, bold text and the 3 px ink bar", () => {
+ render();
+ const active = screen.getByRole("tab", { name: "Königinnen" });
+ expect(active).toHaveAttribute("data-active");
+ for (const token of [
+ "data-active:bg-card",
+ "data-active:text-foreground",
+ "data-active:font-semibold",
+ "border-b-3",
+ "data-active:border-b-primary-ink",
+ ]) {
+ expect(has(active, token), token).toBe(true);
+ }
+ // Inactive tabs: --muted-foreground on --muted (≥ 4.5:1 in all four sets, contrast.spec.ts).
+ const inactive = screen.getByRole("tab", { name: "Völker" });
+ expect(has(inactive, "text-muted-foreground")).toBe(true);
+ expect(inactive.className).not.toMatch(/text-foreground\/\d+/);
+ expect(has(screen.getByRole("tablist"), "bg-muted")).toBe(true);
+ });
+
+ it("tabs are 44 px for a thumb, compact only with a fine pointer, 16 px text, 4 px apart", () => {
+ render();
+ const list = screen.getByRole("tablist");
+ expect(has(list, "p-1")).toBe(true);
+ expect(has(list, "gap-1")).toBe(true);
+ for (const tab of screen.getAllByRole("tab")) {
+ expect(has(tab, "h-11")).toBe(true);
+ expect(has(tab, "pointer-fine:h-9")).toBe(true);
+ expect(has(tab, "text-base")).toBe(true);
+ expect(tab.className).not.toMatch(/(^|\s)sm:(?:min-)?h-/);
+ }
+ });
+
+ it("the list wraps on a narrow screen instead of overflowing", () => {
+ render();
+ const list = screen.getByRole("tablist");
+ expect(has(list, "flex-wrap")).toBe(true);
+ expect(has(list, "max-w-full")).toBe(true);
+ expect(list.className).not.toMatch(/(^|\s)h-10(\s|$)/);
+ });
+
+ it("has one variant, segment, and no own focus ring or movement", () => {
+ render();
+ expect(screen.getByRole("tablist")).toHaveAttribute("data-variant", "segment");
+ for (const tab of screen.getAllByRole("tab")) {
+ expect(tab.className).not.toMatch(/outline-none|ring-ring|focus-visible:ring|translate|transition-all/);
+ }
+ });
+});
diff --git a/ApiCore-Frontend/src/components/ui/tabs.tsx b/ApiCore-Frontend/src/components/ui/tabs.tsx
index e1ebccd..866f96b 100644
--- a/ApiCore-Frontend/src/components/ui/tabs.tsx
+++ b/ApiCore-Frontend/src/components/ui/tabs.tsx
@@ -1,21 +1,36 @@
"use client"
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
-import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
-function Tabs({
- className,
- orientation = "horizontal",
- ...props
-}: TabsPrimitive.Root.Props) {
+/**
+ * Tabs (spec 10.4): one variant, `segment`. Base UI draws role tablist/tab/tabpanel,
+ * aria-controls and the arrow keys; this is the only place in the app with those roles.
+ */
+function Tabs({ className, ...props }: TabsPrimitive.Root.Props) {
return (
+ )
+}
+
+/**
+ * The bar: --muted with 4 px inner padding, tabs 4 px apart. It wraps on a narrow screen
+ * instead of overflowing. Arrow keys move the focus, Enter or Space switch the tab (manual
+ * activation, APG: a panel may fetch on mount), Home/End jump, focus loops at the ends.
+ * Pass `activateOnFocus` only where every panel shows data that is already loaded.
+ */
+function TabsList({ className, ...props }: TabsPrimitive.List.Props) {
+ return (
+ ) {
- return (
-
- )
-}
-
+/**
+ * A tab: 44 px for a thumb (36 px with a fine pointer), 16 px text. Inactive in
+ * --muted-foreground on --muted (≥ 4.5:1 in all four sets). Active on --card, --foreground,
+ * semibold, with a 3 px --primary-ink bar at the bottom: surface, weight and bar, never colour
+ * alone. The top border keeps the label centred against the bar. Focus is the global ring.
+ */
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
)
}
-export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
+export { Tabs, TabsList, TabsTrigger, TabsContent }
diff --git a/ApiCore-Frontend/src/styles/baukasten-guard.spec.ts b/ApiCore-Frontend/src/styles/baukasten-guard.spec.ts
index 7f0e8c7..9da2fc9 100644
--- a/ApiCore-Frontend/src/styles/baukasten-guard.spec.ts
+++ b/ApiCore-Frontend/src/styles/baukasten-guard.spec.ts
@@ -144,6 +144,15 @@ const RULES: GuardRule[] = [
},
],
},
+ // Task A8: one tabs kit for the whole app (spec 10.4, 10.7). Base UI draws role tablist, tab
+ // and tabpanel inside ui/tabs.tsx, so no source file writes them by hand and the list of
+ // exceptions stays empty.
+ {
+ id: "no role tablist, tab or tabpanel outside ui/tabs.tsx",
+ pattern: ownRole("role", "tab(?:list|panel)?"),
+ scope: "src/**/*.{ts,tsx}",
+ allow: [],
+ },
];
const SCOPE_FILE: Record = {
@@ -206,6 +215,20 @@ describe("baukasten guard (spec 10.7)", () => {
expect(users.map(({ file }) => file)).toEqual([]);
});
+ it("TabSwitcher is gone (the settings pages use Tabs directly, spec 10.4)", () => {
+ expect(existsSync(join(SRC, "components", "tab-switcher.tsx"))).toBe(false);
+ const users = FILES.filter(({ text }) => /\bTabSwitcher\b|components\/tab-switcher/.test(text));
+ expect(users.map(({ file }) => file)).toEqual([]);
+ });
+
+ it("the tabs kit has one variant, segment (the line variant is gone)", () => {
+ const tabs = FILES.find(({ file }) => file === "src/components/ui/tabs.tsx")?.text ?? "";
+ expect(tabs).toMatch(/segment/);
+ expect(tabs).not.toMatch(/variant=line|\bline:/);
+ const lineUsers = FILES.filter(({ text }) => /]*variant=["'{]/.test(text));
+ expect(lineUsers.map(({ file }) => file)).toEqual([]);
+ });
+
it("a list of CheckboxFields keeps neighbouring targets at least 4 px apart (spec 4.5)", () => {
// `.map(… => ) and the container opened before it.
const lists = FILES.filter(({ file }) => file.endsWith(".tsx")).flatMap(({ file, text }) =>
@@ -287,6 +310,16 @@ describe("baukasten guard (spec 10.7)", () => {
// The CheckboxField exemption ends with its own tag: a later element is caught again.
expect(toggle.test('\n')).toBe(true);
expect(checkbox.test('')).toBe(true);
+ const tabs = rule("no role tablist, tab or tabpanel outside ui/tabs.tsx").pattern;
+ expect(tabs.test('')).toBe(true);
+ expect(tabs.test('
')).toBe(true);
+ expect(tabs.test("")).toBe(true);
+ expect(tabs.test('{ role: "tab" }')).toBe(true);
+ expect(tabs.test('')).toBe(true);
+ expect(tabs.test('node.setAttribute("role", "tab")')).toBe(true);
+ expect(tabs.test('
')).toBe(false);
+ expect(tabs.test(`querySelector('[role="tab"]')`)).toBe(false);
+ expect(tabs.test('getAllByRole("tab")')).toBe(false);
expect(rule("no text-white").pattern.test('"text-white/80"')).toBe(true);
expect(rule("no gradient prop or gradient token").pattern.test("")).toBe(true);
});
diff --git a/ApiCore-Frontend/src/styles/contrast.spec.ts b/ApiCore-Frontend/src/styles/contrast.spec.ts
index 2bc6b69..ee5726e 100644
--- a/ApiCore-Frontend/src/styles/contrast.spec.ts
+++ b/ApiCore-Frontend/src/styles/contrast.spec.ts
@@ -383,6 +383,38 @@ describe.each(SETS)("focus ring around a selected honey option in %s (task A5)",
});
});
+describe.each(SETS)("tabs and menu items in %s (task A8)", (set) => {
+ it("an inactive tab (--muted-foreground on the --muted bar) reaches 4.5:1", () => {
+ expect(contrastOf(rgb(set, "muted-foreground"), rgb(set, "muted"))).toBeGreaterThanOrEqual(4.5);
+ });
+
+ it("the active tab text (--foreground on --card) reaches 4.5:1", () => {
+ expect(contrastOf(rgb(set, "foreground"), rgb(set, "card"))).toBeGreaterThanOrEqual(4.5);
+ });
+
+ it("the 3 px ink bar of the active tab (--primary-ink) reaches 3:1 on the card", () => {
+ expect(contrastOf(rgb(set, "primary-ink"), rgb(set, "card"))).toBeGreaterThanOrEqual(3);
+ });
+
+ it.each(["popover", "accent"])(
+ "the inset ring of a highlighted menu item (--ring) reaches 3:1 on the %s",
+ (surface) => {
+ // The ring replaces the unfocused popover edge of the item and frames its accent fill.
+ expect(contrastOf(rgb(set, "ring"), rgb(set, surface))).toBeGreaterThanOrEqual(3);
+ },
+ );
+
+ it("the inset ring reaches 3:1 on the highlight of a destructive item (bg-destructive/10)", () => {
+ // popover = card in every set (checked above), so the tint over the card is the fill.
+ expect(value(set, "popover")).toBe(value(set, "card"));
+ expect(contrastOf(rgb(set, "ring"), tint(set, "destructive", ERROR_TINT_ALPHA))).toBeGreaterThanOrEqual(3);
+ });
+
+ it("the accent highlight alone stays below 3:1 on the popover (why the ring is needed)", () => {
+ expect(contrastOf(rgb(set, "accent"), rgb(set, "popover"))).toBeLessThan(3);
+ });
+});
+
describe.each(SETS)("condition tones in %s", (set) => {
it.each(TONES)("%s has surface, text-on-surface and ink, each above its limit", (tone) => {
const surface = rgb(set, `condition-${tone}`);