@cosmicdrift/kumiko-renderer-web 0.65.0 → 0.66.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 (47) hide show
  1. package/package.json +6 -4
  2. package/src/__tests__/form-action-bar.test.tsx +50 -15
  3. package/src/__tests__/nav-tree.test.tsx +250 -16
  4. package/src/__tests__/primitives.test.tsx +9 -6
  5. package/src/__tests__/render-edit.test.tsx +6 -6
  6. package/src/__tests__/test-utils.tsx +21 -0
  7. package/src/__tests__/workspace-shell.test.tsx +21 -66
  8. package/src/app/__tests__/qualify-nav-provider-key.test.ts +27 -0
  9. package/src/app/client-plugin.tsx +12 -27
  10. package/src/app/create-app.tsx +31 -19
  11. package/src/app/nav-providers-context.tsx +56 -0
  12. package/src/index.ts +8 -0
  13. package/src/layout/app-layout.tsx +9 -2
  14. package/src/layout/default-app-shell.tsx +116 -33
  15. package/src/layout/nav-tree.tsx +491 -125
  16. package/src/layout/sidebar-brand.tsx +40 -0
  17. package/src/layout/sidebar-user.tsx +46 -0
  18. package/src/layout/sidebar.tsx +1 -1
  19. package/src/layout/target-resolver-stub.tsx +3 -5
  20. package/src/layout/workspace-shell.tsx +32 -34
  21. package/src/primitives/file-upload.tsx +118 -0
  22. package/src/primitives/index.tsx +314 -175
  23. package/src/styles.css +76 -50
  24. package/src/ui/avatar.tsx +110 -0
  25. package/src/ui/badge.tsx +49 -0
  26. package/src/ui/breadcrumb.tsx +110 -0
  27. package/src/ui/button.tsx +65 -0
  28. package/src/ui/card.tsx +93 -0
  29. package/src/ui/checkbox.tsx +33 -0
  30. package/src/ui/collapsible.tsx +34 -0
  31. package/src/ui/dropdown-menu.tsx +258 -0
  32. package/src/ui/input.tsx +22 -0
  33. package/src/ui/label.tsx +25 -0
  34. package/src/ui/select.tsx +191 -0
  35. package/src/ui/separator.tsx +29 -0
  36. package/src/ui/sheet.tsx +144 -0
  37. package/src/ui/sidebar.tsx +727 -0
  38. package/src/ui/skeleton.tsx +14 -0
  39. package/src/ui/table.tsx +117 -0
  40. package/src/ui/textarea.tsx +19 -0
  41. package/src/ui/tooltip.tsx +58 -0
  42. package/src/ui/use-mobile.ts +20 -0
  43. package/src/__tests__/visual-tree-integration.test.tsx +0 -314
  44. package/src/app/tree-providers-context.tsx +0 -68
  45. package/src/layout/__tests__/visual-tree.test.tsx +0 -303
  46. package/src/layout/tree-node-renderer.tsx +0 -386
  47. package/src/layout/visual-tree.tsx +0 -398
@@ -20,6 +20,7 @@ import {
20
20
  type BannerProps,
21
21
  type ButtonProps,
22
22
  type CorePrimitives,
23
+ type DataTableFacet,
23
24
  type DataTableProps,
24
25
  type FieldProps,
25
26
  type FormProps,
@@ -33,50 +34,60 @@ import {
33
34
  useTranslation,
34
35
  WriteFailedError,
35
36
  } from "@cosmicdrift/kumiko-renderer";
36
- import * as LabelPrimitive from "@radix-ui/react-label";
37
- import { cva } from "class-variance-authority";
38
37
  import {
39
38
  ArrowDown,
40
39
  ArrowUp,
41
40
  ArrowUpDown,
41
+ ChevronDown,
42
42
  ChevronLeft,
43
43
  ChevronRight,
44
44
  Loader2,
45
45
  MoreHorizontal,
46
+ X,
46
47
  } from "lucide-react";
47
- import { type ChangeEvent, type ReactNode, useEffect, useRef, useState } from "react";
48
+ import {
49
+ type ChangeEvent,
50
+ createContext,
51
+ type ReactNode,
52
+ useContext,
53
+ useEffect,
54
+ useRef,
55
+ useState,
56
+ } from "react";
48
57
  import { cn } from "../lib/cn";
58
+ import { Badge } from "../ui/badge";
59
+ import { Button as UiButton } from "../ui/button";
60
+ import { Card, CardContent, CardHeader } from "../ui/card";
61
+ import { Checkbox } from "../ui/checkbox";
62
+ import { Input as UiInput } from "../ui/input";
63
+ import { Label as UiLabel } from "../ui/label";
64
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table";
65
+ import { Textarea } from "../ui/textarea";
49
66
  import { ComboboxInput } from "./combobox";
50
67
  import { DateInput } from "./date-input";
51
68
  import { DefaultDialog } from "./dialog";
52
69
  import {
53
70
  DropdownMenu,
71
+ DropdownMenuCheckboxItem,
54
72
  DropdownMenuContent,
55
73
  DropdownMenuItem,
56
74
  DropdownMenuTrigger,
57
75
  } from "./dropdown-menu";
76
+ import { FileUploadInput } from "./file-upload";
58
77
  import { MoneyInput } from "./money-input";
59
78
  import { TimestampInput } from "./timestamp-input";
60
79
  import { useToast } from "./toast";
61
80
 
62
- // ---- Button ----
81
+ // ---- Button (vendored shadcn ui/button) ----
63
82
 
64
- const buttonVariants = cva(
65
- "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors " +
66
- "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring " +
67
- "disabled:pointer-events-none disabled:opacity-50",
68
- {
69
- variants: {
70
- variant: {
71
- primary: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
72
- secondary:
73
- "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
74
- danger: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
75
- },
76
- },
77
- defaultVariants: { variant: "primary" },
78
- },
79
- );
83
+ // Contract-Variant shadcn-Variant: secondary war schon immer der
84
+ // bordered-bg-background-Look = shadcns `outline`. primary→default,
85
+ // danger→destructive.
86
+ const BUTTON_VARIANT = {
87
+ primary: "default",
88
+ secondary: "outline",
89
+ danger: "destructive",
90
+ } as const;
80
91
 
81
92
  function DefaultButton({
82
93
  type = "button",
@@ -88,16 +99,16 @@ function DefaultButton({
88
99
  testId,
89
100
  }: ButtonProps): ReactNode {
90
101
  return (
91
- <button
102
+ <UiButton
92
103
  type={type}
93
104
  onClick={onClick}
94
105
  disabled={disabled === true || loading === true}
95
106
  data-testid={testId}
96
107
  data-loading={loading === true ? "true" : undefined}
97
- className={cn(buttonVariants({ variant }), "h-9 px-4 py-2")}
108
+ variant={BUTTON_VARIANT[variant]}
98
109
  >
99
110
  {loading === true ? <Loader2 className="size-4 animate-spin" aria-hidden="true" /> : children}
100
- </button>
111
+ </UiButton>
101
112
  );
102
113
  }
103
114
 
@@ -143,28 +154,47 @@ function DefaultField({
143
154
  labelAppendix,
144
155
  fieldAppendix,
145
156
  children,
157
+ layout,
146
158
  testId,
147
159
  }: FieldProps): ReactNode {
148
160
  const t = useTranslation();
149
161
  const hasError = issues !== undefined && issues.length > 0;
162
+ const labelEl = (
163
+ <UiLabel htmlFor={id} className={hasError ? "text-destructive" : "text-foreground"}>
164
+ {label}
165
+ {required === true && <span className="ml-0.5 text-destructive">*</span>}
166
+ </UiLabel>
167
+ );
168
+ const errorsEl = hasError ? (
169
+ <div
170
+ role="alert"
171
+ data-testid={testId !== undefined ? `${testId}-errors` : undefined}
172
+ className="text-xs text-destructive"
173
+ >
174
+ {issues.map((issue) => (
175
+ <div key={`${issue.path}:${issue.code}`}>{t(issue.i18nKey, issue.params)}</div>
176
+ ))}
177
+ </div>
178
+ ) : null;
179
+
180
+ // Inline (boolean/checkbox): Control links, Label rechts — shadcn-Muster.
181
+ if (layout === "inline") {
182
+ return (
183
+ <div data-testid={testId} className="flex flex-col gap-1.5">
184
+ <div className="flex items-center gap-2">
185
+ {children}
186
+ {labelEl}
187
+ {labelAppendix !== undefined && labelAppendix}
188
+ </div>
189
+ {errorsEl}
190
+ </div>
191
+ );
192
+ }
193
+
150
194
  return (
151
195
  <div data-testid={testId} className="flex flex-col gap-1.5">
152
196
  <div className="flex items-center justify-between gap-2">
153
- <LabelPrimitive.Root
154
- htmlFor={id}
155
- className={cn(
156
- // peer-disabled-Sentinel: shadcn-Pattern — wenn das assoziierte
157
- // Input disabled ist (peer + disabled-Klasse), wird das Label
158
- // mitgrayout. Funktioniert weil Radix-Label das nativ-htmlFor-
159
- // verlinkte Element als Peer betrachtet.
160
- "text-sm font-medium leading-none",
161
- "peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
162
- hasError ? "text-destructive" : "text-foreground",
163
- )}
164
- >
165
- {label}
166
- {required === true && <span className="ml-0.5 text-destructive">*</span>}
167
- </LabelPrimitive.Root>
197
+ {labelEl}
168
198
  {/* appendix neben dem <label>, nicht darin — interaktiver Inhalt
169
199
  (Disclosure-Button) gehört nicht in ein label-Element. */}
170
200
  {labelAppendix !== undefined && labelAppendix}
@@ -174,32 +204,16 @@ function DefaultField({
174
204
  Label-Row, nicht durch den Input davon getrennt. */}
175
205
  {fieldAppendix !== undefined && fieldAppendix}
176
206
  {children}
177
- {hasError && (
178
- <div
179
- role="alert"
180
- data-testid={testId !== undefined ? `${testId}-errors` : undefined}
181
- className="text-xs text-destructive"
182
- >
183
- {issues.map((issue) => (
184
- <div key={`${issue.path}:${issue.code}`}>{t(issue.i18nKey, issue.params)}</div>
185
- ))}
186
- </div>
187
- )}
207
+ {errorsEl}
188
208
  </div>
189
209
  );
190
210
  }
191
211
 
192
212
  // ---- Input ----
193
213
 
194
- const inputClassBase =
195
- "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm " +
196
- "transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium " +
197
- "placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring " +
198
- "disabled:cursor-not-allowed disabled:opacity-50";
199
-
200
214
  function DefaultInput(props: InputProps): ReactNode {
201
- const errorClass =
202
- props.hasError === true ? "border-destructive focus-visible:ring-destructive" : "";
215
+ // Vendored ui/input + ui/checkbox stylen Fehler über `aria-invalid`
216
+ // selbst kein manuelles border-destructive mehr nötig.
203
217
  const common = {
204
218
  id: props.id,
205
219
  name: props.name,
@@ -210,42 +224,39 @@ function DefaultInput(props: InputProps): ReactNode {
210
224
  switch (props.kind) {
211
225
  case "text":
212
226
  return (
213
- <input
227
+ <UiInput
214
228
  type="text"
215
229
  {...common}
216
230
  value={props.value}
217
231
  onChange={(e: ChangeEvent<HTMLInputElement>) => props.onChange(e.target.value)}
218
232
  {...(props.placeholder !== undefined && { placeholder: props.placeholder })}
219
233
  {...(props.autoComplete !== undefined && { autoComplete: props.autoComplete })}
220
- className={cn(inputClassBase, errorClass)}
221
234
  />
222
235
  );
223
236
  case "email":
224
237
  return (
225
- <input
238
+ <UiInput
226
239
  type="email"
227
240
  {...common}
228
241
  value={props.value}
229
242
  onChange={(e: ChangeEvent<HTMLInputElement>) => props.onChange(e.target.value)}
230
243
  {...(props.placeholder !== undefined && { placeholder: props.placeholder })}
231
244
  autoComplete={props.autoComplete ?? "email"}
232
- className={cn(inputClassBase, errorClass)}
233
245
  />
234
246
  );
235
247
  case "password":
236
248
  return (
237
- <input
249
+ <UiInput
238
250
  type="password"
239
251
  {...common}
240
252
  value={props.value}
241
253
  onChange={(e: ChangeEvent<HTMLInputElement>) => props.onChange(e.target.value)}
242
254
  autoComplete={props.autoComplete ?? "current-password"}
243
- className={cn(inputClassBase, errorClass)}
244
255
  />
245
256
  );
246
257
  case "number":
247
258
  return (
248
- <input
259
+ <UiInput
249
260
  type="number"
250
261
  {...common}
251
262
  value={props.value}
@@ -253,20 +264,33 @@ function DefaultInput(props: InputProps): ReactNode {
253
264
  const v = e.target.value;
254
265
  props.onChange(v === "" ? undefined : Number(v));
255
266
  }}
256
- className={cn(inputClassBase, "text-right tabular-nums", errorClass)}
267
+ className="text-right tabular-nums"
257
268
  />
258
269
  );
259
270
  case "boolean":
260
271
  return (
261
- <input
262
- type="checkbox"
263
- {...common}
272
+ <Checkbox
273
+ id={props.id}
274
+ name={props.name}
275
+ disabled={props.disabled}
276
+ aria-required={props.required}
277
+ aria-invalid={props.hasError === true ? true : undefined}
264
278
  checked={props.value}
265
- onChange={(e: ChangeEvent<HTMLInputElement>) => props.onChange(e.target.checked)}
266
- className={cn(
267
- "h-4 w-4 rounded-sm border border-input accent-primary focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
268
- errorClass,
269
- )}
279
+ onCheckedChange={(checked) => props.onChange(checked === true)}
280
+ />
281
+ );
282
+ case "file":
283
+ case "image":
284
+ return (
285
+ <FileUploadInput
286
+ kind={props.kind}
287
+ id={props.id}
288
+ value={props.value}
289
+ onChange={props.onChange}
290
+ {...(props.accept !== undefined && { accept: props.accept })}
291
+ {...(props.disabled !== undefined && { disabled: props.disabled })}
292
+ {...(props.entityType !== undefined && { entityType: props.entityType })}
293
+ {...(props.fieldName !== undefined && { fieldName: props.fieldName })}
270
294
  />
271
295
  );
272
296
  case "date":
@@ -366,23 +390,13 @@ function DefaultInput(props: InputProps): ReactNode {
366
390
  />
367
391
  );
368
392
  case "textarea":
369
- // Default 4 Zeilen — vertikal-resize via resize-y. min-h damit
370
- // ein bewusstes rows={2} nicht unter eine sinnvolle Mindesthöhe
371
- // schrumpft. Field-Klasse wird leicht angepasst (kein h-9 weil
372
- // multiline).
373
393
  return (
374
- <textarea
394
+ <Textarea
375
395
  {...common}
376
396
  value={props.value}
377
397
  onChange={(e: ChangeEvent<HTMLTextAreaElement>) => props.onChange(e.target.value)}
378
398
  rows={props.rows ?? 4}
379
- className={cn(
380
- "flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm",
381
- "transition-colors placeholder:text-muted-foreground focus-visible:outline-none",
382
- "focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
383
- "resize-y min-h-[80px]",
384
- errorClass,
385
- )}
399
+ className="resize-y"
386
400
  />
387
401
  );
388
402
  }
@@ -390,6 +404,51 @@ function DefaultInput(props: InputProps): ReactNode {
390
404
 
391
405
  // ---- DataTable (shadcn: Table) ----
392
406
 
407
+ // Faceted-Filter-Dropdown: Outline-Button (wie shadcns "Columns"-Toggle) +
408
+ // Multi-Select-Checkboxen. Aktive Auswahl → Count-Badge am Button.
409
+ function FacetFilter({
410
+ facet,
411
+ selected,
412
+ onChange,
413
+ }: {
414
+ facet: DataTableFacet;
415
+ selected: readonly string[];
416
+ onChange: (field: string, values: readonly string[]) => void;
417
+ }): ReactNode {
418
+ const toggle = (value: string, checked: boolean): void => {
419
+ const next = checked ? [...selected, value] : selected.filter((v) => v !== value);
420
+ onChange(facet.field, next);
421
+ };
422
+ return (
423
+ <DropdownMenu>
424
+ <DropdownMenuTrigger asChild>
425
+ <UiButton variant="outline" size="sm" className="h-9" data-testid={`facet-${facet.field}`}>
426
+ {facet.label}
427
+ {selected.length > 0 && (
428
+ <Badge variant="secondary" className="ml-1 rounded-sm px-1 font-normal tabular-nums">
429
+ {selected.length}
430
+ </Badge>
431
+ )}
432
+ <ChevronDown className="text-muted-foreground" />
433
+ </UiButton>
434
+ </DropdownMenuTrigger>
435
+ <DropdownMenuContent align="start" className="w-44">
436
+ {facet.options.map((opt) => (
437
+ <DropdownMenuCheckboxItem
438
+ key={opt.value}
439
+ checked={selected.includes(opt.value)}
440
+ onCheckedChange={(checked: boolean) => toggle(opt.value, checked)}
441
+ onSelect={(e: Event) => e.preventDefault()}
442
+ data-testid={`facet-${facet.field}-${opt.value}`}
443
+ >
444
+ {opt.label}
445
+ </DropdownMenuCheckboxItem>
446
+ ))}
447
+ </DropdownMenuContent>
448
+ </DropdownMenu>
449
+ );
450
+ }
451
+
393
452
  function DefaultDataTable({
394
453
  columns,
395
454
  rows,
@@ -397,7 +456,6 @@ function DefaultDataTable({
397
456
  sort,
398
457
  onSortChange,
399
458
  emptyState,
400
- toolbarTitle,
401
459
  toolbarStart,
402
460
  toolbarEnd,
403
461
  pager,
@@ -406,6 +464,10 @@ function DefaultDataTable({
406
464
  hasMore,
407
465
  rowActions,
408
466
  rowActionMode,
467
+ filterFacets,
468
+ filterValues,
469
+ onFilterChange,
470
+ onFilterReset,
409
471
  testId,
410
472
  }: DataTableProps): ReactNode {
411
473
  // Toolbar-Wrapper: gemeinsamer Container für Toolbar+Tabelle damit
@@ -421,10 +483,12 @@ function DefaultDataTable({
421
483
  {emptyState ?? <span>No entries.</span>}
422
484
  </div>
423
485
  ) : (
424
- <div className="rounded-md border overflow-x-auto">
425
- <table data-testid={testId} className="w-full caption-bottom text-sm">
486
+ // dashboard-01-Muster: schlichter `rounded-lg border`-Rahmen (kein
487
+ // bg-card/shadow), die Header-Zeile trägt den bg-muted-Grauton.
488
+ <div className="overflow-hidden rounded-lg border">
489
+ <Table data-testid={testId}>
426
490
  {tableInner(columns, rows, onRowClick, sort, onSortChange, rowActions, rowActionMode)}
427
- </table>
491
+ </Table>
428
492
  </div>
429
493
  );
430
494
 
@@ -459,29 +523,56 @@ function DefaultDataTable({
459
523
  tableContent
460
524
  );
461
525
 
526
+ const hasFacets =
527
+ filterFacets !== undefined && filterFacets.length > 0 && onFilterChange !== undefined;
528
+ const hasActiveFilters =
529
+ filterValues !== undefined && Object.values(filterValues).some((v) => v.length > 0);
530
+ const facetCluster = hasFacets ? (
531
+ <div className="flex items-center gap-2">
532
+ {filterFacets.map((facet) => (
533
+ <FacetFilter
534
+ key={facet.field}
535
+ facet={facet}
536
+ selected={filterValues?.[facet.field] ?? []}
537
+ onChange={onFilterChange}
538
+ />
539
+ ))}
540
+ {hasActiveFilters && onFilterReset !== undefined && (
541
+ <UiButton
542
+ variant="ghost"
543
+ size="sm"
544
+ className="h-9 px-2"
545
+ onClick={onFilterReset}
546
+ data-testid="facet-reset"
547
+ >
548
+ Reset
549
+ <X />
550
+ </UiButton>
551
+ )}
552
+ </div>
553
+ ) : undefined;
554
+
462
555
  const hasToolbar =
463
- toolbarTitle !== undefined || toolbarStart !== undefined || toolbarEnd !== undefined;
464
- if (!hasToolbar) return <div className="p-6">{content}</div>;
556
+ toolbarStart !== undefined || toolbarEnd !== undefined || facetCluster !== undefined;
465
557
 
466
- // Toolbar als full-width Bar (Main hat kein Padding, also nimmt die
467
- // Bar von alleine die volle Breite). bg-muted/30 + border-b geben
468
- // die visuelle Distinction zum Content. Content darunter bekommt
469
- // eigenes p-6 damit Tabelle/Empty-State nicht an die Edges kleben.
558
+ // dashboard-01-Muster: die Toolbar (Search + Facets + "+ Neu") sitzt ÜBER
559
+ // der Tabelle im selben Padding-Block kein separater bg-Bar, kein Screen-
560
+ // Titel (der steht im Breadcrumb der Shell).
470
561
  return (
471
- <div className="flex flex-col w-full">
472
- <div
473
- data-testid={testId !== undefined ? `${testId}-toolbar` : "render-list-toolbar"}
474
- className="h-12 px-6 bg-muted/30 border-b flex items-center gap-3"
475
- >
476
- {toolbarTitle !== undefined && (
477
- <div className="text-lg font-semibold tracking-tight truncate">{toolbarTitle}</div>
478
- )}
479
- {toolbarStart !== undefined && <div className="flex-1 max-w-sm">{toolbarStart}</div>}
480
- {toolbarEnd !== undefined && (
481
- <div className="flex items-center gap-2 ml-auto">{toolbarEnd}</div>
482
- )}
483
- </div>
484
- <div className="p-6">{content}</div>
562
+ <div className="flex flex-col gap-4 p-6 w-full">
563
+ {hasToolbar && (
564
+ <div
565
+ data-testid={testId !== undefined ? `${testId}-toolbar` : "render-list-toolbar"}
566
+ className="flex items-center gap-3"
567
+ >
568
+ {toolbarStart !== undefined && <div className="flex-1 max-w-sm">{toolbarStart}</div>}
569
+ {facetCluster}
570
+ {toolbarEnd !== undefined && (
571
+ <div className="flex items-center gap-2 ml-auto">{toolbarEnd}</div>
572
+ )}
573
+ </div>
574
+ )}
575
+ {content}
485
576
  </div>
486
577
  );
487
578
  }
@@ -498,8 +589,8 @@ function tableInner(
498
589
  const hasActions = rowActions !== undefined && rowActions.length > 0;
499
590
  return (
500
591
  <>
501
- <thead className="[&_tr]:border-b">
502
- <tr className="border-b transition-colors hover:bg-muted/50">
592
+ <TableHeader className="bg-muted">
593
+ <TableRow className="hover:bg-transparent">
503
594
  {columns.map((col) => (
504
595
  <SortableHeader
505
596
  key={col.field}
@@ -511,39 +602,34 @@ function tableInner(
511
602
  />
512
603
  ))}
513
604
  {hasActions && (
514
- <th
605
+ <TableHead
515
606
  data-testid="column-actions"
516
- // sticky right-0 + bg-background damit die Action-Spalte
517
- // beim horizontalen Scroll am rechten Rand bleibt und
518
- // nicht vom Inhalt überdeckt wird (Linear-Pattern).
519
- 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"
607
+ // sticky right-0 + bg-muted (= Header-Ton) damit die Action-
608
+ // Spalte beim horizontalen Scroll am rechten Rand bleibt.
609
+ className="sticky right-0 z-10 w-px border-l bg-muted text-right text-muted-foreground"
520
610
  aria-label="Actions"
521
611
  />
522
612
  )}
523
- </tr>
524
- </thead>
525
- <tbody className="[&_tr:last-child]:border-0">
613
+ </TableRow>
614
+ </TableHeader>
615
+ <TableBody>
526
616
  {rows.map((row) => (
527
- <tr
617
+ <TableRow
528
618
  key={row.id}
529
619
  data-testid={`row-${row.id}`}
530
620
  onClick={onRowClick !== undefined ? () => onRowClick(row) : undefined}
531
- className={cn(
532
- "border-b transition-colors hover:bg-muted/50",
533
- onRowClick !== undefined && "cursor-pointer",
534
- )}
621
+ className={cn(onRowClick !== undefined && "cursor-pointer")}
535
622
  >
536
623
  {columns.map((col) => (
537
- <td
624
+ <TableCell
538
625
  key={col.field}
539
626
  data-testid={`cell-${row.id}-${col.field}`}
540
627
  // Cells truncaten lange Werte mit ellipsis statt umzu-
541
628
  // brechen — Lists bleiben einzeilig + scannbar (Linear-
542
629
  // Pattern). max-w-xs gibt eine vernünftige Default-
543
- // Obergrenze; die <table>-Wrapper hat overflow-x für
544
- // horizontalen Scroll falls die Summe der Spalten zu
545
- // breit wird.
546
- className="p-4 align-middle max-w-xs truncate"
630
+ // Obergrenze; der Table-Container scrollt horizontal
631
+ // falls die Summe der Spalten zu breit wird.
632
+ className="max-w-xs truncate"
547
633
  title={cellTitle(row.values[col.field])}
548
634
  >
549
635
  <DataTableCell
@@ -554,15 +640,15 @@ function tableInner(
554
640
  renderer={col.renderer}
555
641
  {...(col.optionLabels !== undefined && { optionLabels: col.optionLabels })}
556
642
  />
557
- </td>
643
+ </TableCell>
558
644
  ))}
559
645
  {hasActions && (
560
- <td
646
+ <TableCell
561
647
  data-testid={`cell-${row.id}-actions`}
562
648
  // Sticky-right damit beim horizontalen Scroll die Actions
563
649
  // am rechten Rand sichtbar bleiben. bg-background +
564
650
  // border-l für den visuellen Abschluss.
565
- className="p-2 align-middle text-right whitespace-nowrap sticky right-0 bg-background z-10 border-l"
651
+ className="sticky right-0 z-10 border-l bg-background text-right"
566
652
  // Action-Cell-Events dürfen nicht den Row-Click/Activation
567
653
  // triggern (typisch "Open Detail" — der User wollte ja die
568
654
  // Action, nicht navigieren). Wir stopPropagation für Mouse
@@ -571,11 +657,11 @@ function tableInner(
571
657
  onKeyDown={(e) => e.stopPropagation()}
572
658
  >
573
659
  <RowActionsCell row={row} actions={rowActions} mode={rowActionMode} />
574
- </td>
660
+ </TableCell>
575
661
  )}
576
- </tr>
662
+ </TableRow>
577
663
  ))}
578
- </tbody>
664
+ </TableBody>
579
665
  </>
580
666
  );
581
667
  }
@@ -1031,13 +1117,13 @@ function SortableHeader({
1031
1117
 
1032
1118
  if (!sortable || onSortChange === undefined) {
1033
1119
  return (
1034
- <th
1120
+ <TableHead
1035
1121
  data-testid={`column-${field}`}
1036
1122
  data-sortable={sortable === true ? true : undefined}
1037
- className="h-10 px-4 text-left align-middle font-medium text-muted-foreground whitespace-nowrap"
1123
+ className="px-4 text-muted-foreground"
1038
1124
  >
1039
1125
  {label}
1040
- </th>
1126
+ </TableHead>
1041
1127
  );
1042
1128
  }
1043
1129
 
@@ -1045,11 +1131,11 @@ function SortableHeader({
1045
1131
  const next = nextSortState(active?.dir, field);
1046
1132
 
1047
1133
  return (
1048
- <th
1134
+ <TableHead
1049
1135
  data-testid={`column-${field}`}
1050
1136
  data-sortable="true"
1051
1137
  aria-sort={ariaSort}
1052
- className="h-10 px-4 text-left align-middle font-medium text-muted-foreground"
1138
+ className="px-4 text-muted-foreground"
1053
1139
  >
1054
1140
  <button
1055
1141
  type="button"
@@ -1064,7 +1150,7 @@ function SortableHeader({
1064
1150
  <span>{label}</span>
1065
1151
  <Icon className={cn("size-3.5", active === undefined && "opacity-40")} aria-hidden="true" />
1066
1152
  </button>
1067
- </th>
1153
+ </TableHead>
1068
1154
  );
1069
1155
  }
1070
1156
 
@@ -1185,18 +1271,40 @@ function DataTableCell({
1185
1271
  // biome-ignore lint/suspicious/noConsole: dev-warning für Schema-Konflikte
1186
1272
  console.warn(`[kumiko] columnRenderer "${componentRef.name}" not registered`);
1187
1273
  }
1274
+ // select-Werte als neutrale Badge-Pill (shadcn secondary) statt Plain-Text.
1275
+ // Farbige Status-Semantik (grün/amber) bleibt App-Sache via columnRenderer.
1276
+ if (type === "select" && value !== null && value !== undefined && value !== "") {
1277
+ // dashboard-01-Muster: outline-Badge + muted statt gefülltem secondary.
1278
+ return (
1279
+ <Badge variant="outline" className="px-1.5 text-muted-foreground">
1280
+ {defaultCellRender(value, type, optionLabels)}
1281
+ </Badge>
1282
+ );
1283
+ }
1188
1284
  return defaultCellRender(value, type, optionLabels);
1189
1285
  }
1190
1286
 
1191
1287
  // ---- Form + Section + Grid + Text ----
1192
1288
 
1193
- function DefaultForm({ onSubmit, children, title, actions, testId }: FormProps): ReactNode {
1194
- // Form ist full-width main hat kein Padding, also fügen wir's hier
1195
- // pro Bereich hinzu. Action-Bar bekommt h-12 + horizontal-px-6 und
1196
- // klebt sticky am main-Top (ohne Negative-Margin-Tricks). Content
1197
- // unten kriegt eigenes p-6 + max-w-2xl — schmaler als die volle
1198
- // Sidebar-flankierte Breite, damit Zeilen nicht reißen und der
1199
- // Single-Column-Linear-Look erhalten bleibt.
1289
+ // Setzt DefaultSection in den Inner-Region-Modus: das ganze Form ist EINE
1290
+ // Card, Sections sind divider-getrennte Abschnitte darin (shadcn-Muster wie
1291
+ // Shipping/Invoice/Profile). Standalone (außerhalb Form) bleibt Section eine
1292
+ // eigene Card.
1293
+ const InsideFormContext = createContext(false);
1294
+
1295
+ function DefaultForm({
1296
+ onSubmit,
1297
+ children,
1298
+ title,
1299
+ subtitle,
1300
+ actions,
1301
+ testId,
1302
+ }: FormProps): ReactNode {
1303
+ // shadcn-Form-Muster: das Formular ist EINE Card — Titel als Card-Header
1304
+ // OHNE Trennlinie darunter (Titel fließt in die erste Section). Sections
1305
+ // sind divide-y-getrennt (Linien nur ZWISCHEN ihnen). Action-Footer mit
1306
+ // bg-muted/30 als Farb-Trenner statt harter Linie. main hat kein Padding;
1307
+ // der max-w-3xl-Body rahmt die Card.
1200
1308
  return (
1201
1309
  <form
1202
1310
  onSubmit={(e) => {
@@ -1206,43 +1314,74 @@ function DefaultForm({ onSubmit, children, title, actions, testId }: FormProps):
1206
1314
  data-testid={testId}
1207
1315
  className="flex flex-col w-full"
1208
1316
  >
1209
- {(title !== undefined || actions !== undefined) && (
1210
- <div
1211
- data-testid={testId !== undefined ? `${testId}-actions` : undefined}
1212
- className="sticky top-0 z-10 h-12 bg-muted/30 border-b"
1213
- >
1214
- {/* Bar-Hintergrund spannt die volle Breite, der Inhalt aligned
1215
- aber mit dem max-w-2xl-Form-Body sonst kleben die Buttons
1216
- am Fensterrand, optisch abgekoppelt vom Formular. */}
1217
- <div className="h-full px-6 max-w-2xl w-full flex items-center gap-3">
1218
- {title !== undefined && (
1219
- <div className="text-lg font-semibold tracking-tight truncate">{title}</div>
1220
- )}
1221
- {actions !== undefined && (
1222
- <div className="flex items-center gap-2 ml-auto">{actions}</div>
1223
- )}
1317
+ <div className="px-6 pt-6 pb-12 max-w-3xl w-full">
1318
+ <div className="bg-card overflow-hidden rounded-xl border shadow-sm">
1319
+ {(title !== undefined || subtitle !== undefined) && (
1320
+ <div className="px-6 pb-2 pt-5">
1321
+ {title !== undefined && (
1322
+ <h2
1323
+ data-testid={testId !== undefined ? `${testId}-title` : undefined}
1324
+ className="text-lg font-semibold tracking-tight"
1325
+ >
1326
+ {title}
1327
+ </h2>
1328
+ )}
1329
+ {subtitle !== undefined && (
1330
+ <p
1331
+ data-testid={testId !== undefined ? `${testId}-subtitle` : undefined}
1332
+ className="mt-1 text-sm text-muted-foreground"
1333
+ >
1334
+ {subtitle}
1335
+ </p>
1336
+ )}
1337
+ </div>
1338
+ )}
1339
+ <div className="divide-y">
1340
+ <InsideFormContext.Provider value={true}>{children}</InsideFormContext.Provider>
1224
1341
  </div>
1342
+ {actions !== undefined && (
1343
+ <div
1344
+ data-testid={testId !== undefined ? `${testId}-actions` : undefined}
1345
+ className="flex items-center justify-end gap-2 border-t bg-muted/30 px-6 py-4"
1346
+ >
1347
+ {actions}
1348
+ </div>
1349
+ )}
1225
1350
  </div>
1226
- )}
1227
- <div className="px-6 pt-6 pb-12 max-w-2xl w-full flex flex-col gap-8">{children}</div>
1351
+ </div>
1228
1352
  </form>
1229
1353
  );
1230
1354
  }
1231
1355
 
1232
1356
  function DefaultSection({ title, children, testId }: SectionProps): ReactNode {
1233
- // Linear-Pattern: keine Card-Box, nur ein dezenter Section-Header
1234
- // (uppercase, klein, muted) als Trennung. Sections fließen vertikal
1235
- // im Form-Container; Visualität entsteht aus Whitespace + Typo, nicht
1236
- // aus Border + Shadow. Spart Chrome und sieht weniger "boxy" aus.
1357
+ const insideForm = useContext(InsideFormContext);
1358
+
1359
+ // Innerhalb eines Forms: divider-loser Abschnitt OHNE eigene Card-Fläche.
1360
+ // Die Trennlinien ZWISCHEN Sections macht der divide-y-Wrapper im Form
1361
+ // keine Linie unter dem Titel, keine vor dem Footer.
1362
+ if (insideForm) {
1363
+ return (
1364
+ <section data-testid={testId} className="flex flex-col gap-4 px-6 py-6">
1365
+ {title !== undefined && (
1366
+ <h3 className="text-base font-semibold leading-none tracking-tight">{title}</h3>
1367
+ )}
1368
+ {children}
1369
+ </section>
1370
+ );
1371
+ }
1372
+
1373
+ // Standalone: eigene Card (abgesetzter border-b-Header + gap-4-Body).
1237
1374
  return (
1238
- <section data-testid={testId} className="flex flex-col gap-4">
1375
+ <Card data-testid={testId} className="gap-0 rounded-lg py-0">
1239
1376
  {title !== undefined && (
1240
- <h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
1241
- {title}
1242
- </h3>
1377
+ <CardHeader className="border-b px-6 py-4">
1378
+ {/* h3 statt CardTitle (= div): erhält die Heading-Semantik für
1379
+ Screenreader-Navigation. Klassen = CardTitle-Default. */}
1380
+ <h3 className="text-base font-semibold leading-none tracking-tight">{title}</h3>
1381
+ </CardHeader>
1243
1382
  )}
1244
- <div className="flex flex-col gap-4">{children}</div>
1245
- </section>
1383
+ <CardContent className="flex flex-col gap-4 px-6 py-6">{children}</CardContent>
1384
+ </Card>
1246
1385
  );
1247
1386
  }
1248
1387