@cosmicdrift/kumiko-renderer 0.250.0 → 0.252.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.250.0",
3
+ "version": "0.252.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.250.0",
19
- "@cosmicdrift/kumiko-headless": "0.250.0",
18
+ "@cosmicdrift/kumiko-framework": "0.252.0",
19
+ "@cosmicdrift/kumiko-headless": "0.252.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.250.0"
30
+ "@cosmicdrift/kumiko-locale-de": "0.252.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,
@@ -1066,58 +1071,6 @@ export function buildListQueryPayload(state: {
1066
1071
  return payload;
1067
1072
  }
1068
1073
 
1069
- // One resolved facet, independent of where the type info came from — an
1070
- // entity field (entityList) or an explicit ListFacetSpec (projectionList,
1071
- // fw#2224). Shared by buildFilterFacets/buildFilterPayload below so both
1072
- // screen types build their query-payload filters and DataTable facet-UI
1073
- // through the same code, instead of two copies that can drift.
1074
- type ResolvedFacetSpec = {
1075
- readonly field: string;
1076
- readonly type: "select" | "boolean";
1077
- readonly label: string;
1078
- readonly options: readonly { readonly value: string; readonly label: string }[];
1079
- };
1080
-
1081
- function buildFilterFacets(specs: readonly ResolvedFacetSpec[]): DataTableFacet[] {
1082
- return specs.map((spec) => ({ field: spec.field, label: spec.label, options: spec.options }));
1083
- }
1084
-
1085
- // User-selected faceted filters from URL-state → payload.filters. Boolean
1086
- // fields coerce "true"/"false" strings to real booleans (DB column is
1087
- // boolean); everything else stays string[] under op:"in" (multi-select
1088
- // semantics). `typeOf` resolves a field to its known type string —
1089
- // undefined means "unknown field", so it's dropped (typo-safe: a stale/
1090
- // hand-crafted URL param for an undeclared field never reaches the
1091
- // server). Deliberately NOT gated on the field being a *facet* — entityList
1092
- // passes through any field present in entity.fields, matching its
1093
- // pre-fw#2224 behavior; only the boolean-coercion branch cares about type.
1094
-
1095
- function isFacetI18nKey(label: string): boolean {
1096
- return !/\s/.test(label) && (label.includes(".") || label.includes(":"));
1097
- }
1098
-
1099
- function buildFilterPayload(
1100
- urlFilters: Readonly<Record<string, readonly string[]>>,
1101
- typeOf: (field: string) => string | undefined,
1102
- ): { field: string; op: "in"; value: unknown }[] {
1103
- const out: { field: string; op: "in"; value: unknown }[] = [];
1104
- for (const [field, values] of Object.entries(urlFilters)) {
1105
- if (values.length === 0) continue;
1106
- // `id` is a base column (not a declared facet), allowed as an id-set
1107
- // filter so a header-slot control — e.g. the tags TagFilter — can narrow
1108
- // ANY list to a resolved set of row ids without the host declaring a facet.
1109
- if (field === "id") {
1110
- out.push({ field, op: "in", value: values });
1111
- continue;
1112
- }
1113
- const type = typeOf(field);
1114
- if (type === undefined) continue;
1115
- const value = type === "boolean" ? values.map((v) => v === "true") : values;
1116
- out.push({ field, op: "in", value });
1117
- }
1118
- return out;
1119
- }
1120
-
1121
1074
  // entityList adapter — one DataTableFacet per filterable select/boolean
1122
1075
  // entity field, labels via the standard field/option i18n convention.
1123
1076
  function resolveEntityFacetSpecs(
@@ -1164,39 +1117,6 @@ function resolveEntityFacetSpecs(
1164
1117
  return out;
1165
1118
  }
1166
1119
 
1167
- // projectionList adapter — a projectionList has no entity/i18n convention to
1168
- // derive labels from, so ListFacetSpec carries every label explicitly
1169
- // (fw#2224). Labels may be raw display strings or i18n keys; run them
1170
- // through translate like entity facets (passthrough when the key is missing).
1171
- function resolveProjectionFacetSpecs(
1172
- facets: readonly ListFacetSpec[] | undefined,
1173
- translate: Translate,
1174
- ): ResolvedFacetSpec[] {
1175
- if (facets === undefined) return [];
1176
- const tr = (label: string): string => (isFacetI18nKey(label) ? translate(label) : label);
1177
- return facets.map((facet) =>
1178
- facet.type === "select"
1179
- ? {
1180
- field: facet.field,
1181
- type: "select",
1182
- label: tr(facet.label),
1183
- options: facet.options.map((opt) => ({
1184
- value: opt.value,
1185
- label: tr(opt.label),
1186
- })),
1187
- }
1188
- : {
1189
- field: facet.field,
1190
- type: "boolean",
1191
- label: tr(facet.label),
1192
- options: [
1193
- { value: "true", label: tr(facet.trueLabel) },
1194
- { value: "false", label: tr(facet.falseLabel) },
1195
- ],
1196
- },
1197
- );
1198
- }
1199
-
1200
1120
  // ---- drawer-kind actions: ToolbarAction (fw#2225) + RowAction (fw#2710) ----
1201
1121
  //
1202
1122
  // Shared across EntityListBody, ProjectionListBody, ProjectionDetailBody and
@@ -1841,6 +1761,7 @@ function EntityListBody({
1841
1761
  onSearchChange={urlState.setQ}
1842
1762
  sort={effectiveSort}
1843
1763
  onSortChange={urlState.setSort}
1764
+ screenPadding
1844
1765
  {...(pager !== undefined && { pager })}
1845
1766
  {...(rowActions !== undefined && { rowActions })}
1846
1767
  {...(rowActionMode !== undefined && { rowActionMode })}
@@ -2131,6 +2052,7 @@ function ProjectionListBody({
2131
2052
  onSearchChange={urlState.setQ}
2132
2053
  sort={activeSort}
2133
2054
  onSortChange={urlState.setSort}
2055
+ screenPadding
2134
2056
  {...(pager !== undefined && { pager })}
2135
2057
  {...(rowActions !== undefined && { rowActions })}
2136
2058
  {...(rowActionMode !== undefined && { rowActionMode })}
@@ -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
+ }
@@ -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
+ });
@@ -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
@@ -631,11 +631,12 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
631
631
 
632
632
  // A lone relatedList tab (hideSectionTitles is only ever set by the tabs
633
633
  // layout, which also narrows filteredSections to that one active section)
634
- // 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
635
635
  // scrolls inside the panel instead of the whole page stretching to the
636
- // row count (fw#2722). Any other layout multiple sections, a non-
637
- // relatedList tab, stacked (non-tabs) forms keeps normal document-flow
638
- // 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.
639
640
  const fillHeight = hideSectionTitles === true && filteredSections[0]?.kind === "relatedList";
640
641
 
641
642
  // Persistiert alle composed Extension-Sections mit der aufgelösten entityId.
@@ -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
@@ -172,6 +177,7 @@ export function RenderList(props: RenderListProps): ReactNode {
172
177
  onFilterReset,
173
178
  chromeless,
174
179
  scrollBody,
180
+ screenPadding,
175
181
  } = props;
176
182
  // Wie RenderEdit: Translate-Fallback aus dem i18next-Context, sonst
177
183
  // wären Column-Header raw i18n-Keys.
@@ -388,6 +394,7 @@ export function RenderList(props: RenderListProps): ReactNode {
388
394
  {...(onFilterReset !== undefined && { onFilterReset })}
389
395
  {...(chromeless !== undefined && { chromeless })}
390
396
  {...(scrollBody !== undefined && { scrollBody })}
397
+ {...(screenPadding !== undefined && { screenPadding })}
391
398
  testId="render-list-table"
392
399
  />
393
400
  </>
@@ -637,6 +637,14 @@ export type DataTableProps = {
637
637
  * has no effect outside a sized flex-col ancestor). Default false:
638
638
  * unchanged document-flow table that grows with its content. */
639
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;
640
648
  };
641
649
 
642
650
  // ---- EmbeddedListInput (createEmbeddedListField widget) ----
@@ -807,9 +815,10 @@ export type SectionProps = {
807
815
  };
808
816
 
809
817
  /** Chromeless flex-fill layout host — no title, no card frame, no padding,
810
- * just a container that sizes to fill its parent and lets one scrolling
811
- * child scroll internally instead of the page growing (fw#2722). Web:
812
- * `<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
813
822
  * already flex-column and the parent is already a bounded viewport there,
814
823
  * so a native impl may render this as a bare Fragment. Used only as the
815
824
  * terminal link in `RenderEdit`'s `fillHeight` chain (`RelatedListSection`'s