feat(t5): ein Tabs-Baustein für Listen, Einstellungen und Statistik (T5a A8)
- 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) <[email protected]>
This commit is contained in:
1 parent
759d85dc67
commit
fce9d10192
26 files changed
+1137
-606
No files matched your search
@@ -35,7 +35,25 @@ export default function ApiariesPage() {
|
|||||||
);
|
);
|
||||||
const activeApiaries = byLocation.filter((apiary) => apiary.status === "ACTIVE");
|
const activeApiaries = byLocation.filter((apiary) => apiary.status === "ACTIVE");
|
||||||
const historyApiaries = 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 (
|
||||||
|
<QueryState query={apiariesQuery} what="Bienenstände">
|
||||||
|
{() =>
|
||||||
|
apiaries.length === 0 ? (
|
||||||
|
<ApiariesEmpty
|
||||||
|
tab={bucket}
|
||||||
|
isFiltered={location !== ""}
|
||||||
|
onResetFilter={() => setLocation("")}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ApiaryGrid apiaries={apiaries} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</QueryState>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||||
@@ -63,35 +81,23 @@ export default function ApiariesPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<ActiveHistoryTabs
|
||||||
<ActiveHistoryTabs
|
value={tab}
|
||||||
value={tab}
|
onValueChange={setTab}
|
||||||
onChange={setTab}
|
activeCount={activeApiaries.length}
|
||||||
activeCount={activeApiaries.length}
|
historyCount={historyApiaries.length}
|
||||||
historyCount={historyApiaries.length}
|
toolbar={
|
||||||
/>
|
<Input
|
||||||
<Input
|
className="w-48"
|
||||||
className="w-48"
|
placeholder="Standort"
|
||||||
placeholder="Standort"
|
aria-label="Nach Standort filtern"
|
||||||
aria-label="Nach Standort filtern"
|
value={location}
|
||||||
value={location}
|
onChange={(event) => setLocation(event.target.value)}
|
||||||
onChange={(event) => setLocation(event.target.value)}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<QueryState query={apiariesQuery} what="Bienenstände">
|
|
||||||
{() =>
|
|
||||||
visibleApiaries.length === 0 ? (
|
|
||||||
<ApiariesEmpty
|
|
||||||
tab={tab}
|
|
||||||
isFiltered={location !== ""}
|
|
||||||
onResetFilter={() => setLocation("")}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ApiaryGrid apiaries={visibleApiaries} />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
</QueryState>
|
>
|
||||||
|
{{ active: listOf("active"), history: listOf("history") }}
|
||||||
|
</ActiveHistoryTabs>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,10 +48,30 @@ export default function ColoniesPage() {
|
|||||||
const colonies = coloniesQuery.data ?? [];
|
const colonies = coloniesQuery.data ?? [];
|
||||||
const activeColonies = colonies.filter((colony) => colony.deactivatedAt === null);
|
const activeColonies = colonies.filter((colony) => colony.deactivatedAt === null);
|
||||||
const historyColonies = 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 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 (
|
||||||
|
<QueryState query={coloniesQuery} what="Völker">
|
||||||
|
{() =>
|
||||||
|
visibleColonies.length === 0 ? (
|
||||||
|
<ColoniesEmpty
|
||||||
|
tab={bucket}
|
||||||
|
isFiltered={status !== "" && bucketColonies.length > 0}
|
||||||
|
onResetFilter={() => setStatus("")}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ColonyGrid colonies={visibleColonies} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</QueryState>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -82,40 +102,28 @@ export default function ColoniesPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<ActiveHistoryTabs
|
||||||
<ActiveHistoryTabs
|
value={tab}
|
||||||
value={tab}
|
onValueChange={(next) => {
|
||||||
onChange={(next) => {
|
setTab(next);
|
||||||
setTab(next);
|
setStatus("");
|
||||||
setStatus("");
|
}}
|
||||||
}}
|
activeCount={activeColonies.length}
|
||||||
activeCount={activeColonies.length}
|
historyCount={historyColonies.length}
|
||||||
historyCount={historyColonies.length}
|
toolbar={
|
||||||
/>
|
<EnumSelect
|
||||||
<EnumSelect
|
ariaLabel="Nach Status filtern"
|
||||||
ariaLabel="Nach Status filtern"
|
className="w-40"
|
||||||
className="w-40"
|
value={status}
|
||||||
value={status}
|
onChange={setStatus}
|
||||||
onChange={setStatus}
|
values={statusOptions}
|
||||||
values={statusOptions}
|
labels={COLONY_STATUS_LABELS}
|
||||||
labels={COLONY_STATUS_LABELS}
|
noneLabel="Alle Status"
|
||||||
noneLabel="Alle Status"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<QueryState query={coloniesQuery} what="Völker">
|
|
||||||
{() =>
|
|
||||||
visibleColonies.length === 0 ? (
|
|
||||||
<ColoniesEmpty
|
|
||||||
tab={tab}
|
|
||||||
isFiltered={status !== "" && bucketColonies.length > 0}
|
|
||||||
onResetFilter={() => setStatus("")}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ColonyGrid colonies={visibleColonies} />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
</QueryState>
|
>
|
||||||
|
{{ active: listOf("active"), history: listOf("history") }}
|
||||||
|
</ActiveHistoryTabs>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,34 @@ export default function HoneyBatchesPage() {
|
|||||||
const honeyBatches = honeyBatchesQuery.data ?? [];
|
const honeyBatches = honeyBatchesQuery.data ?? [];
|
||||||
const activeBatches = honeyBatches.filter((batch) => !HISTORY_STATUSES.includes(batch.status));
|
const activeBatches = honeyBatches.filter((batch) => !HISTORY_STATUSES.includes(batch.status));
|
||||||
const historyBatches = 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 (
|
||||||
|
<QueryState query={honeyBatchesQuery} what="Honigchargen">
|
||||||
|
{() =>
|
||||||
|
batches.length === 0 ? (
|
||||||
|
bucket === "active" ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Droplet}
|
||||||
|
title="Noch keine Honigchargen"
|
||||||
|
description="Lege eine Charge an, sobald du geschleudert hast."
|
||||||
|
action={<CreateHoneyBatchDialog />}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon={Droplet}
|
||||||
|
title="Keine Historie"
|
||||||
|
description="Verkaufte oder verbrauchte Chargen erscheinen hier."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<HoneyBatchGrid honeyBatches={batches} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</QueryState>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||||
@@ -74,33 +101,12 @@ export default function HoneyBatchesPage() {
|
|||||||
|
|
||||||
<ActiveHistoryTabs
|
<ActiveHistoryTabs
|
||||||
value={tab}
|
value={tab}
|
||||||
onChange={setTab}
|
onValueChange={setTab}
|
||||||
activeCount={activeBatches.length}
|
activeCount={activeBatches.length}
|
||||||
historyCount={historyBatches.length}
|
historyCount={historyBatches.length}
|
||||||
/>
|
>
|
||||||
|
{{ active: listOf("active"), history: listOf("history") }}
|
||||||
<QueryState query={honeyBatchesQuery} what="Honigchargen">
|
</ActiveHistoryTabs>
|
||||||
{() =>
|
|
||||||
visibleBatches.length === 0 ? (
|
|
||||||
tab === "active" ? (
|
|
||||||
<EmptyState
|
|
||||||
icon={Droplet}
|
|
||||||
title="Noch keine Honigchargen"
|
|
||||||
description="Lege eine Charge an, sobald du geschleudert hast."
|
|
||||||
action={<CreateHoneyBatchDialog />}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<EmptyState
|
|
||||||
icon={Droplet}
|
|
||||||
title="Keine Historie"
|
|
||||||
description="Verkaufte oder verbrauchte Chargen erscheinen hier."
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<HoneyBatchGrid honeyBatches={visibleBatches} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</QueryState>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"}`
|
? `${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 <MedicineGrid medicines={shown} />;
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={Pill}
|
||||||
|
title={bucket === "active" ? "Keine aktiven Mittel" : "Keine deaktivierten Mittel"}
|
||||||
|
description={
|
||||||
|
bucket === "active"
|
||||||
|
? "Alle Mittel sind deaktiviert. Aktiviere eins wieder auf seiner Seite."
|
||||||
|
: "Deaktivierte Mittel erscheinen hier."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (!canRead) return <NoReadAccess />;
|
if (!canRead) return <NoReadAccess />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -47,35 +63,24 @@ export default function MedicinesPage() {
|
|||||||
/>
|
/>
|
||||||
{canCreate ? <OnlineOnlyHint id={hintId} /> : null}
|
{canCreate ? <OnlineOnlyHint id={hintId} /> : null}
|
||||||
|
|
||||||
{medicines.length > 0 ? (
|
|
||||||
<ActiveHistoryTabs
|
|
||||||
value={tab}
|
|
||||||
onChange={setTab}
|
|
||||||
activeCount={active.length}
|
|
||||||
historyCount={inactive.length}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<p aria-live="polite" className="min-h-5 text-sm text-muted-foreground">
|
|
||||||
{medicines.length > 0 ? countText : ""}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<QueryState query={medicinesQuery} what="Tierarzneimittel">
|
<QueryState query={medicinesQuery} what="Tierarzneimittel">
|
||||||
{() =>
|
{() =>
|
||||||
medicines.length === 0 ? (
|
medicines.length === 0 ? (
|
||||||
<MedicineSuggestions canCreate={canCreate} disabled={isOffline} describedBy={describedBy} />
|
<MedicineSuggestions canCreate={canCreate} disabled={isOffline} describedBy={describedBy} />
|
||||||
) : visible.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
icon={Pill}
|
|
||||||
title={tab === "active" ? "Keine aktiven Mittel" : "Keine deaktivierten Mittel"}
|
|
||||||
description={
|
|
||||||
tab === "active"
|
|
||||||
? "Alle Mittel sind deaktiviert. Aktiviere eins wieder auf seiner Seite."
|
|
||||||
: "Deaktivierte Mittel erscheinen hier."
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<MedicineGrid medicines={visible} />
|
<ActiveHistoryTabs
|
||||||
|
value={tab}
|
||||||
|
onValueChange={setTab}
|
||||||
|
activeCount={active.length}
|
||||||
|
historyCount={inactive.length}
|
||||||
|
toolbar={
|
||||||
|
<p aria-live="polite" className="text-sm text-muted-foreground">
|
||||||
|
{countText}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{{ active: listOf("active"), history: listOf("history") }}
|
||||||
|
</ActiveHistoryTabs>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</QueryState>
|
</QueryState>
|
||||||
|
|||||||
@@ -27,32 +27,14 @@ export default function ProductsPage() {
|
|||||||
const products = productsQuery.data ?? [];
|
const products = productsQuery.data ?? [];
|
||||||
const activeProducts = products.filter((product) => product.status === "ACTIVE");
|
const activeProducts = products.filter((product) => product.status === "ACTIVE");
|
||||||
const historyProducts = products.filter((product) => product.status !== "ACTIVE");
|
const historyProducts = products.filter((product) => product.status !== "ACTIVE");
|
||||||
const visibleProducts = tab === "active" ? activeProducts : historyProducts;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
|
||||||
<PageHeader
|
|
||||||
area="products"
|
|
||||||
title="Produkte"
|
|
||||||
description="Hier legst du deine verkaufsfertigen Produkte an und pflegst sie."
|
|
||||||
actions={
|
|
||||||
<Button size="touch" nativeButton={false} render={<Link href="/products/new" />}>
|
|
||||||
+ Neues Produkt
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ActiveHistoryTabs
|
|
||||||
value={tab}
|
|
||||||
onChange={setTab}
|
|
||||||
activeCount={activeProducts.length}
|
|
||||||
historyCount={historyProducts.length}
|
|
||||||
/>
|
|
||||||
|
|
||||||
|
const listOf = (bucket: EntityLifecycleTab) => {
|
||||||
|
const visibleProducts = bucket === "active" ? activeProducts : historyProducts;
|
||||||
|
return (
|
||||||
<QueryState query={productsQuery} what="Produkte">
|
<QueryState query={productsQuery} what="Produkte">
|
||||||
{() =>
|
{() =>
|
||||||
visibleProducts.length === 0 ? (
|
visibleProducts.length === 0 ? (
|
||||||
tab === "active" ? (
|
bucket === "active" ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={ShoppingBag}
|
icon={ShoppingBag}
|
||||||
title="Noch keine Produkte"
|
title="Noch keine Produkte"
|
||||||
@@ -75,6 +57,30 @@ export default function ProductsPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
</QueryState>
|
</QueryState>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||||
|
<PageHeader
|
||||||
|
area="products"
|
||||||
|
title="Produkte"
|
||||||
|
description="Hier legst du deine verkaufsfertigen Produkte an und pflegst sie."
|
||||||
|
actions={
|
||||||
|
<Button size="touch" nativeButton={false} render={<Link href="/products/new" />}>
|
||||||
|
+ Neues Produkt
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ActiveHistoryTabs
|
||||||
|
value={tab}
|
||||||
|
onValueChange={setTab}
|
||||||
|
activeCount={activeProducts.length}
|
||||||
|
historyCount={historyProducts.length}
|
||||||
|
>
|
||||||
|
{{ active: listOf("active"), history: listOf("history") }}
|
||||||
|
</ActiveHistoryTabs>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,26 @@ export default function QueensPage() {
|
|||||||
const queens = queensQuery.data ?? [];
|
const queens = queensQuery.data ?? [];
|
||||||
const activeQueens = queens.filter((queen) => queen.deactivatedAt === null);
|
const activeQueens = queens.filter((queen) => queen.deactivatedAt === null);
|
||||||
const historyQueens = 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 (
|
||||||
|
<QueryState query={queensQuery} what="Königinnen">
|
||||||
|
{() =>
|
||||||
|
visibleQueens.length === 0 ? (
|
||||||
|
<QueensEmpty
|
||||||
|
tab={bucket}
|
||||||
|
isFiltered={status !== "" && bucketQueens.length > 0}
|
||||||
|
onResetFilter={() => setStatus("")}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<QueenGrid queens={visibleQueens} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</QueryState>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||||
@@ -44,40 +62,28 @@ export default function QueensPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<ActiveHistoryTabs
|
||||||
<ActiveHistoryTabs
|
value={tab}
|
||||||
value={tab}
|
onValueChange={(next) => {
|
||||||
onChange={(next) => {
|
setTab(next);
|
||||||
setTab(next);
|
setStatus("");
|
||||||
setStatus("");
|
}}
|
||||||
}}
|
activeCount={activeQueens.length}
|
||||||
activeCount={activeQueens.length}
|
historyCount={historyQueens.length}
|
||||||
historyCount={historyQueens.length}
|
toolbar={
|
||||||
/>
|
<EnumSelect
|
||||||
<EnumSelect
|
ariaLabel="Nach Status filtern"
|
||||||
ariaLabel="Nach Status filtern"
|
className="w-40"
|
||||||
className="w-40"
|
value={status}
|
||||||
value={status}
|
onChange={setStatus}
|
||||||
onChange={setStatus}
|
values={QUEEN_STATUSES}
|
||||||
values={QUEEN_STATUSES}
|
labels={QUEEN_STATUS_LABELS}
|
||||||
labels={QUEEN_STATUS_LABELS}
|
noneLabel="Alle Status"
|
||||||
noneLabel="Alle Status"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<QueryState query={queensQuery} what="Königinnen">
|
|
||||||
{() =>
|
|
||||||
visibleQueens.length === 0 ? (
|
|
||||||
<QueensEmpty
|
|
||||||
tab={tab}
|
|
||||||
isFiltered={status !== "" && bucketQueens.length > 0}
|
|
||||||
onResetFilter={() => setStatus("")}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<QueenGrid queens={visibleQueens} />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
</QueryState>
|
>
|
||||||
|
{{ active: listOf("active"), history: listOf("history") }}
|
||||||
|
</ActiveHistoryTabs>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Suspense } from "react";
|
import { Suspense, type ComponentType } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { PageHeader } from "@/components/page-header";
|
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 { WebhooksTab } from "./webhooks-tab";
|
||||||
import { TaskTemplatesTab } from "./task-templates-tab";
|
import { TaskTemplatesTab } from "./task-templates-tab";
|
||||||
import { LabelTemplatesTab } from "./label-templates-tab";
|
import { LabelTemplatesTab } from "./label-templates-tab";
|
||||||
@@ -22,17 +22,17 @@ type Tab =
|
|||||||
| "api-keys";
|
| "api-keys";
|
||||||
|
|
||||||
// Everyday settings first, connections to other systems last.
|
// Everyday settings first, connections to other systems last.
|
||||||
const TABS: { value: Tab; label: string }[] = [
|
const TABS: { value: Tab; label: string; Content: ComponentType }[] = [
|
||||||
{ value: "rules", label: "Vorschläge" },
|
{ value: "rules", label: "Vorschläge", Content: AutomationRulesTab },
|
||||||
{ value: "task-templates", label: "Wiederkehrende Aufgaben" },
|
{ value: "task-templates", label: "Wiederkehrende Aufgaben", Content: TaskTemplatesTab },
|
||||||
{ value: "label-templates", label: "Etikettenvorlagen" },
|
{ value: "label-templates", label: "Etikettenvorlagen", Content: LabelTemplatesTab },
|
||||||
{ value: "social-media", label: "Stammdaten & Web" },
|
{ value: "social-media", label: "Stammdaten & Web", Content: SocialMediaTab },
|
||||||
{ value: "calendar-sync", label: "Kalender-Sync" },
|
{ value: "calendar-sync", label: "Kalender-Sync", Content: CalendarSyncTab },
|
||||||
{ value: "api-keys", label: "API-Schlüssel" },
|
{ value: "api-keys", label: "API-Schlüssel", Content: ApiKeysTab },
|
||||||
{ value: "webhooks", label: "Webhooks" },
|
{ 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);
|
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."
|
description="Was ApiCore dir abnimmt – und wie es mit anderen Systemen verbunden ist."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TabSwitcher tabs={TABS} value={tab} onChange={setTab} />
|
<Tabs
|
||||||
|
value={tab}
|
||||||
{tab === "webhooks" ? <WebhooksTab /> : null}
|
onValueChange={(next) => {
|
||||||
{tab === "task-templates" ? <TaskTemplatesTab /> : null}
|
if (isTab(next)) setTab(next);
|
||||||
{tab === "label-templates" ? <LabelTemplatesTab /> : null}
|
}}
|
||||||
{tab === "social-media" ? <SocialMediaTab /> : null}
|
>
|
||||||
{tab === "rules" ? <AutomationRulesTab /> : null}
|
<TabsList aria-label="Bereiche der Automatisierung">
|
||||||
{tab === "calendar-sync" ? <CalendarSyncTab /> : null}
|
{TABS.map((entry) => (
|
||||||
{tab === "api-keys" ? <ApiKeysTab /> : null}
|
<TabsTrigger key={entry.value} value={entry.value}>
|
||||||
|
{entry.label}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
{TABS.map(({ value, Content }) => (
|
||||||
|
<TabsContent key={value} value={value}>
|
||||||
|
<Content />
|
||||||
|
</TabsContent>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,74 +48,81 @@ export function TaskTemplatesTab() {
|
|||||||
|
|
||||||
const activeTemplates = (templates ?? []).filter((template) => template.active);
|
const activeTemplates = (templates ?? []).filter((template) => template.active);
|
||||||
const historyTemplates = (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 (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||||
|
{isError ? (
|
||||||
|
<p role="alert" className="text-sm text-destructive">
|
||||||
|
Vorlagen konnten nicht geladen werden.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{templates && visibleTemplates.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Repeat}
|
||||||
|
title={bucket === "active" ? "Noch keine Vorlagen angelegt" : "Keine Historie"}
|
||||||
|
description={
|
||||||
|
bucket === "active"
|
||||||
|
? "Lege eine Vorlage an, um wiederkehrende Aufgaben zu automatisieren."
|
||||||
|
: "Deaktivierte Vorlagen erscheinen hier."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{visibleTemplates.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{visibleTemplates.map((template) => (
|
||||||
|
<EntityCard
|
||||||
|
key={template.id}
|
||||||
|
area="tasks"
|
||||||
|
title={template.title}
|
||||||
|
badge={
|
||||||
|
<Badge variant={template.active ? "default" : "outline"}>
|
||||||
|
{template.active ? "Aktiv" : "Inaktiv"}
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
fields={[
|
||||||
|
{ label: "Wiederholung", value: describeRecurrence(template) },
|
||||||
|
{ label: "Nächste Fälligkeit", value: formatDate(template.nextDueDate) },
|
||||||
|
]}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<EditTaskTemplateDialog template={template} />
|
||||||
|
<ConfirmDialog
|
||||||
|
triggerLabel="Deaktivieren"
|
||||||
|
triggerVariant="ghost"
|
||||||
|
triggerDisabled={!template.active || deactivateMutation.isPending}
|
||||||
|
title="Wiederkehrende Aufgabe beenden?"
|
||||||
|
description={`Für „${template.title}“ werden keine neuen Aufgaben mehr angelegt. Bereits angelegte Aufgaben bleiben in der Liste.`}
|
||||||
|
confirmLabel="Beenden"
|
||||||
|
isPending={deactivateMutation.isPending}
|
||||||
|
onConfirm={() => deactivateMutation.mutate(template.id)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<ActiveHistoryTabs
|
||||||
<ActiveHistoryTabs
|
value={tab}
|
||||||
value={tab}
|
onValueChange={setTab}
|
||||||
onChange={setTab}
|
activeCount={activeTemplates.length}
|
||||||
activeCount={activeTemplates.length}
|
historyCount={historyTemplates.length}
|
||||||
historyCount={historyTemplates.length}
|
toolbar={<CreateTaskTemplateDialog />}
|
||||||
/>
|
toolbarAlign="end"
|
||||||
<CreateTaskTemplateDialog />
|
>
|
||||||
</div>
|
{{ active: listOf("active"), history: listOf("history") }}
|
||||||
|
</ActiveHistoryTabs>
|
||||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
|
||||||
{isError ? (
|
|
||||||
<p role="alert" className="text-sm text-destructive">
|
|
||||||
Vorlagen konnten nicht geladen werden.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{templates && visibleTemplates.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
icon={Repeat}
|
|
||||||
title={tab === "active" ? "Noch keine Vorlagen angelegt" : "Keine Historie"}
|
|
||||||
description={
|
|
||||||
tab === "active"
|
|
||||||
? "Lege eine Vorlage an, um wiederkehrende Aufgaben zu automatisieren."
|
|
||||||
: "Deaktivierte Vorlagen erscheinen hier."
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{visibleTemplates.length > 0 ? (
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{visibleTemplates.map((template) => (
|
|
||||||
<EntityCard
|
|
||||||
key={template.id}
|
|
||||||
area="tasks"
|
|
||||||
title={template.title}
|
|
||||||
badge={
|
|
||||||
<Badge variant={template.active ? "default" : "outline"}>
|
|
||||||
{template.active ? "Aktiv" : "Inaktiv"}
|
|
||||||
</Badge>
|
|
||||||
}
|
|
||||||
fields={[
|
|
||||||
{ label: "Wiederholung", value: describeRecurrence(template) },
|
|
||||||
{ label: "Nächste Fälligkeit", value: formatDate(template.nextDueDate) },
|
|
||||||
]}
|
|
||||||
actions={
|
|
||||||
<>
|
|
||||||
<EditTaskTemplateDialog template={template} />
|
|
||||||
<ConfirmDialog
|
|
||||||
triggerLabel="Deaktivieren"
|
|
||||||
triggerVariant="ghost"
|
|
||||||
triggerDisabled={!template.active || deactivateMutation.isPending}
|
|
||||||
title="Wiederkehrende Aufgabe beenden?"
|
|
||||||
description={`Für „${template.title}“ werden keine neuen Aufgaben mehr angelegt. Bereits angelegte Aufgaben bleiben in der Liste.`}
|
|
||||||
confirmLabel="Beenden"
|
|
||||||
isPending={deactivateMutation.isPending}
|
|
||||||
onConfirm={() => deactivateMutation.mutate(template.id)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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: () => <p>Inhalt Webhooks</p> }));
|
||||||
|
vi.mock("./automation/task-templates-tab", () => ({ TaskTemplatesTab: () => <p>Inhalt Vorlagen</p> }));
|
||||||
|
vi.mock("./automation/label-templates-tab", () => ({ LabelTemplatesTab: () => <p>Inhalt Etiketten</p> }));
|
||||||
|
vi.mock("./automation/social-media-tab", () => ({ SocialMediaTab: () => <p>Inhalt Stammdaten</p> }));
|
||||||
|
vi.mock("./automation/automation-rules-tab", () => ({ AutomationRulesTab: () => <p>Inhalt Vorschläge</p> }));
|
||||||
|
vi.mock("./automation/calendar-sync-tab", () => ({ CalendarSyncTab: () => <p>Inhalt Kalender</p> }));
|
||||||
|
vi.mock("./automation/api-keys-tab", () => ({ ApiKeysTab: () => <p>Inhalt API</p> }));
|
||||||
|
vi.mock("./users-roles/users-tab", () => ({ UsersTab: () => <p>Inhalt Benutzer</p> }));
|
||||||
|
vi.mock("./users-roles/roles-tab", () => ({ RolesTab: () => <p>Inhalt Rollen</p> }));
|
||||||
|
|
||||||
|
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(<AutomationPage />);
|
||||||
|
|
||||||
|
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(<AutomationPage />);
|
||||||
|
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(<UsersRolesPage />);
|
||||||
|
|
||||||
|
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(<UsersRolesPage />);
|
||||||
|
expect(screen.getByRole("tab", { name: "Rollen & Rechte" })).toHaveAttribute("aria-selected", "true");
|
||||||
|
expect(screen.getByRole("tabpanel")).toHaveTextContent("Inhalt Rollen");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { PageHeader } from "@/components/page-header";
|
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 { UsersTab } from "./users-tab";
|
||||||
import { RolesTab } from "./roles-tab";
|
import { RolesTab } from "./roles-tab";
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ const TABS: { value: Tab; label: string }[] = [
|
|||||||
{ value: "roles", label: "Rollen & Rechte" },
|
{ 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";
|
return value === "users" || value === "roles";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,9 +36,26 @@ function UsersRolesPageContent() {
|
|||||||
description="Zugänge, Rollen und Berechtigungen verwalten"
|
description="Zugänge, Rollen und Berechtigungen verwalten"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TabSwitcher tabs={TABS} value={tab} onChange={setTab} />
|
<Tabs
|
||||||
|
value={tab}
|
||||||
{tab === "users" ? <UsersTab /> : <RolesTab />}
|
onValueChange={(next) => {
|
||||||
|
if (isTab(next)) setTab(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TabsList aria-label="Benutzer oder Rollen">
|
||||||
|
{TABS.map((entry) => (
|
||||||
|
<TabsTrigger key={entry.value} value={entry.value}>
|
||||||
|
{entry.label}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="users">
|
||||||
|
<UsersTab />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="roles">
|
||||||
|
<RolesTab />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,112 +54,119 @@ export function RolesTab() {
|
|||||||
|
|
||||||
const activeRoles = (roles ?? []).filter((role) => role.status === "ACTIVE");
|
const activeRoles = (roles ?? []).filter((role) => role.status === "ACTIVE");
|
||||||
const historyRoles = (roles ?? []).filter((role) => role.status === "INACTIVE");
|
const historyRoles = (roles ?? []).filter((role) => role.status === "INACTIVE");
|
||||||
const visibleRoles = tab === "active" ? activeRoles : historyRoles;
|
|
||||||
const catalogLength = catalog?.length ?? 0;
|
const catalogLength = catalog?.length ?? 0;
|
||||||
|
|
||||||
return (
|
const listOf = (bucket: EntityLifecycleTab) => {
|
||||||
<div className="flex flex-col gap-4">
|
const visibleRoles = bucket === "active" ? activeRoles : historyRoles;
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
return (
|
||||||
<ActiveHistoryTabs
|
<div className="flex flex-col gap-4">
|
||||||
value={tab}
|
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||||
onChange={setTab}
|
{isError ? (
|
||||||
activeCount={activeRoles.length}
|
<p role="alert" className="text-sm text-destructive">
|
||||||
historyCount={historyRoles.length}
|
Rollen konnten nicht geladen werden.
|
||||||
/>
|
</p>
|
||||||
<CreateRoleDialog />
|
) : null}
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
{roles && visibleRoles.length === 0 ? (
|
||||||
{isError ? (
|
<EmptyState
|
||||||
<p role="alert" className="text-sm text-destructive">
|
icon={Shield}
|
||||||
Rollen konnten nicht geladen werden.
|
title={bucket === "active" ? "Noch keine Rollen" : "Keine Historie"}
|
||||||
</p>
|
description={
|
||||||
) : null}
|
bucket === "active"
|
||||||
|
? "Lege eine Rolle an, um Zugriffe granular zu steuern."
|
||||||
|
: "Deaktivierte Rollen erscheinen hier."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{roles && visibleRoles.length === 0 ? (
|
{visibleRoles.length > 0 ? (
|
||||||
<EmptyState
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
icon={Shield}
|
{visibleRoles.map((role) => (
|
||||||
title={tab === "active" ? "Noch keine Rollen" : "Keine Historie"}
|
<EntityCard
|
||||||
description={
|
key={role.id}
|
||||||
tab === "active"
|
area="users-roles"
|
||||||
? "Lege eine Rolle an, um Zugriffe granular zu steuern."
|
title={role.name}
|
||||||
: "Deaktivierte Rollen erscheinen hier."
|
subtitle={role.key}
|
||||||
}
|
badge={
|
||||||
/>
|
role.isSystem ? (
|
||||||
) : null}
|
<Badge variant="secondary">System-Rolle</Badge>
|
||||||
|
) : (
|
||||||
{visibleRoles.length > 0 ? (
|
<Badge variant={role.status === "ACTIVE" ? "default" : "outline"}>
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
{role.status === "ACTIVE" ? "Aktiv" : "Inaktiv"}
|
||||||
{visibleRoles.map((role) => (
|
</Badge>
|
||||||
<EntityCard
|
)
|
||||||
key={role.id}
|
}
|
||||||
area="users-roles"
|
fields={[
|
||||||
title={role.name}
|
{
|
||||||
subtitle={role.key}
|
label: "Rechte",
|
||||||
badge={
|
value: `${role.permissionKeys.length}/${catalogLength}`,
|
||||||
role.isSystem ? (
|
},
|
||||||
<Badge variant="secondary">System-Rolle</Badge>
|
{ label: "Benutzer", value: role.userCount },
|
||||||
) : (
|
]}
|
||||||
<Badge variant={role.status === "ACTIVE" ? "default" : "outline"}>
|
actions={
|
||||||
{role.status === "ACTIVE" ? "Aktiv" : "Inaktiv"}
|
role.isSystem ? (
|
||||||
</Badge>
|
<p className="text-xs text-muted-foreground">
|
||||||
)
|
Systemrollen können nicht bearbeitet werden.
|
||||||
}
|
</p>
|
||||||
fields={[
|
) : (
|
||||||
{
|
<>
|
||||||
label: "Rechte",
|
|
||||||
value: `${role.permissionKeys.length}/${catalogLength}`,
|
|
||||||
},
|
|
||||||
{ label: "Benutzer", value: role.userCount },
|
|
||||||
]}
|
|
||||||
actions={
|
|
||||||
role.isSystem ? (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Systemrollen können nicht bearbeitet werden.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setEditingRole(role)}
|
|
||||||
>
|
|
||||||
Bearbeiten
|
|
||||||
</Button>
|
|
||||||
{role.status === "ACTIVE" ? (
|
|
||||||
<ConfirmDialog
|
|
||||||
triggerLabel="Deaktivieren"
|
|
||||||
triggerSize="sm"
|
|
||||||
triggerDisabled={role.userCount > 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)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={reactivateMutation.isPending}
|
onClick={() => setEditingRole(role)}
|
||||||
onClick={() => reactivateMutation.mutate(role.id)}
|
|
||||||
>
|
>
|
||||||
Reaktivieren
|
Bearbeiten
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
{role.status === "ACTIVE" ? (
|
||||||
</>
|
<ConfirmDialog
|
||||||
)
|
triggerLabel="Deaktivieren"
|
||||||
}
|
triggerSize="sm"
|
||||||
/>
|
triggerDisabled={role.userCount > 0 || deactivateMutation.isPending}
|
||||||
))}
|
title="Rolle deaktivieren?"
|
||||||
</div>
|
description={
|
||||||
) : null}
|
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)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={reactivateMutation.isPending}
|
||||||
|
onClick={() => reactivateMutation.mutate(role.id)}
|
||||||
|
>
|
||||||
|
Reaktivieren
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<ActiveHistoryTabs
|
||||||
|
value={tab}
|
||||||
|
onValueChange={setTab}
|
||||||
|
activeCount={activeRoles.length}
|
||||||
|
historyCount={historyRoles.length}
|
||||||
|
toolbar={<CreateRoleDialog />}
|
||||||
|
toolbarAlign="end"
|
||||||
|
>
|
||||||
|
{{ active: listOf("active"), history: listOf("history") }}
|
||||||
|
</ActiveHistoryTabs>
|
||||||
|
|
||||||
<EditRoleDialog role={editingRole} onClose={() => setEditingRole(null)} />
|
<EditRoleDialog role={editingRole} onClose={() => setEditingRole(null)} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -55,4 +55,12 @@ describe("StatisticsPage", () => {
|
|||||||
expect(fetchColonyStats).toHaveBeenCalledTimes(2);
|
expect(fetchColonyStats).toHaveBeenCalledTimes(2);
|
||||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("the statistics page uses the segment variant", () => {
|
||||||
|
vi.mocked(fetchColonyStats).mockReturnValue(new Promise(() => {}));
|
||||||
|
renderWithClient(<StatisticsPage />);
|
||||||
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -35,7 +35,21 @@ export default function StoragePage() {
|
|||||||
const containers = containersQuery.data ?? [];
|
const containers = containersQuery.data ?? [];
|
||||||
const activeContainers = containers.filter((container) => container.status !== "OUT_OF_SERVICE");
|
const activeContainers = containers.filter((container) => container.status !== "OUT_OF_SERVICE");
|
||||||
const historyContainers = 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 (
|
||||||
|
<QueryState query={containersQuery} what="Lagerbehälter">
|
||||||
|
{() =>
|
||||||
|
visibleContainers.length === 0 ? (
|
||||||
|
<ContainersEmpty tab={bucket} />
|
||||||
|
) : (
|
||||||
|
<ContainerGrid containers={visibleContainers} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</QueryState>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-8 p-4 sm:p-6">
|
<main className="flex flex-col gap-8 p-4 sm:p-6">
|
||||||
@@ -47,24 +61,15 @@ export default function StoragePage() {
|
|||||||
actions={<CreateStorageContainerDialog />}
|
actions={<CreateStorageContainerDialog />}
|
||||||
/>
|
/>
|
||||||
{hasPermission("veterinary-medicines:read") ? <MedicinesCard /> : null}
|
{hasPermission("veterinary-medicines:read") ? <MedicinesCard /> : null}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<ActiveHistoryTabs
|
||||||
<h2 className="text-lg font-semibold tracking-tight">Lagerbehälter</h2>
|
heading={<h2 className="text-lg font-semibold tracking-tight">Lagerbehälter</h2>}
|
||||||
<ActiveHistoryTabs
|
value={containerTab}
|
||||||
value={containerTab}
|
onValueChange={setContainerTab}
|
||||||
onChange={setContainerTab}
|
activeCount={activeContainers.length}
|
||||||
activeCount={activeContainers.length}
|
historyCount={historyContainers.length}
|
||||||
historyCount={historyContainers.length}
|
>
|
||||||
/>
|
{{ active: containersOf("active"), history: containersOf("history") }}
|
||||||
</div>
|
</ActiveHistoryTabs>
|
||||||
<QueryState query={containersQuery} what="Lagerbehälter">
|
|
||||||
{() =>
|
|
||||||
visibleContainers.length === 0 ? (
|
|
||||||
<ContainersEmpty tab={containerTab} />
|
|
||||||
) : (
|
|
||||||
<ContainerGrid containers={visibleContainers} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</QueryState>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="flex flex-col gap-4 border-t border-border/60 pt-6">
|
<section className="flex flex-col gap-4 border-t border-border/60 pt-6">
|
||||||
|
|||||||
@@ -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 }) => (
|
||||||
|
<a href={href} {...rest}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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(
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
|
<TasksPage />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,7 +27,9 @@ export default function TasksPage() {
|
|||||||
const [onlyOverdue, setOnlyOverdue] = useState(false);
|
const [onlyOverdue, setOnlyOverdue] = useState(false);
|
||||||
|
|
||||||
const { data: tasks, isLoading, isError } = useQuery({
|
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 }),
|
queryFn: () => fetchTasks({ priority: priority || undefined }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -44,11 +46,79 @@ export default function TasksPage() {
|
|||||||
const [now] = useState(() => Date.now());
|
const [now] = useState(() => Date.now());
|
||||||
const activeTasks = (tasks ?? []).filter((task) => !HISTORY_STATUSES.includes(task.status));
|
const activeTasks = (tasks ?? []).filter((task) => !HISTORY_STATUSES.includes(task.status));
|
||||||
const historyTasks = (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) => {
|
const listOf = (bucket: EntityLifecycleTab) => {
|
||||||
if (!onlyOverdue) return true;
|
const bucketTasks = bucket === "active" ? activeTasks : historyTasks;
|
||||||
return task.dueDate !== null && new Date(task.dueDate).getTime() < now;
|
const visibleTasks = bucketTasks.filter((task) => {
|
||||||
});
|
if (!onlyOverdue) return true;
|
||||||
|
return task.dueDate !== null && new Date(task.dueDate).getTime() < now;
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||||
|
{isError ? (
|
||||||
|
<p role="alert" className="text-sm text-destructive">
|
||||||
|
Aufgaben konnten nicht geladen werden.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{tasks && visibleTasks.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={ListTodo}
|
||||||
|
title={bucket === "active" ? "Keine Aufgaben" : "Keine Historie"}
|
||||||
|
description={
|
||||||
|
bucket === "active"
|
||||||
|
? onlyOverdue
|
||||||
|
? "Keine überfälligen Aufgaben – gut gemacht!"
|
||||||
|
: "Lege deine erste Aufgabe an, um den Überblick zu behalten."
|
||||||
|
: "Erledigte oder abgebrochene Aufgaben erscheinen hier."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{visibleTasks.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{visibleTasks.map((task) => (
|
||||||
|
<EntityCard
|
||||||
|
key={task.id}
|
||||||
|
area="tasks"
|
||||||
|
title={task.title}
|
||||||
|
badge={
|
||||||
|
<span className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<TaskStatusPill status={task.status} />
|
||||||
|
<TaskSourceBadge task={task} />
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
fields={[
|
||||||
|
{ label: "Priorität", value: TASK_PRIORITY_LABELS[task.priority] },
|
||||||
|
{
|
||||||
|
label: "Fällig",
|
||||||
|
value: task.dueDate ? new Date(task.dueDate).toLocaleDateString("de-DE") : "—",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<TaskEditDialog task={task} />
|
||||||
|
{task.status !== "DONE" && task.status !== "CANCELLED" ? (
|
||||||
|
<Button
|
||||||
|
size="touch"
|
||||||
|
variant="outline"
|
||||||
|
disabled={completeMutation.isPending}
|
||||||
|
onClick={() => completeMutation.mutate(task.id)}
|
||||||
|
>
|
||||||
|
<Check aria-hidden />
|
||||||
|
Erledigt
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||||
@@ -63,93 +133,35 @@ export default function TasksPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<ActiveHistoryTabs
|
||||||
<ActiveHistoryTabs
|
value={tab}
|
||||||
value={tab}
|
onValueChange={setTab}
|
||||||
onChange={setTab}
|
activeCount={activeTasks.length}
|
||||||
activeCount={activeTasks.length}
|
historyCount={historyTasks.length}
|
||||||
historyCount={historyTasks.length}
|
toolbar={
|
||||||
/>
|
<>
|
||||||
<EnumSelect
|
<EnumSelect
|
||||||
ariaLabel="Nach Priorität filtern"
|
ariaLabel="Nach Priorität filtern"
|
||||||
className="w-40"
|
className="w-40"
|
||||||
value={priority}
|
value={priority}
|
||||||
onChange={setPriority}
|
onChange={setPriority}
|
||||||
values={TASK_PRIORITIES}
|
values={TASK_PRIORITIES}
|
||||||
labels={TASK_PRIORITY_LABELS}
|
labels={TASK_PRIORITY_LABELS}
|
||||||
noneLabel="Alle Prioritäten"
|
noneLabel="Alle Prioritäten"
|
||||||
/>
|
|
||||||
{tab === "active" ? (
|
|
||||||
<CheckboxField
|
|
||||||
className="w-auto"
|
|
||||||
label="Nur überfällige"
|
|
||||||
checked={onlyOverdue}
|
|
||||||
onCheckedChange={setOnlyOverdue}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
|
||||||
{isError ? (
|
|
||||||
<p role="alert" className="text-sm text-destructive">
|
|
||||||
Aufgaben konnten nicht geladen werden.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{tasks && visibleTasks.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
icon={ListTodo}
|
|
||||||
title={tab === "active" ? "Keine Aufgaben" : "Keine Historie"}
|
|
||||||
description={
|
|
||||||
tab === "active"
|
|
||||||
? onlyOverdue
|
|
||||||
? "Keine überfälligen Aufgaben – gut gemacht!"
|
|
||||||
: "Lege deine erste Aufgabe an, um den Überblick zu behalten."
|
|
||||||
: "Erledigte oder abgebrochene Aufgaben erscheinen hier."
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{visibleTasks.length > 0 ? (
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{visibleTasks.map((task) => (
|
|
||||||
<EntityCard
|
|
||||||
key={task.id}
|
|
||||||
area="tasks"
|
|
||||||
title={task.title}
|
|
||||||
badge={
|
|
||||||
<span className="flex flex-wrap items-center gap-1.5">
|
|
||||||
<TaskStatusPill status={task.status} />
|
|
||||||
<TaskSourceBadge task={task} />
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
fields={[
|
|
||||||
{ label: "Priorität", value: TASK_PRIORITY_LABELS[task.priority] },
|
|
||||||
{
|
|
||||||
label: "Fällig",
|
|
||||||
value: task.dueDate ? new Date(task.dueDate).toLocaleDateString("de-DE") : "—",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
actions={
|
|
||||||
<>
|
|
||||||
<TaskEditDialog task={task} />
|
|
||||||
{task.status !== "DONE" && task.status !== "CANCELLED" ? (
|
|
||||||
<Button
|
|
||||||
size="touch"
|
|
||||||
variant="outline"
|
|
||||||
disabled={completeMutation.isPending}
|
|
||||||
onClick={() => completeMutation.mutate(task.id)}
|
|
||||||
>
|
|
||||||
<Check aria-hidden />
|
|
||||||
Erledigt
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))}
|
{tab === "active" ? (
|
||||||
</div>
|
<CheckboxField
|
||||||
) : null}
|
className="w-auto"
|
||||||
|
label="Nur überfällige"
|
||||||
|
checked={onlyOverdue}
|
||||||
|
onCheckedChange={setOnlyOverdue}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{{ active: listOf("active"), history: listOf("history") }}
|
||||||
|
</ActiveHistoryTabs>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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<EntityLifecycleTab>("active");
|
||||||
|
return (
|
||||||
|
<ActiveHistoryTabs
|
||||||
|
value={value}
|
||||||
|
onValueChange={(next) => {
|
||||||
|
setValue(next);
|
||||||
|
onValueChange?.(next);
|
||||||
|
}}
|
||||||
|
activeCount={1}
|
||||||
|
historyCount={0}
|
||||||
|
toolbar={<button type="button">Filter</button>}
|
||||||
|
>
|
||||||
|
{{ active: <p>Liste aktiv</p>, history: <p>Liste Historie</p> }}
|
||||||
|
</ActiveHistoryTabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(<Harness onValueChange={onValueChange} />);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<ActiveHistoryTabs value="active" onValueChange={() => {}}>
|
||||||
|
{{ active: null, history: null }}
|
||||||
|
</ActiveHistoryTabs>,
|
||||||
|
);
|
||||||
|
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(<Harness />);
|
||||||
|
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(<Harness />);
|
||||||
|
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(
|
||||||
|
<ActiveHistoryTabs
|
||||||
|
value="active"
|
||||||
|
onValueChange={() => {}}
|
||||||
|
toolbar={<button type="button">Neue Rolle</button>}
|
||||||
|
toolbarAlign="end"
|
||||||
|
>
|
||||||
|
{{ active: null, history: null }}
|
||||||
|
</ActiveHistoryTabs>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<ActiveHistoryTabs
|
||||||
|
value="active"
|
||||||
|
onValueChange={() => {}}
|
||||||
|
heading={<h2>Lagerbehälter</h2>}
|
||||||
|
>
|
||||||
|
{{ active: null, history: null }}
|
||||||
|
</ActiveHistoryTabs>,
|
||||||
|
);
|
||||||
|
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(<Harness />);
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,53 +1,69 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
|
||||||
export type EntityLifecycleTab = "active" | "history";
|
export type EntityLifecycleTab = "active" | "history";
|
||||||
|
|
||||||
interface ActiveHistoryTabsProps {
|
export interface ActiveHistoryTabsProps {
|
||||||
value: EntityLifecycleTab;
|
value: EntityLifecycleTab;
|
||||||
onChange: (value: EntityLifecycleTab) => void;
|
onValueChange: (value: EntityLifecycleTab) => void;
|
||||||
activeCount?: number;
|
activeCount?: number;
|
||||||
historyCount?: 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({
|
export function ActiveHistoryTabs({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onValueChange,
|
||||||
activeCount,
|
activeCount,
|
||||||
historyCount,
|
historyCount,
|
||||||
|
heading,
|
||||||
|
toolbar,
|
||||||
|
toolbarAlign = "start",
|
||||||
|
children,
|
||||||
}: ActiveHistoryTabsProps) {
|
}: ActiveHistoryTabsProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<Tabs
|
||||||
role="tablist"
|
value={value}
|
||||||
className="inline-flex w-fit rounded-xl border border-border/60 bg-muted/40 p-1"
|
onValueChange={(next) => {
|
||||||
|
if (isLifecycleTab(next)) onValueChange(next);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
type="button"
|
{heading}
|
||||||
role="tab"
|
<TabsList
|
||||||
aria-selected={value === "active"}
|
aria-label="Aktiv oder Historie"
|
||||||
onClick={() => onChange("active")}
|
activateOnFocus
|
||||||
className={cn(
|
className={cn(heading !== undefined && "ml-auto")}
|
||||||
"rounded-lg px-4 py-1.5 text-sm font-medium transition-colors",
|
>
|
||||||
value === "active"
|
<TabsTrigger value="active">{withCount("Aktiv", activeCount)}</TabsTrigger>
|
||||||
? "bg-card text-foreground shadow-[var(--shadow-soft)]"
|
<TabsTrigger value="history">{withCount("Historie", historyCount)}</TabsTrigger>
|
||||||
: "text-muted-foreground hover:text-foreground",
|
</TabsList>
|
||||||
|
{toolbarAlign === "end" && toolbar !== undefined ? (
|
||||||
|
<div className="ml-auto">{toolbar}</div>
|
||||||
|
) : (
|
||||||
|
toolbar
|
||||||
)}
|
)}
|
||||||
>
|
</div>
|
||||||
Aktiv{activeCount !== undefined ? ` (${activeCount})` : ""}
|
<TabsContent value="active">{children.active}</TabsContent>
|
||||||
</button>
|
<TabsContent value="history">{children.history}</TabsContent>
|
||||||
<button
|
</Tabs>
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={value === "history"}
|
|
||||||
onClick={() => onChange("history")}
|
|
||||||
className={cn(
|
|
||||||
"rounded-lg px-4 py-1.5 text-sm font-medium transition-colors",
|
|
||||||
value === "history"
|
|
||||||
? "bg-card text-foreground shadow-[var(--shadow-soft)]"
|
|
||||||
: "text-muted-foreground hover:text-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Historie{historyCount !== undefined ? ` (${historyCount})` : ""}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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(
|
|
||||||
<TabSwitcher
|
|
||||||
tabs={[
|
|
||||||
{ value: "a", label: "A" },
|
|
||||||
{ value: "b", label: "B" },
|
|
||||||
]}
|
|
||||||
value="a"
|
|
||||||
onChange={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
for (const tab of screen.getAllByRole("tab")) {
|
|
||||||
expect(tab).toHaveClass("min-h-11", "pointer-fine:min-h-9");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface TabSwitcherProps<T extends string> {
|
|
||||||
tabs: { value: T; label: string }[];
|
|
||||||
value: T;
|
|
||||||
onChange: (value: T) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TabSwitcher<T extends string>({ tabs, value, onChange }: TabSwitcherProps<T>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="tablist"
|
|
||||||
className="inline-flex w-fit flex-wrap gap-1 rounded-xl border border-border/60 bg-muted/40 p-1"
|
|
||||||
>
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.value}
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={value === tab.value}
|
|
||||||
onClick={() => 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}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -88,7 +88,7 @@ function DropdownMenuItem({
|
|||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -113,7 +113,7 @@ function DropdownMenuSubTrigger({
|
|||||||
data-slot="dropdown-menu-sub-trigger"
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -159,7 +159,7 @@ function DropdownMenuCheckboxItem({
|
|||||||
data-slot="dropdown-menu-checkbox-item"
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
@@ -201,7 +201,7 @@ function DropdownMenuRadioItem({
|
|||||||
data-slot="dropdown-menu-radio-item"
|
data-slot="dropdown-menu-radio-item"
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<DropdownMenu open>
|
||||||
|
<DropdownMenuTrigger>Mehr</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem>Exportieren</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem variant="destructive">Löschen</DropdownMenuItem>
|
||||||
|
<DropdownMenuCheckboxItem checked>Mit Details</DropdownMenuCheckboxItem>
|
||||||
|
<DropdownMenuRadioGroup value="a">
|
||||||
|
<DropdownMenuRadioItem value="a">Liste</DropdownMenuRadioItem>
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger>Weitere</DropdownMenuSubTrigger>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<Select open value="a">
|
||||||
|
<SelectTrigger aria-label="Status">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="a">Aktiv</SelectItem>
|
||||||
|
<SelectItem value="b">Aufgelöst</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>,
|
||||||
|
);
|
||||||
|
for (const option of screen.getAllByRole("option")) expectFocusCue(option);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -117,7 +117,7 @@ function SelectItem({
|
|||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
data-slot="select-item"
|
data-slot="select-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-lg py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
"relative flex w-full cursor-default items-center gap-1.5 rounded-lg py-1.5 pr-8 pl-2 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-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs";
|
||||||
|
|
||||||
|
const has = (element: HTMLElement, token: string) => element.className.split(/\s+/).includes(token);
|
||||||
|
|
||||||
|
function Harness({ initial = "a" }: { initial?: string }) {
|
||||||
|
const [value, setValue] = useState(initial);
|
||||||
|
return (
|
||||||
|
<Tabs value={value} onValueChange={(next) => setValue(String(next))}>
|
||||||
|
<TabsList aria-label="Bereiche">
|
||||||
|
<TabsTrigger value="a">Völker</TabsTrigger>
|
||||||
|
<TabsTrigger value="b">Königinnen</TabsTrigger>
|
||||||
|
<TabsTrigger value="c">Ernte</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="a">Panel Völker</TabsContent>
|
||||||
|
<TabsContent value="b">Panel Königinnen</TabsContent>
|
||||||
|
<TabsContent value="c">Panel Ernte</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(<Harness />);
|
||||||
|
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(<Harness initial="b" />);
|
||||||
|
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(<Harness />);
|
||||||
|
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(<Harness />);
|
||||||
|
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(<Harness />);
|
||||||
|
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/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,21 +1,36 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function Tabs({
|
/**
|
||||||
className,
|
* Tabs (spec 10.4): one variant, `segment`. Base UI draws role tablist/tab/tabpanel,
|
||||||
orientation = "horizontal",
|
* aria-controls and the arrow keys; this is the only place in the app with those roles.
|
||||||
...props
|
*/
|
||||||
}: TabsPrimitive.Root.Props) {
|
function Tabs({ className, ...props }: TabsPrimitive.Root.Props) {
|
||||||
return (
|
return (
|
||||||
<TabsPrimitive.Root
|
<TabsPrimitive.Root
|
||||||
data-slot="tabs"
|
data-slot="tabs"
|
||||||
data-orientation={orientation}
|
className={cn("flex flex-col gap-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
data-variant="segment"
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
"inline-flex w-fit max-w-full flex-wrap items-center gap-1 rounded-xl bg-muted p-1",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -23,45 +38,21 @@ function Tabs({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabsListVariants = cva(
|
/**
|
||||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-xl p-1 text-muted-foreground group-data-horizontal/tabs:h-10 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
* 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,
|
||||||
variants: {
|
* semibold, with a 3 px --primary-ink bar at the bottom: surface, weight and bar, never colour
|
||||||
variant: {
|
* alone. The top border keeps the label centred against the bar. Focus is the global ring.
|
||||||
default: "bg-muted/70",
|
*/
|
||||||
line: "gap-1 bg-transparent",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
function TabsList({
|
|
||||||
className,
|
|
||||||
variant = "default",
|
|
||||||
...props
|
|
||||||
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
|
|
||||||
return (
|
|
||||||
<TabsPrimitive.List
|
|
||||||
data-slot="tabs-list"
|
|
||||||
data-variant={variant}
|
|
||||||
className={cn(tabsListVariants({ variant }), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||||
return (
|
return (
|
||||||
<TabsPrimitive.Tab
|
<TabsPrimitive.Tab
|
||||||
data-slot="tabs-trigger"
|
data-slot="tabs-trigger"
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-lg border border-transparent px-2.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 group-data-[variant=default]/tabs-list:data-active:shadow-[var(--shadow-soft)] group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"inline-flex h-11 items-center justify-center gap-1.5 rounded-lg border-t-3 border-b-3 border-transparent px-4 text-base font-medium whitespace-nowrap text-muted-foreground pointer-fine:h-9",
|
||||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
"hover:text-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50",
|
||||||
"data-active:bg-card data-active:text-primary-ink",
|
"data-active:border-b-primary-ink data-active:bg-card data-active:font-semibold data-active:text-foreground",
|
||||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -73,10 +64,10 @@ function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
|||||||
return (
|
return (
|
||||||
<TabsPrimitive.Panel
|
<TabsPrimitive.Panel
|
||||||
data-slot="tabs-content"
|
data-slot="tabs-content"
|
||||||
className={cn("flex-1 text-sm", className)}
|
className={cn("flex-1", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
@@ -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<GuardScope, RegExp> = {
|
const SCOPE_FILE: Record<GuardScope, RegExp> = {
|
||||||
@@ -206,6 +215,20 @@ describe("baukasten guard (spec 10.7)", () => {
|
|||||||
expect(users.map(({ file }) => file)).toEqual([]);
|
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 }) => /<TabsList\b[^>]*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)", () => {
|
it("a list of CheckboxFields keeps neighbouring targets at least 4 px apart (spec 4.5)", () => {
|
||||||
// `.map(… => <CheckboxField` (optionally inside an <li>) and the container opened before it.
|
// `.map(… => <CheckboxField` (optionally inside an <li>) and the container opened before it.
|
||||||
const lists = FILES.filter(({ file }) => file.endsWith(".tsx")).flatMap(({ file, text }) =>
|
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.
|
// The CheckboxField exemption ends with its own tag: a later element is caught again.
|
||||||
expect(toggle.test('<CheckboxField label="A" />\n<input role="switch" />')).toBe(true);
|
expect(toggle.test('<CheckboxField label="A" />\n<input role="switch" />')).toBe(true);
|
||||||
expect(checkbox.test('<CheckboxField label="A" /><span role="checkbox" />')).toBe(true);
|
expect(checkbox.test('<CheckboxField label="A" /><span role="checkbox" />')).toBe(true);
|
||||||
|
const tabs = rule("no role tablist, tab or tabpanel outside ui/tabs.tsx").pattern;
|
||||||
|
expect(tabs.test('<div role="tablist" className="flex">')).toBe(true);
|
||||||
|
expect(tabs.test('<button type="button" role="tab" aria-selected={on}>')).toBe(true);
|
||||||
|
expect(tabs.test("<section role={'tabpanel'}>")).toBe(true);
|
||||||
|
expect(tabs.test('{ role: "tab" }')).toBe(true);
|
||||||
|
expect(tabs.test('<div role={wide ? "tablist" : undefined}>')).toBe(true);
|
||||||
|
expect(tabs.test('node.setAttribute("role", "tab")')).toBe(true);
|
||||||
|
expect(tabs.test('<Table role="table">')).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 text-white").pattern.test('"text-white/80"')).toBe(true);
|
||||||
expect(rule("no gradient prop or gradient token").pattern.test("<PageHeader gradient=\"honey\" />")).toBe(true);
|
expect(rule("no gradient prop or gradient token").pattern.test("<PageHeader gradient=\"honey\" />")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) => {
|
describe.each(SETS)("condition tones in %s", (set) => {
|
||||||
it.each(TONES)("%s has surface, text-on-surface and ink, each above its limit", (tone) => {
|
it.each(TONES)("%s has surface, text-on-surface and ink, each above its limit", (tone) => {
|
||||||
const surface = rgb(set, `condition-${tone}`);
|
const surface = rgb(set, `condition-${tone}`);
|
||||||
|
|||||||
Reference in new issue
Block a user