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} -
-

Lagerbehälter

- -
- - {() => - 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) => ( - - ))} -
- ); -} 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('