@cosmicdrift/kumiko-renderer 0.246.0 → 0.247.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.246.0",
3
+ "version": "0.247.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.246.0",
19
- "@cosmicdrift/kumiko-headless": "0.246.0",
18
+ "@cosmicdrift/kumiko-framework": "0.247.0",
19
+ "@cosmicdrift/kumiko-headless": "0.247.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.246.0"
30
+ "@cosmicdrift/kumiko-locale-de": "0.247.0"
31
31
  },
32
32
  "repository": {
33
33
  "type": "git",
@@ -159,6 +159,21 @@ type OpenDrawer = (
159
159
  initialValues: Readonly<Record<string, unknown>> | undefined,
160
160
  ) => void;
161
161
 
162
+ // buildProjectionRowActions runs inside a useMemo (re-evaluated on every dep
163
+ // change), so this dedupes the "openDrawer not wired" warning per action id
164
+ // instead of firing on every recompute.
165
+ const warnedDrawerRowActionIds = new Set<string>();
166
+
167
+ function warnDrawerActionDropped(actionId: string): void {
168
+ // skip: already warned for this id — suppresses the repeat, not the warning itself.
169
+ if (warnedDrawerRowActionIds.has(actionId)) return;
170
+ warnedDrawerRowActionIds.add(actionId);
171
+ // biome-ignore lint/suspicious/noConsole: dev-warning for a setup error
172
+ console.warn(
173
+ `[kumiko] rowAction "${actionId}" is kind:"drawer", but the host did not wire openDrawer (RelatedListSection: pass onOpenDrawer) — it will not render.`,
174
+ );
175
+ }
176
+
162
177
  function buildDrawerRowAction(
163
178
  action: RowActionDrawer,
164
179
  translate: Translate,
@@ -193,7 +208,8 @@ export function buildProjectionRowActions(options: {
193
208
  readonly nav: NavApi;
194
209
  readonly refetch: () => Promise<unknown>;
195
210
  /** Opens the drawer-kind action's target actionForm, prefilled from the
196
- * clicked row. Omitted callers (none today) simply drop drawer actions —
211
+ * clicked row. Omitted callers (e.g. RelatedListSection embedded directly
212
+ * without onOpenDrawer) drop drawer actions and get a dev warning —
197
213
  * mirrors the `dispatcher === undefined` skip below for writeHandler. */
198
214
  readonly openDrawer?: OpenDrawer;
199
215
  }): readonly DataTableRowAction[] | undefined {
@@ -206,7 +222,10 @@ export function buildProjectionRowActions(options: {
206
222
  continue;
207
223
  }
208
224
  if (action.kind === "drawer") {
209
- if (openDrawer === undefined) continue;
225
+ if (openDrawer === undefined) {
226
+ warnDrawerActionDropped(action.id);
227
+ continue;
228
+ }
210
229
  out.push(buildDrawerRowAction(action, translate, openDrawer));
211
230
  continue;
212
231
  }
@@ -1,7 +1,7 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { describe, expect, spyOn, test } from "bun:test";
2
2
  import type { RowAction } from "@cosmicdrift/kumiko-framework/ui-types";
3
3
  import type { Dispatcher, EditRelatedListSectionViewModel } from "@cosmicdrift/kumiko-headless";
4
- import { render, screen as rtlScreen, waitFor } from "@testing-library/react";
4
+ import { fireEvent, render, screen as rtlScreen, waitFor } from "@testing-library/react";
5
5
  import type { ComponentType, ReactNode } from "react";
6
6
  import { type NavApi, NavProvider } from "../../app/nav";
7
7
  import { DispatcherProvider } from "../../context/dispatcher-context";
@@ -79,7 +79,10 @@ function testPrimitives(): CorePrimitives {
79
79
  } as unknown as CorePrimitives;
80
80
  }
81
81
 
82
- function stubDispatcher(rows: readonly Record<string, unknown>[] = [{ id: "r1", name: "Alice" }]): {
82
+ function stubDispatcher(
83
+ rows: readonly Record<string, unknown>[] = [{ id: "r1", name: "Alice" }],
84
+ nextCursor: string | null = null,
85
+ ): {
83
86
  dispatcher: Dispatcher;
84
87
  writes: Array<{ type: string; payload: unknown }>;
85
88
  queryCount: () => number;
@@ -93,7 +96,7 @@ function stubDispatcher(rows: readonly Record<string, unknown>[] = [{ id: "r1",
93
96
  }) as Dispatcher["write"],
94
97
  query: (async () => {
95
98
  queryCalls += 1;
96
- return { isSuccess: true, data: { rows, nextCursor: null } };
99
+ return { isSuccess: true, data: { rows, nextCursor } };
97
100
  }) as Dispatcher["query"],
98
101
  batch: (async () => ({ isSuccess: true, results: [] })) as Dispatcher["batch"],
99
102
  statusStore: {
@@ -168,11 +171,13 @@ function renderRelatedList(
168
171
  }
169
172
 
170
173
  describe("RelatedListSection — tabs-mode card chrome (fw#2722)", () => {
171
- test("hideTitle (tabs mode) renders the list without a Section wrapper and marks the table chromeless", async () => {
174
+ test("hideTitle (tabs mode) renders the list without a Section wrapper and marks the table chromeless + scrollBody", async () => {
172
175
  const { dispatcher } = stubDispatcher();
173
176
  let capturedChromeless: boolean | undefined;
177
+ let capturedScrollBody: boolean | undefined;
174
178
  const capturingDataTable: ComponentType<DataTableProps> = (props) => {
175
179
  capturedChromeless = props.chromeless;
180
+ capturedScrollBody = props.scrollBody;
176
181
  return testDataTable(props);
177
182
  };
178
183
  render(
@@ -198,13 +203,16 @@ describe("RelatedListSection — tabs-mode card chrome (fw#2722)", () => {
198
203
  await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
199
204
  expect(rtlScreen.queryByTestId(`related-list-${historySection.title}`)).toBeNull();
200
205
  expect(capturedChromeless).toBe(true);
206
+ expect(capturedScrollBody).toBe(true);
201
207
  });
202
208
 
203
- test("without hideTitle (stacked mode), the same section keeps its Section wrapper and an un-chromeless table", async () => {
209
+ test("without hideTitle (stacked mode), the same section keeps its Section wrapper and an un-chromeless, unbounded-height table", async () => {
204
210
  const { dispatcher } = stubDispatcher();
205
211
  let capturedChromeless: boolean | undefined;
212
+ let capturedScrollBody: boolean | undefined;
206
213
  const capturingDataTable: ComponentType<DataTableProps> = (props) => {
207
214
  capturedChromeless = props.chromeless;
215
+ capturedScrollBody = props.scrollBody;
208
216
  return testDataTable(props);
209
217
  };
210
218
  render(
@@ -229,6 +237,7 @@ describe("RelatedListSection — tabs-mode card chrome (fw#2722)", () => {
229
237
  await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
230
238
  expect(rtlScreen.getByTestId(`related-list-${historySection.title}`)).toBeTruthy();
231
239
  expect(capturedChromeless).toBeUndefined();
240
+ expect(capturedScrollBody).toBeUndefined();
232
241
  });
233
242
  });
234
243
 
@@ -378,4 +387,254 @@ describe("RelatedListSection — rowActions drawer-kind (fw#2710)", () => {
378
387
  await waitFor(() => expect(rtlScreen.getByTestId("row-item-7")).toBeTruthy());
379
388
  expect(rtlScreen.queryByTestId("action-adjust-rent-item-7")).toBeNull();
380
389
  });
390
+
391
+ test("a dropped drawer rowAction without onOpenDrawer logs a dev warning naming the action id (fw#2733)", async () => {
392
+ const { dispatcher } = stubDispatcher([{ id: "item-7", name: "Rent 2024", amount: 1200 }]);
393
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
394
+ try {
395
+ renderRelatedList(dispatcher, {
396
+ kind: "relatedList",
397
+ title: "Positions",
398
+ query: "lease:query:items:list",
399
+ columns: [{ field: "name" }],
400
+ rowActions: [
401
+ {
402
+ kind: "drawer",
403
+ id: "adjust-rent-warn-only-2733",
404
+ label: "actions.adjustRent",
405
+ screen: "adjust-rent-form",
406
+ },
407
+ ],
408
+ });
409
+
410
+ await waitFor(() => expect(rtlScreen.getByTestId("row-item-7")).toBeTruthy());
411
+ expect(warnSpy).toHaveBeenCalledTimes(1);
412
+ expect(warnSpy.mock.calls[0]?.[0]).toContain("adjust-rent-warn-only-2733");
413
+ } finally {
414
+ warnSpy.mockRestore();
415
+ }
416
+ });
417
+
418
+ test("a drawer rowAction with onOpenDrawer wired renders without a dev warning (fw#2733)", async () => {
419
+ const { dispatcher } = stubDispatcher([{ id: "item-7", name: "Rent 2024", amount: 1200 }]);
420
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
421
+ try {
422
+ render(
423
+ <LocaleProvider
424
+ resolver={createStaticLocaleResolver({ locale: "en-US" })}
425
+ fallbackBundles={[kumikoDefaultTranslations]}
426
+ >
427
+ <DispatcherProvider dispatcher={dispatcher}>
428
+ <PrimitivesProvider value={testPrimitives()}>
429
+ <NavProvider value={stubNav().nav}>
430
+ <RelatedListSection
431
+ section={{
432
+ kind: "relatedList",
433
+ title: "Positions",
434
+ query: "lease:query:items:list",
435
+ columns: [{ field: "name" }],
436
+ rowActions: [
437
+ {
438
+ kind: "drawer",
439
+ id: "adjust-rent-warn-wired-2733",
440
+ label: "actions.adjustRent",
441
+ screen: "adjust-rent-form",
442
+ },
443
+ ],
444
+ }}
445
+ parentId="order-1"
446
+ featureName="orders"
447
+ onOpenDrawer={noop}
448
+ />
449
+ </NavProvider>
450
+ </PrimitivesProvider>
451
+ </DispatcherProvider>
452
+ </LocaleProvider>,
453
+ );
454
+
455
+ await waitFor(() => expect(rtlScreen.getByTestId("row-item-7")).toBeTruthy());
456
+ expect(rtlScreen.getByTestId("action-adjust-rent-warn-wired-2733-item-7")).toBeTruthy();
457
+ expect(warnSpy).not.toHaveBeenCalled();
458
+ } finally {
459
+ warnSpy.mockRestore();
460
+ }
461
+ });
462
+ });
463
+
464
+ // A DataTable stub that renders one row per query row (DOM order == passed
465
+ // `rows` order, so a re-sort is observable as a re-ordered row list) plus a
466
+ // button that calls `onSortChange` the same way a real SortableHeader click
467
+ // would — enough to prove RelatedListSection actually re-orders rows, not
468
+ // just that a header is clickable.
469
+ const orderedDataTable: ComponentType<DataTableProps> = ({ rows, onSortChange }) => (
470
+ <div>
471
+ <table>
472
+ <tbody>
473
+ {rows.map((row) => (
474
+ <tr key={row.id} data-testid={`row-${row.id}`} />
475
+ ))}
476
+ </tbody>
477
+ </table>
478
+ {onSortChange !== undefined && (
479
+ <button
480
+ type="button"
481
+ data-testid="sort-amount-desc"
482
+ onClick={() => onSortChange({ field: "amount", dir: "desc" })}
483
+ >
484
+ sort
485
+ </button>
486
+ )}
487
+ </div>
488
+ );
489
+
490
+ function renderedRowOrder(): string[] {
491
+ return rtlScreen
492
+ .getAllByTestId(/^row-/)
493
+ .map((el) => el.getAttribute("data-testid")?.replace("row-", "") ?? "");
494
+ }
495
+
496
+ describe("RelatedListSection — sorting (fw#2722)", () => {
497
+ const unsortedRows = [
498
+ { id: "r1", name: "Charlie", amount: 300 },
499
+ { id: "r2", name: "Alice", amount: 100 },
500
+ { id: "r3", name: "Bob", amount: 200 },
501
+ ];
502
+
503
+ test("defaultSort sorts the already-loaded rows client-side, independent of query response order", async () => {
504
+ const { dispatcher } = stubDispatcher(unsortedRows);
505
+ render(
506
+ <LocaleProvider
507
+ resolver={createStaticLocaleResolver({ locale: "en-US" })}
508
+ fallbackBundles={[kumikoDefaultTranslations]}
509
+ >
510
+ <DispatcherProvider dispatcher={dispatcher}>
511
+ <PrimitivesProvider value={{ ...testPrimitives(), DataTable: orderedDataTable }}>
512
+ <NavProvider value={stubNav().nav}>
513
+ <RelatedListSection
514
+ section={{
515
+ kind: "relatedList",
516
+ title: "Positions",
517
+ query: "lease:query:items:list",
518
+ columns: [{ field: "amount", sortable: true }],
519
+ defaultSort: { field: "amount", dir: "asc" },
520
+ }}
521
+ parentId="order-1"
522
+ featureName="orders"
523
+ />
524
+ </NavProvider>
525
+ </PrimitivesProvider>
526
+ </DispatcherProvider>
527
+ </LocaleProvider>,
528
+ );
529
+
530
+ await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
531
+ expect(renderedRowOrder()).toEqual(["r2", "r3", "r1"]);
532
+ });
533
+
534
+ test("toggling sort via onSortChange re-orders rows without a refetch", async () => {
535
+ const { dispatcher, queryCount } = stubDispatcher(unsortedRows);
536
+ render(
537
+ <LocaleProvider
538
+ resolver={createStaticLocaleResolver({ locale: "en-US" })}
539
+ fallbackBundles={[kumikoDefaultTranslations]}
540
+ >
541
+ <DispatcherProvider dispatcher={dispatcher}>
542
+ <PrimitivesProvider value={{ ...testPrimitives(), DataTable: orderedDataTable }}>
543
+ <NavProvider value={stubNav().nav}>
544
+ <RelatedListSection
545
+ section={{
546
+ kind: "relatedList",
547
+ title: "Positions",
548
+ query: "lease:query:items:list",
549
+ columns: [{ field: "amount", sortable: true }],
550
+ }}
551
+ parentId="order-1"
552
+ featureName="orders"
553
+ />
554
+ </NavProvider>
555
+ </PrimitivesProvider>
556
+ </DispatcherProvider>
557
+ </LocaleProvider>,
558
+ );
559
+
560
+ await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
561
+ expect(renderedRowOrder()).toEqual(["r1", "r2", "r3"]);
562
+
563
+ fireEvent.click(rtlScreen.getByTestId("sort-amount-desc"));
564
+
565
+ await waitFor(() => expect(renderedRowOrder()).toEqual(["r1", "r3", "r2"]));
566
+ expect(queryCount()).toBe(1);
567
+ });
568
+
569
+ test("a section without defaultSort renders rows in the original query order (unchanged behavior)", async () => {
570
+ const { dispatcher } = stubDispatcher(unsortedRows);
571
+ render(
572
+ <LocaleProvider
573
+ resolver={createStaticLocaleResolver({ locale: "en-US" })}
574
+ fallbackBundles={[kumikoDefaultTranslations]}
575
+ >
576
+ <DispatcherProvider dispatcher={dispatcher}>
577
+ <PrimitivesProvider value={{ ...testPrimitives(), DataTable: orderedDataTable }}>
578
+ <NavProvider value={stubNav().nav}>
579
+ <RelatedListSection
580
+ section={{
581
+ kind: "relatedList",
582
+ title: "Positions",
583
+ query: "lease:query:items:list",
584
+ columns: [{ field: "amount" }],
585
+ }}
586
+ parentId="order-1"
587
+ featureName="orders"
588
+ />
589
+ </NavProvider>
590
+ </PrimitivesProvider>
591
+ </DispatcherProvider>
592
+ </LocaleProvider>,
593
+ );
594
+
595
+ await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
596
+ expect(renderedRowOrder()).toEqual(["r1", "r2", "r3"]);
597
+ });
598
+ });
599
+
600
+ describe("RelatedListSection — truncation banner (fw#2722 review)", () => {
601
+ test("nextCursor !== null renders a truncation banner", async () => {
602
+ const { dispatcher } = stubDispatcher([{ id: "r1", name: "Alice" }], "cursor-abc");
603
+ renderRelatedList(dispatcher);
604
+
605
+ await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
606
+ const banner = rtlScreen.getByTestId("related-list-truncated");
607
+ expect(banner).toBeTruthy();
608
+ // Proves {count} actually interpolates (single-brace, the renderer's own
609
+ // i18n.tsx interpolate() syntax) rather than rendering as literal braces.
610
+ expect(banner.textContent).toContain("Showing the first 1 entries.");
611
+ });
612
+
613
+ test("nextCursor === null renders no truncation banner", async () => {
614
+ const { dispatcher } = stubDispatcher([{ id: "r1", name: "Alice" }], null);
615
+ renderRelatedList(dispatcher);
616
+
617
+ await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
618
+ expect(rtlScreen.queryByTestId("related-list-truncated")).toBeNull();
619
+ });
620
+
621
+ test("the banner shows regardless of an active sort — an unsorted truncated list is equally misleading", async () => {
622
+ const { dispatcher } = stubDispatcher(
623
+ [
624
+ { id: "r1", name: "Charlie", amount: 300 },
625
+ { id: "r2", name: "Alice", amount: 100 },
626
+ ],
627
+ "cursor-abc",
628
+ );
629
+ renderRelatedList(dispatcher, {
630
+ kind: "relatedList",
631
+ title: "Positions",
632
+ query: "lease:query:items:list",
633
+ columns: [{ field: "amount", sortable: true }],
634
+ defaultSort: { field: "amount", dir: "asc" },
635
+ });
636
+
637
+ await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
638
+ expect(rtlScreen.getByTestId("related-list-truncated")).toBeTruthy();
639
+ });
381
640
  });
@@ -10,7 +10,7 @@ import type {
10
10
  ListRowViewModel,
11
11
  Translate,
12
12
  } from "@cosmicdrift/kumiko-headless";
13
- import { type ReactNode, useMemo } from "react";
13
+ import { type ReactNode, useMemo, useState } from "react";
14
14
  import { useNav } from "../app/nav";
15
15
  import {
16
16
  buildProjectionRowActions,
@@ -19,9 +19,11 @@ import {
19
19
  } from "../app/row-actions";
20
20
  import { dispatcherErrorText } from "../app/write-failed-error";
21
21
  import { useOptionalDispatcher } from "../context/dispatcher-context";
22
+ import type { ListSort } from "../hooks/use-list-url-state";
22
23
  import { useQuery } from "../hooks/use-query";
23
24
  import { useTranslation } from "../i18n";
24
25
  import { usePrimitives } from "../primitives";
26
+ import { sortByAccessor } from "../sort-by-accessor";
25
27
  import { RenderList } from "./render-list";
26
28
 
27
29
  const RELATED_LIST_PSEUDO_ENTITY = "__related-list__";
@@ -36,14 +38,16 @@ type PagedRows = {
36
38
  };
37
39
 
38
40
  // Minimal EntityDefinition from the section's own columns — same shape as
39
- // projection-list-shim's synthesizeProjectionEntity, but relatedList columns
40
- // aren't sortable (no sort UI on this section, see RelatedListSection below).
41
+ // projection-list-shim's synthesizeProjectionEntity, but sortable is read
42
+ // per-column (ListColumnSpec.sortable) instead of screen-wide, since a
43
+ // relatedList section has no single Zod schema to derive it from.
41
44
  function synthesizeRelatedListEntity(
42
45
  columns: EditRelatedListSectionViewModel["columns"],
43
46
  ): EntityDefinition {
44
- const fields: Record<string, { type: "text" }> = {};
47
+ const fields: Record<string, { type: "text"; sortable: boolean }> = {};
45
48
  for (const col of columns) {
46
- fields[normalizeListColumn(col).field] = { type: "text" };
49
+ const normalized = normalizeListColumn(col);
50
+ fields[normalized.field] = { type: "text", sortable: normalized.sortable === true };
47
51
  }
48
52
  return { fields } as unknown as EntityDefinition;
49
53
  }
@@ -69,7 +73,7 @@ export function RelatedListSection({
69
73
  initialValues: Readonly<Record<string, unknown>> | undefined,
70
74
  ) => void;
71
75
  }): ReactNode {
72
- const { Banner, Section } = usePrimitives();
76
+ const { Banner, Section, FillContainer } = usePrimitives();
73
77
  const t = useTranslation();
74
78
  const effectiveTranslate = translate ?? t;
75
79
  const nav = useNav();
@@ -100,6 +104,28 @@ export function RelatedListSection({
100
104
 
101
105
  const rowsQuery = useQuery<PagedRows>(section.query, payload);
102
106
 
107
+ // Sorted client-side over the already-loaded rows — this section has no
108
+ // pager (see `payload` above: a one-shot fetch, no cursor/offset), so the
109
+ // loaded set already IS the full display set and there is no "other page"
110
+ // a client-side sort could misleadingly hide (fw#2722).
111
+ const [sort, setSort] = useState<ListSort | null>(section.defaultSort ?? null);
112
+ const sortAccessors = useMemo(() => {
113
+ const accessors: Record<string, (row: Readonly<Record<string, unknown>>) => string | number> =
114
+ {};
115
+ for (const col of section.columns) {
116
+ const field = normalizeListColumn(col).field;
117
+ accessors[field] = (row) => {
118
+ const value = row[field];
119
+ return typeof value === "number" ? value : String(value ?? "");
120
+ };
121
+ }
122
+ return accessors;
123
+ }, [section.columns]);
124
+ const sortedRows = useMemo(
125
+ () => sortByAccessor(rowsQuery.data?.rows ?? [], sort, sortAccessors),
126
+ [rowsQuery.data, sort, sortAccessors],
127
+ );
128
+
103
129
  const rowClick = section.rowClick;
104
130
  // A row-body click target comes from EITHER the legacy `rowClick` field OR
105
131
  // a `rowActions` entry marked rowClick:true — the boot-validator rejects
@@ -136,6 +162,14 @@ export function RelatedListSection({
136
162
  );
137
163
  const rowActionMode = rowActionModeFor(rowActions);
138
164
 
165
+ // A truncated fetch means `sortedRows` is a sort of a partial set, not of
166
+ // the full related-row set — the client-side sort above (or even plain
167
+ // unsorted display) would silently claim "these are the top N" when they
168
+ // are really just "the first N in the server's own order" (fw#2722 review).
169
+ // `nextCursor` is exactly how the paged envelope marks that: non-null means
170
+ // more rows exist server-side beyond what was fetched.
171
+ const truncated = rowsQuery.data !== null && rowsQuery.data.nextCursor !== null;
172
+
139
173
  const content =
140
174
  rowsQuery.loading && rowsQuery.data === null ? (
141
175
  <Banner padded variant="loading" testId="related-list-loading">
@@ -146,24 +180,47 @@ export function RelatedListSection({
146
180
  {dispatcherErrorText(rowsQuery.error, effectiveTranslate)}
147
181
  </Banner>
148
182
  ) : (
149
- <RenderList
150
- screen={listScreen}
151
- entity={entity}
152
- rows={rowsQuery.data?.rows ?? []}
153
- featureName={featureName}
154
- translate={effectiveTranslate}
155
- {...(onRowClick !== undefined && { onRowClick })}
156
- {...(rowActions !== undefined && { rowActions })}
157
- {...(rowActionMode !== undefined && { rowActionMode })}
158
- {...(hideTitle === true && { chromeless: true })}
159
- />
183
+ <>
184
+ {truncated && (
185
+ <Banner variant="info" testId="related-list-truncated">
186
+ {effectiveTranslate("kumiko.list.related-list-truncated", {
187
+ count: sortedRows.length,
188
+ })}
189
+ </Banner>
190
+ )}
191
+ <RenderList
192
+ screen={listScreen}
193
+ entity={entity}
194
+ rows={sortedRows}
195
+ featureName={featureName}
196
+ translate={effectiveTranslate}
197
+ sort={sort}
198
+ onSortChange={setSort}
199
+ {...(onRowClick !== undefined && { onRowClick })}
200
+ {...(rowActions !== undefined && { rowActions })}
201
+ {...(rowActionMode !== undefined && { rowActionMode })}
202
+ {...(hideTitle === true && { chromeless: true, scrollBody: true })}
203
+ />
204
+ </>
160
205
  );
161
206
 
162
207
  // hideTitle (tabs mode) → the tab panel is already the boundary: no
163
- // Section card wrapper here, and `chromeless` above drops the table's own
164
- // card frame too (fw#2722) the list sits directly in the tab. Stacked
165
- // (non-tabs) sections keep both cards since they render a visible title.
166
- if (hideTitle) return content;
208
+ // 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
212
+ // link in RenderEdit's `fillHeight` chain (see render-edit.tsx): it is
213
+ // always this section's own root whenever hideTitle is set, since tabs
214
+ // mode narrows RenderEdit to exactly this one active section. A platform
215
+ // primitive (not a raw `<div>`) because `renderer` stays DOM-free —
216
+ // `Section`/`Card` were rejected for this spot in favor of a dedicated
217
+ // chromeless primitive; see `FillContainerProps` in primitives.tsx.
218
+ // Stacked (non-tabs) sections keep the card frame and document-flow
219
+ // height since they render a visible title and aren't confined to a tab
220
+ // panel.
221
+ if (hideTitle) {
222
+ return FillContainer !== undefined ? <FillContainer>{content}</FillContainer> : content;
223
+ }
167
224
 
168
225
  return (
169
226
  <Section title={section.title} testId={`related-list-${section.title}`}>
@@ -628,6 +628,15 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
628
628
  // true when editable fields exist or an extension opted into composed submit (fw#2359).
629
629
  const isFormEditable = hasEditableSection(filteredSections);
630
630
 
631
+ // A lone relatedList tab (hideSectionTitles is only ever set by the tabs
632
+ // 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
+ // 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.
638
+ const fillHeight = hideSectionTitles === true && filteredSections[0]?.kind === "relatedList";
639
+
631
640
  // Persistiert alle composed Extension-Sections mit der aufgelösten entityId.
632
641
  // false = eine Section schlug fehl (ihr i18n-Key landet im Banner). Ohne
633
642
  // Entity-Kontext (create-mode ohne route-id) gibt es nichts zu schreiben.
@@ -1077,6 +1086,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1077
1086
  stickyActions={isWizard}
1078
1087
  {...(screen.layout.width !== undefined && { width: screen.layout.width })}
1079
1088
  {...(headerRegion !== undefined && { headerRegion })}
1089
+ {...(fillHeight && { fillHeight })}
1080
1090
  >
1081
1091
  {draftCandidates !== null && (
1082
1092
  <Banner
@@ -114,6 +114,10 @@ export type RenderListProps = {
114
114
  * frame for a host that already provides one (relatedList in a tabs-mode
115
115
  * section, fw#2722). Default false. */
116
116
  readonly chromeless?: boolean;
117
+ /** Forwarded to `DataTableProps.scrollBody` — bounds the table height and
118
+ * scrolls rows internally instead of growing the page (relatedList in a
119
+ * tabs-mode section, fw#2722). Default false. */
120
+ readonly scrollBody?: boolean;
117
121
  };
118
122
 
119
123
  // Resolved-Form einer Toolbar-Action: KumikoScreen baut das aus dem
@@ -163,6 +167,7 @@ export function RenderList(props: RenderListProps): ReactNode {
163
167
  onFilterChange,
164
168
  onFilterReset,
165
169
  chromeless,
170
+ scrollBody,
166
171
  } = props;
167
172
  // Wie RenderEdit: Translate-Fallback aus dem i18next-Context, sonst
168
173
  // wären Column-Header raw i18n-Keys.
@@ -378,6 +383,7 @@ export function RenderList(props: RenderListProps): ReactNode {
378
383
  {...(onFilterChange !== undefined && { onFilterChange })}
379
384
  {...(onFilterReset !== undefined && { onFilterReset })}
380
385
  {...(chromeless !== undefined && { chromeless })}
386
+ {...(scrollBody !== undefined && { scrollBody })}
381
387
  testId="render-list-table"
382
388
  />
383
389
  </>
@@ -64,6 +64,8 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
64
64
  "kumiko.list.end-of-list": "— End of list —",
65
65
  "kumiko.list.sort.label": "Sort",
66
66
  "kumiko.list.sort.unsorted": "Unsorted",
67
+ "kumiko.list.related-list-truncated":
68
+ "Showing the first {count} entries. More are available but not loaded — this list does not paginate.",
67
69
  "kumiko.reference.system-tenant": "System",
68
70
 
69
71
  "kumiko.pager.status": "{from}–{to} of {total}",
package/src/index.ts CHANGED
@@ -184,6 +184,7 @@ export type {
184
184
  EmbeddedListInputProps,
185
185
  EmbeddedListTotal,
186
186
  FieldProps,
187
+ FillContainerProps,
187
188
  FormProps,
188
189
  FormWidth,
189
190
  GridCellProps,
@@ -623,6 +623,15 @@ export type DataTableProps = {
623
623
  * and would otherwise show a card nested inside that boundary. Default
624
624
  * false: unchanged card-framed table. */
625
625
  readonly chromeless?: boolean;
626
+ /** Fills the available height of its flex container and scrolls rows
627
+ * internally instead of growing with row count — for a host that would
628
+ * otherwise have a long table stretch the whole page, or a short table
629
+ * leave dead space below it (a tab panel, fw#2722). Requires the same
630
+ * flex-fill chain FormProps.fillHeight sets up above it; without that
631
+ * ancestor chain this collapses to zero height (Web: `flex-1 min-h-0`
632
+ * has no effect outside a sized flex-col ancestor). Default false:
633
+ * unchanged document-flow table that grows with its content. */
634
+ readonly scrollBody?: boolean;
626
635
  };
627
636
 
628
637
  // ---- EmbeddedListInput (createEmbeddedListField widget) ----
@@ -755,6 +764,14 @@ export type FormProps = {
755
764
  * instead of rendering as unpadded siblings before it. Native impls may
756
765
  * ignore this prop. */
757
766
  readonly headerRegion?: ReactNode;
767
+ /** Sizes the form to fill its container's height (instead of the page's
768
+ * natural content height) so a single scrolling child — a lone
769
+ * relatedList tab's table — can scroll internally instead of stretching
770
+ * the whole page (fw#2722). Only set by RenderEdit for a lone relatedList
771
+ * tab section; every other caller leaves it unset and keeps normal
772
+ * document-flow height. Native impls may ignore this prop (already a
773
+ * bounded viewport there). */
774
+ readonly fillHeight?: boolean;
758
775
  };
759
776
 
760
777
  /** Titled Gruppe von Feldern. Web: `<fieldset>` + `<legend>`, Native:
@@ -784,6 +801,21 @@ export type SectionProps = {
784
801
  readonly icon?: IconKey;
785
802
  };
786
803
 
804
+ /** 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
808
+ * already flex-column and the parent is already a bounded viewport there,
809
+ * so a native impl may render this as a bare Fragment. Used only as the
810
+ * terminal link in `RenderEdit`'s `fillHeight` chain (`RelatedListSection`'s
811
+ * `hideTitle` branch) — `Section`/`Card` were rejected here because both
812
+ * carry title/padding/border chrome this spot doesn't want and would double
813
+ * up with the table's own padding. */
814
+ export type FillContainerProps = {
815
+ readonly children: ReactNode;
816
+ readonly testId?: string;
817
+ };
818
+
787
819
  /** Columns-basiertes Layout. Web: CSS grid, Native: Flex-Wrap mit
788
820
  * Width-%, oder react-native-grid. Jedes direkte Child kann eine
789
821
  * `GridCell`-Wrapping bekommen für span-Kontrolle.
@@ -1139,6 +1171,10 @@ export type CorePrimitives = {
1139
1171
  * CorePrimitives mocks in tests keep compiling — additive rollout of
1140
1172
  * a new primitive shouldn't force every test double to grow a stub. */
1141
1173
  readonly JsonView?: ComponentType<JsonViewProps>;
1174
+ /** Optional (unlike the other Core-Primitives) so existing partial
1175
+ * CorePrimitives mocks in tests keep compiling — additive rollout of
1176
+ * a new primitive shouldn't force every test double to grow a stub. */
1177
+ readonly FillContainer?: ComponentType<FillContainerProps>;
1142
1178
  };
1143
1179
 
1144
1180
  /** Offene Extension-Zone für App-eigene Primitives. Devs erweitern