@cosmicdrift/kumiko-renderer 0.326.1 → 0.328.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/src/changes.json CHANGED
@@ -1,4 +1,56 @@
1
1
  [
2
+ {
3
+ "version": "0.328.0",
4
+ "type": "breaking",
5
+ "title": "Screens fill the shell height by default",
6
+ "detail": "entityList, projectionList, projectionDetail, entityEdit, actionForm and wizard screens now fill the height of the shell content. The table or the form body scrolls inside, the toolbar stays on top and the pager or action bar stays pinned at the bottom (`scrollBody`, `stickyActions`). The shell header is 56px. Custom screens and direct DataTable or Form usage are unchanged.",
7
+ "migration": "Set `fillHeight: false` on a screen to restore the old page scrolling (table grows with its rows, pager below the last row, form footer in the flow). Apps with e2e or screenshot tests that scroll the page to reach the pager or the form footer need to adjust them or opt out per screen."
8
+ },
9
+ {
10
+ "version": "0.328.0",
11
+ "type": "breaking",
12
+ "title": "Pager shows a status line, page size select and Page X of Y instead of page numbers",
13
+ "detail": "The numbered page list is gone. The list footer shows \"1-19 of 19 <entities>\" (with the entity plural label when known), \"Page X of Y\", previous and next buttons and a page size select (25, 50, 100). Lists without a pager show a plain entry count. The chosen size is kept in the URL under `<screen>.size`.",
14
+ "migration": "Remove code and tests that click numbered page buttons (`-page-N` test ids); use the next and previous buttons. The screen's declared `pageSize` stays the default, `<screen>.size` in the URL overrides it. The new keys `kumiko.pager.pageOf`, `kumiko.pager.status.noun`, `kumiko.pager.pageSize`, `kumiko.pager.pageSizeLabel`, `kumiko.list.count.*` exist in en, de and es; apps with their own locale need them too."
15
+ },
16
+ {
17
+ "version": "0.328.0",
18
+ "type": "breaking",
19
+ "title": "rowActionMode adaptive has new semantics and mobile rows lose the kebab for plain navigation",
20
+ "detail": "With `rowActionMode: \"adaptive\"` (the default), a table with `onRowClick` renders the first cell as a keyboard-operable link and puts all row actions into the kebab. Without `onRowClick` the primary row action is a link-button and the remaining actions go into the kebab (a single action gets no kebab). Below the md breakpoint a row that only navigates shows a chevron and no kebab.",
21
+ "migration": "Code or tests that expect up to two inline row action buttons must open the kebab or use `rowActionMode: \"inline\"`, which is unchanged. To keep a visible action next to the row link, drop `onRowClick` and declare the action as the primary row action."
22
+ },
23
+ {
24
+ "version": "0.328.0",
25
+ "type": "breaking",
26
+ "title": "Form, wizard and detail screens use the board layout",
27
+ "detail": "Screen forms have no card, sections are separated by lines, fields flow in rows with widths by type (text 240px, number 96px, money 160px, date and select 200px, textarea full row) and the page title lives in the shell header. Delete and Copy link move into the header kebab, the footer reads status, Cancel, Save, and the wizard footer has no Cancel. Wizard steps sit in a vertical rail from the lg breakpoint. The first detail header action is a button, further ones sit in the kebab. Detail screens have no header card and use the full width: metrics render as a dl band, tabs are underlined only when active, related lists are full-bleed with a count footer. Required-field asterisks are omitted when every field is required and \"All fields are required.\" is shown instead.",
28
+ "migration": "Adjust screenshots and DOM tests that pin the old card layout. `fillHeight: false` restores page scrolling only; the footer order, the moved actions and the new field layout apply in both modes. Empty list cells render a dash."
29
+ },
30
+ {
31
+ "version": "0.328.0",
32
+ "type": "improvement",
33
+ "title": "PageHeader slot, MetricBand, wizard Save and close, drawer row actions with unsaved-input guard",
34
+ "detail": "Core primitives `PageHeader` (status and actions portalled into the shell header when a shell provides the slot, otherwise the caller keeps its placement) and `MetricBand` (borderless dl of metrics) are new and optional. Wizards on an existing record show \"Save and close\". Row actions and related list row actions with `kind: \"drawer\"` open the action form in a flush drawer. Closing it with unsaved input (X, Escape, overlay, Cancel) asks for confirmation first; nothing is asked after a successful submit. Row action drawers are prefilled from the row through `params`. The action form `summary` renders a context box above the fields."
35
+ },
36
+ {
37
+ "version": "0.328.0",
38
+ "type": "fix",
39
+ "title": "Related list no longer claims \"showing the first N entries\" for a complete list",
40
+ "detail": "The hint \"Zeigt die ersten 1 Einträge. Es gibt weitere, die hier nicht angezeigt werden.\" appeared for a single row even when nothing else existed. It now appears only when more rows exist than were loaded."
41
+ },
42
+ {
43
+ "version": "0.328.0",
44
+ "type": "improvement",
45
+ "title": "writeForm section Save button without icon, parity with entityEdit",
46
+ "detail": "The Save button of a writeForm section no longer shows a check icon; it matches the entityEdit submit button. Tests that look for the icon inside the section Save button need updating."
47
+ },
48
+ {
49
+ "version": "0.328.0",
50
+ "type": "improvement",
51
+ "title": "useQuery gains a refetchIntervalMs option for polling",
52
+ "detail": "With `refetchIntervalMs` set, an enabled query re-runs on a timer. Background ticks keep `loading` false, are skipped while a fetch is still in flight, and stop on unmount or when enabled, the interval, type or payload change. Only timers are used, so it works on web and React Native."
53
+ },
2
54
  {
3
55
  "version": "0.325.2",
4
56
  "type": "fix",
@@ -1,4 +1,4 @@
1
- import { describe, expect, spyOn, test } from "bun:test";
1
+ import { beforeEach, describe, expect, spyOn, test } from "bun:test";
2
2
  import type { EntityEditScreenDefinition, RowAction } from "@cosmicdrift/kumiko-framework/ui-types";
3
3
  import type { Dispatcher, EditRelatedListSectionViewModel } from "@cosmicdrift/kumiko-headless";
4
4
  import { act, fireEvent, render, screen as rtlScreen, waitFor } from "@testing-library/react";
@@ -6,6 +6,7 @@ import type { ComponentType, ReactNode } from "react";
6
6
  import { AppFeaturesProvider } from "../../app/app-features-context";
7
7
  import type { FeatureSchema } from "../../app/feature-schema";
8
8
  import { type NavApi, NavProvider } from "../../app/nav";
9
+ import { resetDrawerActionWarningsForTests } from "../../app/row-actions";
9
10
  import { DispatcherProvider } from "../../context/dispatcher-context";
10
11
  import { UserRolesProvider } from "../../context/user-roles-context";
11
12
  import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
@@ -95,6 +96,7 @@ function testPrimitives(): CorePrimitives {
95
96
  function stubDispatcher(
96
97
  rows: readonly Record<string, unknown>[] = [{ id: "r1", name: "Alice" }],
97
98
  nextCursor: string | null = null,
99
+ envelope: { readonly total?: number; readonly omitCursor?: boolean } = {},
98
100
  ): {
99
101
  dispatcher: Dispatcher;
100
102
  writes: Array<{ type: string; payload: unknown }>;
@@ -109,7 +111,14 @@ function stubDispatcher(
109
111
  }) as Dispatcher["write"],
110
112
  query: (async () => {
111
113
  queryCalls += 1;
112
- return { isSuccess: true, data: { rows, nextCursor } };
114
+ return {
115
+ isSuccess: true,
116
+ data: {
117
+ rows,
118
+ ...(envelope.omitCursor !== true && { nextCursor }),
119
+ ...(envelope.total !== undefined && { total: envelope.total }),
120
+ },
121
+ };
113
122
  }) as Dispatcher["query"],
114
123
  batch: (async () => ({ isSuccess: true, results: [] })) as Dispatcher["batch"],
115
124
  statusStore: {
@@ -188,6 +197,42 @@ function renderRelatedList(
188
197
  );
189
198
  }
190
199
 
200
+ describe("RelatedListSection column value type", () => {
201
+ test("a column declaring valueType money reaches the table as a money column", async () => {
202
+ const { dispatcher } = stubDispatcher();
203
+ let capturedTypes: Record<string, string | undefined> = {};
204
+ const capturingDataTable: ComponentType<DataTableProps> = (props) => {
205
+ capturedTypes = Object.fromEntries(props.columns.map((c) => [c.field, c.type]));
206
+ return testDataTable(props);
207
+ };
208
+ render(
209
+ <LocaleProvider
210
+ resolver={createStaticLocaleResolver({ locale: "en-US" })}
211
+ fallbackBundles={[kumikoDefaultTranslations]}
212
+ >
213
+ <DispatcherProvider dispatcher={dispatcher}>
214
+ <PrimitivesProvider value={{ ...testPrimitives(), DataTable: capturingDataTable }}>
215
+ <NavProvider value={stubNav().nav}>
216
+ <RelatedListSection
217
+ section={{
218
+ ...historySection,
219
+ columns: [{ field: "name" }, { field: "betrag", valueType: "money" }],
220
+ }}
221
+ parentId="order-1"
222
+ record={{ id: "order-1" }}
223
+ featureName="orders"
224
+ />
225
+ </NavProvider>
226
+ </PrimitivesProvider>
227
+ </DispatcherProvider>
228
+ </LocaleProvider>,
229
+ );
230
+ await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
231
+ expect(capturedTypes["betrag"]).toBe("money");
232
+ expect(capturedTypes["name"]).toBe("text");
233
+ });
234
+ });
235
+
191
236
  describe("RelatedListSection — tabs-mode card chrome (fw#2722)", () => {
192
237
  test("hideTitle (tabs mode) renders the list without a Section wrapper, drops the table frame (the tab card frames it), and marks scrollBody", async () => {
193
238
  const { dispatcher } = stubDispatcher();
@@ -736,6 +781,10 @@ describe("RelatedListSection — rowActions", () => {
736
781
  // pins that hand-off, not the Drawer UI itself (covered by kumiko-screen's
737
782
  // own tests).
738
783
  describe("RelatedListSection — rowActions drawer-kind (fw#2710)", () => {
784
+ beforeEach(() => {
785
+ resetDrawerActionWarningsForTests();
786
+ });
787
+
739
788
  test("clicking a drawer rowAction calls onOpenDrawer with the action and the row's extracted values", async () => {
740
789
  const { dispatcher } = stubDispatcher([{ id: "item-7", name: "Rent 2024", amount: 1200 }]);
741
790
  const openDrawerCalls: unknown[][] = [];
@@ -1041,6 +1090,26 @@ describe("RelatedListSection — truncation banner (fw#2722 review)", () => {
1041
1090
  expect(rtlScreen.queryByTestId("related-list-truncated")).toBeNull();
1042
1091
  });
1043
1092
 
1093
+ test("a handler that omits nextCursor renders no truncation banner, even at exactly one row", async () => {
1094
+ const { dispatcher } = stubDispatcher([{ id: "r1", name: "Alice" }], null, {
1095
+ omitCursor: true,
1096
+ });
1097
+ renderRelatedList(dispatcher);
1098
+
1099
+ await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
1100
+ expect(rtlScreen.queryByTestId("related-list-truncated")).toBeNull();
1101
+ });
1102
+
1103
+ test("a cursor with a total the rows already cover renders no truncation banner", async () => {
1104
+ const { dispatcher } = stubDispatcher([{ id: "r1", name: "Alice" }], "cursor-abc", {
1105
+ total: 1,
1106
+ });
1107
+ renderRelatedList(dispatcher);
1108
+
1109
+ await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
1110
+ expect(rtlScreen.queryByTestId("related-list-truncated")).toBeNull();
1111
+ });
1112
+
1044
1113
  test("the banner shows regardless of an active sort — an unsorted truncated list is equally misleading", async () => {
1045
1114
  const { dispatcher } = stubDispatcher(
1046
1115
  [
@@ -128,7 +128,7 @@ describe("RenderList toolbar actions collapse to icon-only", () => {
128
128
  renderToolbar([toolbarAction("sync", "Sync", true), toolbarAction("export", "Export", true)]);
129
129
 
130
130
  const button = rtlScreen.getByTestId("render-list-toolbar-action-sync");
131
- expect(button.getAttribute("data-size")).toBe("md");
131
+ expect(button.getAttribute("data-size")).toBe("sm");
132
132
  expect(button.textContent).toBe("Sync");
133
133
  });
134
134
 
@@ -140,7 +140,7 @@ describe("RenderList toolbar actions collapse to icon-only", () => {
140
140
  ]);
141
141
 
142
142
  const button = rtlScreen.getByTestId("render-list-toolbar-action-sync");
143
- expect(button.getAttribute("data-size")).toBe("md");
143
+ expect(button.getAttribute("data-size")).toBe("sm");
144
144
  expect(button.textContent).toBe("Sync");
145
145
  });
146
146
  });
@@ -188,7 +188,7 @@ describe("WriteFormSection", () => {
188
188
 
189
189
  expect(actions.contains(button)).toBe(true);
190
190
  expect(body.contains(button)).toBe(false);
191
- expect(button.dataset["icon"]).toBe("check");
191
+ expect(button.dataset["icon"]).toBeUndefined();
192
192
  });
193
193
 
194
194
  test("submit dispatches through the section's configured write handler with the entered values", async () => {
@@ -1,6 +1,6 @@
1
1
  import type { EditFieldViewModel, FieldIssue } from "@cosmicdrift/kumiko-headless";
2
2
  import type { ReactNode } from "react";
3
- import type { usePrimitives } from "../primitives";
3
+ import type { FieldCellWidth, usePrimitives } from "../primitives";
4
4
  import { RenderField } from "./render-field";
5
5
 
6
6
  // Extracted out of render-edit.tsx so write-form-section.tsx can reuse it
@@ -23,8 +23,33 @@ export type GridCellForFieldProps = {
23
23
  readonly valueDisplay: "form" | "text";
24
24
  /** Passed through to RenderField as `row` — see RenderFieldProps.row. */
25
25
  readonly row: Readonly<Record<string, unknown>>;
26
+ readonly changed?: boolean;
27
+ readonly flow?: boolean;
26
28
  };
27
29
 
30
+ const FIELD_CELL_WIDTH_BY_TYPE: Readonly<Record<string, FieldCellWidth>> = {
31
+ number: "number",
32
+ decimal: "number",
33
+ bigInt: "number",
34
+ money: "money",
35
+ date: "date",
36
+ select: "select",
37
+ multiSelect: "select",
38
+ boolean: "auto",
39
+ longText: "full",
40
+ embedded: "full",
41
+ jsonb: "full",
42
+ file: "full",
43
+ files: "full",
44
+ image: "full",
45
+ images: "full",
46
+ };
47
+
48
+ function fieldCellWidth(field: EditFieldViewModel): FieldCellWidth {
49
+ if (field.span !== undefined || (field.type === "text" && field.multiline)) return "full";
50
+ return FIELD_CELL_WIDTH_BY_TYPE[field.type] ?? "text";
51
+ }
52
+
28
53
  export function GridCellForField({
29
54
  field,
30
55
  columns,
@@ -37,13 +62,15 @@ export function GridCellForField({
37
62
  allIssues,
38
63
  valueDisplay,
39
64
  row,
65
+ changed,
66
+ flow,
40
67
  }: GridCellForFieldProps): ReactNode {
41
68
  // RenderField renders nothing for a hidden field, but the GridCell around it still claims the row.
42
69
  if (!field.visible) return null;
43
70
 
44
71
  const effectiveSpan = field.span !== undefined ? Math.min(field.span, columns) : 1;
45
72
  return (
46
- <GridCell span={effectiveSpan}>
73
+ <GridCell span={effectiveSpan} {...(flow === true && { width: fieldCellWidth(field) })}>
47
74
  <RenderField
48
75
  field={field}
49
76
  {...(issues !== undefined && { issues })}
@@ -54,6 +81,7 @@ export function GridCellForField({
54
81
  allIssues={allIssues}
55
82
  valueDisplay={valueDisplay}
56
83
  row={row}
84
+ {...(changed === true && { changed })}
57
85
  />
58
86
  </GridCell>
59
87
  );
@@ -35,6 +35,7 @@ import { useUserRoles } from "../context/user-roles-context";
35
35
  import type { ListSort } from "../hooks/use-list-url-state";
36
36
  import { useQuery } from "../hooks/use-query";
37
37
  import { useTranslation } from "../i18n";
38
+ import { PageHeaderSlotAvailableProvider } from "../page-header-slot";
38
39
  import { type DataTableFacet, usePrimitives } from "../primitives";
39
40
  import { sortByAccessor } from "../sort-by-accessor";
40
41
  import { RenderEditActionButton } from "./render-edit-action-button";
@@ -58,10 +59,13 @@ type PagedRows = {
58
59
  function synthesizeRelatedListEntity(
59
60
  columns: EditRelatedListSectionViewModel["columns"],
60
61
  ): EntityDefinition {
61
- const fields: Record<string, { type: "text"; sortable: boolean }> = {};
62
+ const fields: Record<string, { type: string; sortable: boolean }> = {};
62
63
  for (const col of columns) {
63
64
  const normalized = normalizeListColumn(col);
64
- fields[normalized.field] = { type: "text", sortable: normalized.sortable === true };
65
+ fields[normalized.field] = {
66
+ type: normalized.valueType ?? "text",
67
+ sortable: normalized.sortable === true,
68
+ };
65
69
  }
66
70
  return { fields } as unknown as EntityDefinition;
67
71
  }
@@ -84,6 +88,7 @@ export function RelatedListSection({
84
88
  featureName,
85
89
  translate,
86
90
  hideTitle,
91
+ grow,
87
92
  onOpenDrawer,
88
93
  actions,
89
94
  }: {
@@ -96,6 +101,8 @@ export function RelatedListSection({
96
101
  readonly featureName: string;
97
102
  readonly translate?: Translate;
98
103
  readonly hideTitle?: boolean;
104
+ /** Under a fixed-height screen the tab panel chain stretches so the count footer sits at the bottom. */
105
+ readonly grow?: boolean;
99
106
  /** Opens a drawer-kind rowAction (fw#2710). Supplied by the parent
100
107
  * (ProjectionDetailBody), which owns schema + the actual Drawer render —
101
108
  * this component only ever invokes the callback. */
@@ -379,7 +386,12 @@ export function RelatedListSection({
379
386
  // are really just "the first N in the server's own order" (fw#2722 review).
380
387
  // `nextCursor` is exactly how the paged envelope marks that: non-null means
381
388
  // more rows exist server-side beyond what was fetched.
382
- const truncated = rowsQuery.data !== null && rowsQuery.data.nextCursor !== null;
389
+ // A handler that omits `nextCursor` (or reports a `total` the fetched rows
390
+ // already cover) has no further rows — only a real cursor marks truncation.
391
+ const truncated =
392
+ rowsQuery.data !== null &&
393
+ typeof rowsQuery.data.nextCursor === "string" &&
394
+ (rowsQuery.data.total === undefined || rowsQuery.data.total > rowsQuery.data.rows.length);
383
395
 
384
396
  const content =
385
397
  rowsQuery.loading && rowsQuery.data === null ? (
@@ -404,31 +416,39 @@ export function RelatedListSection({
404
416
  {emptyStateActionError}
405
417
  </Banner>
406
418
  )}
407
- <RenderList
408
- screen={listScreen}
409
- entity={entity}
410
- rows={sortedRows}
411
- featureName={featureName}
412
- translate={effectiveTranslate}
413
- sort={sort}
414
- onSortChange={setSort}
415
- {...(section.searchable === true && {
416
- searchable: true,
417
- searchValue: search,
418
- onSearchChange: setSearch,
419
- })}
420
- {...(filterFacets.length > 0 && {
421
- filterFacets,
422
- filterValues: filters,
423
- onFilterChange,
424
- onFilterReset,
425
- })}
426
- {...(onRowClick !== undefined && { onRowClick })}
427
- {...(rowActions !== undefined && { rowActions })}
428
- {...(toolbarActionButtons !== undefined && { toolbarActions: toolbarActionButtons })}
429
- {...(emptyStateContent !== undefined && { emptyState: emptyStateContent })}
430
- {...(hideTitle === true && { scrollBody: true, screenPadding: false, chromeless: true })}
431
- />
419
+ <PageHeaderSlotAvailableProvider value={false}>
420
+ <RenderList
421
+ screen={listScreen}
422
+ entity={entity}
423
+ rows={sortedRows}
424
+ featureName={featureName}
425
+ translate={effectiveTranslate}
426
+ {...(section.description !== undefined && { description: section.description })}
427
+ {...(section.itemNoun !== undefined && { itemNounKey: section.itemNoun })}
428
+ sort={sort}
429
+ onSortChange={setSort}
430
+ {...(section.searchable === true && {
431
+ searchable: true,
432
+ searchValue: search,
433
+ onSearchChange: setSearch,
434
+ })}
435
+ {...(filterFacets.length > 0 && {
436
+ filterFacets,
437
+ filterValues: filters,
438
+ onFilterChange,
439
+ onFilterReset,
440
+ })}
441
+ {...(onRowClick !== undefined && { onRowClick })}
442
+ {...(rowActions !== undefined && { rowActions })}
443
+ {...(toolbarActionButtons !== undefined && { toolbarActions: toolbarActionButtons })}
444
+ {...(emptyStateContent !== undefined && { emptyState: emptyStateContent })}
445
+ {...(hideTitle === true && {
446
+ scrollBody: true,
447
+ screenPadding: false,
448
+ chromeless: true,
449
+ })}
450
+ />
451
+ </PageHeaderSlotAvailableProvider>
432
452
  </>
433
453
  );
434
454
 
@@ -447,7 +467,11 @@ export function RelatedListSection({
447
467
  return (
448
468
  <>
449
469
  {bridges}
450
- {FillContainer !== undefined ? <FillContainer>{content}</FillContainer> : content}
470
+ {FillContainer !== undefined ? (
471
+ <FillContainer {...(grow === true && { grow: true })}>{content}</FillContainer>
472
+ ) : (
473
+ content
474
+ )}
451
475
  </>
452
476
  );
453
477
  }
@@ -45,6 +45,7 @@ export function RenderEditActionConfirmDialog({
45
45
  export function RenderEditActionButton({
46
46
  action,
47
47
  iconOnly = false,
48
+ hideIcon = false,
48
49
  Button,
49
50
  Dialog,
50
51
  onError,
@@ -53,6 +54,8 @@ export function RenderEditActionButton({
53
54
  /** Group-level collapse (see `shouldRenderActionsIconOnly`) — only takes
54
55
  * effect when this action actually resolved an icon. */
55
56
  readonly iconOnly?: boolean;
57
+ /** Record-header actions are text-only; the icon still shows when collapsed to icon-only. */
58
+ readonly hideIcon?: boolean;
56
59
  readonly Button: ReturnType<typeof usePrimitives>["Button"];
57
60
  readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
58
61
  readonly onError: (text: string | null) => void;
@@ -81,7 +84,7 @@ export function RenderEditActionButton({
81
84
  type="button"
82
85
  variant={variant}
83
86
  loading={busy}
84
- {...(action.icon !== undefined && { icon: action.icon })}
87
+ {...(action.icon !== undefined && (!hideIcon || showIconOnly) && { icon: action.icon })}
85
88
  {...(showIconOnly && { size: "icon" as const, ariaLabel: action.label })}
86
89
  onClick={() => {
87
90
  if (needsActionConfirm(action)) {
@@ -53,6 +53,9 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
53
53
  readonly onDelete?: () => Promise<void> | void;
54
54
  readonly onCancel?: () => void;
55
55
  readonly onReload?: () => void;
56
+ /** Fires when the form gains or loses unsaved input (field changes or a
57
+ * dirty extension section). Lets a host such as a drawer guard closing. */
58
+ readonly onDirtyChange?: (dirty: boolean) => void;
56
59
  /** Copy-link action (issue #912) — only set in update mode (create mode
57
60
  * has no entity id yet, hence no permalink). The callback is already fully
58
61
  * bound (URL building + clipboard happen outside, in
@@ -83,6 +86,8 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
83
86
  readonly submitLabel?: string;
84
87
  /** Visual style of the submit button (actionForm `submitStyle`). Default "primary". */
85
88
  readonly submitVariant?: "primary" | "danger";
89
+ /** Context box above the drawer form (title + optional subtitle, already resolved). */
90
+ readonly summary?: { readonly title: string; readonly subtitle?: string };
86
91
  /** Per-field extra content inline after the label (e.g.
87
92
  * ConfigSourceBadge). Called with the field name, returns a ReactNode or
88
93
  * undefined. */
@@ -161,6 +166,11 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
161
166
  * redundant title above its content. Omitting this prop keeps unchanged
162
167
  * behavior. */
163
168
  readonly hideSectionTitles?: boolean;
169
+ /** Screen body fills the shell height: the form gets `fillHeight` and
170
+ * `stickyActions` (sections scroll, footer pinned). Set by KumikoScreen for
171
+ * screens with `fillHeight !== false`; dialogs and other embedded hosts
172
+ * omit it and keep document-flow height. */
173
+ readonly fillScreenHeight?: boolean;
164
174
  /** Extra content rendered above the card, sharing its left padding and
165
175
  * width — for a host with its own header region (title/metrics/tabs)
166
176
  * that would otherwise render as unpadded siblings before RenderEdit.