@cosmicdrift/kumiko-renderer-web 0.232.0 → 0.234.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.
@@ -8,7 +8,7 @@
8
8
  // basierte Stile. Radix-UI-Unterbau für interaktive Elemente (Modal,
9
9
  // Dropdown etc. kommen später).
10
10
 
11
- import type { FieldIconKey } from "@cosmicdrift/kumiko-framework/ui-types";
11
+ import type { FieldIconKey, IconKey } from "@cosmicdrift/kumiko-framework/ui-types";
12
12
  import type { ListRowViewModel } from "@cosmicdrift/kumiko-headless";
13
13
  import { applyFormatSpec, isSafeHref } from "@cosmicdrift/kumiko-headless";
14
14
  import type {
@@ -35,6 +35,8 @@ import {
35
35
  type ProgressProps,
36
36
  type SectionProps,
37
37
  type StepBarProps,
38
+ shouldRenderActionsIconOnly,
39
+ statusToneForValue,
38
40
  type TextProps,
39
41
  useColumnRenderer,
40
42
  useOptionalLocale,
@@ -73,6 +75,7 @@ import {
73
75
  Children,
74
76
  type CSSProperties,
75
77
  createContext,
78
+ type KeyboardEvent,
76
79
  type MouseEvent,
77
80
  type ReactNode,
78
81
  useContext,
@@ -80,15 +83,18 @@ import {
80
83
  useRef,
81
84
  useState,
82
85
  } from "react";
86
+ import { Icon, NAV_ICONS } from "../icons";
83
87
  import { cn } from "../lib/cn";
84
88
  import { Badge } from "../ui/badge";
85
89
  import { buttonVariants, Button as UiButton } from "../ui/button";
86
90
  import { Checkbox } from "../ui/checkbox";
87
91
  import { Input as UiInput } from "../ui/input";
88
92
  import { Label as UiLabel } from "../ui/label";
93
+ import { Switch } from "../ui/switch";
89
94
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table";
90
95
  import { Textarea } from "../ui/textarea";
91
96
  import { ProgressBar } from "../widgets/progress-bar";
97
+ import { StatusBadge } from "../widgets/status-badge";
92
98
  import { StepBar } from "../widgets/step-bar";
93
99
  import { ComboboxInput } from "./combobox";
94
100
  import { DateInput } from "./date-input";
@@ -113,6 +119,7 @@ import { DefaultTabs } from "./tabs";
113
119
  import { TimestampInput } from "./timestamp-input";
114
120
  import { useToast } from "./toast";
115
121
  import { TzInput } from "./tz-input";
122
+ import { useIsNarrowViewport } from "./use-narrow-viewport";
116
123
 
117
124
  // ---- Card-Chrome (eine Definition für Form/Section/Card) ----
118
125
 
@@ -129,7 +136,10 @@ const cardSurface = cva(
129
136
  );
130
137
  // Wraps instead of running off-screen when the row outgrows its container (fw#2528).
131
138
  const cardFooter = "flex flex-wrap items-center justify-end gap-2 px-[var(--card-padding)] py-4";
132
- const cardFooterBorder = "border-t bg-muted/30";
139
+ // /30 read as nearly invisible against the light-theme card (white card,
140
+ // muted at 94% lightness) — /50 keeps the same token, just a stronger step.
141
+ const cardFooterBorder = "border-t bg-muted/50";
142
+ const cardHeaderBorder = "border-b bg-muted/50";
133
143
 
134
144
  // ---- Button (vendored shadcn ui/button) ----
135
145
 
@@ -141,6 +151,7 @@ const BUTTON_VARIANT = {
141
151
  secondary: "outline",
142
152
  danger: "destructive",
143
153
  link: "link",
154
+ "danger-ghost": "ghost",
144
155
  } as const;
145
156
 
146
157
  const BUTTON_SIZE = {
@@ -149,6 +160,14 @@ const BUTTON_SIZE = {
149
160
  icon: "icon",
150
161
  } as const;
151
162
 
163
+ // Closed IconKey vocabulary, checked against the shared NAV_ICONS registry
164
+ // (icons.tsx) — an unknown key (schema data isn't statically typed against
165
+ // IconKey the way this prop is) renders no icon instead of crashing, same
166
+ // fallback shape as fieldIconFor above.
167
+ function actionIconFor(icon: IconKey | undefined): IconKey | undefined {
168
+ return icon !== undefined && Object.hasOwn(NAV_ICONS, icon) ? icon : undefined;
169
+ }
170
+
152
171
  function DefaultButton({
153
172
  type = "button",
154
173
  onClick,
@@ -162,14 +181,29 @@ function DefaultButton({
162
181
  testId,
163
182
  className,
164
183
  ref,
184
+ icon,
185
+ iconEnd,
165
186
  }: ButtonProps): ReactNode {
166
187
  // link-Variant rendert text-artig (Inline-Link im Fließtext/Banner), nicht als
167
188
  // gepolsterte Fläche; width="full" streckt CTA-Buttons in Karten/Panels.
168
189
  const resolvedClassName = cn(
169
190
  variant === "link" ? "h-auto px-0 py-0" : "",
191
+ variant === "danger-ghost"
192
+ ? "text-destructive hover:text-destructive hover:bg-destructive/10"
193
+ : "",
170
194
  width === "full" ? "w-full" : "",
171
195
  className,
172
196
  );
197
+ const IconStart = icon !== undefined ? NAV_ICONS[icon] : undefined;
198
+ const IconEnd = iconEnd !== undefined ? NAV_ICONS[iconEnd] : undefined;
199
+ // Loading swaps the leading icon slot for a spinner and keeps `children` —
200
+ // replacing the label would shift the button's width mid-submit.
201
+ const leading =
202
+ loading === true ? (
203
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
204
+ ) : IconStart !== undefined ? (
205
+ <IconStart className="size-4" aria-hidden="true" />
206
+ ) : null;
173
207
  return (
174
208
  <UiButton
175
209
  ref={ref}
@@ -183,7 +217,9 @@ function DefaultButton({
183
217
  aria-label={ariaLabel}
184
218
  className={resolvedClassName}
185
219
  >
186
- {loading === true ? <Loader2 className="size-4 animate-spin" aria-hidden="true" /> : children}
220
+ {leading}
221
+ {children}
222
+ {IconEnd !== undefined && <IconEnd className="size-4" aria-hidden="true" />}
187
223
  </UiButton>
188
224
  );
189
225
  }
@@ -236,6 +272,13 @@ function fieldLabelId(id: string): string {
236
272
  return `${id}-label`;
237
273
  }
238
274
 
275
+ // Boolean optics depend on the surrounding Field layout: layout="inline"
276
+ // (checkbox lists like MultiSelectCheckboxes, the BooleanField widget) keeps
277
+ // the checkbox, the standard form field (layout="stacked"/default) gets the
278
+ // new switch. Pure web-renderer rendering decision, no contract field needed
279
+ // — Field hands its resolved layout down to the nested Input via context.
280
+ const FieldLayoutContext = createContext<FieldProps["layout"]>("stacked");
281
+
239
282
  function DefaultField({
240
283
  id,
241
284
  label,
@@ -284,7 +327,7 @@ function DefaultField({
284
327
  return (
285
328
  <div data-testid={testId} className="flex flex-col gap-1.5">
286
329
  <div className="flex items-center gap-2">
287
- {children}
330
+ <FieldLayoutContext.Provider value="inline">{children}</FieldLayoutContext.Provider>
288
331
  {labelEl}
289
332
  {labelAppendix !== undefined && labelAppendix}
290
333
  </div>
@@ -359,9 +402,134 @@ function withFieldIcon(icon: string | undefined, input: ReactNode): ReactNode {
359
402
  );
360
403
  }
361
404
 
405
+ // Mirrors withFieldIcon on the right side: a muted, non-interactive unit
406
+ // suffix rendered inside the input's visual box. Pure decoration — never
407
+ // focusable, never touches the input's value.
408
+ function withUnitSuffix(unit: string | undefined, input: ReactNode): ReactNode {
409
+ if (unit === undefined) return input;
410
+ return (
411
+ <div className="relative">
412
+ {input}
413
+ <span
414
+ aria-hidden="true"
415
+ className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-sm text-muted-foreground"
416
+ >
417
+ {unit}
418
+ </span>
419
+ </div>
420
+ );
421
+ }
422
+
423
+ // Segmented control for `kind: "select"` with a small closed option set —
424
+ // a 4-value Status field looked wrong stretched into a full-width dropdown
425
+ // (edit-existing screenshot feedback). Purely a rendering choice inside the
426
+ // "select" branch below; the primitives contract is untouched (still
427
+ // `options` + string value/onChange).
428
+ const SEGMENTED_SELECT_MAX_OPTIONS = 4;
429
+ const SEGMENTED_SELECT_MAX_LABEL_LENGTH = 14;
430
+
431
+ function isSegmentedSelectEligible(options: readonly { readonly label: string }[]): boolean {
432
+ return (
433
+ options.length > 0 &&
434
+ options.length <= SEGMENTED_SELECT_MAX_OPTIONS &&
435
+ options.every((o) => o.label.length <= SEGMENTED_SELECT_MAX_LABEL_LENGTH)
436
+ );
437
+ }
438
+
439
+ // WAI-ARIA radiogroup pattern (role="radiogroup" + role="radio" children):
440
+ // arrow keys move focus AND selection in the same step, only the checked
441
+ // segment (or the first when none is checked) sits in the tab order.
442
+ function SegmentedSelect({
443
+ id,
444
+ name,
445
+ value,
446
+ onChange,
447
+ options,
448
+ disabled,
449
+ required,
450
+ hasError,
451
+ }: {
452
+ readonly id: string;
453
+ readonly name: string;
454
+ readonly value: string;
455
+ readonly onChange: (v: string) => void;
456
+ readonly options: readonly { readonly value: string; readonly label: string }[];
457
+ readonly disabled?: boolean;
458
+ readonly required?: boolean;
459
+ readonly hasError?: boolean;
460
+ }): ReactNode {
461
+ const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
462
+
463
+ const selectAt = (index: number): void => {
464
+ const target = options[index];
465
+ if (target === undefined) return;
466
+ onChange(target.value);
467
+ buttonRefs.current[index]?.focus();
468
+ };
469
+
470
+ const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>, index: number): void => {
471
+ if (e.key === "ArrowRight" || e.key === "ArrowDown") {
472
+ e.preventDefault();
473
+ selectAt((index + 1) % options.length);
474
+ } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
475
+ e.preventDefault();
476
+ selectAt((index - 1 + options.length) % options.length);
477
+ }
478
+ };
479
+
480
+ return (
481
+ <div
482
+ role="radiogroup"
483
+ aria-labelledby={fieldLabelId(id)}
484
+ aria-required={required}
485
+ aria-invalid={hasError === true ? true : undefined}
486
+ data-testid={`segmented-${id}`}
487
+ className={cn(
488
+ "inline-flex w-fit flex-wrap divide-x overflow-hidden rounded-md border",
489
+ hasError === true
490
+ ? "divide-destructive/50 border-destructive"
491
+ : "divide-border border-input",
492
+ )}
493
+ >
494
+ <input type="hidden" name={name} value={value} />
495
+ {options.map((opt, index) => {
496
+ const checked = opt.value === value;
497
+ return (
498
+ // biome-ignore lint/a11y/useSemanticElements: a native <input type="radio"> can't render the segment's label as content — button+role="radio" is the standard WAI-ARIA composite-widget substitute.
499
+ <button
500
+ key={opt.value}
501
+ ref={(node) => {
502
+ buttonRefs.current[index] = node;
503
+ }}
504
+ type="button"
505
+ role="radio"
506
+ aria-checked={checked}
507
+ tabIndex={checked || (value === "" && index === 0) ? 0 : -1}
508
+ disabled={disabled}
509
+ data-testid={`segmented-${id}-${opt.value}`}
510
+ onClick={() => onChange(opt.value)}
511
+ onKeyDown={(e) => handleKeyDown(e, index)}
512
+ className={cn(
513
+ "px-3 py-1.5 text-sm font-medium transition-colors",
514
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
515
+ "disabled:pointer-events-none disabled:opacity-50",
516
+ checked
517
+ ? "bg-primary text-primary-foreground"
518
+ : "bg-transparent text-foreground hover:bg-accent",
519
+ )}
520
+ >
521
+ {opt.label}
522
+ </button>
523
+ );
524
+ })}
525
+ </div>
526
+ );
527
+ }
528
+
362
529
  function DefaultInput(props: InputProps): ReactNode {
363
530
  // Vendored ui/input + ui/checkbox stylen Fehler über `aria-invalid`
364
531
  // selbst — kein manuelles border-destructive mehr nötig.
532
+ const booleanLayout = useContext(FieldLayoutContext);
365
533
  const common = {
366
534
  id: props.id,
367
535
  name: props.name,
@@ -409,23 +577,27 @@ function DefaultInput(props: InputProps): ReactNode {
409
577
  />
410
578
  );
411
579
  case "number":
412
- return withFieldIcon(
413
- props.icon,
414
- <UiInput
415
- type="number"
416
- {...common}
417
- data-testid={props.testId}
418
- value={props.value}
419
- step={props.step}
420
- onChange={(e: ChangeEvent<HTMLInputElement>) => {
421
- const v = e.target.value;
422
- props.onChange(v === "" ? undefined : Number(v));
423
- }}
424
- className={cn(
425
- "text-right tabular-nums",
426
- fieldIconFor(props.icon) !== undefined ? "pl-8" : undefined,
427
- )}
428
- />,
580
+ return withUnitSuffix(
581
+ props.unit,
582
+ withFieldIcon(
583
+ props.icon,
584
+ <UiInput
585
+ type="number"
586
+ {...common}
587
+ data-testid={props.testId}
588
+ value={props.value}
589
+ step={props.step}
590
+ onChange={(e: ChangeEvent<HTMLInputElement>) => {
591
+ const v = e.target.value;
592
+ props.onChange(v === "" ? undefined : Number(v));
593
+ }}
594
+ className={cn(
595
+ "text-right tabular-nums",
596
+ fieldIconFor(props.icon) !== undefined ? "pl-8" : undefined,
597
+ props.unit !== undefined ? "pr-8" : undefined,
598
+ )}
599
+ />,
600
+ ),
429
601
  );
430
602
  case "range":
431
603
  return (
@@ -441,7 +613,10 @@ function DefaultInput(props: InputProps): ReactNode {
441
613
  />
442
614
  );
443
615
  case "boolean":
444
- return (
616
+ // layout="inline" (checkbox lists like MultiSelectCheckboxes, the
617
+ // BooleanField widget) keeps the checkbox optics; the standard form
618
+ // field (render-field.tsx, layout="stacked") gets the switch.
619
+ return booleanLayout === "inline" ? (
445
620
  <Checkbox
446
621
  id={props.id}
447
622
  name={props.name}
@@ -451,6 +626,17 @@ function DefaultInput(props: InputProps): ReactNode {
451
626
  checked={props.value}
452
627
  onCheckedChange={(checked) => props.onChange(checked === true)}
453
628
  />
629
+ ) : (
630
+ <Switch
631
+ id={props.id}
632
+ name={props.name}
633
+ disabled={props.disabled}
634
+ aria-required={props.required}
635
+ aria-invalid={props.hasError === true ? true : undefined}
636
+ checked={props.value}
637
+ onCheckedChange={(checked) => props.onChange(checked === true)}
638
+ className="aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40"
639
+ />
454
640
  );
455
641
  case "file":
456
642
  case "image":
@@ -495,6 +681,20 @@ function DefaultInput(props: InputProps): ReactNode {
495
681
  const comboOptions = props.options.map((o) =>
496
682
  typeof o === "string" ? { value: o, label: o } : o,
497
683
  );
684
+ if (isSegmentedSelectEligible(comboOptions)) {
685
+ return (
686
+ <SegmentedSelect
687
+ id={props.id}
688
+ name={props.name}
689
+ value={props.value}
690
+ onChange={props.onChange}
691
+ options={comboOptions}
692
+ {...(props.disabled !== undefined && { disabled: props.disabled })}
693
+ {...(props.required !== undefined && { required: props.required })}
694
+ {...(props.hasError !== undefined && { hasError: props.hasError })}
695
+ />
696
+ );
697
+ }
498
698
  return (
499
699
  <ComboboxInput
500
700
  id={props.id}
@@ -682,20 +882,29 @@ function DefaultDataTable({
682
882
  // Optional hooks: a bare DataTable outside LocaleProvider must not crash.
683
883
  const tableTranslate = useOptionalTranslation();
684
884
  const tableLocale = useOptionalLocale();
885
+ // Below 768px a table scrolls its columns out of reach with no visible
886
+ // affordance (fw#2159 fixed the desktop case; narrow viewports never had
887
+ // one). Cards replace the table entirely below the breakpoint — same
888
+ // single-mount pattern as EmbeddedListInput/embedded-list-input.tsx.
889
+ const isNarrow = useIsNarrowViewport();
685
890
  // Toolbar-Wrapper: gemeinsamer Container für Toolbar+Tabelle damit
686
891
  // beide visuell zusammengehören. Toolbar ist NICHT sticky — Lists
687
892
  // scrollen typischerweise mit dem Page-Container, nicht intern.
688
893
  // Sticky würde mit der Topbar konkurrieren.
689
894
  const hasTableActions = rowActions !== undefined && rowActions.length > 0;
895
+ const isEmpty = rows.length === 0;
896
+ const emptyBlock: ReactNode = (
897
+ <div
898
+ data-testid={testId !== undefined ? `${testId}-empty` : "render-list-empty"}
899
+ className="flex flex-col items-center justify-center rounded-md border border-dashed p-12 text-sm text-muted-foreground gap-3"
900
+ >
901
+ {emptyState ?? <span>No entries.</span>}
902
+ </div>
903
+ );
904
+
690
905
  function tableInner(): ReactNode {
691
- return rows.length === 0 ? (
692
- <div
693
- data-testid={testId !== undefined ? `${testId}-empty` : "render-list-empty"}
694
- className="flex flex-col items-center justify-center rounded-md border border-dashed p-12 text-sm text-muted-foreground gap-3"
695
- >
696
- {emptyState ?? <span>No entries.</span>}
697
- </div>
698
- ) : (
906
+ if (isEmpty) return emptyBlock;
907
+ return (
699
908
  // dashboard-01-Muster: `rounded-lg border`-Rahmen, die Header-Zeile
700
909
  // trägt den bg-muted-Grauton. `bg-card` (statt transparent) → die Liste
701
910
  // sitzt auf derselben Card-Fläche wie Forms; auf Themes mit farbigem
@@ -794,7 +1003,147 @@ function DefaultDataTable({
794
1003
  </div>
795
1004
  );
796
1005
  }
797
- const tableContent = tableInner();
1006
+
1007
+ // No column headers below the breakpoint, so the click-to-sort affordance
1008
+ // on SortableHeader has nothing to attach to. A single native <select> is
1009
+ // the whole fix — no custom widget, no menu — fed straight from the
1010
+ // columns marked sortable in the ViewModel. Options carry the resolved
1011
+ // {field, dir} directly so onChange can look the pick up by value instead
1012
+ // of parsing/casting the option string back apart.
1013
+ const sortableColumns = columns.filter((col) => col.sortable);
1014
+ const sortOptions: readonly {
1015
+ readonly value: string;
1016
+ readonly field: string;
1017
+ readonly dir: DataTableSortDir;
1018
+ readonly label: string;
1019
+ }[] = sortableColumns.flatMap((col) => [
1020
+ { value: `${col.field}:asc`, field: col.field, dir: "asc", label: `${col.label} ↑` },
1021
+ { value: `${col.field}:desc`, field: col.field, dir: "desc", label: `${col.label} ↓` },
1022
+ ]);
1023
+
1024
+ function renderCard(row: ListRowViewModel): ReactNode {
1025
+ const titleColumn = columns.find((col) => col.highlighted === true) ?? columns[0];
1026
+ const detailColumns = columns.filter((col) => col !== titleColumn);
1027
+ return (
1028
+ <div
1029
+ key={row.id}
1030
+ data-testid={getRowTestId?.(row) ?? `row-${row.id}`}
1031
+ {...(onRowClick !== undefined && {
1032
+ role: "button" as const,
1033
+ tabIndex: 0,
1034
+ onClick: () => onRowClick(row),
1035
+ onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {
1036
+ if (e.key !== "Enter" && e.key !== " ") return;
1037
+ e.preventDefault();
1038
+ onRowClick(row);
1039
+ },
1040
+ })}
1041
+ className={cn(
1042
+ "flex flex-col gap-3 rounded-lg border bg-card p-4",
1043
+ onRowClick !== undefined && "cursor-pointer",
1044
+ )}
1045
+ >
1046
+ {titleColumn !== undefined && (
1047
+ <div
1048
+ data-testid={
1049
+ getCellTestId?.(row, titleColumn.field) ?? `cell-${row.id}-${titleColumn.field}`
1050
+ }
1051
+ className="text-base font-medium"
1052
+ >
1053
+ <DataTableCell
1054
+ value={row.values[titleColumn.field]}
1055
+ row={row.values}
1056
+ field={titleColumn.field}
1057
+ type={titleColumn.type}
1058
+ renderer={titleColumn.renderer}
1059
+ translate={tableTranslate}
1060
+ locale={tableLocale}
1061
+ {...(titleColumn.optionLabels !== undefined && {
1062
+ optionLabels: titleColumn.optionLabels,
1063
+ })}
1064
+ {...(onCellChange !== undefined && {
1065
+ onChange: (value: unknown) => onCellChange(row.id, titleColumn.field, value),
1066
+ })}
1067
+ />
1068
+ </div>
1069
+ )}
1070
+ <div className="flex flex-col gap-2">
1071
+ {detailColumns.map((col) => (
1072
+ <div key={col.field} className="flex flex-col gap-0.5">
1073
+ <span className="text-xs text-muted-foreground">{col.label}</span>
1074
+ <span
1075
+ data-testid={getCellTestId?.(row, col.field) ?? `cell-${row.id}-${col.field}`}
1076
+ className="text-sm"
1077
+ >
1078
+ <DataTableCell
1079
+ value={row.values[col.field]}
1080
+ row={row.values}
1081
+ field={col.field}
1082
+ type={col.type}
1083
+ renderer={col.renderer}
1084
+ translate={tableTranslate}
1085
+ locale={tableLocale}
1086
+ {...(col.optionLabels !== undefined && { optionLabels: col.optionLabels })}
1087
+ {...(onCellChange !== undefined && {
1088
+ onChange: (value: unknown) => onCellChange(row.id, col.field, value),
1089
+ })}
1090
+ />
1091
+ </span>
1092
+ </div>
1093
+ ))}
1094
+ </div>
1095
+ {hasTableActions && (
1096
+ // biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation only — not a control
1097
+ <div
1098
+ className="flex items-center justify-end gap-1 border-t pt-3"
1099
+ onClick={(e) => e.stopPropagation()}
1100
+ onKeyDown={(e) => e.stopPropagation()}
1101
+ >
1102
+ <RowActionsCell row={row} actions={rowActions} mode={rowActionMode} />
1103
+ </div>
1104
+ )}
1105
+ </div>
1106
+ );
1107
+ }
1108
+
1109
+ function cardsInner(): ReactNode {
1110
+ if (isEmpty) return emptyBlock;
1111
+ return (
1112
+ <div
1113
+ data-testid={testId !== undefined ? `${testId}-cards` : "render-list-cards"}
1114
+ className="flex flex-col gap-3"
1115
+ >
1116
+ {onSortChange !== undefined && sortableColumns.length > 0 && (
1117
+ <select
1118
+ aria-label={tableTranslate?.("kumiko.list.sort.label") ?? "Sort"}
1119
+ data-testid={testId !== undefined ? `${testId}-sort` : "render-list-sort"}
1120
+ value={sort !== undefined && sort !== null ? `${sort.field}:${sort.dir}` : ""}
1121
+ onChange={(e) => {
1122
+ const raw = e.target.value;
1123
+ if (raw === "") {
1124
+ onSortChange(null);
1125
+ return;
1126
+ }
1127
+ const picked = sortOptions.find((o) => o.value === raw);
1128
+ if (picked === undefined) return;
1129
+ onSortChange({ field: picked.field, dir: picked.dir });
1130
+ }}
1131
+ className="h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
1132
+ >
1133
+ <option value="">{tableTranslate?.("kumiko.list.sort.unsorted") ?? "Unsorted"}</option>
1134
+ {sortOptions.map((o) => (
1135
+ <option key={o.value} value={o.value}>
1136
+ {o.label}
1137
+ </option>
1138
+ ))}
1139
+ </select>
1140
+ )}
1141
+ {rows.map((row) => renderCard(row))}
1142
+ </div>
1143
+ );
1144
+ }
1145
+
1146
+ const tableContent = isNarrow ? cardsInner() : tableInner();
798
1147
 
799
1148
  // Pager wird IMMER unter der Tabelle gerendert (auch bei rows=[]),
800
1149
  // damit der User bei einem Filter-Hit-of-Zero zurückblättern kann
@@ -904,10 +1253,14 @@ function RowActionsCell({
904
1253
  const visible = actions.filter((a) => a.isVisible === undefined || a.isVisible(row));
905
1254
  if (visible.length === 0) return null;
906
1255
  if (mode === "inline") {
1256
+ // Teil C: >2 actions all carrying an icon collapse to icon-only —
1257
+ // otherwise "inline" is exactly the wall-to-wall text-button problem
1258
+ // this feature exists to fix.
1259
+ const iconOnly = shouldRenderActionsIconOnly(visible);
907
1260
  return (
908
1261
  <div className="flex w-full items-center gap-1 justify-start">
909
1262
  {visible.map((a) => (
910
- <RowActionButton key={a.id} row={row} action={a} />
1263
+ <RowActionButton key={a.id} row={row} action={a} iconOnly={iconOnly} />
911
1264
  ))}
912
1265
  </div>
913
1266
  );
@@ -963,9 +1316,13 @@ function useRowActionTrigger(row: ListRowViewModel) {
963
1316
  function RowActionButton({
964
1317
  row,
965
1318
  action,
1319
+ iconOnly = false,
966
1320
  }: {
967
1321
  readonly row: ListRowViewModel;
968
1322
  readonly action: DataTableRowAction;
1323
+ /** Group-level collapse (see `shouldRenderActionsIconOnly`) — only takes
1324
+ * effect when this action actually resolved an icon. */
1325
+ readonly iconOnly?: boolean;
969
1326
  }): ReactNode {
970
1327
  const { busy, triggerNow } = useRowActionTrigger(row);
971
1328
  const [confirmOpen, setConfirmOpen] = useState(false);
@@ -977,12 +1334,16 @@ function RowActionButton({
977
1334
  ? "text-primary hover:bg-primary/10"
978
1335
  : "text-foreground hover:bg-accent";
979
1336
 
1337
+ const resolvedIcon = actionIconFor(action.icon);
1338
+ const showIconOnly = iconOnly && resolvedIcon !== undefined;
1339
+
980
1340
  return (
981
1341
  <>
982
1342
  <button
983
1343
  type="button"
984
1344
  data-testid={`row-${row.id}-action-${action.id}`}
985
1345
  disabled={busy}
1346
+ {...(showIconOnly && { "aria-label": action.label, title: action.label })}
986
1347
  onClick={(e) => {
987
1348
  e.stopPropagation();
988
1349
  if (needsConfirm(action)) {
@@ -992,13 +1353,25 @@ function RowActionButton({
992
1353
  }
993
1354
  }}
994
1355
  className={cn(
995
- "inline-flex h-8 items-center justify-center rounded-sm px-2 text-sm",
1356
+ "inline-flex h-8 items-center justify-center gap-1.5 rounded-sm text-sm",
1357
+ showIconOnly ? "w-8" : "px-2",
996
1358
  "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
997
1359
  "disabled:opacity-50 disabled:pointer-events-none",
998
1360
  variantClass,
999
1361
  )}
1000
1362
  >
1001
- {busy ? <Loader2 className="size-3.5 animate-spin" aria-hidden="true" /> : action.label}
1363
+ {busy ? (
1364
+ <Loader2 className="size-3.5 animate-spin" aria-hidden="true" />
1365
+ ) : resolvedIcon === undefined ? (
1366
+ action.label
1367
+ ) : showIconOnly ? (
1368
+ <Icon name={resolvedIcon} className="size-4" />
1369
+ ) : (
1370
+ <>
1371
+ <Icon name={resolvedIcon} className="size-4" />
1372
+ {action.label}
1373
+ </>
1374
+ )}
1002
1375
  </button>
1003
1376
  <DefaultDialog
1004
1377
  open={confirmOpen}
@@ -1456,11 +1829,30 @@ function isMoneyValue(value: unknown): value is MoneyCellValue {
1456
1829
  // - select/multiSelect → human-readable (kebab-case → Title Case), multiSelect values joined with ", "
1457
1830
  // - money → { amount, currency } formatted via Intl (not "[object Object]")
1458
1831
  // - text/else → toString
1832
+
1833
+ const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/;
1834
+ const warnedTimestampColumns = new Set<string>();
1835
+
1836
+ // A raw ISO string in a "text" column means the author forgot `renderer:
1837
+ // { format: "timestamp" }` — guessing a format here could be wrong, so we
1838
+ // warn and leave the value as-is instead of auto-formatting.
1839
+ function warnMissingTimestampFormat(columnKey: string | undefined): void {
1840
+ if (typeof process === "undefined" || process.env.NODE_ENV === "production") return;
1841
+ const key = columnKey ?? "<unknown column>";
1842
+ if (warnedTimestampColumns.has(key)) return;
1843
+ warnedTimestampColumns.add(key);
1844
+ // biome-ignore lint/suspicious/noConsole: dev-only assertion
1845
+ console.warn(
1846
+ `[kumiko] column "${key}" renders a raw ISO timestamp as text — add renderer: { format: "timestamp" } to its column definition.`,
1847
+ );
1848
+ }
1849
+
1459
1850
  export function defaultCellRender(
1460
1851
  value: unknown,
1461
1852
  type: string,
1462
1853
  optionLabels?: Readonly<Record<string, string>>,
1463
1854
  locale?: string,
1855
+ columnKey?: string,
1464
1856
  ): string {
1465
1857
  if (value === null || value === undefined || value === "") return "";
1466
1858
  if (type === "boolean") return value === true ? "✓" : "";
@@ -1495,6 +1887,9 @@ export function defaultCellRender(
1495
1887
  })
1496
1888
  .join(", ");
1497
1889
  }
1890
+ if (type === "text" && typeof value === "string" && ISO_DATETIME_RE.test(value)) {
1891
+ warnMissingTimestampFormat(columnKey);
1892
+ }
1498
1893
  return typeof value === "string" ? value : String(value);
1499
1894
  }
1500
1895
 
@@ -1593,17 +1988,25 @@ function DataTableCell({
1593
1988
  // biome-ignore lint/suspicious/noConsole: dev-warning für Schema-Konflikte
1594
1989
  console.warn(`[kumiko] columnRenderer "${componentRef.name}" not registered`);
1595
1990
  }
1596
- // select-Werte als neutrale Badge-Pill (shadcn secondary) statt Plain-Text.
1597
- // Farbige Status-Semantik (grün/amber) bleibt App-Sache via columnRenderer.
1991
+ // A select value renders as a pill instead of plain text. When the raw
1992
+ // value is a known status word, it carries the same tone as the
1993
+ // projectionDetail header badge — a status column stayed grey while the
1994
+ // detail view of the same value was coloured (fw#2579). Unknown values
1995
+ // keep the neutral outline pill.
1598
1996
  if (type === "select" && value !== null && value !== undefined && value !== "") {
1599
- // dashboard-01-Muster: outline-Badge + muted statt gefülltem secondary.
1997
+ const label = defaultCellRender(value, type, optionLabels, locale);
1998
+ const tone = typeof value === "string" ? statusToneForValue(value) : undefined;
1999
+ if (tone !== undefined) {
2000
+ return <StatusBadge tone={tone}>{label}</StatusBadge>;
2001
+ }
2002
+ // dashboard-01 pattern: outline badge + muted instead of a filled secondary.
1600
2003
  return (
1601
2004
  <Badge variant="outline" className="px-1.5 text-muted-foreground">
1602
- {defaultCellRender(value, type, optionLabels, locale)}
2005
+ {label}
1603
2006
  </Badge>
1604
2007
  );
1605
2008
  }
1606
- return defaultCellRender(value, type, optionLabels, locale);
2009
+ return defaultCellRender(value, type, optionLabels, locale, field);
1607
2010
  }
1608
2011
 
1609
2012
  // ---- Form + Section + Grid + Text ----
@@ -1630,6 +2033,7 @@ function DefaultForm({
1630
2033
  title,
1631
2034
  subtitle,
1632
2035
  actions,
2036
+ secondaryActions,
1633
2037
  testId,
1634
2038
  width,
1635
2039
  stickyActions,
@@ -1653,8 +2057,11 @@ function DefaultForm({
1653
2057
  )}
1654
2058
  >
1655
2059
  <InsideFormContext.Provider value={true}>{children}</InsideFormContext.Provider>
1656
- {actions !== undefined && (
1657
- <div className="flex items-center justify-end gap-2">{actions}</div>
2060
+ {(secondaryActions !== undefined || actions !== undefined) && (
2061
+ <div className="flex items-center justify-end gap-2">
2062
+ {secondaryActions}
2063
+ {actions}
2064
+ </div>
1658
2065
  )}
1659
2066
  </form>
1660
2067
  );
@@ -1678,7 +2085,7 @@ function DefaultForm({
1678
2085
  )}
1679
2086
  <div className={cn(cardSurface(), "overflow-hidden")}>
1680
2087
  {(title !== undefined || subtitle !== undefined) && (
1681
- <div className="px-6 pb-2 pt-5">
2088
+ <div className={cn(cardHeaderBorder, "px-6 pb-4 pt-5")}>
1682
2089
  {title !== undefined && (
1683
2090
  <h2
1684
2091
  data-testid={testId !== undefined ? `${testId}-title` : undefined}
@@ -1714,11 +2121,10 @@ function DefaultForm({
1714
2121
  >
1715
2122
  <InsideFormContext.Provider value={true}>{children}</InsideFormContext.Provider>
1716
2123
  </div>
1717
- {actions !== undefined && (
2124
+ {(secondaryActions !== undefined || actions !== undefined) && (
1718
2125
  <div
1719
- data-testid={testId !== undefined ? `${testId}-actions` : undefined}
1720
2126
  className={cn(
1721
- cardFooter,
2127
+ "flex flex-col-reverse gap-3 px-[var(--card-padding)] py-3 sm:flex-row sm:items-center sm:justify-between sm:py-4",
1722
2128
  cardFooterBorder,
1723
2129
  // Below sm (640px): pin to the viewport bottom instead of normal
1724
2130
  // flow, so a virtual keyboard shrinking the viewport can't push
@@ -1729,7 +2135,22 @@ function DefaultForm({
1729
2135
  "max-sm:fixed max-sm:inset-x-0 max-sm:bottom-0 max-sm:z-20 max-sm:bg-background max-sm:shadow-[0_-4px_12px_-4px_rgb(0_0_0_/_0.15)] max-sm:pb-4",
1730
2136
  )}
1731
2137
  >
1732
- {actions}
2138
+ {secondaryActions !== undefined && (
2139
+ <div
2140
+ data-testid={testId !== undefined ? `${testId}-actions-secondary` : undefined}
2141
+ className="flex flex-wrap items-center gap-1 max-sm:[&_button]:text-xs"
2142
+ >
2143
+ {secondaryActions}
2144
+ </div>
2145
+ )}
2146
+ {actions !== undefined && (
2147
+ <div
2148
+ data-testid={testId !== undefined ? `${testId}-actions` : undefined}
2149
+ className="flex items-center gap-2 max-sm:w-full max-sm:[&>button]:flex-1 max-sm:[&>button]:min-h-11 sm:ml-auto"
2150
+ >
2151
+ {actions}
2152
+ </div>
2153
+ )}
1733
2154
  </div>
1734
2155
  )}
1735
2156
  </div>
@@ -1754,7 +2175,7 @@ export function FormScreenShell({
1754
2175
  children,
1755
2176
  className,
1756
2177
  testId,
1757
- maxWidth = "full",
2178
+ maxWidth = "4xl",
1758
2179
  }: {
1759
2180
  readonly children: ReactNode;
1760
2181
  readonly className?: string;
@@ -1778,20 +2199,24 @@ function DefaultSection({
1778
2199
  actions,
1779
2200
  variant = "default",
1780
2201
  testId,
2202
+ icon,
1781
2203
  }: SectionProps): ReactNode {
1782
2204
  const insideForm = useContext(InsideFormContext);
1783
2205
 
1784
2206
  // h3 statt CardTitle (= div): erhält die Heading-Semantik für
1785
2207
  // Screenreader-Navigation. Subtitle fließt darunter (kein Divider —
1786
2208
  // shadcn CardTitle+CardDescription-Muster).
2209
+ // icon only renders alongside a title — a title-less section has nothing
2210
+ // for a lone icon to sit next to, so it stays as-is (no heading grows).
1787
2211
  const header =
1788
2212
  title !== undefined || subtitle !== undefined ? (
1789
2213
  <div className="flex flex-col gap-1">
1790
2214
  {title !== undefined && (
1791
2215
  <h3
1792
2216
  data-testid={testId !== undefined ? `${testId}-title` : undefined}
1793
- className="text-base font-semibold leading-none tracking-tight"
2217
+ className="flex items-center gap-2 text-base font-semibold leading-none tracking-tight"
1794
2218
  >
2219
+ {icon !== undefined && <Icon name={icon} className="size-4 text-muted-foreground" />}
1795
2220
  {title}
1796
2221
  </h3>
1797
2222
  )}
@@ -1836,8 +2261,8 @@ function DefaultSection({
1836
2261
  );
1837
2262
  }
1838
2263
 
1839
- // Standalone: eigene Card, Header fließt in den Body (kein Divider).
1840
- // actions = abgehobene Footer-Row (border-t bg-muted/30, wie DefaultForm).
2264
+ // Standalone: own card, header flows into the body (no divider).
2265
+ // actions = raised footer row (cardFooterBorder, same as DefaultForm).
1841
2266
  // overflow-hidden clips the footer-corner radius correctly for portaled
1842
2267
  // overlays (Combobox/Select/Tooltip escape to document.body, unaffected).
1843
2268
  // A non-portaled overlay (e.g. a custom dropdown built directly into