@cosmicdrift/kumiko-renderer 0.249.0 → 0.251.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.249.0",
3
+ "version": "0.251.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>",
@@ -15,8 +15,8 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.249.0",
19
- "@cosmicdrift/kumiko-headless": "0.249.0",
18
+ "@cosmicdrift/kumiko-framework": "0.251.0",
19
+ "@cosmicdrift/kumiko-headless": "0.251.0",
20
20
  "react": "^19.2.6",
21
21
  "temporal-polyfill": "^0.3.2",
22
22
  "zod": "^4.4.3"
@@ -27,7 +27,7 @@
27
27
  "@types/react-dom": "^19.2.3",
28
28
  "jsdom": "^29.1.1",
29
29
  "react-dom": "^19.2.6",
30
- "@cosmicdrift/kumiko-locale-de": "0.249.0"
30
+ "@cosmicdrift/kumiko-locale-de": "0.251.0"
31
31
  },
32
32
  "repository": {
33
33
  "type": "git",
@@ -0,0 +1,111 @@
1
+ // A list screen's chrome is the DataTable's own outer wrapper — no
2
+ // FormScreenShell/PageSection sits around it. `screenPadding` is how that
3
+ // wrapper reaches the shared screen-padding token, so it has to be set on the
4
+ // real list path (KumikoScreen → EntityListScreen → EntityListBody →
5
+ // RenderList), not just be available on the primitive (fw#2640).
6
+ import { describe, expect, test } from "bun:test";
7
+ import type {
8
+ EntityDefinition,
9
+ EntityListScreenDefinition,
10
+ } from "@cosmicdrift/kumiko-framework/ui-types";
11
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
12
+ import { render, waitFor } from "@testing-library/react";
13
+ import type { ComponentType, ReactNode } from "react";
14
+ import { DispatcherProvider } from "../../context/dispatcher-context";
15
+ import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
16
+ import { type CorePrimitives, type DataTableProps, PrimitivesProvider } from "../../primitives";
17
+ import type { FeatureSchema } from "../feature-schema";
18
+ import { KumikoScreen } from "../kumiko-screen";
19
+ import { NavProvider } from "../nav";
20
+
21
+ const noop = (): ReactNode => null;
22
+ const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
23
+
24
+ function stubDispatcher(): Dispatcher {
25
+ return {
26
+ write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
27
+ query: (async () => ({
28
+ isSuccess: true,
29
+ data: { rows: [], total: 0 },
30
+ })) as unknown as Dispatcher["query"],
31
+ batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
32
+ statusStore: {
33
+ getState: () => "online",
34
+ subscribe: () => () => {},
35
+ } as unknown as Dispatcher["statusStore"],
36
+ async *stream() {},
37
+ pendingWrites: () => [],
38
+ pendingFiles: () => [],
39
+ };
40
+ }
41
+
42
+ function buildSchema(): FeatureSchema {
43
+ const entity: EntityDefinition = {
44
+ fields: {
45
+ name: { type: "text", maxLength: 200, required: false, searchable: false, sortable: false },
46
+ },
47
+ };
48
+ const listScreen: EntityListScreenDefinition = {
49
+ id: "org-list",
50
+ type: "entityList",
51
+ entity: "org",
52
+ columns: ["name"],
53
+ };
54
+ return {
55
+ featureName: "orgs",
56
+ entities: { org: entity },
57
+ screens: [listScreen],
58
+ } as FeatureSchema;
59
+ }
60
+
61
+ describe("entityList screen padding (fw#2640)", () => {
62
+ test("the list screen marks its table as the screen body", async () => {
63
+ let capturedScreenPadding: boolean | undefined;
64
+ const capturingDataTable: ComponentType<DataTableProps> = (props) => {
65
+ capturedScreenPadding = props.screenPadding;
66
+ return null;
67
+ };
68
+ const primitives: CorePrimitives = {
69
+ Button: noop,
70
+ Banner: passChildren,
71
+ Field: passChildren,
72
+ Input: noop,
73
+ DataTable: capturingDataTable,
74
+ Form: passChildren,
75
+ Section: passChildren,
76
+ Card: passChildren,
77
+ Grid: passChildren,
78
+ GridCell: passChildren,
79
+ Text: passChildren,
80
+ Heading: noop,
81
+ Dialog: noop,
82
+ Modal: noop,
83
+ Lightbox: noop,
84
+ ConfigSourceBadge: noop,
85
+ ConfigCascadeView: noop,
86
+ Link: noop,
87
+ };
88
+ render(
89
+ <LocaleProvider resolver={createStaticLocaleResolver({ locale: "de-DE" })}>
90
+ <DispatcherProvider dispatcher={stubDispatcher()}>
91
+ <NavProvider
92
+ value={{
93
+ route: { screenId: "orgs:org-list" },
94
+ navigate: () => {},
95
+ replace: () => {},
96
+ hrefFor: () => "",
97
+ searchParams: {},
98
+ setSearchParams: () => {},
99
+ }}
100
+ >
101
+ <PrimitivesProvider value={primitives}>
102
+ <KumikoScreen schema={buildSchema()} qn="orgs:screen:org-list" />
103
+ </PrimitivesProvider>
104
+ </NavProvider>
105
+ </DispatcherProvider>
106
+ </LocaleProvider>,
107
+ );
108
+
109
+ await waitFor(() => expect(capturedScreenPadding).toBe(true));
110
+ });
111
+ });
@@ -6,7 +6,6 @@ import type {
6
6
  EntityDefinition,
7
7
  EntityEditScreenDefinition,
8
8
  EntityListScreenDefinition,
9
- ListFacetSpec,
10
9
  ProjectionDetailScreenDefinition,
11
10
  ProjectionListScreenDefinition,
12
11
  RowAction,
@@ -50,6 +49,12 @@ import { useDashboardBody } from "./dashboard-body";
50
49
  import type { FeatureSchema } from "./feature-schema";
51
50
  import { buildFormSchema } from "./form-schema";
52
51
  import { layoutFieldNames } from "./layout-fields";
52
+ import {
53
+ buildFilterFacets,
54
+ buildFilterPayload,
55
+ type ResolvedFacetSpec,
56
+ resolveProjectionFacetSpecs,
57
+ } from "./list-facets";
53
58
  import { useNav } from "./nav";
54
59
  import {
55
60
  synthesizeProjectionDetailEntity,
@@ -763,6 +768,7 @@ function EntityEditUpdateForm({
763
768
  id: action.id,
764
769
  label: effectiveTranslate(action.label),
765
770
  ...(action.style !== undefined && { style: action.style }),
771
+ confirmRequired: false,
766
772
  ...(actionIcon !== undefined && { icon: actionIcon }),
767
773
  onPress: () => {
768
774
  if (id === "") return;
@@ -783,6 +789,7 @@ function EntityEditUpdateForm({
783
789
  id: action.id,
784
790
  label: effectiveTranslate(action.label),
785
791
  ...(action.style !== undefined && { style: action.style }),
792
+ confirmRequired: false,
786
793
  ...(actionIcon !== undefined && { icon: actionIcon }),
787
794
  onPress: () => {
788
795
  nav.navigate({
@@ -802,6 +809,7 @@ function EntityEditUpdateForm({
802
809
  id: action.id,
803
810
  label: effectiveTranslate(action.label),
804
811
  ...(action.style !== undefined && { style: action.style }),
812
+ confirmRequired: false,
805
813
  ...(actionIcon !== undefined && { icon: actionIcon }),
806
814
  onPress: () => {
807
815
  const initialValues =
@@ -1063,58 +1071,6 @@ export function buildListQueryPayload(state: {
1063
1071
  return payload;
1064
1072
  }
1065
1073
 
1066
- // One resolved facet, independent of where the type info came from — an
1067
- // entity field (entityList) or an explicit ListFacetSpec (projectionList,
1068
- // fw#2224). Shared by buildFilterFacets/buildFilterPayload below so both
1069
- // screen types build their query-payload filters and DataTable facet-UI
1070
- // through the same code, instead of two copies that can drift.
1071
- type ResolvedFacetSpec = {
1072
- readonly field: string;
1073
- readonly type: "select" | "boolean";
1074
- readonly label: string;
1075
- readonly options: readonly { readonly value: string; readonly label: string }[];
1076
- };
1077
-
1078
- function buildFilterFacets(specs: readonly ResolvedFacetSpec[]): DataTableFacet[] {
1079
- return specs.map((spec) => ({ field: spec.field, label: spec.label, options: spec.options }));
1080
- }
1081
-
1082
- // User-selected faceted filters from URL-state → payload.filters. Boolean
1083
- // fields coerce "true"/"false" strings to real booleans (DB column is
1084
- // boolean); everything else stays string[] under op:"in" (multi-select
1085
- // semantics). `typeOf` resolves a field to its known type string —
1086
- // undefined means "unknown field", so it's dropped (typo-safe: a stale/
1087
- // hand-crafted URL param for an undeclared field never reaches the
1088
- // server). Deliberately NOT gated on the field being a *facet* — entityList
1089
- // passes through any field present in entity.fields, matching its
1090
- // pre-fw#2224 behavior; only the boolean-coercion branch cares about type.
1091
-
1092
- function isFacetI18nKey(label: string): boolean {
1093
- return !/\s/.test(label) && (label.includes(".") || label.includes(":"));
1094
- }
1095
-
1096
- function buildFilterPayload(
1097
- urlFilters: Readonly<Record<string, readonly string[]>>,
1098
- typeOf: (field: string) => string | undefined,
1099
- ): { field: string; op: "in"; value: unknown }[] {
1100
- const out: { field: string; op: "in"; value: unknown }[] = [];
1101
- for (const [field, values] of Object.entries(urlFilters)) {
1102
- if (values.length === 0) continue;
1103
- // `id` is a base column (not a declared facet), allowed as an id-set
1104
- // filter so a header-slot control — e.g. the tags TagFilter — can narrow
1105
- // ANY list to a resolved set of row ids without the host declaring a facet.
1106
- if (field === "id") {
1107
- out.push({ field, op: "in", value: values });
1108
- continue;
1109
- }
1110
- const type = typeOf(field);
1111
- if (type === undefined) continue;
1112
- const value = type === "boolean" ? values.map((v) => v === "true") : values;
1113
- out.push({ field, op: "in", value });
1114
- }
1115
- return out;
1116
- }
1117
-
1118
1074
  // entityList adapter — one DataTableFacet per filterable select/boolean
1119
1075
  // entity field, labels via the standard field/option i18n convention.
1120
1076
  function resolveEntityFacetSpecs(
@@ -1161,39 +1117,6 @@ function resolveEntityFacetSpecs(
1161
1117
  return out;
1162
1118
  }
1163
1119
 
1164
- // projectionList adapter — a projectionList has no entity/i18n convention to
1165
- // derive labels from, so ListFacetSpec carries every label explicitly
1166
- // (fw#2224). Labels may be raw display strings or i18n keys; run them
1167
- // through translate like entity facets (passthrough when the key is missing).
1168
- function resolveProjectionFacetSpecs(
1169
- facets: readonly ListFacetSpec[] | undefined,
1170
- translate: Translate,
1171
- ): ResolvedFacetSpec[] {
1172
- if (facets === undefined) return [];
1173
- const tr = (label: string): string => (isFacetI18nKey(label) ? translate(label) : label);
1174
- return facets.map((facet) =>
1175
- facet.type === "select"
1176
- ? {
1177
- field: facet.field,
1178
- type: "select",
1179
- label: tr(facet.label),
1180
- options: facet.options.map((opt) => ({
1181
- value: opt.value,
1182
- label: tr(opt.label),
1183
- })),
1184
- }
1185
- : {
1186
- field: facet.field,
1187
- type: "boolean",
1188
- label: tr(facet.label),
1189
- options: [
1190
- { value: "true", label: tr(facet.trueLabel) },
1191
- { value: "false", label: tr(facet.falseLabel) },
1192
- ],
1193
- },
1194
- );
1195
- }
1196
-
1197
1120
  // ---- drawer-kind actions: ToolbarAction (fw#2225) + RowAction (fw#2710) ----
1198
1121
  //
1199
1122
  // Shared across EntityListBody, ProjectionListBody, ProjectionDetailBody and
@@ -1612,6 +1535,7 @@ function EntityListBody({
1612
1535
  id: action.id,
1613
1536
  label: effectiveTranslate(action.label),
1614
1537
  ...(action.style !== undefined && { style: action.style }),
1538
+ confirmRequired: false,
1615
1539
  ...(actionIcon !== undefined && { icon: actionIcon }),
1616
1540
  onTrigger: (row: ListRowViewModel) => runNavigate(navigateAction, row),
1617
1541
  ...(actionVisible !== undefined && {
@@ -1627,6 +1551,7 @@ function EntityListBody({
1627
1551
  id: action.id,
1628
1552
  label: effectiveTranslate(action.label),
1629
1553
  ...(action.style !== undefined && { style: action.style }),
1554
+ confirmRequired: false,
1630
1555
  ...(actionIcon !== undefined && { icon: actionIcon }),
1631
1556
  onTrigger: (row: ListRowViewModel) => {
1632
1557
  const initialValues =
@@ -1709,6 +1634,7 @@ function EntityListBody({
1709
1634
  id: action.id,
1710
1635
  label: effectiveTranslate(action.label),
1711
1636
  ...(action.style !== undefined && { style: action.style }),
1637
+ confirmRequired: false,
1712
1638
  ...(actionIcon !== undefined && { icon: actionIcon }),
1713
1639
  onTrigger: () => nav.navigate({ screenId: action.screen }),
1714
1640
  };
@@ -1718,6 +1644,7 @@ function EntityListBody({
1718
1644
  id: action.id,
1719
1645
  label: effectiveTranslate(action.label),
1720
1646
  ...(action.style !== undefined && { style: action.style }),
1647
+ confirmRequired: false,
1721
1648
  ...(actionIcon !== undefined && { icon: actionIcon }),
1722
1649
  onTrigger: () => openDrawer(action),
1723
1650
  };
@@ -1834,6 +1761,7 @@ function EntityListBody({
1834
1761
  onSearchChange={urlState.setQ}
1835
1762
  sort={effectiveSort}
1836
1763
  onSortChange={urlState.setSort}
1764
+ screenPadding
1837
1765
  {...(pager !== undefined && { pager })}
1838
1766
  {...(rowActions !== undefined && { rowActions })}
1839
1767
  {...(rowActionMode !== undefined && { rowActionMode })}
@@ -2017,6 +1945,7 @@ function ProjectionListBody({
2017
1945
  id: action.id,
2018
1946
  label: effectiveTranslate(action.label),
2019
1947
  ...(action.style !== undefined && { style: action.style }),
1948
+ confirmRequired: false,
2020
1949
  ...(actionIcon !== undefined && { icon: actionIcon }),
2021
1950
  onTrigger: () => nav.navigate({ screenId: target }),
2022
1951
  });
@@ -2027,6 +1956,7 @@ function ProjectionListBody({
2027
1956
  id: action.id,
2028
1957
  label: effectiveTranslate(action.label),
2029
1958
  ...(action.style !== undefined && { style: action.style }),
1959
+ confirmRequired: false,
2030
1960
  ...(actionIcon !== undefined && { icon: actionIcon }),
2031
1961
  onTrigger: () => openDrawer(action),
2032
1962
  });
@@ -2122,6 +2052,7 @@ function ProjectionListBody({
2122
2052
  onSearchChange={urlState.setQ}
2123
2053
  sort={activeSort}
2124
2054
  onSortChange={urlState.setSort}
2055
+ screenPadding
2125
2056
  {...(pager !== undefined && { pager })}
2126
2057
  {...(rowActions !== undefined && { rowActions })}
2127
2058
  {...(rowActionMode !== undefined && { rowActionMode })}
@@ -2315,6 +2246,7 @@ function ProjectionDetailBody({
2315
2246
  id: action.id,
2316
2247
  label: effectiveTranslate(action.label),
2317
2248
  ...(action.style !== undefined && { style: action.style }),
2249
+ confirmRequired: false,
2318
2250
  ...(actionIcon !== undefined && { icon: actionIcon }),
2319
2251
  onPress: () => {
2320
2252
  if (id === "") return;
@@ -2348,6 +2280,7 @@ function ProjectionDetailBody({
2348
2280
  id: action.id,
2349
2281
  label: effectiveTranslate(action.label),
2350
2282
  ...(action.style !== undefined && { style: action.style }),
2283
+ confirmRequired: false,
2351
2284
  ...(actionIcon !== undefined && { icon: actionIcon }),
2352
2285
  onPress: () => {
2353
2286
  nav.navigate({
@@ -2367,6 +2300,7 @@ function ProjectionDetailBody({
2367
2300
  id: action.id,
2368
2301
  label: effectiveTranslate(action.label),
2369
2302
  ...(action.style !== undefined && { style: action.style }),
2303
+ confirmRequired: false,
2370
2304
  ...(actionIcon !== undefined && { icon: actionIcon }),
2371
2305
  onPress: () => {
2372
2306
  const initialValues =
@@ -2728,6 +2662,7 @@ function ActionFormBody({
2728
2662
  onSubmit={handleSubmitted}
2729
2663
  {...(handleCancel !== undefined && { onCancel: handleCancel })}
2730
2664
  {...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
2665
+ {...(screen.submitStyle !== undefined && { submitVariant: screen.submitStyle })}
2731
2666
  {...(translate !== undefined && { translate })}
2732
2667
  />
2733
2668
  );
@@ -0,0 +1,90 @@
1
+ // Split from kumiko-screen.tsx: related-list-section.tsx renders through
2
+ // kumiko-screen.tsx, so importing this back from there would be a require cycle.
3
+ import type { ListFacetSpec } from "@cosmicdrift/kumiko-framework/ui-types";
4
+ import type { Translate } from "@cosmicdrift/kumiko-headless";
5
+ import type { DataTableFacet } from "../primitives";
6
+
7
+ // One resolved facet, independent of where the type info came from — an
8
+ // entity field (entityList) or an explicit ListFacetSpec (projectionList,
9
+ // fw#2224). Shared by buildFilterFacets/buildFilterPayload below so both
10
+ // screen types build their query-payload filters and DataTable facet-UI
11
+ // through the same code, instead of two copies that can drift.
12
+ export type ResolvedFacetSpec = {
13
+ readonly field: string;
14
+ readonly type: "select" | "boolean";
15
+ readonly label: string;
16
+ readonly options: readonly { readonly value: string; readonly label: string }[];
17
+ };
18
+
19
+ export function buildFilterFacets(specs: readonly ResolvedFacetSpec[]): DataTableFacet[] {
20
+ return specs.map((spec) => ({ field: spec.field, label: spec.label, options: spec.options }));
21
+ }
22
+
23
+ // User-selected faceted filters from URL-state → payload.filters. Boolean
24
+ // fields coerce "true"/"false" strings to real booleans (DB column is
25
+ // boolean); everything else stays string[] under op:"in" (multi-select
26
+ // semantics). `typeOf` resolves a field to its known type string —
27
+ // undefined means "unknown field", so it's dropped (typo-safe: a stale/
28
+ // hand-crafted URL param for an undeclared field never reaches the
29
+ // server). Deliberately NOT gated on the field being a *facet* — entityList
30
+ // passes through any field present in entity.fields, matching its
31
+ // pre-fw#2224 behavior; only the boolean-coercion branch cares about type.
32
+
33
+ function isFacetI18nKey(label: string): boolean {
34
+ return !/\s/.test(label) && (label.includes(".") || label.includes(":"));
35
+ }
36
+
37
+ export function buildFilterPayload(
38
+ urlFilters: Readonly<Record<string, readonly string[]>>,
39
+ typeOf: (field: string) => string | undefined,
40
+ ): { field: string; op: "in"; value: unknown }[] {
41
+ const out: { field: string; op: "in"; value: unknown }[] = [];
42
+ for (const [field, values] of Object.entries(urlFilters)) {
43
+ if (values.length === 0) continue;
44
+ // `id` is a base column (not a declared facet), allowed as an id-set
45
+ // filter so a header-slot control — e.g. the tags TagFilter — can narrow
46
+ // ANY list to a resolved set of row ids without the host declaring a facet.
47
+ if (field === "id") {
48
+ out.push({ field, op: "in", value: values });
49
+ continue;
50
+ }
51
+ const type = typeOf(field);
52
+ if (type === undefined) continue;
53
+ const value = type === "boolean" ? values.map((v) => v === "true") : values;
54
+ out.push({ field, op: "in", value });
55
+ }
56
+ return out;
57
+ }
58
+
59
+ // projectionList adapter — a projectionList has no entity/i18n convention to
60
+ // derive labels from, so ListFacetSpec carries every label explicitly
61
+ // (fw#2224). Labels may be raw display strings or i18n keys; run them
62
+ // through translate like entity facets (passthrough when the key is missing).
63
+ export function resolveProjectionFacetSpecs(
64
+ facets: readonly ListFacetSpec[] | undefined,
65
+ translate: Translate,
66
+ ): ResolvedFacetSpec[] {
67
+ if (facets === undefined) return [];
68
+ const tr = (label: string): string => (isFacetI18nKey(label) ? translate(label) : label);
69
+ return facets.map((facet) =>
70
+ facet.type === "select"
71
+ ? {
72
+ field: facet.field,
73
+ type: "select",
74
+ label: tr(facet.label),
75
+ options: facet.options.map((opt) => ({
76
+ value: opt.value,
77
+ label: tr(opt.label),
78
+ })),
79
+ }
80
+ : {
81
+ field: facet.field,
82
+ type: "boolean",
83
+ label: tr(facet.label),
84
+ options: [
85
+ { value: "true", label: tr(facet.trueLabel) },
86
+ { value: "false", label: tr(facet.falseLabel) },
87
+ ],
88
+ },
89
+ );
90
+ }
@@ -156,6 +156,7 @@ function buildNavigateRowAction(
156
156
  id: action.id,
157
157
  label: translate(action.label),
158
158
  ...(action.style !== undefined && { style: action.style }),
159
+ confirmRequired: false,
159
160
  ...(actionIcon !== undefined && { icon: actionIcon }),
160
161
  onTrigger: (row: ListRowViewModel) => runProjectionRowNavigate(nav, action, row),
161
162
  ...(visible !== undefined && {
@@ -195,6 +196,7 @@ function buildDrawerRowAction(
195
196
  id: action.id,
196
197
  label: translate(action.label),
197
198
  ...(action.style !== undefined && { style: action.style }),
199
+ confirmRequired: false,
198
200
  ...(actionIcon !== undefined && { icon: actionIcon }),
199
201
  onTrigger: (row: ListRowViewModel) => {
200
202
  openDrawer(action, params !== undefined ? evalRowExtractor(params, row.values) : undefined);
@@ -10,6 +10,7 @@ import { kumikoDefaultTranslations } from "../../i18n-defaults";
10
10
  import {
11
11
  type CorePrimitives,
12
12
  type DataTableProps,
13
+ type InputProps,
13
14
  PrimitivesProvider,
14
15
  type SectionProps,
15
16
  } from "../../primitives";
@@ -54,6 +55,15 @@ const testDataTable: ComponentType<DataTableProps> = ({ rows, rowActions }) => (
54
55
  </table>
55
56
  );
56
57
 
58
+ const testInput = (props: InputProps) =>
59
+ props.kind === "text" ? (
60
+ <input
61
+ data-testid={props.id}
62
+ value={props.value}
63
+ onChange={(e) => props.onChange(e.target.value)}
64
+ />
65
+ ) : null;
66
+
57
67
  function testPrimitives(): CorePrimitives {
58
68
  return {
59
69
  Button: noop,
@@ -61,7 +71,7 @@ function testPrimitives(): CorePrimitives {
61
71
  <div data-testid={testId}>{children}</div>
62
72
  ),
63
73
  Field: passChildren,
64
- Input: noop,
74
+ Input: testInput,
65
75
  DataTable: testDataTable,
66
76
  Form: noop,
67
77
  Section: testSection,
@@ -638,3 +648,293 @@ describe("RelatedListSection — truncation banner (fw#2722 review)", () => {
638
648
  expect(rtlScreen.getByTestId("related-list-truncated")).toBeTruthy();
639
649
  });
640
650
  });
651
+
652
+ // A DataTable stub that renders the toolbarStart slot (carries the search
653
+ // Input) and filterFacets as one button per option, wired straight to
654
+ // onFilterChange/onFilterReset — enough to prove RelatedListSection resolves
655
+ // facets and wires search through RenderList, without the production
656
+ // DataTable's own facet-dropdown chrome.
657
+ const searchFacetDataTable: ComponentType<DataTableProps> = ({
658
+ rows,
659
+ toolbarStart,
660
+ filterFacets,
661
+ onFilterChange,
662
+ onFilterReset,
663
+ }) => (
664
+ <div>
665
+ {toolbarStart}
666
+ {(filterFacets ?? []).map((facet) => (
667
+ <div key={facet.field}>
668
+ {facet.options.map((opt) => (
669
+ <button
670
+ key={opt.value}
671
+ type="button"
672
+ data-testid={`facet-${facet.field}-${opt.value}`}
673
+ onClick={() => onFilterChange?.(facet.field, [opt.value])}
674
+ >
675
+ {opt.label}
676
+ </button>
677
+ ))}
678
+ </div>
679
+ ))}
680
+ {onFilterReset !== undefined && (
681
+ <button type="button" data-testid="filter-reset" onClick={onFilterReset}>
682
+ Reset
683
+ </button>
684
+ )}
685
+ <table>
686
+ <tbody>
687
+ {rows.map((row) => (
688
+ <tr key={row.id} data-testid={`row-${row.id}`} />
689
+ ))}
690
+ </tbody>
691
+ </table>
692
+ </div>
693
+ );
694
+
695
+ // A dispatcher stub that genuinely filters a fixed row set by the received
696
+ // payload — `search` as a case-insensitive substring on `name`, `filters`
697
+ // entries (`{ field, op: "in", value }`) as membership on that field —
698
+ // instead of returning canned rows, so a test can tell a real payload from a
699
+ // stale one. Every payload it sees is recorded for the "last payload"
700
+ // assertions below.
701
+ function filteringDispatcher(rows: readonly Record<string, unknown>[]): {
702
+ dispatcher: Dispatcher;
703
+ payloads: Record<string, unknown>[];
704
+ } {
705
+ const payloads: Record<string, unknown>[] = [];
706
+ const dispatcher: Dispatcher = {
707
+ write: (async () => ({ isSuccess: true, data: null })) as Dispatcher["write"],
708
+ query: (async (_type: string, payload: unknown) => {
709
+ const p = payload as {
710
+ search?: string;
711
+ filters?: readonly { field: string; op: "in"; value: readonly unknown[] }[];
712
+ };
713
+ payloads.push(p);
714
+ let result = rows;
715
+ if (p.search !== undefined && p.search !== "") {
716
+ const term = p.search.toLowerCase();
717
+ result = result.filter((r) =>
718
+ String(r["name"] ?? "")
719
+ .toLowerCase()
720
+ .includes(term),
721
+ );
722
+ }
723
+ for (const f of p.filters ?? []) {
724
+ result = result.filter((r) => f.value.includes(r[f.field]));
725
+ }
726
+ return { isSuccess: true, data: { rows: result, nextCursor: null } };
727
+ }) as Dispatcher["query"],
728
+ batch: (async () => ({ isSuccess: true, results: [] })) as Dispatcher["batch"],
729
+ statusStore: {
730
+ getState: () => "online",
731
+ subscribe: () => () => {},
732
+ } as unknown as Dispatcher["statusStore"],
733
+ async *stream() {},
734
+ pendingWrites: () => [],
735
+ pendingFiles: () => [],
736
+ };
737
+ return { dispatcher, payloads };
738
+ }
739
+
740
+ function renderWithDataTable(
741
+ dispatcher: Dispatcher,
742
+ section: EditRelatedListSectionViewModel,
743
+ DataTable: ComponentType<DataTableProps>,
744
+ hideTitle?: boolean,
745
+ ) {
746
+ return render(
747
+ <LocaleProvider
748
+ resolver={createStaticLocaleResolver({ locale: "en-US" })}
749
+ fallbackBundles={[kumikoDefaultTranslations]}
750
+ >
751
+ <DispatcherProvider dispatcher={dispatcher}>
752
+ <PrimitivesProvider value={{ ...testPrimitives(), DataTable }}>
753
+ <NavProvider value={stubNav().nav}>
754
+ <RelatedListSection
755
+ section={section}
756
+ parentId="order-1"
757
+ featureName="orders"
758
+ {...(hideTitle === true && { hideTitle: true })}
759
+ />
760
+ </NavProvider>
761
+ </PrimitivesProvider>
762
+ </DispatcherProvider>
763
+ </LocaleProvider>,
764
+ );
765
+ }
766
+
767
+ describe("RelatedListSection — search + facets (fw#2740)", () => {
768
+ const nameRows = [
769
+ { id: "r1", name: "Alice" },
770
+ { id: "r2", name: "Bob" },
771
+ ];
772
+
773
+ test("searchable: true — typing (after the 300ms debounce) sends payload.search and narrows the rendered rows", async () => {
774
+ const { dispatcher, payloads } = filteringDispatcher(nameRows);
775
+ renderWithDataTable(
776
+ dispatcher,
777
+ {
778
+ kind: "relatedList",
779
+ title: "Contacts",
780
+ query: "lease:query:contacts:list",
781
+ columns: [{ field: "name" }],
782
+ searchable: true,
783
+ },
784
+ searchFacetDataTable,
785
+ );
786
+
787
+ await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
788
+ expect(rtlScreen.getByTestId("row-r2")).toBeTruthy();
789
+
790
+ fireEvent.change(rtlScreen.getByTestId("render-list-search"), {
791
+ target: { value: "ali" },
792
+ });
793
+
794
+ await waitFor(
795
+ () => {
796
+ const last = payloads[payloads.length - 1];
797
+ expect(last?.["search"]).toBe("ali");
798
+ },
799
+ { timeout: 2000 },
800
+ );
801
+ await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
802
+ expect(rtlScreen.queryByTestId("row-r2")).toBeNull();
803
+ });
804
+
805
+ test("without searchable, RenderList shows no search input and no payload ever carries a search key", async () => {
806
+ const { dispatcher, payloads } = filteringDispatcher(nameRows);
807
+ renderWithDataTable(
808
+ dispatcher,
809
+ {
810
+ kind: "relatedList",
811
+ title: "Contacts",
812
+ query: "lease:query:contacts:list",
813
+ columns: [{ field: "name" }],
814
+ },
815
+ searchFacetDataTable,
816
+ );
817
+
818
+ await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
819
+ expect(rtlScreen.queryByTestId("render-list-search")).toBeNull();
820
+ for (const p of payloads) {
821
+ expect("search" in p).toBe(false);
822
+ }
823
+ });
824
+
825
+ test("tabs mode (hideTitle): the search box still renders and narrows the rows", async () => {
826
+ const { dispatcher, payloads } = filteringDispatcher(nameRows);
827
+ renderWithDataTable(
828
+ dispatcher,
829
+ {
830
+ kind: "relatedList",
831
+ title: "Contacts",
832
+ query: "lease:query:contacts:list",
833
+ columns: [{ field: "name" }],
834
+ searchable: true,
835
+ },
836
+ searchFacetDataTable,
837
+ true,
838
+ );
839
+
840
+ await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
841
+ expect(rtlScreen.getByTestId("row-r2")).toBeTruthy();
842
+ expect(rtlScreen.getByTestId("render-list-search")).toBeTruthy();
843
+
844
+ fireEvent.change(rtlScreen.getByTestId("render-list-search"), {
845
+ target: { value: "ali" },
846
+ });
847
+
848
+ await waitFor(
849
+ () => {
850
+ const last = payloads[payloads.length - 1];
851
+ expect(last?.["search"]).toBe("ali");
852
+ },
853
+ { timeout: 2000 },
854
+ );
855
+ await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
856
+ expect(rtlScreen.queryByTestId("row-r2")).toBeNull();
857
+ });
858
+
859
+ const statusSection: EditRelatedListSectionViewModel = {
860
+ kind: "relatedList",
861
+ title: "Positions",
862
+ query: "lease:query:items:list",
863
+ columns: [{ field: "name" }, { field: "status" }],
864
+ facets: [
865
+ {
866
+ field: "status",
867
+ type: "select",
868
+ label: "Status",
869
+ options: [
870
+ { value: "active", label: "Active" },
871
+ { value: "ended", label: "Ended" },
872
+ ],
873
+ },
874
+ ],
875
+ };
876
+ const statusRows = [
877
+ { id: "active-1", name: "Running", status: "active" },
878
+ { id: "ended-1", name: "Closed", status: "ended" },
879
+ ];
880
+
881
+ test("select facet: clicking an option filters payload.filters and rows, resetting clears both", async () => {
882
+ const { dispatcher, payloads } = filteringDispatcher(statusRows);
883
+ renderWithDataTable(dispatcher, statusSection, searchFacetDataTable);
884
+
885
+ await waitFor(() => expect(rtlScreen.getByTestId("row-active-1")).toBeTruthy());
886
+ expect(rtlScreen.getByTestId("row-ended-1")).toBeTruthy();
887
+
888
+ fireEvent.click(rtlScreen.getByTestId("facet-status-active"));
889
+
890
+ await waitFor(() => expect(rtlScreen.queryByTestId("row-ended-1")).toBeNull());
891
+ expect(rtlScreen.getByTestId("row-active-1")).toBeTruthy();
892
+ expect(payloads[payloads.length - 1]?.["filters"]).toEqual([
893
+ { field: "status", op: "in", value: ["active"] },
894
+ ]);
895
+
896
+ fireEvent.click(rtlScreen.getByTestId("filter-reset"));
897
+
898
+ await waitFor(() => expect(rtlScreen.getByTestId("row-ended-1")).toBeTruthy());
899
+ expect(rtlScreen.getByTestId("row-active-1")).toBeTruthy();
900
+ expect(payloads[payloads.length - 1]?.["filters"]).toBeUndefined();
901
+ });
902
+
903
+ test("boolean facet: selecting a value sends a real boolean in payload.filters and narrows rows accordingly", async () => {
904
+ const paidRows = [
905
+ { id: "paid-1", name: "Invoice A", paid: true },
906
+ { id: "unpaid-1", name: "Invoice B", paid: false },
907
+ ];
908
+ const { dispatcher, payloads } = filteringDispatcher(paidRows);
909
+ renderWithDataTable(
910
+ dispatcher,
911
+ {
912
+ kind: "relatedList",
913
+ title: "Invoices",
914
+ query: "lease:query:invoices:list",
915
+ columns: [{ field: "name" }, { field: "paid" }],
916
+ facets: [
917
+ {
918
+ field: "paid",
919
+ type: "boolean",
920
+ label: "Paid",
921
+ trueLabel: "Yes",
922
+ falseLabel: "No",
923
+ },
924
+ ],
925
+ },
926
+ searchFacetDataTable,
927
+ );
928
+
929
+ await waitFor(() => expect(rtlScreen.getByTestId("row-paid-1")).toBeTruthy());
930
+ expect(rtlScreen.getByTestId("row-unpaid-1")).toBeTruthy();
931
+
932
+ fireEvent.click(rtlScreen.getByTestId("facet-paid-true"));
933
+
934
+ await waitFor(() => expect(rtlScreen.queryByTestId("row-unpaid-1")).toBeNull());
935
+ expect(rtlScreen.getByTestId("row-paid-1")).toBeTruthy();
936
+ expect(payloads[payloads.length - 1]?.["filters"]).toEqual([
937
+ { field: "paid", op: "in", value: [true] },
938
+ ]);
939
+ });
940
+ });
@@ -137,6 +137,26 @@ describe("RenderEditActionButton", () => {
137
137
  await waitFor(() => expect(pressed).toBe(1));
138
138
  });
139
139
 
140
+ // fw#2752: schema-driven navigate/drawer actions set confirmRequired: false
141
+ // to opt a danger-styled action out of the forced dialog — the colour still
142
+ // marks it destructive, but the target form is itself the confirmation.
143
+ test("danger style with confirmRequired=false fires onPress directly, no dialog", async () => {
144
+ let pressed = 0;
145
+ renderAction({
146
+ id: "open-terminate-form",
147
+ label: "Terminate",
148
+ style: "danger",
149
+ confirmRequired: false,
150
+ onPress: async () => {
151
+ pressed += 1;
152
+ },
153
+ });
154
+
155
+ fireEvent.click(rtlScreen.getByTestId("render-edit-action-open-terminate-form"));
156
+ expect(rtlScreen.queryByTestId("render-edit-action-open-terminate-form-dialog")).toBeNull();
157
+ await waitFor(() => expect(pressed).toBe(1));
158
+ });
159
+
140
160
  test("onPress failure reports via onError", async () => {
141
161
  const errors: Array<string | null> = [];
142
162
  renderAction(
@@ -10,7 +10,12 @@ import type {
10
10
  ListRowViewModel,
11
11
  Translate,
12
12
  } from "@cosmicdrift/kumiko-headless";
13
- import { type ReactNode, useMemo, useState } from "react";
13
+ import { type ReactNode, useCallback, useMemo, useState } from "react";
14
+ import {
15
+ buildFilterFacets,
16
+ buildFilterPayload,
17
+ resolveProjectionFacetSpecs,
18
+ } from "../app/list-facets";
14
19
  import { useNav } from "../app/nav";
15
20
  import {
16
21
  buildProjectionRowActions,
@@ -22,7 +27,7 @@ import { useOptionalDispatcher } from "../context/dispatcher-context";
22
27
  import type { ListSort } from "../hooks/use-list-url-state";
23
28
  import { useQuery } from "../hooks/use-query";
24
29
  import { useTranslation } from "../i18n";
25
- import { usePrimitives } from "../primitives";
30
+ import { type DataTableFacet, usePrimitives } from "../primitives";
26
31
  import { sortByAccessor } from "../sort-by-accessor";
27
32
  import { RenderList } from "./render-list";
28
33
 
@@ -94,16 +99,53 @@ export function RelatedListSection({
94
99
  [section.columns],
95
100
  );
96
101
 
102
+ // Local state, not URL state: a section `id` is optional, so there is no
103
+ // stable URL key to namespace against — the section's sort is local for the
104
+ // same reason. Consequence: search/filters reset on reload.
105
+ const [search, setSearch] = useState("");
106
+ const [filters, setFilters] = useState<Readonly<Record<string, readonly string[]>>>({});
107
+
108
+ const facetSpecs = useMemo(
109
+ () => resolveProjectionFacetSpecs(section.facets, effectiveTranslate),
110
+ [section.facets, effectiveTranslate],
111
+ );
112
+ const filterPayload = useMemo(
113
+ () =>
114
+ buildFilterPayload(filters, (field) => facetSpecs.find((spec) => spec.field === field)?.type),
115
+ [filters, facetSpecs],
116
+ );
117
+ const filterFacets = useMemo<DataTableFacet[]>(() => buildFilterFacets(facetSpecs), [facetSpecs]);
118
+
97
119
  const payload = useMemo(
98
120
  () => ({
99
121
  [section.parentParam ?? "id"]: parentId,
100
122
  ...(section.pageSize !== undefined && { limit: section.pageSize }),
123
+ // Gated on the declared capability, not just on state carrying a value —
124
+ // same rule as ProjectionListBody: a param the bound query's Zod schema
125
+ // doesn't accept would 422 the whole section.
126
+ ...(section.searchable === true && search !== "" && { search }),
127
+ ...(section.facets !== undefined && filterPayload.length > 0 && { filters: filterPayload }),
101
128
  }),
102
- [section.parentParam, section.pageSize, parentId],
129
+ [
130
+ section.parentParam,
131
+ section.pageSize,
132
+ section.searchable,
133
+ section.facets,
134
+ parentId,
135
+ search,
136
+ filterPayload,
137
+ ],
103
138
  );
104
139
 
105
140
  const rowsQuery = useQuery<PagedRows>(section.query, payload);
106
141
 
142
+ const onFilterChange = useCallback(
143
+ (field: string, values: readonly string[]) =>
144
+ setFilters((prev) => ({ ...prev, [field]: values })),
145
+ [],
146
+ );
147
+ const onFilterReset = useCallback(() => setFilters({}), []);
148
+
107
149
  // Sorted client-side over the already-loaded rows — this section has no
108
150
  // pager (see `payload` above: a one-shot fetch, no cursor/offset), so the
109
151
  // loaded set already IS the full display set and there is no "other page"
@@ -196,6 +238,17 @@ export function RelatedListSection({
196
238
  translate={effectiveTranslate}
197
239
  sort={sort}
198
240
  onSortChange={setSort}
241
+ {...(section.searchable === true && {
242
+ searchable: true,
243
+ searchValue: search,
244
+ onSearchChange: setSearch,
245
+ })}
246
+ {...(filterFacets.length > 0 && {
247
+ filterFacets,
248
+ filterValues: filters,
249
+ onFilterChange,
250
+ onFilterReset,
251
+ })}
199
252
  {...(onRowClick !== undefined && { onRowClick })}
200
253
  {...(rowActions !== undefined && { rowActions })}
201
254
  {...(rowActionMode !== undefined && { rowActionMode })}
@@ -206,9 +259,11 @@ export function RelatedListSection({
206
259
 
207
260
  // hideTitle (tabs mode) → the tab panel is already the boundary: no
208
261
  // Section card wrapper here, `chromeless` above drops the table's own
209
- // card frame too, and `scrollBody` fills this wrapper's height so a long
210
- // Akte tab scrolls internally instead of stretching the page (fw#2722) —
211
- // the list sits directly in the tab. `FillContainer` is this section's
262
+ // card frame too, and `scrollBody` caps this wrapper at the panel's
263
+ // available height so a long Akte tab scrolls internally instead of
264
+ // stretching the page, while a short one still sizes to its content
265
+ // instead of stretching to the bottom (fw#2722, fw#2778) — the list sits
266
+ // directly in the tab. `FillContainer` is this section's
212
267
  // link in RenderEdit's `fillHeight` chain (see render-edit.tsx): it is
213
268
  // always this section's own root whenever hideTitle is set, since tabs
214
269
  // mode narrows RenderEdit to exactly this one active section. A platform
@@ -39,8 +39,11 @@ export function RenderEditActionButton({
39
39
 
40
40
  const variant = action.style ?? "secondary";
41
41
  // Same rule as RowActionWriteHandler: "danger" forces a confirm even
42
- // without an explicit confirm key.
43
- const needsConfirm = action.confirm !== undefined || action.style === "danger";
42
+ // without an explicit confirm key — unless `confirmRequired` overrides it
43
+ // (schema-driven navigate/drawer actions, where the target form is itself
44
+ // the confirmation).
45
+ const needsConfirm =
46
+ action.confirm !== undefined || (action.confirmRequired ?? action.style === "danger");
44
47
  const showIconOnly = iconOnly && action.icon !== undefined;
45
48
 
46
49
  return (
@@ -76,6 +76,8 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
76
76
  * "Save" can be replaced by domain-specific strings ("Approve" /
77
77
  * "Dispatch" / etc.). */
78
78
  readonly submitLabel?: string;
79
+ /** Visual style of the submit button (actionForm `submitStyle`). Default "primary". */
80
+ readonly submitVariant?: "primary" | "danger";
79
81
  /** Per-field extra content inline after the label (e.g.
80
82
  * ConfigSourceBadge). Called with the field name, returns a ReactNode or
81
83
  * undefined. */
@@ -168,6 +170,10 @@ export type RenderEditAction = {
168
170
  readonly style?: "primary" | "secondary" | "danger";
169
171
  readonly confirm?: string;
170
172
  readonly confirmLabel?: string;
173
+ /** Overrides the default "danger implies a confirm dialog" rule. Schema-driven
174
+ * navigate/drawer actions set it to false: the colour marks the action as
175
+ * destructive, but the target form is the confirmation. */
176
+ readonly confirmRequired?: boolean;
171
177
  /** Resolved icon (author `RowAction.icon` or the id-derived default) —
172
178
  * drives both the icon-left-of-text render and the icon-only collapse
173
179
  * rule (see `shouldRenderActionsIconOnly`). */
@@ -212,6 +212,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
212
212
  actions,
213
213
  onRelatedListDrawerAction,
214
214
  submitLabel,
215
+ submitVariant,
215
216
  labelAppendix,
216
217
  fieldAppendix,
217
218
  entityId: entityIdProp,
@@ -630,11 +631,12 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
630
631
 
631
632
  // A lone relatedList tab (hideSectionTitles is only ever set by the tabs
632
633
  // layout, which also narrows filteredSections to that one active section)
633
- // needs its own tab panel to fill the available height so its table
634
+ // needs its own tab panel capped at the available height so its table
634
635
  // scrolls inside the panel instead of the whole page stretching to the
635
- // row count (fw#2722). Any other layout multiple sections, a non-
636
- // relatedList tab, stacked (non-tabs) forms keeps normal document-flow
637
- // height untouched.
636
+ // row count (fw#2722) a short table still sizes to its content instead
637
+ // of stretching the panel to the bottom (fw#2778). Any other layout
638
+ // multiple sections, a non-relatedList tab, stacked (non-tabs) forms —
639
+ // keeps normal document-flow height untouched.
638
640
  const fillHeight = hideSectionTitles === true && filteredSections[0]?.kind === "relatedList";
639
641
 
640
642
  // Persistiert alle composed Extension-Sections mit der aufgelösten entityId.
@@ -1032,7 +1034,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1032
1034
  type="submit"
1033
1035
  disabled={(snapshot.isUnchanged && !extensionDirty) || isSubmitting || disabled}
1034
1036
  loading={isSubmitting}
1035
- variant="primary"
1037
+ variant={submitVariant ?? "primary"}
1036
1038
  icon="check"
1037
1039
  testId="render-edit-submit"
1038
1040
  >
@@ -118,6 +118,11 @@ export type RenderListProps = {
118
118
  * scrolls rows internally instead of growing the page (relatedList in a
119
119
  * tabs-mode section, fw#2722). Default false. */
120
120
  readonly scrollBody?: boolean;
121
+ /** Forwarded to `DataTableProps.screenPadding` — the table carries the
122
+ * shared screen padding because it is the screen body (entityList/
123
+ * projectionList, fw#2640). Not set for an embedded relatedList.
124
+ * Default false. */
125
+ readonly screenPadding?: boolean;
121
126
  };
122
127
 
123
128
  // Resolved-Form einer Toolbar-Action: KumikoScreen baut das aus dem
@@ -130,6 +135,10 @@ export type ToolbarActionButton = {
130
135
  readonly style?: "primary" | "secondary" | "danger";
131
136
  readonly confirm?: string;
132
137
  readonly confirmLabel?: string;
138
+ /** Overrides the default "danger implies a confirm dialog" rule. Schema-driven
139
+ * navigate/drawer actions set it to false: the colour marks the action as
140
+ * destructive, but the target form is the confirmation. */
141
+ readonly confirmRequired?: boolean;
133
142
  readonly onTrigger: () => Promise<void> | void;
134
143
  /** Id-derived default icon (ACTION_ICON_BY_ID in kumiko-screen.tsx) —
135
144
  * ToolbarAction has no author-declared icon field, unlike RowAction. */
@@ -168,6 +177,7 @@ export function RenderList(props: RenderListProps): ReactNode {
168
177
  onFilterReset,
169
178
  chromeless,
170
179
  scrollBody,
180
+ screenPadding,
171
181
  } = props;
172
182
  // Wie RenderEdit: Translate-Fallback aus dem i18next-Context, sonst
173
183
  // wären Column-Header raw i18n-Keys.
@@ -384,6 +394,7 @@ export function RenderList(props: RenderListProps): ReactNode {
384
394
  {...(onFilterReset !== undefined && { onFilterReset })}
385
395
  {...(chromeless !== undefined && { chromeless })}
386
396
  {...(scrollBody !== undefined && { scrollBody })}
397
+ {...(screenPadding !== undefined && { screenPadding })}
387
398
  testId="render-list-table"
388
399
  />
389
400
  </>
@@ -492,7 +503,8 @@ function ToolbarActionView({
492
503
  };
493
504
 
494
505
  const variant: "primary" | "secondary" | "danger" = action.style ?? "secondary";
495
- const needsConfirm = action.confirm !== undefined || action.style === "danger";
506
+ const needsConfirm =
507
+ action.confirm !== undefined || (action.confirmRequired ?? action.style === "danger");
496
508
  const showIconOnly = iconOnly && action.icon !== undefined;
497
509
 
498
510
  return (
@@ -488,8 +488,9 @@ export type DataTableRowAction = {
488
488
  readonly id: string;
489
489
  /** Translated Label. */
490
490
  readonly label: string;
491
- /** Visual-Style — danger triggert in der Default-Primitive eine rote
492
- * Variante UND erzwingt einen Confirm-Dialog wenn keiner gesetzt ist. */
491
+ /** Visual style "danger" renders the red variant in the default
492
+ * primitive. Whether it also forces a confirm dialog is governed by
493
+ * `confirmRequired` (falls back to `style === "danger"` when unset). */
493
494
  readonly style?: "primary" | "secondary" | "danger";
494
495
  /** Translated Confirm-Prompt (Description im Dialog) — wenn gesetzt,
495
496
  * öffnet ein Modal vor der Ausführung. Bei style=danger ohne expliziten
@@ -498,6 +499,10 @@ export type DataTableRowAction = {
498
499
  /** Translated Confirm-Button-Label im Dialog. Default = `label`
499
500
  * (Action-Label wird wiederverwendet). */
500
501
  readonly confirmLabel?: string;
502
+ /** Overrides the default "danger implies a confirm dialog" rule. Schema-driven
503
+ * navigate/drawer actions set it to false: the colour marks the action as
504
+ * destructive, but the target form is the confirmation. */
505
+ readonly confirmRequired?: boolean;
501
506
  /** Wird mit der ListRowViewModel der geklickten Row aufgerufen. Async
502
507
  * erlaubt — der Renderer kann während der Promise-Resolution einen
503
508
  * Loading-State auf dem Button zeigen. */
@@ -632,6 +637,14 @@ export type DataTableProps = {
632
637
  * has no effect outside a sized flex-col ancestor). Default false:
633
638
  * unchanged document-flow table that grows with its content. */
634
639
  readonly scrollBody?: boolean;
640
+ /** Uses the shared screen padding (wider bottom inset) instead of the
641
+ * table's symmetric embedded inset — for a table that IS the screen body
642
+ * (entityList/projectionList), so a list screen ends at the same footer
643
+ * distance as a form or custom screen (fw#2640). Hosts set this or
644
+ * `scrollBody`, not both: the wider bottom inset competes with
645
+ * `scrollBody`'s flex-fill height budget.
646
+ * Default false: unchanged symmetric inset for an embedded table. */
647
+ readonly screenPadding?: boolean;
635
648
  };
636
649
 
637
650
  // ---- EmbeddedListInput (createEmbeddedListField widget) ----
@@ -802,9 +815,10 @@ export type SectionProps = {
802
815
  };
803
816
 
804
817
  /** Chromeless flex-fill layout host — no title, no card frame, no padding,
805
- * just a container that sizes to fill its parent and lets one scrolling
806
- * child scroll internally instead of the page growing (fw#2722). Web:
807
- * `<div className="flex flex-1 min-h-0 flex-col">`. Native: Views are
818
+ * just a container that sizes to its content and, once the ancestor chain
819
+ * is height-constrained, shrinks so one scrolling child can scroll
820
+ * internally instead of the page growing (fw#2722, height fw#2778). Web:
821
+ * `<div className="flex min-h-0 flex-col">`. Native: Views are
808
822
  * already flex-column and the parent is already a bounded viewport there,
809
823
  * so a native impl may render this as a bare Fragment. Used only as the
810
824
  * terminal link in `RenderEdit`'s `fillHeight` chain (`RelatedListSection`'s