@cosmicdrift/kumiko-renderer 0.307.0 → 0.308.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.
@@ -25,6 +25,7 @@ import {
25
25
  buildDefaultEditRowAction,
26
26
  buildProjectionRowActions,
27
27
  buildProjectionToolbarActions,
28
+ buildRecordActions,
28
29
  runProjectionRowNavigate,
29
30
  } from "../app/row-actions";
30
31
  import { findEditScreenFor } from "../app/screen-access";
@@ -36,6 +37,7 @@ import { useQuery } from "../hooks/use-query";
36
37
  import { useTranslation } from "../i18n";
37
38
  import { type DataTableFacet, usePrimitives } from "../primitives";
38
39
  import { sortByAccessor } from "../sort-by-accessor";
40
+ import { RenderEditActionButton } from "./render-edit-action-button";
39
41
  import { RenderList } from "./render-list";
40
42
 
41
43
  const RELATED_LIST_PSEUDO_ENTITY = "__related-list__";
@@ -64,6 +66,17 @@ function synthesizeRelatedListEntity(
64
66
  return { fields } as unknown as EntityDefinition;
65
67
  }
66
68
 
69
+ // Mirrors row-actions.ts's own warnDrawerActionDropped (module-private
70
+ // there, so not reusable directly) — a drawer-kind emptyState.action with no
71
+ // onOpenDrawer wired is dropped (not rendered) with a dev warning, same as a
72
+ // drawer-kind rowAction/toolbarAction without one.
73
+ function warnRelatedListEmptyStateDrawerDropped(actionId: string): void {
74
+ // biome-ignore lint/suspicious/noConsole: dev-time warning, same pattern as row-actions.ts
75
+ console.warn(
76
+ `[kumiko] relatedList emptyState.action "${actionId}" is kind: "drawer" but no onOpenDrawer was supplied — dropped.`,
77
+ );
78
+ }
79
+
67
80
  export function RelatedListSection({
68
81
  section,
69
82
  parentId,
@@ -72,6 +85,7 @@ export function RelatedListSection({
72
85
  translate,
73
86
  hideTitle,
74
87
  onOpenDrawer,
88
+ actions,
75
89
  }: {
76
90
  readonly section: EditRelatedListSectionViewModel;
77
91
  readonly parentId: string;
@@ -89,8 +103,14 @@ export function RelatedListSection({
89
103
  action: RowActionDrawer,
90
104
  initialValues: Readonly<Record<string, unknown>> | undefined,
91
105
  ) => void;
106
+ /** section.actions, already resolved into buttons by the caller —
107
+ * rendered in the Section's title row. Only reachable in the standalone
108
+ * (non-`hideTitle`) branch below — `hideTitle` uses `FillContainer`, which
109
+ * has no title row to render actions into (see the comment on that branch). */
110
+ readonly actions?: ReactNode;
92
111
  }): ReactNode {
93
- const { Banner, Section, FillContainer } = usePrimitives();
112
+ const { Banner, Section, FillContainer, Text, Button, Dialog } = usePrimitives();
113
+ const [emptyStateActionError, setEmptyStateActionError] = useState<string | null>(null);
94
114
  const t = useTranslation();
95
115
  const effectiveTranslate = translate ?? t;
96
116
  const nav = useNav();
@@ -301,6 +321,58 @@ export function RelatedListSection({
301
321
  ],
302
322
  );
303
323
 
324
+ // emptyState.action is a RowAction — the same record-bound action shape
325
+ // (and evalRowExtractor payload/params evaluation) screen-level and
326
+ // section-level actions already use via buildRecordActions, NOT the
327
+ // toolbar-action builder (whose `payload` is a literal Record, not a
328
+ // RowFieldExtractor — casting a RowAction into it would silently skip the
329
+ // declared `pick`/`map` evaluation). "Row" here is the parent record: a
330
+ // relatedList's emptyState has no row of its own.
331
+ const emptyStateActionButton = useMemo(() => {
332
+ const action = section.emptyState?.action;
333
+ if (action === undefined) return undefined;
334
+ if (action.kind === "drawer" && onOpenDrawer === undefined) {
335
+ warnRelatedListEmptyStateDrawerDropped(action.id);
336
+ return undefined;
337
+ }
338
+ return buildRecordActions({
339
+ actions: [action],
340
+ record,
341
+ translate: effectiveTranslate,
342
+ nav,
343
+ host,
344
+ dispatcher,
345
+ openDrawer: onOpenDrawer ?? (() => {}),
346
+ onWriteSuccess: rowsQuery.refetch,
347
+ })?.[0];
348
+ }, [
349
+ section.emptyState,
350
+ record,
351
+ effectiveTranslate,
352
+ nav,
353
+ host,
354
+ dispatcher,
355
+ onOpenDrawer,
356
+ rowsQuery.refetch,
357
+ ]);
358
+ const emptyStateContent =
359
+ section.emptyState !== undefined ? (
360
+ <>
361
+ <Text>{section.emptyState.title}</Text>
362
+ {section.emptyState.description !== undefined && (
363
+ <Text variant="small">{section.emptyState.description}</Text>
364
+ )}
365
+ {emptyStateActionButton !== undefined && (
366
+ <RenderEditActionButton
367
+ action={emptyStateActionButton}
368
+ Button={Button}
369
+ Dialog={Dialog}
370
+ onError={setEmptyStateActionError}
371
+ />
372
+ )}
373
+ </>
374
+ ) : undefined;
375
+
304
376
  // A truncated fetch means `sortedRows` is a sort of a partial set, not of
305
377
  // the full related-row set — the client-side sort above (or even plain
306
378
  // unsorted display) would silently claim "these are the top N" when they
@@ -327,6 +399,11 @@ export function RelatedListSection({
327
399
  })}
328
400
  </Banner>
329
401
  )}
402
+ {emptyStateActionError !== null && (
403
+ <Banner variant="error" testId="related-list-empty-state-action-error">
404
+ {emptyStateActionError}
405
+ </Banner>
406
+ )}
330
407
  <RenderList
331
408
  screen={listScreen}
332
409
  entity={entity}
@@ -349,23 +426,21 @@ export function RelatedListSection({
349
426
  {...(onRowClick !== undefined && { onRowClick })}
350
427
  {...(rowActions !== undefined && { rowActions })}
351
428
  {...(toolbarActionButtons !== undefined && { toolbarActions: toolbarActionButtons })}
352
- {...(hideTitle === true && { scrollBody: true })}
429
+ {...(emptyStateContent !== undefined && { emptyState: emptyStateContent })}
430
+ {...(hideTitle === true && { scrollBody: true, screenPadding: false, chromeless: true })}
353
431
  />
354
432
  </>
355
433
  );
356
434
 
357
- // hideTitle (tabs mode) → the tab panel is already the boundary: no
358
- // Section card wrapper here — the table keeps its own frame to match the
359
- // list-screen look; `scrollBody` caps it to the panel height. `FillContainer` is this section's
360
- // link in RenderEdit's `fillHeight` chain (see render-edit.tsx): it is
361
- // always this section's own root whenever hideTitle is set, since tabs
362
- // mode narrows RenderEdit to exactly this one active section. A platform
363
- // primitive (not a raw `<div>`) because `renderer` stays DOM-free —
364
- // `Section`/`Card` were rejected for this spot in favor of a dedicated
365
- // chromeless primitive; see `FillContainerProps` in primitives.tsx.
366
- // Stacked (non-tabs) sections keep the card frame and document-flow
367
- // height since they render a visible title and aren't confined to a tab
368
- // panel.
435
+ // hideTitle (tabs mode) → the caller (render-edit.tsx) already wraps this
436
+ // component's own return value in the same Card frame every other tab
437
+ // kind gets — no card here, or the two would nest. `scrollBody` caps the
438
+ // table to the panel height; `FillContainer` is this section's link in the
439
+ // fillHeight flex chain that Card's `options.fillHeight` continues (see
440
+ // render-edit.tsx). A platform primitive (not a raw `<div>`) because
441
+ // `renderer` stays DOM-free. Stacked (non-tabs) sections keep their own
442
+ // Section card and document-flow height since they render a visible title
443
+ // and aren't confined to a tab panel.
369
444
  const bridges = <ReferenceFacetBridges specs={facetSpecs} onOptions={handleFacetOptions} />;
370
445
 
371
446
  if (hideTitle) {
@@ -380,7 +455,11 @@ export function RelatedListSection({
380
455
  return (
381
456
  <>
382
457
  {bridges}
383
- <Section title={section.title} testId={`related-list-${section.title}`}>
458
+ <Section
459
+ title={section.title}
460
+ {...(actions !== undefined && { actions })}
461
+ testId={`related-list-${section.title}`}
462
+ >
384
463
  {content}
385
464
  </Section>
386
465
  </>
@@ -2,6 +2,7 @@ import type {
2
2
  EntityDefinition,
3
3
  EntityEditScreenDefinition,
4
4
  IconKey,
5
+ RowAction,
5
6
  RowActionDrawer,
6
7
  } from "@cosmicdrift/kumiko-framework/ui-types";
7
8
  import type {
@@ -11,7 +12,7 @@ import type {
11
12
  Translate,
12
13
  } from "@cosmicdrift/kumiko-headless";
13
14
  import type { ReactNode } from "react";
14
- import type { z } from "zod";
15
+ import type * as z from "zod";
15
16
 
16
17
  export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
17
18
  readonly screen: EntityEditScreenDefinition;
@@ -160,7 +161,17 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
160
161
  * width — for a host with its own header region (title/metrics/tabs)
161
162
  * that would otherwise render as unpadded siblings before RenderEdit.
162
163
  * Omitting this prop keeps unchanged behavior. */
163
- readonly headerRegion?: ReactNode;
164
+ readonly headerRegion?: ReactNode | ((headerSlot: ReactNode | undefined) => ReactNode);
165
+ /** resolves an `EditFieldsSection`/`EditExtensionSection`/
166
+ * `EditRelatedListSection`/`EditWriteFormSection`'s own `actions` into
167
+ * already-bound buttons, rendered in that section's title row — same
168
+ * split as `onCopyLink`/header `actions`: RenderEdit has no dispatcher/nav
169
+ * context of its own, so the caller (which does) resolves each RowAction
170
+ * against the record being edited. Returns `undefined`/`[]` to render no
171
+ * actions for that section. */
172
+ readonly buildSectionActions?: (
173
+ actions: readonly RowAction[],
174
+ ) => readonly RenderEditAction[] | undefined;
164
175
  };
165
176
 
166
177
  export type RenderEditAction = {
@@ -160,6 +160,7 @@ function ExtensionSectionMount({
160
160
  patch,
161
161
  validate,
162
162
  hideTitle,
163
+ actions,
163
164
  }: {
164
165
  readonly section: EditExtensionSectionViewModel;
165
166
  readonly entityName: string;
@@ -169,40 +170,61 @@ function ExtensionSectionMount({
169
170
  readonly patch?: (partial: Readonly<Record<string, unknown>>) => void;
170
171
  readonly validate?: () => boolean;
171
172
  readonly hideTitle?: boolean;
173
+ /** section.actions, already resolved into buttons by the caller. */
174
+ readonly actions?: ReactNode;
172
175
  }): ReactNode {
173
- const { Banner, Section, Text } = usePrimitives();
176
+ const { Banner, FillContainer, Section, Text } = usePrimitives();
174
177
  const name = extensionSectionName(section.component);
175
178
  const Component = useExtensionSectionComponent(name);
179
+ const testId = `section-extension-${section.title}`;
176
180
  if (Component === undefined) {
177
- return (
178
- <Section
179
- key={section.title}
180
- title={section.title}
181
- testId={`section-extension-${section.title}`}
182
- >
183
- <Banner variant="info" testId={`section-extension-placeholder-${section.title}`}>
184
- <Text>
185
- Extension section component{" "}
186
- <Text variant="code">{name ?? "(no __component name)"}</Text> not registered in
187
- clientFeatures.extensionSectionComponents.
188
- </Text>
189
- </Banner>
181
+ const placeholder = (
182
+ <Banner variant="info" testId={`section-extension-placeholder-${section.title}`}>
183
+ <Text>
184
+ Extension section component <Text variant="code">{name ?? "(no __component name)"}</Text>{" "}
185
+ not registered in clientFeatures.extensionSectionComponents.
186
+ </Text>
187
+ </Banner>
188
+ );
189
+ // Tabs mode already frames this in the caller's own Card — a Section
190
+ // here would add a second, unwanted divider inset (px-6 py-4) on top of
191
+ // the Card's own padding, misaligning this tab's content against the
192
+ // fields tab (Grid sits directly in the Card there). FillContainer keeps
193
+ // the testId without adding chrome (same primitive RelatedListSection
194
+ // uses for its own hideTitle branch).
195
+ return hideTitle === true ? (
196
+ FillContainer !== undefined ? (
197
+ <FillContainer testId={testId}>{placeholder}</FillContainer>
198
+ ) : (
199
+ placeholder
200
+ )
201
+ ) : (
202
+ <Section key={section.title} title={section.title} testId={testId}>
203
+ {placeholder}
190
204
  </Section>
191
205
  );
192
206
  }
193
- return (
194
- <Section
195
- {...(hideTitle !== true && { title: section.title })}
196
- testId={`section-extension-${section.title}`}
197
- >
198
- <Component
199
- entityName={section.entityName ?? hostEntityName}
200
- entityId={entityId}
201
- initialValues={initialValues}
202
- values={values}
203
- patch={patch}
204
- validate={validate}
205
- />
207
+ const mounted = (
208
+ <Component
209
+ entityName={section.entityName ?? hostEntityName}
210
+ entityId={entityId}
211
+ initialValues={initialValues}
212
+ values={values}
213
+ patch={patch}
214
+ validate={validate}
215
+ />
216
+ );
217
+ // Same reasoning as the placeholder branch above: tabs mode's outer Card
218
+ // is the only frame this content gets, no nested Section divider.
219
+ return hideTitle === true ? (
220
+ FillContainer !== undefined ? (
221
+ <FillContainer testId={testId}>{mounted}</FillContainer>
222
+ ) : (
223
+ mounted
224
+ )
225
+ ) : (
226
+ <Section {...(actions !== undefined && { actions })} title={section.title} testId={testId}>
227
+ {mounted}
206
228
  </Section>
207
229
  );
208
230
  }
@@ -289,6 +311,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
289
311
  valueDisplay = "form",
290
312
  hideSectionTitles,
291
313
  headerRegion,
314
+ buildSectionActions,
292
315
  } = props;
293
316
  const { customSubmit } = props;
294
317
  // Translate-Fallback: wenn der Caller keine Translate-Fn übergibt,
@@ -1111,10 +1134,13 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1111
1134
  values={snapshot.values}
1112
1135
  />
1113
1136
  ) : undefined;
1114
- // Slot renders above the caller's own headerRegion; without slots.header
1115
- // this is bit-identical to the plain headerRegion prop below.
1137
+ // A function headerRegion places the slot itself (projectionDetail puts it
1138
+ // into its header card next to the actions); otherwise the slot renders
1139
+ // above the caller's own headerRegion.
1116
1140
  const formHeaderRegion =
1117
- headerSlotMount !== undefined ? (
1141
+ typeof headerRegion === "function" ? (
1142
+ headerRegion(headerSlotMount)
1143
+ ) : headerSlotMount !== undefined ? (
1118
1144
  <>
1119
1145
  {headerSlotMount}
1120
1146
  {headerRegion}
@@ -1356,6 +1382,23 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1356
1382
  );
1357
1383
  })()}
1358
1384
  {filteredSections.map((section: EditSectionViewModel, sectionIndex: number) => {
1385
+ // turns this section's own `actions` (RowAction[]) into
1386
+ // already-bound buttons via the caller-supplied builder, rendered
1387
+ // through the same RenderEditActionButton the top-level `actions`
1388
+ // prop uses (same style/confirm/icon/error handling).
1389
+ const sectionActions = section.actions;
1390
+ const sectionActionsEl: ReactNode =
1391
+ sectionActions !== undefined && sectionActions.length > 0
1392
+ ? buildSectionActions?.(sectionActions)?.map((action) => (
1393
+ <RenderEditActionButton
1394
+ key={action.id}
1395
+ action={action}
1396
+ Button={Button}
1397
+ Dialog={Dialog}
1398
+ onError={setActionError}
1399
+ />
1400
+ ))
1401
+ : undefined;
1359
1402
  // Wizard steps and tabs stay mounted while off-screen (native
1360
1403
  // `hidden`, not unmounted) so an extension section's submit-registry
1361
1404
  // entry (useExtensionFormSubmit → registry.remove on unmount)
@@ -1402,9 +1445,35 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1402
1445
  }
1403
1446
  validate={scopedValidate}
1404
1447
  hideTitle={hideSectionTitles}
1448
+ // Tabs mode: actions move to the outer Card below — the inner
1449
+ // Section is flattened (rendered inside this component's own
1450
+ // Form) and would otherwise duplicate them.
1451
+ {...(hideSectionTitles !== true &&
1452
+ sectionActionsEl !== undefined && { actions: sectionActionsEl })}
1405
1453
  />
1406
1454
  );
1407
- return wrapWizardStep(section.title, mount);
1455
+ // Section always flattens to a borderless divider inside this
1456
+ // component's own <Form>, so tabs mode frames it with Card
1457
+ // instead — same reason the fields-section tabs branch below
1458
+ // stays on Card. No title here: the tab strip already labels the
1459
+ // panel (matches the non-tabs Section branch above via hideTitle).
1460
+ // testId stays only on the inner Section — this outer Card is
1461
+ // purely chrome, giving it the same testId would register two
1462
+ // elements under one id.
1463
+ const wrapped =
1464
+ hideSectionTitles === true ? (
1465
+ <Card
1466
+ key={section.title}
1467
+ {...(sectionActionsEl !== undefined && {
1468
+ slots: { headerActions: sectionActionsEl },
1469
+ })}
1470
+ >
1471
+ {mount}
1472
+ </Card>
1473
+ ) : (
1474
+ mount
1475
+ );
1476
+ return wrapWizardStep(section.title, wrapped);
1408
1477
  }
1409
1478
  if (section.kind === "relatedList") {
1410
1479
  // parentId is the displayed record's id — without it there's no
@@ -1412,7 +1481,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1412
1481
  // Rejected at boot in wizard layouts, so no WizardStepGroup here.
1413
1482
  const parentId = resolveExtensionEntityId(entityIdProp, vm.id);
1414
1483
  if (parentId === null) return null;
1415
- return (
1484
+ const mount = (
1416
1485
  <RelatedListSection
1417
1486
  key={section.title}
1418
1487
  section={section}
@@ -1422,11 +1491,36 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1422
1491
  featureName={featureName}
1423
1492
  translate={translate}
1424
1493
  hideTitle={hideSectionTitles}
1494
+ // Tabs mode: actions move to the outer Card below — the
1495
+ // hideTitle branch of RelatedListSection has no title row to
1496
+ // render them into.
1497
+ {...(hideSectionTitles !== true &&
1498
+ sectionActionsEl !== undefined && { actions: sectionActionsEl })}
1425
1499
  {...(onRelatedListDrawerAction !== undefined && {
1426
1500
  onOpenDrawer: onRelatedListDrawerAction,
1427
1501
  })}
1428
1502
  />
1429
1503
  );
1504
+ // Tabs mode: the relatedList tab must sit in the same Card frame
1505
+ // as every other tab (fields/extension/writeForm above) — the
1506
+ // acceptance criterion this unification exists for. `fillHeight`
1507
+ // on the Card threads the same fw#2722/#2778 flex chain the head
1508
+ // card already carries (DefaultCard's `options.fillHeight`), so
1509
+ // the table still scrolls inside the panel instead of the panel
1510
+ // stretching to the row count.
1511
+ return hideSectionTitles === true ? (
1512
+ <Card
1513
+ key={section.title}
1514
+ options={{ fillHeight }}
1515
+ {...(sectionActionsEl !== undefined && {
1516
+ slots: { headerActions: sectionActionsEl },
1517
+ })}
1518
+ >
1519
+ {mount}
1520
+ </Card>
1521
+ ) : (
1522
+ mount
1523
+ );
1430
1524
  }
1431
1525
  if (section.kind === "writeForm") {
1432
1526
  // Own submit button + dispatcher call, entirely independent of
@@ -1440,6 +1534,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1440
1534
  translate={translate}
1441
1535
  hideTitle={hideSectionTitles}
1442
1536
  onSubmitted={() => onReload?.()}
1537
+ {...(sectionActionsEl !== undefined && { actions: sectionActionsEl })}
1443
1538
  />
1444
1539
  );
1445
1540
  }
@@ -1478,10 +1573,14 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1478
1573
  );
1479
1574
  // Tabs mode renders one or more titled cards, so this card's actual
1480
1575
  // heading doesn't visually duplicate the short Tab strip label.
1576
+ // Stays `Card`, not `Section` — Section always flattens to a
1577
+ // borderless divider when rendered inside this component's own
1578
+ // `<Form>` (InsideFormContext), even with `chromeless` set, so it
1579
+ // cannot stand in for the tabs-mode card here.
1481
1580
  if (hideSectionTitles === true) {
1482
1581
  if (section.groups !== undefined) {
1483
- const groupsEl = (
1484
- <Grid key={sectionKey} columns={1}>
1582
+ const groupsGrid = (
1583
+ <Grid key={`${sectionKey}-groups`} columns={1}>
1485
1584
  {section.groups.map((group, groupIndex) => (
1486
1585
  <Card
1487
1586
  key={group.title}
@@ -1497,12 +1596,23 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1497
1596
  ))}
1498
1597
  </Grid>
1499
1598
  );
1500
- return wrapWizardStep(sectionKey, groupsEl);
1599
+ // `actions` on a `groups` section has no group of its own to
1600
+ // sit in and would need an extra title-less Card around the
1601
+ // group cards (Card-in-Card) — the boot-validator rejects that
1602
+ // combination in tabs mode, so `sectionActionsEl` is always
1603
+ // undefined here at runtime.
1604
+ return wrapWizardStep(sectionKey, groupsGrid);
1501
1605
  }
1606
+ // No section title here (fw#3218) — the Tab strip right above
1607
+ // already names this panel, so a repeated Card title would just
1608
+ // duplicate it. The title row only exists to carry actions, and
1609
+ // then it carries only the actions, never the title text.
1502
1610
  const cardEl = (
1503
1611
  <Card
1504
1612
  key={sectionKey}
1505
- {...(section.title !== undefined && { slots: { title: section.title } })}
1613
+ {...(sectionActionsEl !== undefined && {
1614
+ slots: { headerActions: sectionActionsEl },
1615
+ })}
1506
1616
  testId={`section-${sectionKey}`}
1507
1617
  >
1508
1618
  {renderFieldGrid(section.fields, section.columns, `${sectionKey}-grid`)}
@@ -1523,6 +1633,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1523
1633
  {...(sectionTitle !== undefined && { title: sectionTitle })}
1524
1634
  {...(section.description !== undefined && { subtitle: section.description })}
1525
1635
  {...(section.icon !== undefined && { icon: section.icon })}
1636
+ {...(sectionActionsEl !== undefined && { actions: sectionActionsEl })}
1526
1637
  testId={`section-${sectionKey}`}
1527
1638
  >
1528
1639
  {renderFieldGrid(section.fields, section.columns, `${sectionKey}-grid`)}
@@ -913,7 +913,9 @@ function readOnlyDisplayText(field: EditFieldViewModel, appLocale: string): stri
913
913
  case "bigInt":
914
914
  case "decimal": {
915
915
  const n = numberValue(value);
916
- return n === "" ? "—" : new Intl.NumberFormat(appLocale).format(n);
916
+ return n === ""
917
+ ? "—"
918
+ : new Intl.NumberFormat(appLocale, { useGrouping: field.grouping ?? true }).format(n);
917
919
  }
918
920
  case "money": {
919
921
  const currency = resolveMoneyCurrency(value, field.currency);
@@ -123,10 +123,12 @@ export type RenderListProps = {
123
123
  * scrolls rows internally instead of growing the page (relatedList in a
124
124
  * tabs-mode section, fw#2722). Default false. */
125
125
  readonly scrollBody?: boolean;
126
- /** Forwarded to `DataTableProps.screenPadding` — the table carries the
127
- * shared screen padding because it is the screen body (entityList/
128
- * projectionList, fw#2640). Not set for an embedded relatedList.
129
- * Default false. */
126
+ /** Forwarded to `DataTableProps.screenPadding` — `true`: the table carries
127
+ * the shared screen padding because it is the screen body (entityList/
128
+ * projectionList, fw#2640). Explicit `false`: no padding at all — the
129
+ * host already pads its own children (relatedList in a tabs-mode Card,
130
+ * fw#3234). Omitted: legacy `p-6` fallback for a standalone table with no
131
+ * padded host. */
130
132
  readonly screenPadding?: boolean;
131
133
  };
132
134
 
@@ -5,7 +5,7 @@ import type {
5
5
  } from "@cosmicdrift/kumiko-headless";
6
6
  import { I18N_KEY_PARAM } from "@cosmicdrift/kumiko-headless";
7
7
  import { type ReactNode, useMemo, useState } from "react";
8
- import { z } from "zod";
8
+ import * as z from "zod";
9
9
  import { REQUIRED_FIELD_I18N_KEY } from "../app/form-schema";
10
10
  import { dispatcherErrorText } from "../app/write-failed-error";
11
11
  import { useForm } from "../hooks/use-form";
@@ -52,6 +52,10 @@ export type WriteFormSectionProps = {
52
52
  * record (a new one now exists) via a full RenderEdit remount, see
53
53
  * ProjectionDetailBody's reloadNonce/key in kumiko-screen.tsx. */
54
54
  readonly onSubmitted: () => void;
55
+ /** section.actions, already resolved into buttons by the caller —
56
+ * rendered alongside (before) the section's own submit button in the
57
+ * title-row actions slot. */
58
+ readonly actions?: ReactNode;
55
59
  };
56
60
 
57
61
  // A self-persisting form section for projectionDetail (see
@@ -65,8 +69,9 @@ export function WriteFormSection({
65
69
  translate,
66
70
  hideTitle,
67
71
  onSubmitted,
72
+ actions,
68
73
  }: WriteFormSectionProps): ReactNode {
69
- const { Section, Grid, GridCell, Button, Banner } = usePrimitives();
74
+ const { Section, Card, Grid, GridCell, Button, Banner } = usePrimitives();
70
75
  const t = useTranslation();
71
76
  const effectiveTranslate = translate ?? t;
72
77
 
@@ -149,15 +154,43 @@ export function WriteFormSection({
149
154
  </Button>
150
155
  );
151
156
 
157
+ const titleRowActions =
158
+ actions !== undefined ? (
159
+ <>
160
+ {actions}
161
+ {submitButton}
162
+ </>
163
+ ) : (
164
+ submitButton
165
+ );
166
+
167
+ // Section always flattens to a borderless divider when rendered inside
168
+ // RenderEdit's own <Form> in tabs mode (hideTitle) — Card frames it there
169
+ // instead, same reason render-edit.tsx's tabs-mode fields/extension
170
+ // branches stay on Card.
171
+ if (hideTitle) {
172
+ return (
173
+ <Card
174
+ slots={{
175
+ ...(section.description !== undefined && { subtitle: section.description }),
176
+ headerActions: titleRowActions,
177
+ }}
178
+ testId={`write-form-${section.title ?? "section"}`}
179
+ >
180
+ {content}
181
+ </Card>
182
+ );
183
+ }
184
+
152
185
  // Routed through Section's `actions` slot (same mechanism render-edit.tsx
153
186
  // uses via Form's `actions`) so the button gets the established right-
154
187
  // aligned footer treatment instead of stretching full-width inline.
155
188
  return (
156
189
  <Section
157
- {...(!hideTitle && section.title !== undefined && { title: section.title })}
190
+ {...(section.title !== undefined && { title: section.title })}
158
191
  {...(section.description !== undefined && { subtitle: section.description })}
159
192
  {...(section.icon !== undefined && { icon: section.icon })}
160
- actions={submitButton}
193
+ actions={titleRowActions}
161
194
  testId={`write-form-${section.title ?? "section"}`}
162
195
  >
163
196
  {content}
@@ -862,9 +862,9 @@ export type SectionProps = {
862
862
  * Kontext statt nur ein Label (analog FormProps.subtitle). */
863
863
  readonly subtitle?: ReactNode;
864
864
  readonly children: ReactNode;
865
- /** Optionaler Aktions-Slot am Fuß der Section (z.B. „In Finanzierung
866
- * übernehmen"). Web rendert standalone eine abgehobene Footer-Row
867
- * (border-t), innerhalb eines Forms eine rechtsbündige Button-Reihe. */
865
+ /** Rendered top-right in the title row — never a footer. A
866
+ * title-less/subtitle-less Section still draws the row when `actions`
867
+ * is set, so a hideTitle tabs-Section with actions isn't stranded. */
868
868
  readonly actions?: ReactNode;
869
869
  /** "destructive" marks the Section as a warning/danger area (e.g. account
870
870
  * deletion, restrict processing) — border color only, no content change.
@@ -1046,6 +1046,10 @@ export type CardSlots = {
1046
1046
  readonly header?: ReactNode;
1047
1047
  readonly title?: ReactNode;
1048
1048
  readonly subtitle?: ReactNode;
1049
+ /** Free-form block content left of `headerActions`, below title/subtitle —
1050
+ * for a host-supplied header (projectionDetail `slots.header`) that must
1051
+ * share the action row instead of pushing actions into a row of their own. */
1052
+ readonly headerContent?: ReactNode;
1049
1053
  /** Rechtsbündiger Header-Slot (Range-Umschalter, Filter, Badge …). */
1050
1054
  readonly headerActions?: ReactNode;
1051
1055
  /** Footer-Inhalt, abgehoben (border-t). Default rechtsbündig. */
@@ -1060,6 +1064,10 @@ export type CardOptions = {
1060
1064
  readonly radius?: "lg" | "xl";
1061
1065
  /** Footer abgehoben mit border-t + bg-muted/30. Default true. */
1062
1066
  readonly footerBordered?: boolean;
1067
+ /** Joins the ancestor fillHeight flex chain (fw#2722/#2778 — a tabs-mode
1068
+ * relatedList table sizing to the panel instead of the page) instead of
1069
+ * normal document-flow height. Default false. */
1070
+ readonly fillHeight?: boolean;
1063
1071
  };
1064
1072
 
1065
1073
  /** Generische Card-Chrome (border/bg/radius/shadow + Header/Body/Footer) als