@cosmicdrift/kumiko-renderer-web 1.0.0 → 2.0.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.
Files changed (79) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/action-menu.test.tsx +75 -0
  3. package/src/__tests__/breadcrumb.test.tsx +50 -0
  4. package/src/__tests__/create-app.test.tsx +171 -1
  5. package/src/__tests__/dashboard-screen.test.tsx +393 -0
  6. package/src/__tests__/default-app-shell.test.tsx +47 -1
  7. package/src/__tests__/form-action-bar.test.tsx +9 -0
  8. package/src/__tests__/kumiko-screen.test.tsx +155 -1
  9. package/src/__tests__/lightbox.test.tsx +46 -0
  10. package/src/__tests__/nav-tree.test.tsx +122 -0
  11. package/src/__tests__/primitives.test.tsx +32 -1
  12. package/src/__tests__/profile-menu.test.tsx +29 -0
  13. package/src/__tests__/projection-detail.test.tsx +101 -0
  14. package/src/__tests__/projection-list-actions.test.tsx +131 -0
  15. package/src/__tests__/render-list.test.tsx +11 -3
  16. package/src/__tests__/test-utils.tsx +1 -0
  17. package/src/__tests__/toast.test.tsx +9 -9
  18. package/src/__tests__/use-mutation.test.tsx +86 -0
  19. package/src/__tests__/workspace-shell.test.tsx +102 -47
  20. package/src/__tests__/xss-safety.test.tsx +62 -0
  21. package/src/app/__tests__/first-open-screen-qn.test.ts +77 -0
  22. package/src/app/browser-locale.ts +1 -1
  23. package/src/app/create-app.tsx +96 -29
  24. package/src/app/dashboard-body.tsx +454 -0
  25. package/src/index.ts +75 -1
  26. package/src/layout/__tests__/nav-registry.test.ts +105 -0
  27. package/src/layout/__tests__/shell-breadcrumb.test.ts +82 -0
  28. package/src/layout/default-app-shell.tsx +9 -71
  29. package/src/layout/nav-tree.tsx +167 -37
  30. package/src/layout/shell-breadcrumb.ts +46 -0
  31. package/src/layout/shell-header.tsx +114 -0
  32. package/src/layout/workspace-shell.tsx +45 -18
  33. package/src/primitives/__tests__/data-table-logic.test.ts +27 -0
  34. package/src/primitives/__tests__/input-testid.test.tsx +45 -0
  35. package/src/primitives/__tests__/money-input.test.tsx +13 -1
  36. package/src/primitives/dialog.tsx +47 -76
  37. package/src/primitives/index.tsx +215 -37
  38. package/src/primitives/layout.tsx +39 -0
  39. package/src/primitives/lightbox.tsx +39 -0
  40. package/src/primitives/modal-shell.tsx +62 -0
  41. package/src/primitives/money-input.tsx +20 -10
  42. package/src/primitives/toast.tsx +14 -9
  43. package/src/sse/live-events.ts +6 -1
  44. package/src/styles.css +25 -0
  45. package/src/tokens.ts +4 -0
  46. package/src/ui/__tests__/avatar.test.tsx +66 -0
  47. package/src/ui/__tests__/sheet.test.tsx +102 -0
  48. package/src/version/__tests__/update-checker.test.tsx +115 -0
  49. package/src/widgets/__tests__/ai-text-field.test.tsx +267 -0
  50. package/src/widgets/__tests__/drawer.test.tsx +35 -0
  51. package/src/widgets/__tests__/form-kit.test.tsx +368 -0
  52. package/src/widgets/__tests__/infinity-list.test.tsx +132 -0
  53. package/src/widgets/__tests__/query-table.test.tsx +118 -0
  54. package/src/widgets/__tests__/result-panel.test.tsx +117 -0
  55. package/src/widgets/__tests__/widgets.test.tsx +243 -0
  56. package/src/widgets/ai-text-field.tsx +370 -0
  57. package/src/widgets/charts.tsx +228 -0
  58. package/src/widgets/collapsible-section.tsx +42 -0
  59. package/src/widgets/detail-list.tsx +43 -0
  60. package/src/widgets/drawer.tsx +49 -0
  61. package/src/widgets/feed-list.tsx +35 -0
  62. package/src/widgets/form-fields.tsx +326 -0
  63. package/src/widgets/index.ts +59 -0
  64. package/src/widgets/infinity-list.tsx +112 -0
  65. package/src/widgets/mode-switch.tsx +41 -0
  66. package/src/widgets/progress-bar.tsx +27 -0
  67. package/src/widgets/progress-list.tsx +38 -0
  68. package/src/widgets/query-table.tsx +103 -0
  69. package/src/widgets/result-panel.tsx +251 -0
  70. package/src/widgets/section-card.tsx +30 -0
  71. package/src/widgets/stat.tsx +181 -0
  72. package/src/widgets/states.tsx +89 -0
  73. package/src/widgets/status-badge.tsx +51 -0
  74. package/src/widgets/use-draft.ts +40 -0
  75. package/src/ui/card.tsx +0 -93
  76. package/src/ui/collapsible.tsx +0 -34
  77. package/src/ui/dropdown-menu.tsx +0 -258
  78. package/src/ui/select.tsx +0 -191
  79. /package/src/__tests__/{download.test.ts → download.test.tsx} +0 -0
@@ -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,251 @@
1
+ import { usePrimitives } from "@cosmicdrift/kumiko-renderer";
2
+ import type { ReactNode } from "react";
3
+ import { cn } from "../lib/cn";
4
+ import { DetailList } from "./detail-list";
5
+ import { SectionCard } from "./section-card";
6
+
7
+ /** Ergebnis-Sektion eines Rechners: SectionCard mit Empty-Zustand (Banner)
8
+ * oder Kennzahl-Liste (DetailList) + optionalen Extras (Tabelle, Hinweise).
9
+ * Ersetzt die handgebauten `<dl>`/Banner-Blöcke in Custom-Screens. */
10
+ export function ResultPanel({
11
+ title,
12
+ subtitle,
13
+ empty,
14
+ emptyText,
15
+ rows,
16
+ footer,
17
+ children,
18
+ testId,
19
+ }: {
20
+ readonly title: string;
21
+ readonly subtitle?: string;
22
+ readonly empty?: boolean;
23
+ readonly emptyText?: ReactNode;
24
+ readonly rows?: readonly {
25
+ readonly label: string;
26
+ readonly value: ReactNode;
27
+ readonly emphasize?: boolean;
28
+ }[];
29
+ /** Action-Slot am Karten-Fuß (z.B. „In Finanzierung übernehmen"). */
30
+ readonly footer?: ReactNode;
31
+ readonly children?: ReactNode;
32
+ readonly testId?: string;
33
+ }): ReactNode {
34
+ const { Banner } = usePrimitives();
35
+ return (
36
+ <SectionCard title={title} subtitle={subtitle} footer={footer} testId={testId}>
37
+ {empty === true ? (
38
+ <Banner variant="info" padded>
39
+ {emptyText}
40
+ </Banner>
41
+ ) : (
42
+ <div className="flex flex-col gap-4">
43
+ {rows !== undefined && rows.length > 0 && <DetailList rows={rows} />}
44
+ {children}
45
+ </div>
46
+ )}
47
+ </SectionCard>
48
+ );
49
+ }
50
+
51
+ export interface ResultColumn<Row> {
52
+ readonly header: string;
53
+ readonly align?: "left" | "right";
54
+ readonly cell: (row: Row) => ReactNode;
55
+ }
56
+
57
+ export interface ResultTableFooterRow {
58
+ readonly label: string;
59
+ readonly value: ReactNode;
60
+ readonly emphasize?: boolean;
61
+ }
62
+
63
+ /** Statische, prop-getriebene Ergebnistabelle für berechnete Zeilen (Tranchen,
64
+ * Szenarien). Nimmt dem Screen die `<table>`+tabular-nums-Ketten ab.
65
+ * ponytail: bewusst die minimale Read-only-Tabelle — für Sort/Pager/Facets
66
+ * stattdessen usePrimitives().DataTable bzw. QueryTable nutzen. */
67
+ export function ResultTable<Row>({
68
+ columns,
69
+ rows,
70
+ rowKey,
71
+ testId,
72
+ className,
73
+ card,
74
+ footer,
75
+ }: {
76
+ readonly columns: readonly ResultColumn<Row>[];
77
+ readonly rows: readonly Row[];
78
+ readonly rowKey: (row: Row, index: number) => string;
79
+ readonly testId?: string;
80
+ readonly className?: string;
81
+ /** Rahmen-Look wie die CRUD-Liste (DataTable): gerundeter Border-Container +
82
+ * bg-muted-Header-Band. Default = bare (nur die Tabelle, ohne Container). */
83
+ readonly card?: boolean;
84
+ /** Summary rows (Subtotal/Tax/Total) rendered as real `<tr>`s in the same
85
+ * `<table>` instead of a separate flex-div next to it — guarantees
86
+ * column-alignment to the last (typically "Amount") column instead of
87
+ * approximating it with an independent layout. */
88
+ readonly footer?: readonly ResultTableFooterRow[];
89
+ }): ReactNode {
90
+ const table = (
91
+ <div className="overflow-x-auto">
92
+ <table data-testid={testId} className={cn("w-full text-sm", className)}>
93
+ <thead className={cn(card === true && "bg-muted")}>
94
+ <tr className="border-b text-left text-muted-foreground">
95
+ {columns.map((col) => (
96
+ <th
97
+ key={col.header}
98
+ className={cn(
99
+ "py-1.5 font-medium",
100
+ card === true && "px-3",
101
+ col.align === "right" && "text-right",
102
+ )}
103
+ >
104
+ {col.header}
105
+ </th>
106
+ ))}
107
+ </tr>
108
+ </thead>
109
+ <tbody>
110
+ {rows.map((row, i) => (
111
+ <tr key={rowKey(row, i)} className="border-b last:border-0">
112
+ {columns.map((col) => (
113
+ <td
114
+ key={col.header}
115
+ className={cn(
116
+ "py-1.5",
117
+ card === true && "px-3",
118
+ col.align === "right" && "text-right tabular-nums",
119
+ )}
120
+ >
121
+ {col.cell(row)}
122
+ </td>
123
+ ))}
124
+ </tr>
125
+ ))}
126
+ {footer?.map((row, i) => (
127
+ <tr
128
+ key={row.label}
129
+ className={cn(i === 0 && "border-t", row.emphasize === true && "font-semibold")}
130
+ >
131
+ <td
132
+ colSpan={columns.length > 1 ? Math.max(columns.length - 1, 1) : undefined}
133
+ className={cn(
134
+ "py-1.5 text-muted-foreground",
135
+ card === true && "px-3",
136
+ row.emphasize === true && "text-foreground",
137
+ columns.length === 1 && "flex items-center justify-between gap-4",
138
+ )}
139
+ >
140
+ {row.label}
141
+ {columns.length === 1 && (
142
+ <span
143
+ className={cn(
144
+ "text-right tabular-nums",
145
+ row.emphasize === true && "text-foreground",
146
+ )}
147
+ >
148
+ {row.value}
149
+ </span>
150
+ )}
151
+ </td>
152
+ {columns.length > 1 && (
153
+ <td className={cn("py-1.5 text-right tabular-nums", card === true && "px-3")}>
154
+ {row.value}
155
+ </td>
156
+ )}
157
+ </tr>
158
+ ))}
159
+ </tbody>
160
+ </table>
161
+ </div>
162
+ );
163
+ return card === true ? (
164
+ <div className="overflow-hidden rounded-lg border bg-card">{table}</div>
165
+ ) : (
166
+ table
167
+ );
168
+ }
169
+
170
+ export interface ComparisonMetric<Col> {
171
+ readonly label: string;
172
+ readonly value: (col: Col, index: number) => ReactNode;
173
+ /** Index der besten Spalte für diese Zeile (hervorgehoben); -1 = keine. */
174
+ readonly bestIndex?: (cols: readonly Col[]) => number;
175
+ }
176
+
177
+ /** Transponierte Vergleichstabelle: Zeile = Kennzahl, Spalte = Variante, je
178
+ * Kennzahl optional die beste Spalte hervorgehoben. Für Szenario-/Angebots-
179
+ * Vergleiche, wo ResultTable (Zeile=Datensatz) nicht passt. */
180
+ export function ComparisonTable<Col>({
181
+ columns,
182
+ columnHeader,
183
+ columnKey,
184
+ metrics,
185
+ metricLabel,
186
+ testId,
187
+ className,
188
+ card,
189
+ }: {
190
+ readonly columns: readonly Col[];
191
+ readonly columnHeader: (col: Col, index: number) => string;
192
+ readonly columnKey: (col: Col, index: number) => string;
193
+ readonly metrics: readonly ComparisonMetric<Col>[];
194
+ readonly metricLabel: string;
195
+ readonly testId?: string;
196
+ readonly className?: string;
197
+ /** Rahmen-Look wie die CRUD-Liste (DataTable): gerundeter Border-Container +
198
+ * bg-muted-Header-Band. Default = bare. */
199
+ readonly card?: boolean;
200
+ }): ReactNode {
201
+ const table = (
202
+ <div className="overflow-x-auto">
203
+ <table data-testid={testId} className={cn("w-full min-w-[24rem] text-sm", className)}>
204
+ <thead className={cn(card === true && "bg-muted")}>
205
+ <tr className="border-b text-left text-muted-foreground">
206
+ <th className={cn("py-1.5 font-medium", card === true && "px-3")}>{metricLabel}</th>
207
+ {columns.map((col, i) => (
208
+ <th
209
+ key={columnKey(col, i)}
210
+ className={cn("py-1.5 text-right font-medium", card === true && "px-3")}
211
+ >
212
+ {columnHeader(col, i)}
213
+ </th>
214
+ ))}
215
+ </tr>
216
+ </thead>
217
+ <tbody>
218
+ {metrics.map((metric) => {
219
+ const best = metric.bestIndex !== undefined ? metric.bestIndex(columns) : -1;
220
+ return (
221
+ <tr key={metric.label} className="border-b last:border-0">
222
+ <td className={cn("py-1.5 text-muted-foreground", card === true && "px-3")}>
223
+ {metric.label}
224
+ </td>
225
+ {columns.map((col, i) => (
226
+ <td
227
+ key={columnKey(col, i)}
228
+ className={cn("py-1.5 text-right tabular-nums", card === true && "px-3")}
229
+ >
230
+ {i === best ? (
231
+ <span className="inline-block rounded bg-primary/10 px-2 py-0.5 font-semibold text-primary">
232
+ {metric.value(col, i)}
233
+ </span>
234
+ ) : (
235
+ metric.value(col, i)
236
+ )}
237
+ </td>
238
+ ))}
239
+ </tr>
240
+ );
241
+ })}
242
+ </tbody>
243
+ </table>
244
+ </div>
245
+ );
246
+ return card === true ? (
247
+ <div className="overflow-hidden rounded-lg border bg-card">{table}</div>
248
+ ) : (
249
+ table
250
+ );
251
+ }
@@ -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
+ }