@cosmicdrift/kumiko-renderer-web 0.1.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 (58) hide show
  1. package/package.json +63 -0
  2. package/src/__tests__/avatar.test.tsx +34 -0
  3. package/src/__tests__/combobox.test.tsx +240 -0
  4. package/src/__tests__/config-edit.test.tsx +172 -0
  5. package/src/__tests__/create-app.test.tsx +261 -0
  6. package/src/__tests__/date-input.test.tsx +91 -0
  7. package/src/__tests__/default-app-shell.test.tsx +60 -0
  8. package/src/__tests__/dispatcher-context.test.tsx +101 -0
  9. package/src/__tests__/dispatcher-status-wiring.test.tsx +119 -0
  10. package/src/__tests__/kumiko-screen.test.tsx +1014 -0
  11. package/src/__tests__/language-switcher.test.tsx +100 -0
  12. package/src/__tests__/money-input.test.tsx +232 -0
  13. package/src/__tests__/nav-base-path.test.tsx +388 -0
  14. package/src/__tests__/nav-search-params.test.tsx +88 -0
  15. package/src/__tests__/nav-tree.test.tsx +183 -0
  16. package/src/__tests__/nav.test.tsx +253 -0
  17. package/src/__tests__/primitives.test.tsx +936 -0
  18. package/src/__tests__/render-edit.test.tsx +178 -0
  19. package/src/__tests__/render-list-column-renderer.test.tsx +124 -0
  20. package/src/__tests__/render-list-debounce.test.tsx +128 -0
  21. package/src/__tests__/render-list.test.tsx +151 -0
  22. package/src/__tests__/sidebar.test.tsx +59 -0
  23. package/src/__tests__/test-utils.tsx +144 -0
  24. package/src/__tests__/theme-toggle.test.tsx +101 -0
  25. package/src/__tests__/toast.test.tsx +162 -0
  26. package/src/__tests__/use-form.test.tsx +112 -0
  27. package/src/__tests__/use-query-live.test.tsx +152 -0
  28. package/src/__tests__/use-query.test.tsx +88 -0
  29. package/src/__tests__/use-store.test.tsx +139 -0
  30. package/src/__tests__/workspace-shell.test.tsx +772 -0
  31. package/src/app/browser-locale.ts +85 -0
  32. package/src/app/client-plugin.tsx +63 -0
  33. package/src/app/create-app.tsx +380 -0
  34. package/src/app/nav.tsx +226 -0
  35. package/src/index.ts +137 -0
  36. package/src/layout/app-layout.tsx +35 -0
  37. package/src/layout/avatar.tsx +93 -0
  38. package/src/layout/default-app-shell.tsx +74 -0
  39. package/src/layout/language-switcher.tsx +101 -0
  40. package/src/layout/nav-tree.tsx +281 -0
  41. package/src/layout/profile-menu.tsx +40 -0
  42. package/src/layout/sidebar.tsx +65 -0
  43. package/src/layout/theme-toggle.tsx +44 -0
  44. package/src/layout/topbar.tsx +22 -0
  45. package/src/layout/workspace-shell.tsx +282 -0
  46. package/src/layout/workspace-switcher.tsx +62 -0
  47. package/src/lib/cn.ts +10 -0
  48. package/src/primitives/action-menu.tsx +111 -0
  49. package/src/primitives/combobox.tsx +261 -0
  50. package/src/primitives/date-input.tsx +165 -0
  51. package/src/primitives/dialog.tsx +119 -0
  52. package/src/primitives/dropdown-menu.tsx +103 -0
  53. package/src/primitives/index.tsx +1271 -0
  54. package/src/primitives/money-input.tsx +192 -0
  55. package/src/primitives/toast.tsx +166 -0
  56. package/src/sse/live-events.ts +90 -0
  57. package/src/styles.css +113 -0
  58. package/src/tokens.ts +63 -0
@@ -0,0 +1,1271 @@
1
+ // shadcn+Tailwind Default-Primitives für den Web-Renderer.
2
+ // Konsumieren den Primitives-Contract aus `@cosmicdrift/kumiko-renderer`. Keine
3
+ // useTokens()-Aufrufe — die Farben kommen aus den Tailwind-Klassen
4
+ // die auf die shadcn-CSS-Variablen referenzieren.
5
+ //
6
+ // Muster: pro Primitive eine Tailwind-Klassen-Komposition,
7
+ // Konfigurierbarkeit über `class-variance-authority` für variant-
8
+ // basierte Stile. Radix-UI-Unterbau für interaktive Elemente (Modal,
9
+ // Dropdown etc. kommen später).
10
+
11
+ import type { ListRowViewModel } from "@cosmicdrift/kumiko-headless";
12
+ import type {
13
+ DataTableRowAction,
14
+ DataTableSort,
15
+ DataTableSortDir,
16
+ } from "@cosmicdrift/kumiko-renderer";
17
+ import {
18
+ type BannerProps,
19
+ type ButtonProps,
20
+ type CorePrimitives,
21
+ type DataTableProps,
22
+ type FieldProps,
23
+ type FormProps,
24
+ type GridCellProps,
25
+ type GridProps,
26
+ type HeadingProps,
27
+ type InputProps,
28
+ type SectionProps,
29
+ type TextProps,
30
+ useColumnRenderer,
31
+ useTranslation,
32
+ } from "@cosmicdrift/kumiko-renderer";
33
+ import * as LabelPrimitive from "@radix-ui/react-label";
34
+ import { cva } from "class-variance-authority";
35
+ import {
36
+ ArrowDown,
37
+ ArrowUp,
38
+ ArrowUpDown,
39
+ ChevronLeft,
40
+ ChevronRight,
41
+ Loader2,
42
+ MoreHorizontal,
43
+ } from "lucide-react";
44
+ import { type ChangeEvent, type ReactNode, useEffect, useRef, useState } from "react";
45
+ import { cn } from "../lib/cn";
46
+ import { ComboboxInput } from "./combobox";
47
+ import { DateInput } from "./date-input";
48
+ import { DefaultDialog } from "./dialog";
49
+ import {
50
+ DropdownMenu,
51
+ DropdownMenuContent,
52
+ DropdownMenuItem,
53
+ DropdownMenuTrigger,
54
+ } from "./dropdown-menu";
55
+ import { MoneyInput } from "./money-input";
56
+
57
+ // ---- Button ----
58
+
59
+ const buttonVariants = cva(
60
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors " +
61
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring " +
62
+ "disabled:pointer-events-none disabled:opacity-50",
63
+ {
64
+ variants: {
65
+ variant: {
66
+ primary: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
67
+ secondary:
68
+ "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
69
+ danger: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
70
+ },
71
+ },
72
+ defaultVariants: { variant: "primary" },
73
+ },
74
+ );
75
+
76
+ function DefaultButton({
77
+ type = "button",
78
+ onClick,
79
+ disabled,
80
+ loading,
81
+ variant = "primary",
82
+ children,
83
+ testId,
84
+ }: ButtonProps): ReactNode {
85
+ return (
86
+ <button
87
+ type={type}
88
+ onClick={onClick}
89
+ disabled={disabled === true || loading === true}
90
+ data-testid={testId}
91
+ data-loading={loading === true ? "true" : undefined}
92
+ className={cn(buttonVariants({ variant }), "h-9 px-4 py-2")}
93
+ >
94
+ {loading === true ? <Loader2 className="size-4 animate-spin" aria-hidden="true" /> : children}
95
+ </button>
96
+ );
97
+ }
98
+
99
+ // ---- Banner (shadcn: Alert) ----
100
+
101
+ function DefaultBanner({
102
+ variant = "info",
103
+ children,
104
+ actions,
105
+ padded,
106
+ testId,
107
+ }: BannerProps): ReactNode {
108
+ const isError = variant === "error";
109
+ const banner = (
110
+ <div
111
+ data-testid={testId}
112
+ role={isError ? "alert" : undefined}
113
+ data-variant={variant}
114
+ className={cn(
115
+ "relative w-full rounded-lg border px-4 py-3 text-sm flex items-center gap-3",
116
+ isError
117
+ ? "border-destructive/50 text-destructive bg-destructive/10 dark:border-destructive"
118
+ : "bg-card text-card-foreground",
119
+ )}
120
+ >
121
+ <div className="flex-1">{children}</div>
122
+ {actions !== undefined && <div data-slot="actions">{actions}</div>}
123
+ </div>
124
+ );
125
+ // Page-State: Banner sitzt alleine im Main (das kein Padding mehr
126
+ // hat). Wrapper gibt 24px Außenabstand damit der Banner nicht edge-
127
+ // to-edge an Sidebar/Browser klebt.
128
+ return padded === true ? <div className="p-6">{banner}</div> : banner;
129
+ }
130
+
131
+ // ---- Field (Label + Error) ----
132
+
133
+ function DefaultField({ id, label, required, issues, children, testId }: FieldProps): ReactNode {
134
+ const t = useTranslation();
135
+ const hasError = issues !== undefined && issues.length > 0;
136
+ return (
137
+ <div data-testid={testId} className="flex flex-col gap-1.5">
138
+ <LabelPrimitive.Root
139
+ htmlFor={id}
140
+ className={cn(
141
+ // peer-disabled-Sentinel: shadcn-Pattern — wenn das assoziierte
142
+ // Input disabled ist (peer + disabled-Klasse), wird das Label
143
+ // mitgrayout. Funktioniert weil Radix-Label das nativ-htmlFor-
144
+ // verlinkte Element als Peer betrachtet.
145
+ "text-sm font-medium leading-none",
146
+ "peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
147
+ hasError ? "text-destructive" : "text-foreground",
148
+ )}
149
+ >
150
+ {label}
151
+ {required === true && <span className="ml-0.5 text-destructive">*</span>}
152
+ </LabelPrimitive.Root>
153
+ {children}
154
+ {hasError && (
155
+ <div
156
+ role="alert"
157
+ data-testid={testId !== undefined ? `${testId}-errors` : undefined}
158
+ className="text-xs text-destructive"
159
+ >
160
+ {issues.map((issue) => (
161
+ <div key={`${issue.path}:${issue.code}`}>{t(issue.i18nKey)}</div>
162
+ ))}
163
+ </div>
164
+ )}
165
+ </div>
166
+ );
167
+ }
168
+
169
+ // ---- Input ----
170
+
171
+ const inputClassBase =
172
+ "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm " +
173
+ "transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium " +
174
+ "placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring " +
175
+ "disabled:cursor-not-allowed disabled:opacity-50";
176
+
177
+ function DefaultInput(props: InputProps): ReactNode {
178
+ const errorClass =
179
+ props.hasError === true ? "border-destructive focus-visible:ring-destructive" : "";
180
+ const common = {
181
+ id: props.id,
182
+ name: props.name,
183
+ disabled: props.disabled,
184
+ "aria-required": props.required,
185
+ "aria-invalid": props.hasError === true ? true : undefined,
186
+ } as const;
187
+ switch (props.kind) {
188
+ case "text":
189
+ return (
190
+ <input
191
+ type="text"
192
+ {...common}
193
+ value={props.value}
194
+ onChange={(e: ChangeEvent<HTMLInputElement>) => props.onChange(e.target.value)}
195
+ {...(props.placeholder !== undefined && { placeholder: props.placeholder })}
196
+ {...(props.autoComplete !== undefined && { autoComplete: props.autoComplete })}
197
+ className={cn(inputClassBase, errorClass)}
198
+ />
199
+ );
200
+ case "email":
201
+ return (
202
+ <input
203
+ type="email"
204
+ {...common}
205
+ value={props.value}
206
+ onChange={(e: ChangeEvent<HTMLInputElement>) => props.onChange(e.target.value)}
207
+ {...(props.placeholder !== undefined && { placeholder: props.placeholder })}
208
+ autoComplete={props.autoComplete ?? "email"}
209
+ className={cn(inputClassBase, errorClass)}
210
+ />
211
+ );
212
+ case "password":
213
+ return (
214
+ <input
215
+ type="password"
216
+ {...common}
217
+ value={props.value}
218
+ onChange={(e: ChangeEvent<HTMLInputElement>) => props.onChange(e.target.value)}
219
+ autoComplete={props.autoComplete ?? "current-password"}
220
+ className={cn(inputClassBase, errorClass)}
221
+ />
222
+ );
223
+ case "number":
224
+ return (
225
+ <input
226
+ type="number"
227
+ {...common}
228
+ value={props.value}
229
+ onChange={(e: ChangeEvent<HTMLInputElement>) => {
230
+ const v = e.target.value;
231
+ props.onChange(v === "" ? undefined : Number(v));
232
+ }}
233
+ className={cn(inputClassBase, "text-right tabular-nums", errorClass)}
234
+ />
235
+ );
236
+ case "boolean":
237
+ return (
238
+ <input
239
+ type="checkbox"
240
+ {...common}
241
+ checked={props.value}
242
+ onChange={(e: ChangeEvent<HTMLInputElement>) => props.onChange(e.target.checked)}
243
+ className={cn(
244
+ "h-4 w-4 rounded-sm border border-input accent-primary focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
245
+ errorClass,
246
+ )}
247
+ />
248
+ );
249
+ case "date":
250
+ return (
251
+ <DateInput
252
+ id={props.id}
253
+ name={props.name}
254
+ value={props.value}
255
+ onChange={props.onChange}
256
+ {...(props.locale !== undefined && { locale: props.locale })}
257
+ {...(props.disabled !== undefined && { disabled: props.disabled })}
258
+ {...(props.required !== undefined && { required: props.required })}
259
+ {...(props.hasError !== undefined && { hasError: props.hasError })}
260
+ />
261
+ );
262
+ case "select": {
263
+ // Visual-Konsolidierung: alle Selects laufen über ComboboxInput
264
+ // (cmdk + Radix-Popover). Vorher hatten wir zwei Pfade — Radix-
265
+ // Select für `kind:"select"` und cmdk für `kind:"combobox"`. Drei
266
+ // visuell unterschiedliche Variants (Single-Select, Combobox-
267
+ // Single, Combobox-Multi) wurden unhandbar. Mit dem Merge ist die
268
+ // Combobox die einzige Implementation: Search-Input ist auch bei
269
+ // 4-Item-Status-Selects vorhanden, das ist eine bewusst akzeptierte
270
+ // UX-Konsequenz für Style-Konsistenz.
271
+ const comboOptions = props.options.map((o) =>
272
+ typeof o === "string" ? { value: o, label: o } : o,
273
+ );
274
+ return (
275
+ <ComboboxInput
276
+ id={props.id}
277
+ name={props.name}
278
+ value={props.value}
279
+ onChange={props.onChange}
280
+ options={comboOptions}
281
+ {...(props.disabled !== undefined && { disabled: props.disabled })}
282
+ {...(props.required !== undefined && { required: props.required })}
283
+ {...(props.hasError !== undefined && { hasError: props.hasError })}
284
+ />
285
+ );
286
+ }
287
+ case "combobox": {
288
+ // Tier 2.1c + Tier 2.7e: Discriminated-Union per `multiple` —
289
+ // wir splittan TS-side in zwei Branches damit ComboboxInput's
290
+ // Single/Multi-Variants typgerecht gerendert werden.
291
+ const baseProps = {
292
+ id: props.id,
293
+ name: props.name,
294
+ options: props.options,
295
+ ...(props.disabled !== undefined && { disabled: props.disabled }),
296
+ ...(props.required !== undefined && { required: props.required }),
297
+ ...(props.hasError !== undefined && { hasError: props.hasError }),
298
+ ...(props.placeholder !== undefined && { placeholder: props.placeholder }),
299
+ ...(props.searchPlaceholder !== undefined && {
300
+ searchPlaceholder: props.searchPlaceholder,
301
+ }),
302
+ ...(props.emptyText !== undefined && { emptyText: props.emptyText }),
303
+ ...(props.onSearchChange !== undefined && { onSearchChange: props.onSearchChange }),
304
+ ...(props.loading !== undefined && { loading: props.loading }),
305
+ } as const;
306
+ if (props.multiple === true) {
307
+ return (
308
+ <ComboboxInput {...baseProps} multiple value={props.value} onChange={props.onChange} />
309
+ );
310
+ }
311
+ return <ComboboxInput {...baseProps} value={props.value} onChange={props.onChange} />;
312
+ }
313
+ case "money":
314
+ return (
315
+ <MoneyInput
316
+ id={props.id}
317
+ name={props.name}
318
+ value={props.value}
319
+ onChange={props.onChange}
320
+ currency={props.currency ?? "EUR"}
321
+ {...(props.locale !== undefined && { locale: props.locale })}
322
+ {...(props.disabled !== undefined && { disabled: props.disabled })}
323
+ {...(props.required !== undefined && { required: props.required })}
324
+ {...(props.hasError !== undefined && { hasError: props.hasError })}
325
+ />
326
+ );
327
+ case "timestamp":
328
+ return (
329
+ <input
330
+ type="datetime-local"
331
+ {...common}
332
+ value={props.value}
333
+ onChange={(e: ChangeEvent<HTMLInputElement>) =>
334
+ props.onChange(e.target.value !== "" ? e.target.value : undefined)
335
+ }
336
+ className={cn(inputClassBase, errorClass)}
337
+ />
338
+ );
339
+ case "textarea":
340
+ // Default 4 Zeilen — vertikal-resize via resize-y. min-h damit
341
+ // ein bewusstes rows={2} nicht unter eine sinnvolle Mindesthöhe
342
+ // schrumpft. Field-Klasse wird leicht angepasst (kein h-9 weil
343
+ // multiline).
344
+ return (
345
+ <textarea
346
+ {...common}
347
+ value={props.value}
348
+ onChange={(e: ChangeEvent<HTMLTextAreaElement>) => props.onChange(e.target.value)}
349
+ rows={props.rows ?? 4}
350
+ className={cn(
351
+ "flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm",
352
+ "transition-colors placeholder:text-muted-foreground focus-visible:outline-none",
353
+ "focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
354
+ "resize-y min-h-[80px]",
355
+ errorClass,
356
+ )}
357
+ />
358
+ );
359
+ }
360
+ }
361
+
362
+ // ---- DataTable (shadcn: Table) ----
363
+
364
+ function DefaultDataTable({
365
+ columns,
366
+ rows,
367
+ onRowClick,
368
+ sort,
369
+ onSortChange,
370
+ emptyState,
371
+ toolbarTitle,
372
+ toolbarStart,
373
+ toolbarEnd,
374
+ pager,
375
+ onReachEnd,
376
+ loadingMore,
377
+ hasMore,
378
+ rowActions,
379
+ testId,
380
+ }: DataTableProps): ReactNode {
381
+ // Toolbar-Wrapper: gemeinsamer Container für Toolbar+Tabelle damit
382
+ // beide visuell zusammengehören. Toolbar ist NICHT sticky — Lists
383
+ // scrollen typischerweise mit dem Page-Container, nicht intern.
384
+ // Sticky würde mit der Topbar konkurrieren.
385
+ const tableContent =
386
+ rows.length === 0 ? (
387
+ <div
388
+ data-testid={testId !== undefined ? `${testId}-empty` : "render-list-empty"}
389
+ className="flex flex-col items-center justify-center rounded-md border border-dashed p-12 text-sm text-muted-foreground gap-3"
390
+ >
391
+ {emptyState ?? <span>No entries.</span>}
392
+ </div>
393
+ ) : (
394
+ <div className="rounded-md border overflow-x-auto">
395
+ <table data-testid={testId} className="w-full caption-bottom text-sm">
396
+ {tableInner(columns, rows, onRowClick, sort, onSortChange, rowActions)}
397
+ </table>
398
+ </div>
399
+ );
400
+
401
+ // Pager wird IMMER unter der Tabelle gerendert (auch bei rows=[]),
402
+ // damit der User bei einem Filter-Hit-of-Zero zurückblättern kann
403
+ // ohne die Liste zu verlieren. Außer total === 0 — dann gibt's
404
+ // nichts zu paginieren. Inkompatibel mit Infinite-Scroll: Caller
405
+ // setzt entweder pager ODER onReachEnd.
406
+ const content =
407
+ pager !== undefined && pager.total > 0 ? (
408
+ <>
409
+ {tableContent}
410
+ <Pager
411
+ page={pager.page}
412
+ limit={pager.limit}
413
+ total={pager.total}
414
+ onPageChange={pager.onPageChange}
415
+ testId={testId !== undefined ? `${testId}-pager` : "render-list-pager"}
416
+ />
417
+ </>
418
+ ) : onReachEnd !== undefined ? (
419
+ <>
420
+ {tableContent}
421
+ <InfiniteSentinel
422
+ onReachEnd={onReachEnd}
423
+ loadingMore={loadingMore === true}
424
+ hasMore={hasMore !== false}
425
+ testId={testId !== undefined ? `${testId}-sentinel` : "render-list-sentinel"}
426
+ />
427
+ </>
428
+ ) : (
429
+ tableContent
430
+ );
431
+
432
+ const hasToolbar =
433
+ toolbarTitle !== undefined || toolbarStart !== undefined || toolbarEnd !== undefined;
434
+ if (!hasToolbar) return <div className="p-6">{content}</div>;
435
+
436
+ // Toolbar als full-width Bar (Main hat kein Padding, also nimmt die
437
+ // Bar von alleine die volle Breite). bg-muted/30 + border-b geben
438
+ // die visuelle Distinction zum Content. Content darunter bekommt
439
+ // eigenes p-6 damit Tabelle/Empty-State nicht an die Edges kleben.
440
+ return (
441
+ <div className="flex flex-col w-full">
442
+ <div
443
+ data-testid={testId !== undefined ? `${testId}-toolbar` : "render-list-toolbar"}
444
+ className="h-12 px-6 bg-muted/30 border-b flex items-center gap-3"
445
+ >
446
+ {toolbarTitle !== undefined && (
447
+ <div className="text-lg font-semibold tracking-tight truncate">{toolbarTitle}</div>
448
+ )}
449
+ {toolbarStart !== undefined && <div className="flex-1 max-w-sm">{toolbarStart}</div>}
450
+ {toolbarEnd !== undefined && (
451
+ <div className="flex items-center gap-2 ml-auto">{toolbarEnd}</div>
452
+ )}
453
+ </div>
454
+ <div className="p-6">{content}</div>
455
+ </div>
456
+ );
457
+ }
458
+
459
+ function tableInner(
460
+ columns: DataTableProps["columns"],
461
+ rows: DataTableProps["rows"],
462
+ onRowClick?: DataTableProps["onRowClick"],
463
+ sort?: DataTableProps["sort"],
464
+ onSortChange?: DataTableProps["onSortChange"],
465
+ rowActions?: DataTableProps["rowActions"],
466
+ ): ReactNode {
467
+ const hasActions = rowActions !== undefined && rowActions.length > 0;
468
+ return (
469
+ <>
470
+ <thead className="[&_tr]:border-b">
471
+ <tr className="border-b transition-colors hover:bg-muted/50">
472
+ {columns.map((col) => (
473
+ <SortableHeader
474
+ key={col.field}
475
+ field={col.field}
476
+ label={col.label}
477
+ sortable={col.sortable === true}
478
+ {...(sort !== undefined && sort !== null && { sort })}
479
+ {...(onSortChange !== undefined && { onSortChange })}
480
+ />
481
+ ))}
482
+ {hasActions && (
483
+ <th
484
+ data-testid="column-actions"
485
+ // sticky right-0 + bg-background damit die Action-Spalte
486
+ // beim horizontalen Scroll am rechten Rand bleibt und
487
+ // nicht vom Inhalt überdeckt wird (Linear-Pattern).
488
+ className="h-10 px-4 text-right align-middle font-medium text-muted-foreground w-px whitespace-nowrap sticky right-0 bg-background z-10 border-l"
489
+ aria-label="Actions"
490
+ />
491
+ )}
492
+ </tr>
493
+ </thead>
494
+ <tbody className="[&_tr:last-child]:border-0">
495
+ {rows.map((row) => (
496
+ <tr
497
+ key={row.id}
498
+ data-testid={`row-${row.id}`}
499
+ onClick={onRowClick !== undefined ? () => onRowClick(row) : undefined}
500
+ className={cn(
501
+ "border-b transition-colors hover:bg-muted/50",
502
+ onRowClick !== undefined && "cursor-pointer",
503
+ )}
504
+ >
505
+ {columns.map((col) => (
506
+ <td
507
+ key={col.field}
508
+ data-testid={`cell-${row.id}-${col.field}`}
509
+ // Cells truncaten lange Werte mit ellipsis statt umzu-
510
+ // brechen — Lists bleiben einzeilig + scannbar (Linear-
511
+ // Pattern). max-w-xs gibt eine vernünftige Default-
512
+ // Obergrenze; die <table>-Wrapper hat overflow-x für
513
+ // horizontalen Scroll falls die Summe der Spalten zu
514
+ // breit wird.
515
+ className="p-4 align-middle max-w-xs truncate"
516
+ title={cellTitle(row.values[col.field])}
517
+ >
518
+ <DataTableCell
519
+ value={row.values[col.field]}
520
+ row={row.values}
521
+ field={col.field}
522
+ type={col.type}
523
+ renderer={col.renderer}
524
+ {...(col.optionLabels !== undefined && { optionLabels: col.optionLabels })}
525
+ />
526
+ </td>
527
+ ))}
528
+ {hasActions && (
529
+ <td
530
+ data-testid={`cell-${row.id}-actions`}
531
+ // Sticky-right damit beim horizontalen Scroll die Actions
532
+ // am rechten Rand sichtbar bleiben. bg-background +
533
+ // border-l für den visuellen Abschluss.
534
+ className="p-2 align-middle text-right whitespace-nowrap sticky right-0 bg-background z-10 border-l"
535
+ // Action-Cell-Events dürfen nicht den Row-Click/Activation
536
+ // triggern (typisch "Open Detail" — der User wollte ja die
537
+ // Action, nicht navigieren). Wir stopPropagation für Mouse
538
+ // UND Keyboard, damit a11y konsistent ist.
539
+ onClick={(e) => e.stopPropagation()}
540
+ onKeyDown={(e) => e.stopPropagation()}
541
+ >
542
+ <RowActionsCell row={row} actions={rowActions} />
543
+ </td>
544
+ )}
545
+ </tr>
546
+ ))}
547
+ </tbody>
548
+ </>
549
+ );
550
+ }
551
+
552
+ // RowActionsCell — entscheidet zwischen Inline-Buttons (≤2 actions) und
553
+ // Kebab-Dropdown (>2). isVisible-Filter wird hier ausgeführt; eine action
554
+ // die für eine Row unsichtbar ist, kommt nicht in den Render. Wenn alle
555
+ // Actions für eine Row hidden sind, bleibt die Cell leer (keine
556
+ // Phantom-Spalte).
557
+ function RowActionsCell({
558
+ row,
559
+ actions,
560
+ }: {
561
+ readonly row: ListRowViewModel;
562
+ readonly actions: readonly DataTableRowAction[];
563
+ }): ReactNode {
564
+ const visible = actions.filter((a) => a.isVisible === undefined || a.isVisible(row));
565
+ if (visible.length === 0) return null;
566
+ if (visible.length <= 2) {
567
+ return (
568
+ <div className="inline-flex items-center gap-1 justify-end">
569
+ {visible.map((a) => (
570
+ <RowActionButton key={a.id} row={row} action={a} />
571
+ ))}
572
+ </div>
573
+ );
574
+ }
575
+ return <RowActionsKebab row={row} actions={visible} />;
576
+ }
577
+
578
+ // Shared trigger-State zwischen Inline-Button + Kebab-Item: busy-Flag
579
+ // (während async onTrigger läuft) + confirm-pending-Action. Beide Sub-
580
+ // Components hatten denselben State-Block dupliziert + parallel zur
581
+ // Confirm-Dialog-Render-Logic — der Hook konsolidiert das.
582
+ //
583
+ // "needsConfirm" Helper kapselt die Regel: explizites confirm ODER
584
+ // style=danger triggert den Dialog, alles andere fired direkt.
585
+ function needsConfirm(action: DataTableRowAction): boolean {
586
+ return action.confirm !== undefined || action.style === "danger";
587
+ }
588
+
589
+ function useRowActionTrigger(row: ListRowViewModel) {
590
+ const [busy, setBusy] = useState(false);
591
+ const triggerNow = async (action: DataTableRowAction): Promise<void> => {
592
+ setBusy(true);
593
+ try {
594
+ await action.onTrigger(row);
595
+ } finally {
596
+ setBusy(false);
597
+ }
598
+ };
599
+ return { busy, triggerNow };
600
+ }
601
+
602
+ function RowActionButton({
603
+ row,
604
+ action,
605
+ }: {
606
+ readonly row: ListRowViewModel;
607
+ readonly action: DataTableRowAction;
608
+ }): ReactNode {
609
+ const { busy, triggerNow } = useRowActionTrigger(row);
610
+ const [confirmOpen, setConfirmOpen] = useState(false);
611
+
612
+ const variantClass =
613
+ action.style === "danger"
614
+ ? "text-destructive hover:bg-destructive/10"
615
+ : action.style === "primary"
616
+ ? "text-primary hover:bg-primary/10"
617
+ : "text-foreground hover:bg-accent";
618
+
619
+ return (
620
+ <>
621
+ <button
622
+ type="button"
623
+ data-testid={`row-${row.id}-action-${action.id}`}
624
+ disabled={busy}
625
+ onClick={(e) => {
626
+ e.stopPropagation();
627
+ if (needsConfirm(action)) {
628
+ setConfirmOpen(true);
629
+ } else {
630
+ void triggerNow(action);
631
+ }
632
+ }}
633
+ className={cn(
634
+ "inline-flex h-8 items-center justify-center rounded-sm px-2 text-sm",
635
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
636
+ "disabled:opacity-50 disabled:pointer-events-none",
637
+ variantClass,
638
+ )}
639
+ >
640
+ {busy ? <Loader2 className="size-3.5 animate-spin" aria-hidden="true" /> : action.label}
641
+ </button>
642
+ <DefaultDialog
643
+ open={confirmOpen}
644
+ onOpenChange={setConfirmOpen}
645
+ title={action.label}
646
+ {...(action.confirm !== undefined && { description: action.confirm })}
647
+ confirmLabel={action.confirmLabel ?? action.label}
648
+ {...(action.style === "danger" && { variant: "danger" as const })}
649
+ onConfirm={() => triggerNow(action)}
650
+ testId={`row-${row.id}-action-${action.id}-dialog`}
651
+ />
652
+ </>
653
+ );
654
+ }
655
+
656
+ // Kebab-Dropdown für >2 actions. Confirm-Dialog ist hier inline pro
657
+ // Item analog zum Inline-Button-Pfad — ein gemeinsamer State-Holder
658
+ // per Action damit das Dropdown nach dem Click zumacht und der Dialog
659
+ // danach öffnet (Radix-Dropdown-Item swallowt den Click sonst).
660
+ function RowActionsKebab({
661
+ row,
662
+ actions,
663
+ }: {
664
+ readonly row: ListRowViewModel;
665
+ readonly actions: readonly DataTableRowAction[];
666
+ }): ReactNode {
667
+ const { triggerNow } = useRowActionTrigger(row);
668
+ const [pendingConfirm, setPendingConfirm] = useState<DataTableRowAction | null>(null);
669
+
670
+ return (
671
+ <>
672
+ <DropdownMenu>
673
+ <DropdownMenuTrigger asChild>
674
+ <button
675
+ type="button"
676
+ aria-label="More actions"
677
+ data-testid={`row-${row.id}-actions-menu`}
678
+ className={cn(
679
+ "inline-flex h-8 w-8 items-center justify-center rounded-sm",
680
+ "hover:bg-accent text-muted-foreground hover:text-foreground",
681
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
682
+ )}
683
+ >
684
+ <MoreHorizontal className="size-4" aria-hidden="true" />
685
+ </button>
686
+ </DropdownMenuTrigger>
687
+ <DropdownMenuContent align="end">
688
+ {actions.map((action) => (
689
+ <DropdownMenuItem
690
+ key={action.id}
691
+ data-testid={`row-${row.id}-action-${action.id}`}
692
+ onSelect={(e) => {
693
+ e.preventDefault();
694
+ if (needsConfirm(action)) {
695
+ setPendingConfirm(action);
696
+ } else {
697
+ void triggerNow(action);
698
+ }
699
+ }}
700
+ className={cn(action.style === "danger" && "text-destructive focus:text-destructive")}
701
+ >
702
+ {action.label}
703
+ </DropdownMenuItem>
704
+ ))}
705
+ </DropdownMenuContent>
706
+ </DropdownMenu>
707
+ {pendingConfirm !== null && (
708
+ <DefaultDialog
709
+ open={true}
710
+ onOpenChange={(open) => {
711
+ if (!open) setPendingConfirm(null);
712
+ }}
713
+ title={pendingConfirm.label}
714
+ {...(pendingConfirm.confirm !== undefined && { description: pendingConfirm.confirm })}
715
+ confirmLabel={pendingConfirm.label}
716
+ {...(pendingConfirm.style === "danger" && { variant: "danger" as const })}
717
+ onConfirm={async () => {
718
+ const action = pendingConfirm;
719
+ setPendingConfirm(null);
720
+ await triggerNow(action);
721
+ }}
722
+ testId={`row-${row.id}-action-${pendingConfirm.id}-dialog`}
723
+ />
724
+ )}
725
+ </>
726
+ );
727
+ }
728
+
729
+ // InfiniteSentinel — leeres div am Ende der Tabelle, das via
730
+ // IntersectionObserver erkennt wann der User in die Nähe des Listen-
731
+ // Endes scrollt. onReachEnd feuert genau einmal pro "wird sichtbar"-
732
+ // Übergang; der Caller debounced via loadingMore (während eine Page
733
+ // lädt, ignorieren wir weitere Sichtbar-Events). Kein observer in
734
+ // Server-Side-Render, kein observer wenn hasMore=false — dann zeigt
735
+ // der Sentinel nur den End-of-list-Hinweis.
736
+ function InfiniteSentinel({
737
+ onReachEnd,
738
+ loadingMore,
739
+ hasMore,
740
+ testId,
741
+ }: {
742
+ readonly onReachEnd: () => void;
743
+ readonly loadingMore: boolean;
744
+ readonly hasMore: boolean;
745
+ readonly testId?: string;
746
+ }): ReactNode {
747
+ const ref = useRef<HTMLDivElement | null>(null);
748
+
749
+ useEffect(() => {
750
+ if (!hasMore) return;
751
+ if (loadingMore) return;
752
+ if (typeof IntersectionObserver === "undefined") return;
753
+ const node = ref.current;
754
+ if (node === null) return;
755
+ const observer = new IntersectionObserver(
756
+ (entries) => {
757
+ // Nur das erste sichtbar-Event pro Mount auslösen — wenn der
758
+ // User weiter scrollt während noch geladen wird, hindert
759
+ // loadingMore=true den useEffect dass er den Observer überhaupt
760
+ // erst remountet.
761
+ for (const entry of entries) {
762
+ if (entry.isIntersecting) {
763
+ onReachEnd();
764
+ break;
765
+ }
766
+ }
767
+ },
768
+ { rootMargin: "200px" }, // pre-fetch wenn der Sentinel 200px vom Viewport ist
769
+ );
770
+ observer.observe(node);
771
+ return () => observer.disconnect();
772
+ }, [onReachEnd, loadingMore, hasMore]);
773
+
774
+ return (
775
+ <div
776
+ ref={ref}
777
+ data-testid={testId}
778
+ className="flex items-center justify-center py-4 text-sm text-muted-foreground"
779
+ >
780
+ {!hasMore ? (
781
+ <span data-testid={testId !== undefined ? `${testId}-end` : undefined}>
782
+ — End of list —
783
+ </span>
784
+ ) : loadingMore ? (
785
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
786
+ ) : (
787
+ // Unsichtbar-Spacer wenn weder loading noch end — der Observer
788
+ // braucht ein DOM-Node, der User soll aber nichts sehen.
789
+ <span aria-hidden="true">&nbsp;</span>
790
+ )}
791
+ </div>
792
+ );
793
+ }
794
+
795
+ // Pager — klassischer Page-Pager (← 1 … N →) für DataTables mit
796
+ // pagination="pages". Layout: Status-Text links ("X – Y of Z"),
797
+ // Page-Buttons mittig, Prev/Next pfeile außen. Window-of-7 zeigt nicht
798
+ // alle Pages bei großen Listen — der User sieht den aktuellen Bereich
799
+ // + first/last als Anchor.
800
+ function Pager({
801
+ page,
802
+ limit,
803
+ total,
804
+ onPageChange,
805
+ testId,
806
+ }: {
807
+ readonly page: number;
808
+ readonly limit: number;
809
+ readonly total: number;
810
+ readonly onPageChange: (next: number) => void;
811
+ readonly testId?: string;
812
+ }): ReactNode {
813
+ const totalPages = Math.max(1, Math.ceil(total / limit));
814
+ const safePage = Math.max(1, Math.min(page, totalPages));
815
+ const from = (safePage - 1) * limit + 1;
816
+ const to = Math.min(safePage * limit, total);
817
+ const visible = computeVisiblePages(safePage, totalPages);
818
+
819
+ return (
820
+ <div
821
+ data-testid={testId}
822
+ className="flex items-center justify-between mt-3 gap-3 text-sm text-muted-foreground"
823
+ >
824
+ <div data-testid={testId !== undefined ? `${testId}-status` : undefined}>
825
+ {from.toLocaleString()}–{to.toLocaleString()} of {total.toLocaleString()}
826
+ </div>
827
+ <div className="flex items-center gap-1">
828
+ <PagerButton
829
+ ariaLabel="Previous page"
830
+ disabled={safePage <= 1}
831
+ onClick={() => onPageChange(safePage - 1)}
832
+ testId={testId !== undefined ? `${testId}-prev` : undefined}
833
+ >
834
+ <ChevronLeft className="size-4" aria-hidden="true" />
835
+ </PagerButton>
836
+ {visible.map((entry, idx) =>
837
+ entry === "ellipsis" ? (
838
+ <span
839
+ // biome-ignore lint/suspicious/noArrayIndexKey: visible array is pure-derived from safePage/totalPages, so idx is stable across renders. No DnD/Reorder.
840
+ key={`ellipsis-${idx}`}
841
+ className="px-2 text-muted-foreground"
842
+ >
843
+
844
+ </span>
845
+ ) : (
846
+ <PagerButton
847
+ key={entry}
848
+ ariaLabel={`Page ${entry}`}
849
+ ariaCurrent={entry === safePage ? "page" : undefined}
850
+ active={entry === safePage}
851
+ onClick={() => onPageChange(entry)}
852
+ testId={testId !== undefined ? `${testId}-page-${entry}` : undefined}
853
+ >
854
+ {entry}
855
+ </PagerButton>
856
+ ),
857
+ )}
858
+ <PagerButton
859
+ ariaLabel="Next page"
860
+ disabled={safePage >= totalPages}
861
+ onClick={() => onPageChange(safePage + 1)}
862
+ testId={testId !== undefined ? `${testId}-next` : undefined}
863
+ >
864
+ <ChevronRight className="size-4" aria-hidden="true" />
865
+ </PagerButton>
866
+ </div>
867
+ </div>
868
+ );
869
+ }
870
+
871
+ function PagerButton({
872
+ children,
873
+ onClick,
874
+ ariaLabel,
875
+ ariaCurrent,
876
+ active,
877
+ disabled,
878
+ testId,
879
+ }: {
880
+ readonly children: ReactNode;
881
+ readonly onClick: () => void;
882
+ readonly ariaLabel: string;
883
+ readonly ariaCurrent?: "page";
884
+ readonly active?: boolean;
885
+ readonly disabled?: boolean;
886
+ readonly testId?: string;
887
+ }): ReactNode {
888
+ return (
889
+ <button
890
+ type="button"
891
+ onClick={onClick}
892
+ disabled={disabled}
893
+ aria-label={ariaLabel}
894
+ aria-current={ariaCurrent}
895
+ data-testid={testId}
896
+ className={cn(
897
+ "inline-flex h-8 min-w-8 items-center justify-center rounded-sm px-2 text-sm",
898
+ "hover:bg-accent hover:text-accent-foreground",
899
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
900
+ "disabled:opacity-40 disabled:pointer-events-none",
901
+ active === true && "bg-accent text-accent-foreground font-medium",
902
+ )}
903
+ >
904
+ {children}
905
+ </button>
906
+ );
907
+ }
908
+
909
+ // Window-of-7 Strategie: erste + letzte Page immer sichtbar als Anker,
910
+ // 5 Pages um den aktuellen Wert + Ellipsen wenn Distanz zu first/last
911
+ // > 1. Beispiele:
912
+ // p=1, total=20: [1] 2 3 4 5 … 20
913
+ // p=10, total=20: 1 … 8 9 [10] 11 12 … 20
914
+ // p=20, total=20: 1 … 16 17 18 19 [20]
915
+ // total=5: 1 2 3 4 5 (kein Window nötig)
916
+ function computeVisiblePages(page: number, totalPages: number): readonly (number | "ellipsis")[] {
917
+ if (totalPages <= 7) return Array.from({ length: totalPages }, (_, i) => i + 1);
918
+ const out: (number | "ellipsis")[] = [];
919
+ // Always show 1
920
+ out.push(1);
921
+ if (page > 4) out.push("ellipsis");
922
+ const start = Math.max(2, page - 2);
923
+ const end = Math.min(totalPages - 1, page + 2);
924
+ for (let i = start; i <= end; i++) out.push(i);
925
+ if (page < totalPages - 3) out.push("ellipsis");
926
+ // Always show last
927
+ out.push(totalPages);
928
+ return out;
929
+ }
930
+
931
+ // SortableHeader — rendert pro Spalte den th-Header, mit oder ohne
932
+ // Click-Sort. Drei Pfade:
933
+ // (a) sortable=false ODER kein onSortChange → plain Label, keine
934
+ // Cursor-Interaktion (DataTable rein als View ohne Sort-Wiring).
935
+ // (b) sortable=true + onSortChange → Header ist ein Button, klick
936
+ // cycled asc → desc → null. aria-sort spiegelt den State.
937
+ // (c) sortable=true im Schema, aber keine onSortChange-Prop → still
938
+ // label-only, aber data-sortable=true bleibt damit Tests die
939
+ // Schema-Sicht kennen.
940
+ function SortableHeader({
941
+ field,
942
+ label,
943
+ sortable,
944
+ sort,
945
+ onSortChange,
946
+ }: {
947
+ readonly field: string;
948
+ readonly label: string;
949
+ readonly sortable: boolean;
950
+ readonly sort?: DataTableSort;
951
+ readonly onSortChange?: (next: DataTableSort | null) => void;
952
+ }): ReactNode {
953
+ const active = sort?.field === field ? sort : undefined;
954
+ const ariaSort: "ascending" | "descending" | "none" =
955
+ active?.dir === "asc" ? "ascending" : active?.dir === "desc" ? "descending" : "none";
956
+
957
+ if (!sortable || onSortChange === undefined) {
958
+ return (
959
+ <th
960
+ data-testid={`column-${field}`}
961
+ data-sortable={sortable === true ? true : undefined}
962
+ className="h-10 px-4 text-left align-middle font-medium text-muted-foreground whitespace-nowrap"
963
+ >
964
+ {label}
965
+ </th>
966
+ );
967
+ }
968
+
969
+ const Icon = active?.dir === "asc" ? ArrowUp : active?.dir === "desc" ? ArrowDown : ArrowUpDown;
970
+ const next = nextSortState(active?.dir, field);
971
+
972
+ return (
973
+ <th
974
+ data-testid={`column-${field}`}
975
+ data-sortable="true"
976
+ aria-sort={ariaSort}
977
+ className="h-10 px-4 text-left align-middle font-medium text-muted-foreground"
978
+ >
979
+ <button
980
+ type="button"
981
+ onClick={() => onSortChange(next)}
982
+ className={cn(
983
+ "inline-flex h-8 items-center gap-1.5 rounded-sm px-2 -mx-2 text-sm font-medium",
984
+ "hover:bg-accent hover:text-accent-foreground",
985
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
986
+ active !== undefined && "text-foreground",
987
+ )}
988
+ >
989
+ <span>{label}</span>
990
+ <Icon className={cn("size-3.5", active === undefined && "opacity-40")} aria-hidden="true" />
991
+ </button>
992
+ </th>
993
+ );
994
+ }
995
+
996
+ // 3-State-Toggle: kein Sort → asc → desc → kein Sort. Idiomatisch für
997
+ // Power-User-Listen wo "ich will die Server-Default-Order zurück" eine
998
+ // echte Aktion ist (statt unendlich asc↔desc zu togglen).
999
+ function nextSortState(current: DataTableSortDir | undefined, field: string): DataTableSort | null {
1000
+ if (current === undefined) return { field, dir: "asc" };
1001
+ if (current === "asc") return { field, dir: "desc" };
1002
+ return null;
1003
+ }
1004
+
1005
+ // Type-guard für die `{ react: { __component: "Name" } }`-Form, in der
1006
+ // PlatformComponent-Renderer im Schema serialisiert ankommen. Schemas
1007
+ // reisen über die Wire (Server → Client), echte Component-Refs würden
1008
+ // das brechen — der String-Key ist die SSoT.
1009
+ function isComponentRendererRef(renderer: unknown): { readonly name: string } | undefined {
1010
+ if (renderer === null || typeof renderer !== "object") return undefined;
1011
+ const reactBranch = (renderer as { react?: unknown }).react;
1012
+ if (reactBranch === null || typeof reactBranch !== "object") return undefined;
1013
+ const component = (reactBranch as { __component?: unknown }).__component;
1014
+ if (typeof component !== "string" || component.length === 0) return undefined;
1015
+ return { name: component };
1016
+ }
1017
+
1018
+ // Type-spezifische Default-Cell-Renderer. Author kann pro Spalte einen
1019
+ // expliziten renderer setzen (Function oder PlatformComponent); ohne
1020
+ // expliziten renderer fällt DataTableCell hier durch.
1021
+ //
1022
+ // - boolean → ✓ / leer
1023
+ // - timestamp/date → locale-formatiert (kein roher ISO-String)
1024
+ // - select → human-lesbar (kebab-case → Title Case)
1025
+ // - text/number/sonst → toString
1026
+ function defaultCellRender(
1027
+ value: unknown,
1028
+ type: string,
1029
+ optionLabels?: Readonly<Record<string, string>>,
1030
+ ): string {
1031
+ if (value === null || value === undefined || value === "") return "";
1032
+ if (type === "boolean") return value === true ? "✓" : "";
1033
+ if (type === "timestamp" || type === "date") return formatDateCell(value, type);
1034
+ if (type === "select") {
1035
+ const raw = String(value);
1036
+ // Translated Label aus dem ViewModel-Builder (Convention-Key
1037
+ // `<feature>:entity:<entity>:field:<field>:option:<value>`).
1038
+ // Fallback humanizeSlug wenn kein Label registriert — gleiches
1039
+ // Verhalten wie vor dem optionLabels-Patch.
1040
+ const translated = optionLabels?.[raw];
1041
+ if (translated !== undefined && translated !== raw) return translated;
1042
+ return humanizeSlug(raw);
1043
+ }
1044
+ return typeof value === "string" ? value : String(value);
1045
+ }
1046
+
1047
+ function formatDateCell(value: unknown, type: string): string {
1048
+ // Server liefert ISO-String oder Temporal.Instant.toJSON() (gleicher
1049
+ // ISO-shape). Für `type:"date"` zeigen wir nur das Datum, für
1050
+ // `type:"timestamp"` Datum + Uhrzeit. Locale-Default = Browser.
1051
+ try {
1052
+ const raw = typeof value === "string" ? value : String(value);
1053
+ const date = new Date(raw);
1054
+ if (Number.isNaN(date.getTime())) return raw;
1055
+ if (type === "date") {
1056
+ return date.toLocaleDateString();
1057
+ }
1058
+ return date.toLocaleString(undefined, {
1059
+ year: "numeric",
1060
+ month: "short",
1061
+ day: "numeric",
1062
+ hour: "2-digit",
1063
+ minute: "2-digit",
1064
+ });
1065
+ } catch {
1066
+ return String(value);
1067
+ }
1068
+ }
1069
+
1070
+ function humanizeSlug(slug: string): string {
1071
+ // "degraded-performance" → "Degraded performance"
1072
+ if (slug.length === 0) return slug;
1073
+ const spaced = slug.replace(/[-_]/g, " ");
1074
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
1075
+ }
1076
+
1077
+ // Tooltip-Text für truncated Cells — bei Hover zeigt der Browser den
1078
+ // vollen Text. Skipping für Object/Array (das ist nicht user-readable);
1079
+ // Number/Boolean stringifyt der Browser ohnehin korrekt.
1080
+ function cellTitle(value: unknown): string | undefined {
1081
+ if (value === null || value === undefined) return undefined;
1082
+ if (typeof value === "string") return value.length > 0 ? value : undefined;
1083
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
1084
+ return undefined;
1085
+ }
1086
+
1087
+ type DataTableCellProps = {
1088
+ readonly value: unknown;
1089
+ readonly row: Readonly<Record<string, unknown>>;
1090
+ readonly field: string;
1091
+ readonly type: string;
1092
+ readonly renderer?: unknown;
1093
+ readonly optionLabels?: Readonly<Record<string, string>>;
1094
+ };
1095
+
1096
+ // Cell-Renderer als Component (statt reiner Funktion) damit der
1097
+ // useColumnRenderer-Hook aus dem Provider lesen kann. Die drei Pfade
1098
+ // sind:
1099
+ // 1. Funktion → ruft fn(value) auf, returnt String. Bestand-Pfad,
1100
+ // bleibt unverändert für alle bestehenden Schemas.
1101
+ // 2. PlatformComponent (`{ react: { __component: "X" } }`) → schaut
1102
+ // "X" über useColumnRenderer auf und rendert `<X value row column/>`.
1103
+ // Nicht registriert → einmalige Warnung + Default-Fallback.
1104
+ // 3. Sonst → defaultCellRender (Type-basierter String-Renderer).
1105
+ function DataTableCell({
1106
+ value,
1107
+ row,
1108
+ field,
1109
+ type,
1110
+ renderer,
1111
+ optionLabels,
1112
+ }: DataTableCellProps): ReactNode {
1113
+ const componentRef = isComponentRendererRef(renderer);
1114
+ const ResolvedComponent = useColumnRenderer(componentRef?.name);
1115
+ if (typeof renderer === "function") {
1116
+ // 2. Argument: ganze Row als read-only — function-Renderer können
1117
+ // context-aware sein (Tier 2.7e-Eagerload nutzt das für _refs).
1118
+ const fn = renderer as (v: unknown, r?: Readonly<Record<string, unknown>>) => string;
1119
+ return fn(value, row);
1120
+ }
1121
+ if (componentRef !== undefined) {
1122
+ if (ResolvedComponent !== undefined) {
1123
+ return <ResolvedComponent value={value} row={row} column={{ field }} />;
1124
+ }
1125
+ // Renderer im Schema referenziert, aber client-side keine Map-Eintrag —
1126
+ // typischer Fall: clientFeatures.columnRenderers vergessen oder
1127
+ // Tippfehler im __component-Key. Warnen statt crashen, damit ein
1128
+ // Schema-Boot trotzdem funktioniert (Default-Type-Renderer übernimmt).
1129
+ // biome-ignore lint/suspicious/noConsole: dev-warning für Schema-Konflikte
1130
+ console.warn(`[kumiko] columnRenderer "${componentRef.name}" not registered`);
1131
+ }
1132
+ return defaultCellRender(value, type, optionLabels);
1133
+ }
1134
+
1135
+ // ---- Form + Section + Grid + Text ----
1136
+
1137
+ function DefaultForm({ onSubmit, children, title, actions, testId }: FormProps): ReactNode {
1138
+ // Form ist full-width — main hat kein Padding, also fügen wir's hier
1139
+ // pro Bereich hinzu. Action-Bar bekommt h-12 + horizontal-px-6 und
1140
+ // klebt sticky am main-Top (ohne Negative-Margin-Tricks). Content
1141
+ // unten kriegt eigenes p-6 + max-w-2xl — schmaler als die volle
1142
+ // Sidebar-flankierte Breite, damit Zeilen nicht reißen und der
1143
+ // Single-Column-Linear-Look erhalten bleibt.
1144
+ return (
1145
+ <form
1146
+ onSubmit={(e) => {
1147
+ e.preventDefault();
1148
+ onSubmit(e);
1149
+ }}
1150
+ data-testid={testId}
1151
+ className="flex flex-col w-full"
1152
+ >
1153
+ {(title !== undefined || actions !== undefined) && (
1154
+ <div
1155
+ data-testid={testId !== undefined ? `${testId}-actions` : undefined}
1156
+ className="sticky top-0 z-10 h-12 px-6 bg-muted/30 border-b flex items-center gap-3"
1157
+ >
1158
+ {title !== undefined && (
1159
+ <div className="text-lg font-semibold tracking-tight truncate">{title}</div>
1160
+ )}
1161
+ {actions !== undefined && (
1162
+ <div className="flex items-center gap-2 ml-auto">{actions}</div>
1163
+ )}
1164
+ </div>
1165
+ )}
1166
+ <div className="px-6 pt-6 pb-12 max-w-2xl w-full flex flex-col gap-8">{children}</div>
1167
+ </form>
1168
+ );
1169
+ }
1170
+
1171
+ function DefaultSection({ title, children, testId }: SectionProps): ReactNode {
1172
+ // Linear-Pattern: keine Card-Box, nur ein dezenter Section-Header
1173
+ // (uppercase, klein, muted) als Trennung. Sections fließen vertikal
1174
+ // im Form-Container; Visualität entsteht aus Whitespace + Typo, nicht
1175
+ // aus Border + Shadow. Spart Chrome und sieht weniger "boxy" aus.
1176
+ return (
1177
+ <section data-testid={testId} className="flex flex-col gap-4">
1178
+ <h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
1179
+ {title}
1180
+ </h3>
1181
+ <div className="flex flex-col gap-4">{children}</div>
1182
+ </section>
1183
+ );
1184
+ }
1185
+
1186
+ function DefaultGrid({ columns, children, testId }: GridProps): ReactNode {
1187
+ // Responsive: Mobile (< sm = 640px) bleibt 1-spaltig, ab sm: greift
1188
+ // die Author-deklarierte Spaltenzahl. Inline-style schreibt
1189
+ // CSS-Variable; Tailwind-Klasse liest die Variable mit
1190
+ // `grid-template-columns: var(--grid-cols)`. Saubere Lösung weil
1191
+ // Tailwind JIT keinen dynamischen `grid-cols-${N}` auflösen kann.
1192
+ return (
1193
+ <div
1194
+ data-testid={testId}
1195
+ className="grid gap-4 grid-cols-1 sm:[grid-template-columns:var(--grid-cols)]"
1196
+ style={{ "--grid-cols": `repeat(${columns}, minmax(0, 1fr))` } as React.CSSProperties}
1197
+ >
1198
+ {children}
1199
+ </div>
1200
+ );
1201
+ }
1202
+
1203
+ function DefaultGridCell({ span, children }: GridCellProps): ReactNode {
1204
+ const s = span !== undefined ? Math.min(span, 12) : 1;
1205
+ return <div style={{ gridColumn: `span ${s}` }}>{children}</div>;
1206
+ }
1207
+
1208
+ function DefaultText({ variant = "body", children, testId }: TextProps): ReactNode {
1209
+ switch (variant) {
1210
+ case "code":
1211
+ return (
1212
+ <code
1213
+ data-testid={testId}
1214
+ className="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm"
1215
+ >
1216
+ {children}
1217
+ </code>
1218
+ );
1219
+ case "small":
1220
+ return (
1221
+ <small data-testid={testId} className="text-xs text-muted-foreground">
1222
+ {children}
1223
+ </small>
1224
+ );
1225
+ case "required-mark":
1226
+ return (
1227
+ <span data-testid={testId} data-required className="text-destructive">
1228
+ {children}
1229
+ </span>
1230
+ );
1231
+ default:
1232
+ return <span data-testid={testId}>{children}</span>;
1233
+ }
1234
+ }
1235
+
1236
+ function DefaultHeading({ variant = "page", children, testId }: HeadingProps): ReactNode {
1237
+ // Page-Heading = h1, sehr selten in einer App (max 1 pro Screen).
1238
+ // Section-Heading = h2 mit uppercase + muted-foreground — derselbe
1239
+ // Look wie der Section-Header in Forms, aber als Standalone-Component
1240
+ // für Demo-Pages und Custom-Screens nutzbar.
1241
+ if (variant === "section") {
1242
+ return (
1243
+ <h2
1244
+ data-testid={testId}
1245
+ className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"
1246
+ >
1247
+ {children}
1248
+ </h2>
1249
+ );
1250
+ }
1251
+ return (
1252
+ <h1 data-testid={testId} className="text-2xl font-semibold tracking-tight">
1253
+ {children}
1254
+ </h1>
1255
+ );
1256
+ }
1257
+
1258
+ export const defaultPrimitives: CorePrimitives = {
1259
+ Button: DefaultButton,
1260
+ Banner: DefaultBanner,
1261
+ Field: DefaultField,
1262
+ Input: DefaultInput,
1263
+ DataTable: DefaultDataTable,
1264
+ Form: DefaultForm,
1265
+ Section: DefaultSection,
1266
+ Grid: DefaultGrid,
1267
+ GridCell: DefaultGridCell,
1268
+ Text: DefaultText,
1269
+ Heading: DefaultHeading,
1270
+ Dialog: DefaultDialog,
1271
+ };