@cosmicdrift/kumiko-renderer 0.44.0 → 0.45.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.44.0",
3
+ "version": "0.45.0",
4
4
  "description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -0,0 +1,118 @@
1
+ // @runtime client
2
+ // Extension-Form-Submit-Registry — erlaubt einer Extension-Section (z.B.
3
+ // Custom-Fields), beim Submit des umgebenden entityEdit-Forms mitzuschreiben,
4
+ // statt einen eigenen Save-Button zu führen ("composed form, ein Save",
5
+ // Bug-Bash 3 #1). Die Section meldet (a) einen Submit-Handler und (b) ihren
6
+ // dirty-State an die Form; die Form aktiviert ihren Save-Button auch wenn nur
7
+ // eine Section dirty ist und ruft nach dem Entity-Write alle Section-Handler
8
+ // mit der entityId.
9
+ //
10
+ // Ohne umgebende Form (Context === null) fällt die Section auf ihren eigenen
11
+ // Save-Button zurück (Standalone-Backward-Compat).
12
+
13
+ import { createContext, useContext, useEffect, useId, useMemo, useRef } from "react";
14
+
15
+ export type ExtensionSubmitContext = { readonly entityId: string };
16
+
17
+ export type ExtensionSubmitResult = {
18
+ readonly isSuccess: boolean;
19
+ /** i18n-Key für die Fehlermeldung, die die Form als Banner zeigt. */
20
+ readonly errorKey?: string;
21
+ };
22
+
23
+ export type ExtensionFormSubmitHandler = (
24
+ ctx: ExtensionSubmitContext,
25
+ ) => Promise<ExtensionSubmitResult>;
26
+
27
+ type Registration = {
28
+ readonly key: string;
29
+ readonly dirty: boolean;
30
+ readonly handler: ExtensionFormSubmitHandler;
31
+ };
32
+
33
+ export type ExtensionFormRegistry = {
34
+ readonly upsert: (reg: Registration) => void;
35
+ readonly remove: (key: string) => void;
36
+ };
37
+
38
+ const ExtensionFormRegistryContext = createContext<ExtensionFormRegistry | null>(null);
39
+
40
+ export const ExtensionFormRegistryProvider = ExtensionFormRegistryContext.Provider;
41
+
42
+ // Host-Seite (render-edit): hält die Registrierungen in einem ref, meldet den
43
+ // aggregierten dirty-State via onDirtyChange hoch (damit der Save-Button
44
+ // re-rendert) und liefert runAll() zum Ausführen aller Handler beim Submit.
45
+ export function useExtensionFormHost(onDirtyChange: (anyDirty: boolean) => void): {
46
+ readonly registry: ExtensionFormRegistry;
47
+ readonly runAll: (ctx: ExtensionSubmitContext) => Promise<readonly ExtensionSubmitResult[]>;
48
+ } {
49
+ const regsRef = useRef<Map<string, Registration>>(new Map());
50
+ const onDirtyRef = useRef(onDirtyChange);
51
+ onDirtyRef.current = onDirtyChange;
52
+
53
+ const registry = useMemo<ExtensionFormRegistry>(() => {
54
+ const emitDirty = (): void => {
55
+ let any = false;
56
+ for (const r of regsRef.current.values()) {
57
+ if (r.dirty) {
58
+ any = true;
59
+ break;
60
+ }
61
+ }
62
+ onDirtyRef.current(any);
63
+ };
64
+ return {
65
+ upsert: (reg) => {
66
+ regsRef.current.set(reg.key, reg);
67
+ emitDirty();
68
+ },
69
+ remove: (key) => {
70
+ regsRef.current.delete(key);
71
+ emitDirty();
72
+ },
73
+ };
74
+ }, []);
75
+
76
+ const runAll = useMemo(
77
+ () =>
78
+ async (ctx: ExtensionSubmitContext): Promise<readonly ExtensionSubmitResult[]> => {
79
+ const results: ExtensionSubmitResult[] = [];
80
+ // Insertion-Order (Map) = Section-Reihenfolge im Form.
81
+ for (const reg of regsRef.current.values()) {
82
+ results.push(await reg.handler(ctx));
83
+ }
84
+ return results;
85
+ },
86
+ [],
87
+ );
88
+
89
+ return { registry, runAll };
90
+ }
91
+
92
+ // Section-Seite: meldet Handler + dirty an die umgebende Form. Returnt true
93
+ // wenn eine composed-Form vorhanden ist (Section blendet dann ihren eigenen
94
+ // Save-Button aus); false = standalone (eigener Save-Button bleibt).
95
+ export function useExtensionFormSubmit(opts: {
96
+ readonly dirty: boolean;
97
+ readonly onSubmit: ExtensionFormSubmitHandler;
98
+ }): boolean {
99
+ const registry = useContext(ExtensionFormRegistryContext);
100
+ const key = useId();
101
+ // Handler über ref, damit das Re-Registrieren NICHT an jeder Handler-
102
+ // Identity hängt (nur an dirty); der Handler liest immer den frischen
103
+ // Closure-State (pending) zur Aufruf-Zeit.
104
+ const handlerRef = useRef(opts.onSubmit);
105
+ handlerRef.current = opts.onSubmit;
106
+
107
+ useEffect(() => {
108
+ if (registry === null) return;
109
+ registry.upsert({
110
+ key,
111
+ dirty: opts.dirty,
112
+ handler: (ctx) => handlerRef.current(ctx),
113
+ });
114
+ return () => registry.remove(key);
115
+ }, [registry, key, opts.dirty]);
116
+
117
+ return registry !== null;
118
+ }
@@ -23,6 +23,7 @@ import type {
23
23
  import { computeEditViewModel } from "@cosmicdrift/kumiko-headless";
24
24
  import { type ReactNode, useMemo, useState } from "react";
25
25
  import type { z } from "zod";
26
+ import { ExtensionFormRegistryProvider, useExtensionFormHost } from "../app/extension-form-submit";
26
27
  import { extensionSectionName, useExtensionSectionComponent } from "../app/extension-sections";
27
28
  import { useForm } from "../hooks/use-form";
28
29
  import { useTranslation } from "../i18n";
@@ -196,6 +197,14 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
196
197
  const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
197
198
  const [isSubmitting, setIsSubmitting] = useState(false);
198
199
  const [formError, setFormError] = useState<DispatcherError | null>(null);
200
+ // Composed-Save: Extension-Sections melden hier ihren dirty-State (damit der
201
+ // Save-Button aktiv wird wenn NUR eine Section geändert wurde) + ihren
202
+ // Submit-Handler (läuft nach dem Entity-Write). extensionErrorKey hält den
203
+ // i18n-Key einer fehlgeschlagenen Section-Persistierung.
204
+ const [extensionDirty, setExtensionDirty] = useState(false);
205
+ const [extensionErrorKey, setExtensionErrorKey] = useState<string | null>(null);
206
+ const { registry: extensionFormRegistry, runAll: runExtensionSubmits } =
207
+ useExtensionFormHost(setExtensionDirty);
199
208
  const { Button, Banner, Dialog, Form, Section, Grid, GridCell, Text } = usePrimitives();
200
209
 
201
210
  const fields = useMemo(() => deriveFormFields<TValues, TCtx>(screen), [screen]);
@@ -232,9 +241,32 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
232
241
  [screen, entity, snapshot.values, translate, featureName],
233
242
  );
234
243
 
244
+ // Persistiert alle composed Extension-Sections mit der aufgelösten entityId.
245
+ // false = eine Section schlug fehl (ihr i18n-Key landet im Banner). Ohne
246
+ // Entity-Kontext (create-mode ohne route-id) gibt es nichts zu schreiben.
247
+ async function persistExtensions(): Promise<boolean> {
248
+ const entityId = entityIdProp ?? null;
249
+ if (entityId === null) return true;
250
+ const results = await runExtensionSubmits({ entityId });
251
+ const failed = results.find((r) => !r.isSuccess);
252
+ if (failed !== undefined) {
253
+ setExtensionErrorKey(failed.errorKey ?? "kumiko.form.extension.save-failed");
254
+ return false;
255
+ }
256
+ return true;
257
+ }
258
+
235
259
  async function handleSubmit(): Promise<void> {
236
260
  setIsSubmitting(true);
261
+ setExtensionErrorKey(null);
237
262
  try {
263
+ // Extension-only: nur eine Section ist dirty, das Haupt-Form unverändert.
264
+ // Kein Entity-Write (würde einen leeren changes-Payload schreiben) — nur
265
+ // die Section-Handler laufen lassen.
266
+ if (snapshot.isUnchanged && extensionDirty) {
267
+ await persistExtensions();
268
+ return;
269
+ }
238
270
  let result: SubmitResult<unknown>;
239
271
  if (customSubmit !== undefined) {
240
272
  // customSubmit-Pfad (z.B. configEdit, das pro Field einen
@@ -274,6 +306,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
274
306
  // Field-Errors fließen über snapshot.errors in die einzelnen Fields.
275
307
  if (result.isSuccess) {
276
308
  setFormError(null);
309
+ await persistExtensions();
277
310
  } else if (!result.validationBlocked) {
278
311
  const fieldIssues = result.error.details?.fields ?? [];
279
312
  setFormError(fieldIssues.length === 0 ? result.error : null);
@@ -312,7 +345,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
312
345
  )}
313
346
  <Button
314
347
  type="submit"
315
- disabled={snapshot.isUnchanged || isSubmitting}
348
+ disabled={(snapshot.isUnchanged && !extensionDirty) || isSubmitting}
316
349
  loading={isSubmitting}
317
350
  variant="primary"
318
351
  testId="render-edit-submit"
@@ -329,94 +362,101 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
329
362
  const formTitle = resolvedTitle === titleKey ? screen.id : resolvedTitle;
330
363
 
331
364
  return (
332
- <Form
333
- onSubmit={() => void handleSubmit()}
334
- title={formTitle}
335
- actions={formActions}
336
- testId="render-edit-form"
337
- >
338
- {vm.sections.map((section: EditSectionViewModel) => {
339
- if (section.kind === "extension") {
365
+ <ExtensionFormRegistryProvider value={extensionFormRegistry}>
366
+ <Form
367
+ onSubmit={() => void handleSubmit()}
368
+ title={formTitle}
369
+ actions={formActions}
370
+ testId="render-edit-form"
371
+ >
372
+ {vm.sections.map((section: EditSectionViewModel) => {
373
+ if (section.kind === "extension") {
374
+ return (
375
+ <ExtensionSectionMount
376
+ key={section.title}
377
+ section={section}
378
+ entityName={vm.entityName}
379
+ entityId={entityIdProp !== undefined ? entityIdProp : vm.id}
380
+ initialValues={extensionInitialValues}
381
+ />
382
+ );
383
+ }
384
+ // Section-Header unterdrücken wenn er den Form-Titel der
385
+ // Action-Bar 1:1 wiederholen würde (typisch bei Single-Section-
386
+ // ActionForms, deren Section-Label = Screen-Titel ist).
387
+ const sectionTitle = section.title === formTitle ? undefined : section.title;
340
388
  return (
341
- <ExtensionSectionMount
389
+ <Section
342
390
  key={section.title}
343
- section={section}
344
- entityName={vm.entityName}
345
- entityId={entityIdProp !== undefined ? entityIdProp : vm.id}
346
- initialValues={extensionInitialValues}
347
- />
391
+ {...(sectionTitle !== undefined && { title: sectionTitle })}
392
+ testId={`section-${section.title}`}
393
+ >
394
+ <Grid columns={section.columns}>
395
+ {section.fields.map((field: EditFieldViewModel) => (
396
+ <GridCellForField
397
+ key={field.field}
398
+ field={field}
399
+ columns={section.columns}
400
+ issues={snapshot.errors[field.field]}
401
+ onChange={(v) => {
402
+ (controller.setField as (k: string, v: unknown) => void)(field.field, v);
403
+ }}
404
+ GridCell={GridCell}
405
+ featureName={featureName}
406
+ {...(labelAppendix !== undefined && {
407
+ labelAppendix: labelAppendix(field.field),
408
+ })}
409
+ {...(fieldAppendix !== undefined && {
410
+ fieldAppendix: fieldAppendix(field.field),
411
+ })}
412
+ />
413
+ ))}
414
+ </Grid>
415
+ </Section>
348
416
  );
349
- }
350
- // Section-Header unterdrücken wenn er den Form-Titel der
351
- // Action-Bar 1:1 wiederholen würde (typisch bei Single-Section-
352
- // ActionForms, deren Section-Label = Screen-Titel ist).
353
- const sectionTitle = section.title === formTitle ? undefined : section.title;
354
- return (
355
- <Section
356
- key={section.title}
357
- {...(sectionTitle !== undefined && { title: sectionTitle })}
358
- testId={`section-${section.title}`}
359
- >
360
- <Grid columns={section.columns}>
361
- {section.fields.map((field: EditFieldViewModel) => (
362
- <GridCellForField
363
- key={field.field}
364
- field={field}
365
- columns={section.columns}
366
- issues={snapshot.errors[field.field]}
367
- onChange={(v) => {
368
- (controller.setField as (k: string, v: unknown) => void)(field.field, v);
417
+ })}
418
+ {formError !== null && (
419
+ <Banner
420
+ variant="error"
421
+ testId="render-edit-form-error"
422
+ actions={
423
+ formError.code === "version_conflict" && onReload !== undefined ? (
424
+ <Button
425
+ variant="secondary"
426
+ onClick={() => {
427
+ onReload();
428
+ setFormError(null);
369
429
  }}
370
- GridCell={GridCell}
371
- featureName={featureName}
372
- {...(labelAppendix !== undefined && {
373
- labelAppendix: labelAppendix(field.field),
374
- })}
375
- {...(fieldAppendix !== undefined && {
376
- fieldAppendix: fieldAppendix(field.field),
377
- })}
378
- />
379
- ))}
380
- </Grid>
381
- </Section>
382
- );
383
- })}
384
- {formError !== null && (
385
- <Banner
386
- variant="error"
387
- testId="render-edit-form-error"
388
- actions={
389
- formError.code === "version_conflict" && onReload !== undefined ? (
390
- <Button
391
- variant="secondary"
392
- onClick={() => {
393
- onReload();
394
- setFormError(null);
395
- }}
396
- testId="render-edit-form-error-reload"
397
- >
398
- {translate("kumiko.actions.reload")}
399
- </Button>
400
- ) : undefined
401
- }
402
- >
403
- <Text testId="render-edit-form-error-key">{translate(formError.i18nKey)}</Text>
404
- </Banner>
405
- )}
406
- {onDelete !== undefined && (
407
- <Dialog
408
- open={confirmDeleteOpen}
409
- onOpenChange={setConfirmDeleteOpen}
410
- title={translate("kumiko.actions.delete-confirm")}
411
- confirmLabel={translate("kumiko.actions.delete")}
412
- variant="danger"
413
- onConfirm={async () => {
414
- await onDelete();
415
- }}
416
- testId="render-edit-delete-dialog"
417
- />
418
- )}
419
- </Form>
430
+ testId="render-edit-form-error-reload"
431
+ >
432
+ {translate("kumiko.actions.reload")}
433
+ </Button>
434
+ ) : undefined
435
+ }
436
+ >
437
+ <Text testId="render-edit-form-error-key">{translate(formError.i18nKey)}</Text>
438
+ </Banner>
439
+ )}
440
+ {extensionErrorKey !== null && (
441
+ <Banner variant="error" testId="render-edit-extension-error">
442
+ <Text testId="render-edit-extension-error-key">{translate(extensionErrorKey)}</Text>
443
+ </Banner>
444
+ )}
445
+ {onDelete !== undefined && (
446
+ <Dialog
447
+ open={confirmDeleteOpen}
448
+ onOpenChange={setConfirmDeleteOpen}
449
+ title={translate("kumiko.actions.delete-confirm")}
450
+ confirmLabel={translate("kumiko.actions.delete")}
451
+ variant="danger"
452
+ onConfirm={async () => {
453
+ await onDelete();
454
+ }}
455
+ testId="render-edit-delete-dialog"
456
+ />
457
+ )}
458
+ </Form>
459
+ </ExtensionFormRegistryProvider>
420
460
  );
421
461
  }
422
462
 
@@ -253,14 +253,18 @@ export function RenderList(props: RenderListProps): ReactNode {
253
253
  />
254
254
  ) : undefined;
255
255
 
256
- // Toolbar-End-Slot: Toolbar-Actions (List-Header-Buttons aus dem
257
- // Schema) + optional "+ Neu" am rechten Edge. Reihenfolge im Rendering
258
- // = Reihenfolge im Array (Schema-deklariert), "+ Neu" kommt zuletzt
259
- // weil das die häufigste/auffälligste CTA ist.
256
+ // Toolbar-End-Slot: optionaler Header-Slot (z.B. Cap-Counter, links im
257
+ // rechten Cluster) + Toolbar-Actions (List-Header-Buttons aus dem Schema)
258
+ // + optional "+ Neu" am rechten Edge. Der Header-Slot lebt IN der Toolbar-
259
+ // Zeile (gleicher Card-Rahmen), nicht als loser Text über dem Screen-Titel
260
+ // (Bug-Bash 3 #12). Reihenfolge = Array-Reihenfolge (Schema-deklariert),
261
+ // "+ Neu" zuletzt weil das die häufigste/auffälligste CTA ist.
260
262
  const hasToolbarActions = toolbarActions !== undefined && toolbarActions.length > 0;
263
+ const hasHeaderSlot = screen.slots?.header !== undefined;
261
264
  const toolbarEnd =
262
- hasToolbarActions || onCreate !== undefined ? (
265
+ hasHeaderSlot || hasToolbarActions || onCreate !== undefined ? (
263
266
  <>
267
+ {hasHeaderSlot && <ListHeaderSlotMount screen={screen} />}
264
268
  {hasToolbarActions &&
265
269
  toolbarActions.map((a) => (
266
270
  <ToolbarActionView key={a.id} action={a} Button={Button} Dialog={Dialog} />
@@ -300,7 +304,6 @@ export function RenderList(props: RenderListProps): ReactNode {
300
304
  // ListSort = DataTableSort (use-list-url-state aliased) — kein Cast nötig.
301
305
  return (
302
306
  <>
303
- <ListHeaderSlotMount screen={screen} />
304
307
  {referenceColumns.map(
305
308
  (rc: { field: string; refEntity: string; refFeature: string; labelField: string }) => (
306
309
  <ReferenceLookupBridge
@@ -334,8 +337,8 @@ export function RenderList(props: RenderListProps): ReactNode {
334
337
  );
335
338
  }
336
339
 
337
- // Header-Slot über der Liste: `screen.slots.header` ist eine
338
- // PlatformComponent (`{ react: { __component: "X" } }`), aufgelöst über
340
+ // Header-Slot in der Listen-Toolbar (toolbarEnd): `screen.slots.header` ist
341
+ // eine PlatformComponent (`{ react: { __component: "X" } }`), aufgelöst über
339
342
  // dieselbe ExtensionSectionsProvider-Registry wie entityEdit-Sections.
340
343
  // entityId ist null (Listen-Kontext, keine Row); die Component lädt ihre
341
344
  // Daten selbst (z.B. ein Cap-Counter aus einer usage-Query). Eigene
@@ -52,7 +52,6 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
52
52
  "kumiko.config.source.computed": "Berechnet",
53
53
  "kumiko.config.source.default": "Standard",
54
54
  "kumiko.config.source.missing": "Fehlt",
55
- "kumiko.config.cascade.preset": "Vorgabe",
56
55
  "kumiko.config.cascade.noValue": "Kein Wert gesetzt",
57
56
  "kumiko.config.cascade.activeMarker": "aktiv",
58
57
  "kumiko.config.cascade.resetTo": "Überschreibung zurücksetzen ({scope})",
@@ -62,6 +61,7 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
62
61
  "kumiko.form.error.generic": "Etwas ist schiefgegangen.",
63
62
  "kumiko.form.error.version-conflict":
64
63
  "Datensatz wurde zwischenzeitlich geändert. Lade neu und versuche es erneut.",
64
+ "kumiko.form.extension.save-failed": "Ein Zusatzfeld konnte nicht gespeichert werden.",
65
65
 
66
66
  // Validation — Default-Reason-Codes aus dem Framework. App-Code
67
67
  // kann eigene Codes via Validation-Hooks reinwerfen; die hier sind
@@ -125,7 +125,6 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
125
125
  "kumiko.config.source.computed": "Computed",
126
126
  "kumiko.config.source.default": "Default",
127
127
  "kumiko.config.source.missing": "Missing",
128
- "kumiko.config.cascade.preset": "Preset",
129
128
  "kumiko.config.cascade.noValue": "No value set",
130
129
  "kumiko.config.cascade.activeMarker": "active",
131
130
  "kumiko.config.cascade.resetTo": "Reset override ({scope})",
@@ -133,6 +132,7 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
133
132
  "kumiko.form.error.generic": "Something went wrong.",
134
133
  "kumiko.form.error.version-conflict":
135
134
  "Record was modified in the meantime. Reload and try again.",
135
+ "kumiko.form.extension.save-failed": "A custom field could not be saved.",
136
136
 
137
137
  "kumiko.validation.required": "Required.",
138
138
  "kumiko.validation.invalid": "Invalid value.",
package/src/index.ts CHANGED
@@ -18,6 +18,13 @@ export type {
18
18
  export { ColumnRenderersProvider, useColumnRenderer } from "./app/column-renderers";
19
19
  export type { CustomScreensMap, CustomScreensProviderProps } from "./app/custom-screens";
20
20
  export { CustomScreensProvider, useCustomScreenComponent } from "./app/custom-screens";
21
+ export type {
22
+ ExtensionFormRegistry,
23
+ ExtensionFormSubmitHandler,
24
+ ExtensionSubmitContext,
25
+ ExtensionSubmitResult,
26
+ } from "./app/extension-form-submit";
27
+ export { ExtensionFormRegistryProvider, useExtensionFormSubmit } from "./app/extension-form-submit";
21
28
  export type {
22
29
  ExtensionSectionComponent,
23
30
  ExtensionSectionProps,
@@ -87,6 +94,7 @@ export type {
87
94
  CorePrimitives,
88
95
  DataTableProps,
89
96
  DataTableRowAction,
97
+ DataTableRowActionMode,
90
98
  DataTableSort,
91
99
  DataTableSortDir,
92
100
  DialogProps,
@@ -302,6 +302,9 @@ export type DataTableSort = {
302
302
  readonly dir: DataTableSortDir;
303
303
  };
304
304
 
305
+ // Render-Modus der Row-Action-Spalte (siehe DataTableProps.rowActionMode).
306
+ export type DataTableRowActionMode = "adaptive" | "inline";
307
+
305
308
  // Resolved-Form einer Row-Action (KumikoScreen baut das aus
306
309
  // EntityListScreenDefinition.rowActions): Labels schon translated,
307
310
  // handler-QN aufgelöst zu einer onTrigger-Function die den dispatcher
@@ -348,6 +351,14 @@ export type DataTableProps = {
348
351
  * Form (Labels + onTrigger schon verdrahtet); DataTable kümmert
349
352
  * sich nur um Render + Confirm-Dialog. */
350
353
  readonly rowActions?: readonly DataTableRowAction[];
354
+ /** Wie die Row-Action-Spalte rendert:
355
+ * - `"adaptive"` (Default): ≤2 sichtbare Actions inline (rechtsbündig),
356
+ * >2 als Kebab-Dropdown. Passt die Optik automatisch an die Anzahl an.
357
+ * - `"inline"`: IMMER Inline-Buttons, linksbündig — auch bei >2 (kein
358
+ * Kebab). So stehen die Aktionen über alle Rows an derselben x-Position
359
+ * (kein Wandern durch unterschiedlich breite Labels) und alle Listen
360
+ * einer App sehen gleich aus. */
361
+ readonly rowActionMode?: DataTableRowActionMode;
351
362
  /** Custom Empty-State-Inhalt (z. B. Icon + Heading + CTA-Button).
352
363
  * Default-Renderer rahmt ihn in einer dashed-border Box. */
353
364
  readonly emptyState?: ReactNode;