@cosmicdrift/kumiko-renderer-web 0.130.2 → 0.131.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,103 @@
1
+ import type { ListRowViewModel } from "@cosmicdrift/kumiko-headless";
2
+ import {
3
+ type DataTableProps,
4
+ usePrimitives,
5
+ useQuery,
6
+ useTranslation,
7
+ } from "@cosmicdrift/kumiko-renderer";
8
+ import type { ReactNode } from "react";
9
+ import { EmptyState, ErrorState, LoadingState } from "./states";
10
+
11
+ // Query-backed Tabelle für Custom-Screens: ein Widget statt der
12
+ // handgebauten useState+fetch+<table>-Trios. Deklarativ: Query-Type +
13
+ // Spalten rein, Loading/Error/Empty/Render übernimmt das Widget über
14
+ // das DataTable-Primitive (gleiche Optik wie entityList-Screens).
15
+ //
16
+ // ponytail: Zellen rendern über die DataTable-Typ-Formatierung (text/
17
+ // number/money/…), kein render-Prop pro Spalte — für Custom-Zellen die
18
+ // entityList-columnRenderers nutzen oder das Widget erweitern, wenn eine
19
+ // Migration es wirklich braucht.
20
+
21
+ export type QueryTableColumn = {
22
+ readonly field: string;
23
+ /** Translated Header-Label (i18n-Key auflösen liegt beim Caller). */
24
+ readonly label: string;
25
+ /** Field-Type für die Zell-Formatierung. Default "text". */
26
+ readonly type?: string;
27
+ };
28
+
29
+ export type QueryTableProps<TData = unknown> = {
30
+ /** Dispatcher-Query-Type (`<feature>:query:<entity>:<verb>`). */
31
+ readonly query: string;
32
+ readonly payload?: unknown;
33
+ /** SSE-Invalidierung — refetcht bei Entity-Events (useQuery live-mode). */
34
+ readonly live?: boolean;
35
+ readonly columns: readonly QueryTableColumn[];
36
+ /** Rows aus dem Query-Result ziehen. Default: Result selbst als Array. */
37
+ readonly rows?: (data: TData) => readonly Readonly<Record<string, unknown>>[];
38
+ /** Row-Id für Keys/Row-Click. Default: `row.id`, sonst der Index. */
39
+ readonly rowId?: (row: Readonly<Record<string, unknown>>, index: number) => string;
40
+ readonly onRowClick?: DataTableProps["onRowClick"];
41
+ readonly rowActions?: DataTableProps["rowActions"];
42
+ /** Empty-Inhalt — Default ist der Standard-Empty-State der Listen. */
43
+ readonly emptyState?: ReactNode;
44
+ readonly toolbarTitle?: ReactNode;
45
+ readonly toolbarEnd?: ReactNode;
46
+ readonly testId?: string;
47
+ };
48
+
49
+ function defaultRows(data: unknown): readonly Readonly<Record<string, unknown>>[] {
50
+ return Array.isArray(data) ? (data as readonly Readonly<Record<string, unknown>>[]) : [];
51
+ }
52
+
53
+ export function QueryTable<TData = unknown>({
54
+ query,
55
+ payload,
56
+ live = false,
57
+ columns,
58
+ rows,
59
+ rowId,
60
+ onRowClick,
61
+ rowActions,
62
+ emptyState,
63
+ toolbarTitle,
64
+ toolbarEnd,
65
+ testId,
66
+ }: QueryTableProps<TData>): ReactNode {
67
+ const { DataTable } = usePrimitives();
68
+ const t = useTranslation();
69
+ const { data, error, loading, refetch } = useQuery<TData>(query, payload ?? {}, { live });
70
+
71
+ if (loading && data === null) return <LoadingState rows={4} testId={testId} />;
72
+ if (error !== null)
73
+ return <ErrorState error={error} onRetry={() => void refetch()} testId={testId} />;
74
+
75
+ const rawRows = rows !== undefined ? rows(data as TData) : defaultRows(data);
76
+ const vmRows: readonly ListRowViewModel[] = rawRows.map((row, index) => ({
77
+ id:
78
+ rowId !== undefined
79
+ ? rowId(row, index)
80
+ : typeof row["id"] === "string"
81
+ ? row["id"]
82
+ : String(index),
83
+ values: row,
84
+ }));
85
+
86
+ return (
87
+ <DataTable
88
+ columns={columns.map((c) => ({
89
+ field: c.field,
90
+ label: c.label,
91
+ type: c.type ?? "text",
92
+ sortable: false,
93
+ }))}
94
+ rows={vmRows}
95
+ onRowClick={onRowClick}
96
+ rowActions={rowActions}
97
+ emptyState={emptyState ?? <EmptyState title={t("kumiko.list.no-entries")} />}
98
+ toolbarTitle={toolbarTitle}
99
+ toolbarEnd={toolbarEnd}
100
+ testId={testId}
101
+ />
102
+ );
103
+ }
@@ -0,0 +1,30 @@
1
+ import { usePrimitives } from "@cosmicdrift/kumiko-renderer";
2
+ import type { ReactNode } from "react";
3
+
4
+ /** Sektion mit Titel + optionalem Action-Slot (Range-Umschalter, Filter) —
5
+ * ersetzt die wiederholten `<section className={CARD}>…<h2>`-Blöcke in
6
+ * Custom-Screens. Dünner Wrapper über das Card-Primitive. */
7
+ export function SectionCard({
8
+ title,
9
+ subtitle,
10
+ action,
11
+ footer,
12
+ children,
13
+ testId,
14
+ }: {
15
+ readonly title: string;
16
+ readonly subtitle?: string;
17
+ readonly action?: ReactNode;
18
+ readonly footer?: ReactNode;
19
+ readonly children: ReactNode;
20
+ readonly testId?: string;
21
+ }): ReactNode {
22
+ const { Card } = usePrimitives();
23
+ return (
24
+ <Card slots={{ title, subtitle, headerActions: action, footer }} testId={testId}>
25
+ {/* h-full füllt den Card-Body (grow) → ein Kind mit mt-auto kann sich unten
26
+ ankern, damit Card-Reihen über mehrere SectionCards fluchten. */}
27
+ <div className="flex h-full flex-col gap-4">{children}</div>
28
+ </Card>
29
+ );
30
+ }
@@ -0,0 +1,181 @@
1
+ import { usePrimitives } from "@cosmicdrift/kumiko-renderer";
2
+ import type { ReactNode } from "react";
3
+ import { cn } from "../lib/cn";
4
+
5
+ // KPI-Kacheln. `tone` steuert Value-/Chip-Farbe semantisch (default |
6
+ // positive | warn), `accentColor` färbt NUR den Icon-Chip mit einer
7
+ // App-semantischen Farbe (z.B. Finanz-Rollen) — Value/Delta/Sparkline
8
+ // bleiben an `tone`.
9
+
10
+ export type StatTone = "default" | "positive" | "warn";
11
+
12
+ const TONE_CHIP: Record<StatTone, string> = {
13
+ default: "bg-muted text-foreground",
14
+ positive: "bg-primary/10 text-primary",
15
+ warn: "bg-destructive/10 text-destructive",
16
+ };
17
+
18
+ const TONE_VALUE: Record<StatTone, string> = {
19
+ default: "text-foreground",
20
+ positive: "text-primary",
21
+ warn: "text-destructive",
22
+ };
23
+
24
+ export type StatDelta = {
25
+ readonly value: string;
26
+ readonly direction: "up" | "down";
27
+ readonly tone?: StatTone;
28
+ };
29
+
30
+ // Inline-SVG-Sparkline (kein Chart-Dep für eine 28px-Kurve): Linie +
31
+ // schwacher Flächen-Verlauf, Farbe via currentColor (Caller setzt text-tone).
32
+ export function Sparkline({
33
+ points,
34
+ className,
35
+ }: {
36
+ readonly points: readonly number[];
37
+ readonly className?: string;
38
+ }): ReactNode {
39
+ if (points.length < 2) return null;
40
+ const w = 100;
41
+ const h = 28;
42
+ const min = Math.min(...points);
43
+ const max = Math.max(...points);
44
+ const span = max - min || 1;
45
+ const line = points
46
+ .map((p, i) => {
47
+ const x = (i / (points.length - 1)) * w;
48
+ const y = h - ((p - min) / span) * h;
49
+ return `${i === 0 ? "M" : "L"}${x.toFixed(1)},${y.toFixed(1)}`;
50
+ })
51
+ .join(" ");
52
+ return (
53
+ <svg
54
+ viewBox={`0 0 ${w} ${h}`}
55
+ preserveAspectRatio="none"
56
+ className={className ?? "h-7 w-full"}
57
+ aria-hidden="true"
58
+ >
59
+ <path d={`${line} L${w},${h} L0,${h} Z`} fill="currentColor" fillOpacity={0.12} />
60
+ <path
61
+ d={line}
62
+ fill="none"
63
+ stroke="currentColor"
64
+ strokeWidth={1.5}
65
+ strokeLinecap="round"
66
+ strokeLinejoin="round"
67
+ vectorEffect="non-scaling-stroke"
68
+ />
69
+ </svg>
70
+ );
71
+ }
72
+
73
+ /** Icon-tragende Kennzahl-Kachel mit optionalem Delta-Chip, Trend-Zeile
74
+ * und Sparkline. `icon` ist ein fertiger Knoten (App liefert ihr SVG). */
75
+ export function StatCard({
76
+ icon,
77
+ label,
78
+ value,
79
+ sub,
80
+ tone = "default",
81
+ accentColor,
82
+ delta,
83
+ trend,
84
+ spark,
85
+ testId,
86
+ }: {
87
+ readonly icon?: ReactNode;
88
+ readonly label: string;
89
+ readonly value: string;
90
+ readonly sub?: string;
91
+ readonly tone?: StatTone;
92
+ readonly accentColor?: string;
93
+ readonly delta?: StatDelta;
94
+ readonly trend?: string;
95
+ readonly spark?: readonly number[];
96
+ readonly testId?: string;
97
+ }): ReactNode {
98
+ const { Card } = usePrimitives();
99
+ return (
100
+ <Card options={{ padded: false }} className="p-4" testId={testId}>
101
+ <div className="flex items-center justify-between gap-2">
102
+ <div className="flex items-center gap-2 text-muted-foreground">
103
+ {icon !== undefined && (
104
+ <span
105
+ className={cn(
106
+ "flex size-7 items-center justify-center rounded-lg",
107
+ accentColor === undefined && TONE_CHIP[tone],
108
+ )}
109
+ style={
110
+ accentColor === undefined
111
+ ? undefined
112
+ : {
113
+ color: accentColor,
114
+ backgroundColor: `color-mix(in srgb, ${accentColor} 12%, transparent)`,
115
+ }
116
+ }
117
+ >
118
+ {icon}
119
+ </span>
120
+ )}
121
+ <span className="text-xs font-medium">{label}</span>
122
+ </div>
123
+ {delta !== undefined && (
124
+ <span
125
+ className={cn(
126
+ "inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums",
127
+ TONE_CHIP[delta.tone ?? tone],
128
+ )}
129
+ >
130
+ {delta.direction === "up" ? "↑" : "↓"}
131
+ {delta.value}
132
+ </span>
133
+ )}
134
+ </div>
135
+ <div className="mt-2 text-xl font-semibold tabular-nums text-foreground">{value}</div>
136
+ {sub !== undefined && <div className="mt-0.5 text-xs text-muted-foreground">{sub}</div>}
137
+ {trend !== undefined && (
138
+ <div className="mt-0.5 text-xs font-medium text-foreground/80">{trend}</div>
139
+ )}
140
+ {spark !== undefined && (
141
+ <Sparkline points={spark} className={cn("mt-2 h-7 w-full", TONE_VALUE[tone])} />
142
+ )}
143
+ </Card>
144
+ );
145
+ }
146
+
147
+ /** Kompakte Kennzahl-Kachel (ohne Icon) — für dichte KPI-Raster.
148
+ * StatCard ist die icon-tragende Variante. */
149
+ export function MiniStat({
150
+ label,
151
+ value,
152
+ tone = "default",
153
+ emphasize = false,
154
+ testId,
155
+ }: {
156
+ readonly label: string;
157
+ readonly value: string;
158
+ readonly tone?: StatTone;
159
+ readonly emphasize?: boolean;
160
+ readonly testId?: string;
161
+ }): ReactNode {
162
+ const { Card } = usePrimitives();
163
+ return (
164
+ <Card
165
+ options={{ padded: false }}
166
+ className={cn("p-3", emphasize && "ring-1 ring-primary/30")}
167
+ testId={testId}
168
+ >
169
+ <div className="text-xs text-muted-foreground">{label}</div>
170
+ <div
171
+ className={cn(
172
+ "mt-0.5 font-semibold tabular-nums",
173
+ TONE_VALUE[tone],
174
+ emphasize ? "text-lg" : "text-sm",
175
+ )}
176
+ >
177
+ {value}
178
+ </div>
179
+ </Card>
180
+ );
181
+ }
@@ -0,0 +1,89 @@
1
+ import type { DispatcherError } from "@cosmicdrift/kumiko-headless";
2
+ import { dispatcherErrorText, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
3
+ import type { ReactNode } from "react";
4
+ import { cn } from "../lib/cn";
5
+ import { Skeleton } from "../ui/skeleton";
6
+
7
+ /** Leerer-Zustand mit optionalem Icon + CTA — die dashed-Box-Optik der
8
+ * entityList-Empty-States, als Standalone-Widget für Custom-Screens. */
9
+ export function EmptyState({
10
+ icon,
11
+ title,
12
+ description,
13
+ action,
14
+ testId,
15
+ }: {
16
+ readonly icon?: ReactNode;
17
+ readonly title: string;
18
+ readonly description?: string;
19
+ readonly action?: ReactNode;
20
+ readonly testId?: string;
21
+ }): ReactNode {
22
+ return (
23
+ <div
24
+ data-testid={testId}
25
+ className="flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-6 py-10 text-center"
26
+ >
27
+ {icon !== undefined && <div className="text-muted-foreground">{icon}</div>}
28
+ <div className="text-sm font-medium">{title}</div>
29
+ {description !== undefined && (
30
+ <div className="text-sm text-muted-foreground">{description}</div>
31
+ )}
32
+ {action !== undefined && <div className="mt-2">{action}</div>}
33
+ </div>
34
+ );
35
+ }
36
+
37
+ /** Lade-Zustand: Skeleton-Zeilen in Card-Optik. `rows` steuert die Höhe. */
38
+ export function LoadingState({
39
+ rows = 3,
40
+ className,
41
+ testId,
42
+ }: {
43
+ readonly rows?: number;
44
+ readonly className?: string;
45
+ readonly testId?: string;
46
+ }): ReactNode {
47
+ const t = useTranslation();
48
+ return (
49
+ <output
50
+ data-testid={testId}
51
+ aria-label={t("kumiko.widget.loading")}
52
+ className={cn("flex w-full flex-col gap-2", className)}
53
+ >
54
+ {Array.from({ length: rows }, (_, i) => (
55
+ // biome-ignore lint/suspicious/noArrayIndexKey: statische Skeleton-Liste, Index ist stabil
56
+ <Skeleton key={i} className="h-9 w-full" />
57
+ ))}
58
+ </output>
59
+ );
60
+ }
61
+
62
+ /** Fehler-Zustand auf Basis des Banner-Primitives, mit Retry-Button. */
63
+ export function ErrorState({
64
+ error,
65
+ onRetry,
66
+ testId,
67
+ }: {
68
+ readonly error: DispatcherError;
69
+ readonly onRetry?: () => void;
70
+ readonly testId?: string;
71
+ }): ReactNode {
72
+ const { Banner, Button } = usePrimitives();
73
+ const t = useTranslation();
74
+ return (
75
+ <Banner
76
+ variant="error"
77
+ testId={testId}
78
+ actions={
79
+ onRetry !== undefined ? (
80
+ <Button variant="secondary" onClick={onRetry}>
81
+ {t("kumiko.actions.reload")}
82
+ </Button>
83
+ ) : undefined
84
+ }
85
+ >
86
+ {t("kumiko.widget.error.title")} {dispatcherErrorText(error, t)}
87
+ </Banner>
88
+ );
89
+ }
@@ -0,0 +1,51 @@
1
+ import type { ReactNode } from "react";
2
+ import { cn } from "../lib/cn";
3
+
4
+ // Semantische Status-Tones — eine Farb-Familie für Component-Status,
5
+ // Incident-Status, Severity, Job-Zustände. Farben kommen aus den
6
+ // --color-status-* Theme-Tokens (styles.css), Apps überschreiben zentral.
7
+ export type StatusTone = "ok" | "warn" | "bad" | "critical" | "muted";
8
+
9
+ export const STATUS_TONE_TEXT: Record<StatusTone, string> = {
10
+ ok: "text-status-ok",
11
+ warn: "text-status-warn",
12
+ bad: "text-status-bad",
13
+ critical: "text-status-critical",
14
+ muted: "text-muted-foreground",
15
+ };
16
+
17
+ const TONE_PILL: Record<StatusTone, string> = {
18
+ ok: "bg-status-ok/10 text-status-ok",
19
+ warn: "bg-status-warn/10 text-status-warn",
20
+ bad: "bg-status-bad/10 text-status-bad",
21
+ critical: "bg-status-critical/15 text-status-critical",
22
+ muted: "bg-muted text-muted-foreground",
23
+ };
24
+
25
+ /** Pill-Badge für Status-Werte. Caller mappt Domain-Werte → Tone
26
+ * (z.B. operational→ok, investigating→warn) und liefert das
27
+ * translated Label als children. */
28
+ export function StatusBadge({
29
+ tone,
30
+ children,
31
+ className,
32
+ testId,
33
+ }: {
34
+ readonly tone: StatusTone;
35
+ readonly children: ReactNode;
36
+ readonly className?: string;
37
+ readonly testId?: string;
38
+ }): ReactNode {
39
+ return (
40
+ <span
41
+ data-testid={testId}
42
+ className={cn(
43
+ "inline-block rounded-xl px-2.5 py-1 text-xs font-semibold",
44
+ TONE_PILL[tone],
45
+ className,
46
+ )}
47
+ >
48
+ {children}
49
+ </span>
50
+ );
51
+ }