@cosmicdrift/kumiko-renderer-web 0.246.0 → 0.248.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-web",
3
- "version": "0.246.0",
3
+ "version": "0.248.0",
4
4
  "description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -16,9 +16,9 @@
16
16
  "./styles.css": "./src/styles.css"
17
17
  },
18
18
  "dependencies": {
19
- "@cosmicdrift/kumiko-dispatcher-live": "0.246.0",
20
- "@cosmicdrift/kumiko-headless": "0.246.0",
21
- "@cosmicdrift/kumiko-renderer": "0.246.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.248.0",
20
+ "@cosmicdrift/kumiko-headless": "0.248.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.248.0",
22
22
  "@radix-ui/react-dialog": "^1.1.15",
23
23
  "@radix-ui/react-dropdown-menu": "^2.1.16",
24
24
  "@radix-ui/react-label": "^2.1.8",
@@ -64,7 +64,7 @@
64
64
  "@types/react-dom": "^19.2.3",
65
65
  "jsdom": "^29.1.1",
66
66
  "tailwindcss": "^4.3.0",
67
- "@cosmicdrift/kumiko-locale-de": "0.246.0"
67
+ "@cosmicdrift/kumiko-locale-de": "0.248.0"
68
68
  },
69
69
  "repository": {
70
70
  "type": "git",
@@ -673,6 +673,105 @@ describe("NavTree dynamic provider nodes", () => {
673
673
  expect(dispatched).toEqual({ featureId: "cms", action: "create", args: { folder: "" } });
674
674
  });
675
675
 
676
+ // fw#2750: createAction/actions[] carry screen XOR target — same
677
+ // polymorphism as the node itself. A `screen` action renders a
678
+ // route-KumikoLink instead of a dispatch-button.
679
+ test("createAction mit screen rendert einen Link auf die richtige Route statt zu dispatchen", async () => {
680
+ let dispatched: TargetRef | undefined;
681
+ restoreDispatch = setDispatchListener((t) => {
682
+ dispatched = t;
683
+ });
684
+ const schema: FeatureSchema = {
685
+ featureName: "cms",
686
+ entities: {},
687
+ screens: [{ id: "new-page", type: "entityList", entity: "item", columns: [] }],
688
+ navs: [
689
+ {
690
+ id: "content",
691
+ label: "Content",
692
+ order: 10,
693
+ provider: true,
694
+ createAction: { icon: "plus", label: "New page", screen: "cms:screen:new-page" },
695
+ },
696
+ ],
697
+ } as FeatureSchema;
698
+ const provider: TreeChildrenSubscribe = () => (emit) => {
699
+ emit([pageLeaf("apex")]);
700
+ return () => {};
701
+ };
702
+ await act(async () => {
703
+ renderDynamic({ schema, providers: new Map([["cms:nav:content", provider]]) });
704
+ });
705
+
706
+ const link = screen.getByRole("link", { name: "New page" });
707
+ expect(link.getAttribute("href")).toBe("/new-page");
708
+ fireEvent.click(link);
709
+ expect(dispatched).toBeUndefined();
710
+ });
711
+
712
+ // fw#2750: same screen/target polymorphism applies to actions[] entries
713
+ // (hover-actions), not just createAction.
714
+ test("actions[]-Eintrag mit screen rendert einen Link auf die richtige Route statt zu dispatchen", async () => {
715
+ let dispatched: TargetRef | undefined;
716
+ restoreDispatch = setDispatchListener((t) => {
717
+ dispatched = t;
718
+ });
719
+ const schema: FeatureSchema = {
720
+ featureName: "cms",
721
+ entities: {},
722
+ screens: [{ id: "hero", type: "entityList", entity: "item", columns: [] }],
723
+ navs: [
724
+ {
725
+ id: "hero",
726
+ label: "Hero",
727
+ order: 10,
728
+ actions: [{ icon: "edit", label: "Edit hero", screen: "cms:screen:hero" }],
729
+ },
730
+ ],
731
+ } as FeatureSchema;
732
+ await act(async () => {
733
+ renderDynamic({ schema, providers: new Map() });
734
+ });
735
+
736
+ const link = screen.getByRole("link", { name: "Edit hero" });
737
+ expect(link.getAttribute("href")).toBe("/hero");
738
+ fireEvent.click(link);
739
+ expect(dispatched).toBeUndefined();
740
+ });
741
+
742
+ // fw#2751: boot validates screen XOR target for NavDefinition actions, but
743
+ // a schema handed straight to NavTree (as here, and as a provider-emitted
744
+ // TreeNode at runtime) bypasses that — neither must not render a dead
745
+ // button, it must warn once and drop the action.
746
+ test("actions[]-Eintrag ohne screen und ohne target rendert nichts und warnt einmal", async () => {
747
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
748
+ try {
749
+ const schema: FeatureSchema = {
750
+ featureName: "cms",
751
+ entities: {},
752
+ screens: [],
753
+ navs: [
754
+ {
755
+ id: "hero",
756
+ label: "Hero",
757
+ order: 10,
758
+ actions: [{ icon: "edit", label: "Broken action" }],
759
+ },
760
+ ],
761
+ } as FeatureSchema;
762
+ await act(async () => {
763
+ renderDynamic({ schema, providers: new Map() });
764
+ });
765
+
766
+ expect(screen.queryByRole("button", { name: "Broken action" })).toBeNull();
767
+ expect(screen.queryByRole("link", { name: "Broken action" })).toBeNull();
768
+ expect(warnSpy).toHaveBeenCalledTimes(1);
769
+ expect(warnSpy.mock.calls[0]?.[0]).toContain("Broken action");
770
+ } finally {
771
+ warnSpy.mockRestore();
772
+ }
773
+ });
774
+
676
775
  test("target-Knoten dispatcht beim Klick (statt Route-Link)", async () => {
677
776
  let dispatched: TargetRef | undefined;
678
777
  restoreDispatch = setDispatchListener((t) => {
@@ -773,6 +872,58 @@ describe("NavTree dynamic provider nodes", () => {
773
872
  });
774
873
  });
775
874
 
875
+ // fw#2750: the actions container clears the collapse chevron via `right-7`.
876
+ // A node without a chevron (not expandable) has no chevron — the narrower
877
+ // `right-1` spacing must only apply there.
878
+ describe("NavTree Actions-Positionierung", () => {
879
+ test("expandable Knoten (mit Chevron) hält den weiten right-7-Abstand", async () => {
880
+ const provider: TreeChildrenSubscribe = () => (emit) => {
881
+ emit([pageLeaf("apex")]);
882
+ return () => {};
883
+ };
884
+ await act(async () => {
885
+ renderDynamic({
886
+ schema: dynamicSchema(),
887
+ providers: new Map([["cms:nav:content", provider]]),
888
+ });
889
+ });
890
+
891
+ const btn = screen.getByRole("button", { name: "New page" });
892
+ const actionsContainer = btn.parentElement;
893
+ expect(actionsContainer?.className).toContain("right-7");
894
+ expect(actionsContainer?.className).not.toContain("right-1");
895
+ });
896
+
897
+ test("nicht-expandable Knoten (kein Chevron) rückt auf right-1 auf", async () => {
898
+ const schema: FeatureSchema = {
899
+ featureName: "cms",
900
+ entities: {},
901
+ screens: [{ id: "hero", type: "entityList", entity: "item", columns: [] }],
902
+ navs: [
903
+ {
904
+ id: "hero",
905
+ label: "Hero",
906
+ order: 10,
907
+ screen: "hero",
908
+ createAction: {
909
+ icon: "plus",
910
+ label: "New sub-page",
911
+ target: { featureId: "cms", action: "create", args: {} },
912
+ },
913
+ },
914
+ ],
915
+ } as FeatureSchema;
916
+ await act(async () => {
917
+ renderDynamic({ schema, providers: new Map() });
918
+ });
919
+
920
+ const btn = screen.getByRole("button", { name: "New sub-page" });
921
+ const actionsContainer = btn.parentElement;
922
+ expect(actionsContainer?.className).toContain("right-1");
923
+ expect(actionsContainer?.className).not.toContain("right-7");
924
+ });
925
+ });
926
+
776
927
  describe("NavTree Suchfeld", () => {
777
928
  // Das Suchfeld ist das einzige <input> im Baum; über die Rolle statt den
778
929
  // i18n-Placeholder gefunden (i18n-Bundle resolvt im Worktree gegen den
@@ -465,6 +465,43 @@ describe("DataTable", () => {
465
465
  });
466
466
  });
467
467
 
468
+ // scrollBody (fw#2722): a relatedList section in a tabs-mode Akte must not
469
+ // stretch the whole page with a long list, or leave dead space below a
470
+ // short one — bounds the table to a fixed height and scrolls rows inside
471
+ // it, regardless of how many rows there are.
472
+ describe("scrollBody", () => {
473
+ const cols = [{ field: "name", label: "Name", type: "string", sortable: false }] as const;
474
+ const shortRows = [{ id: "r1", values: { name: "Alice" } }];
475
+ const longRows = Array.from({ length: 50 }, (_, i) => ({
476
+ id: `r${i}`,
477
+ values: { name: `Row ${i}` },
478
+ }));
479
+
480
+ test("default: table has no fixed height and grows with its content", () => {
481
+ render(<DataTable columns={cols} rows={longRows} testId="t" />);
482
+ const wrapper = screen.getByTestId("t").parentElement?.parentElement;
483
+ expect(wrapper?.className).not.toContain("overflow-y-auto");
484
+ expect(wrapper?.className).toContain("overflow-hidden");
485
+ });
486
+
487
+ test("scrollBody: a long list fills its flex container and scrolls internally", () => {
488
+ render(<DataTable columns={cols} rows={longRows} testId="t" scrollBody />);
489
+ const wrapper = screen.getByTestId("t").parentElement?.parentElement;
490
+ expect(wrapper?.className).toContain("overflow-y-auto");
491
+ expect(wrapper?.className).toContain("flex-1");
492
+ expect(wrapper?.className).toContain("min-h-0");
493
+ expect(screen.getAllByTestId(/^row-/)).toHaveLength(50);
494
+ });
495
+
496
+ test("scrollBody: a short list gets the same flex-fill treatment (fills space instead of shrinking)", () => {
497
+ render(<DataTable columns={cols} rows={shortRows} testId="t" scrollBody />);
498
+ const wrapper = screen.getByTestId("t").parentElement?.parentElement;
499
+ expect(wrapper?.className).toContain("overflow-y-auto");
500
+ expect(wrapper?.className).toContain("flex-1");
501
+ expect(wrapper?.className).toContain("min-h-0");
502
+ });
503
+ });
504
+
468
505
  // Sort-Header pinnt das 3-State-Toggle-Verhalten + Visual-Indicator
469
506
  // + aria-sort. Renderer-Vertrag mit dem Caller (RenderList): jede
470
507
  // sortable-Column liefert beim Click den nächsten Sort-State zurück
@@ -1478,6 +1515,37 @@ describe("Form", () => {
1478
1515
  const contentContainer = footer.previousElementSibling as HTMLElement;
1479
1516
  expect(contentContainer.className).not.toContain("max-sm:pb-32");
1480
1517
  });
1518
+
1519
+ // fillHeight (fw#2722): the flex-fill chain RenderEdit opts a lone
1520
+ // relatedList tab into, so its table can scroll inside the tab panel
1521
+ // instead of the whole page stretching to the row count.
1522
+ test("fillHeight: form root and its content container size to h-full/flex-1 min-h-0", () => {
1523
+ render(
1524
+ <Form onSubmit={() => undefined} testId="form" fillHeight>
1525
+ <div>content</div>
1526
+ </Form>,
1527
+ );
1528
+ const form = screen.getByTestId("form");
1529
+ expect(form.className).toContain("h-full");
1530
+ expect(form.className).toContain("min-h-0");
1531
+ // form > FormScreenShell > card(overflow-hidden) — the card is the
1532
+ // flex-1 min-h-0 child that claims the remaining height below headerRegion.
1533
+ const card = form.firstElementChild?.firstElementChild as HTMLElement;
1534
+ expect(card.className).toContain("flex-1");
1535
+ expect(card.className).toContain("min-h-0");
1536
+ });
1537
+
1538
+ test("without fillHeight: form root keeps its normal, content-sized height", () => {
1539
+ render(
1540
+ <Form onSubmit={() => undefined} testId="form">
1541
+ <div>content</div>
1542
+ </Form>,
1543
+ );
1544
+ const form = screen.getByTestId("form");
1545
+ expect(form.className).not.toContain("h-full");
1546
+ const card = form.firstElementChild?.firstElementChild as HTMLElement;
1547
+ expect(card.className).not.toContain("flex-1");
1548
+ });
1481
1549
  });
1482
1550
 
1483
1551
  describe("Banner padded", () => {
@@ -493,11 +493,87 @@ function ActionGlyph({ icon }: { readonly icon: string }): ReactNode {
493
493
  );
494
494
  }
495
495
 
496
- // Hover-Actions + „+"-Affordance, absolut rechts (links vom Chevron via
497
- // right-7). createAction ist persistent (User-sichtbares „+"), übrige
498
- // Actions erst bei Hover. stopPropagation, damit der Row-Click (toggle/
499
- // dispatch) nicht zusätzlich feuert.
500
- function NodeActions({ node }: { node: NavNode }): ReactNode {
496
+ // Boot validates screen XOR target for NavDefinition actions, but a
497
+ // provider-emitted TreeNode reaches here without going through boot — warn
498
+ // once per label instead of rendering a dead button (fw#2751 review).
499
+ const warnedTreeActionLabels = new Set<string>();
500
+
501
+ function warnTreeActionDropped(label: string): void {
502
+ // skip: already warned for this label — suppresses the repeat, not the warning itself.
503
+ if (warnedTreeActionLabels.has(label)) return;
504
+ warnedTreeActionLabels.add(label);
505
+ // biome-ignore lint/suspicious/noConsole: dev-warning for a setup error
506
+ console.warn(
507
+ `[kumiko] Nav action "${label}" has neither "screen" nor "target" set — it will not render.`,
508
+ );
509
+ }
510
+
511
+ // An action button carries screen XOR target (see TreeAction) — `screen`
512
+ // renders a KumikoLink to the route, `target` a dispatch button. Same
513
+ // classes/size either way, only the element changes.
514
+ function TreeActionControl({
515
+ action,
516
+ ariaLabel,
517
+ className,
518
+ dispatch,
519
+ workspaceId,
520
+ children,
521
+ }: {
522
+ readonly action: TreeAction;
523
+ readonly ariaLabel: string;
524
+ readonly className: string;
525
+ readonly dispatch: (target: TargetRef) => void;
526
+ readonly workspaceId: string | undefined;
527
+ readonly children: ReactNode;
528
+ }): ReactNode {
529
+ if (action.screen !== undefined) {
530
+ const screenId = lastSegment(action.screen);
531
+ return (
532
+ <KumikoLink
533
+ to={{ ...(workspaceId !== undefined && { workspaceId }), screenId }}
534
+ aria-label={ariaLabel}
535
+ className={className}
536
+ onClick={(e) => e.stopPropagation()}
537
+ >
538
+ {children}
539
+ </KumikoLink>
540
+ );
541
+ }
542
+ if (action.target !== undefined) {
543
+ const target = action.target;
544
+ return (
545
+ <button
546
+ type="button"
547
+ aria-label={ariaLabel}
548
+ className={className}
549
+ onClick={(e) => {
550
+ e.stopPropagation();
551
+ dispatch(target);
552
+ }}
553
+ >
554
+ {children}
555
+ </button>
556
+ );
557
+ }
558
+ warnTreeActionDropped(action.label);
559
+ return null;
560
+ }
561
+
562
+ // Hover-actions + the "+" affordance, positioned absolute right.
563
+ // createAction is persistent (a user-visible "+"), the other actions only
564
+ // show on hover. stopPropagation so the row click (toggle/dispatch) doesn't
565
+ // also fire. `expandable` comes from useNavNodeState (s.expandable) instead
566
+ // of being re-derived here — a node without a chevron doesn't need the
567
+ // right-7 gap, otherwise the "+" hangs 28px off the edge.
568
+ function NodeActions({
569
+ node,
570
+ expandable,
571
+ workspaceId,
572
+ }: {
573
+ readonly node: NavNode;
574
+ readonly expandable: boolean;
575
+ readonly workspaceId: string | undefined;
576
+ }): ReactNode {
501
577
  const dispatch = useDispatchTarget();
502
578
  const t = useTranslation();
503
579
  const create = node.createAction;
@@ -509,33 +585,31 @@ function NodeActions({ node }: { node: NavNode }): ReactNode {
509
585
  const hover =
510
586
  "opacity-0 group-hover/menu-item:opacity-100 group-hover/menu-sub-item:opacity-100 group-focus-within/menu-item:opacity-100";
511
587
  return (
512
- <div className="absolute top-1 right-7 flex items-center gap-0.5">
588
+ <div
589
+ className={cn("absolute top-1 flex items-center gap-0.5", expandable ? "right-7" : "right-1")}
590
+ >
513
591
  {create !== undefined && (
514
- <button
515
- type="button"
516
- aria-label={label(create.label)}
592
+ <TreeActionControl
593
+ action={create}
594
+ ariaLabel={label(create.label)}
517
595
  className={btn}
518
- onClick={(e) => {
519
- e.stopPropagation();
520
- dispatch(create.target);
521
- }}
596
+ dispatch={dispatch}
597
+ workspaceId={workspaceId}
522
598
  >
523
599
  <Plus className="size-3.5" />
524
- </button>
600
+ </TreeActionControl>
525
601
  )}
526
602
  {actions.map((a: TreeAction) => (
527
- <button
603
+ <TreeActionControl
528
604
  key={a.label}
529
- type="button"
530
- aria-label={label(a.label)}
605
+ action={a}
606
+ ariaLabel={label(a.label)}
531
607
  className={cn(btn, hover)}
532
- onClick={(e) => {
533
- e.stopPropagation();
534
- dispatch(a.target);
535
- }}
608
+ dispatch={dispatch}
609
+ workspaceId={workspaceId}
536
610
  >
537
611
  <ActionGlyph icon={a.icon} />
538
- </button>
612
+ </TreeActionControl>
539
613
  ))}
540
614
  </div>
541
615
  );
@@ -649,7 +723,7 @@ function NavMenuNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
649
723
  <NavBadge node={node} />
650
724
  </KumikoLink>
651
725
  </SidebarMenuButton>
652
- <NodeActions node={node} />
726
+ <NodeActions node={node} expandable={s.expandable} workspaceId={s.workspaceId} />
653
727
  {chevron}
654
728
  {sub}
655
729
  </SidebarMenuItem>
@@ -674,7 +748,7 @@ function NavMenuNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
674
748
  </span>
675
749
  <NavBadge node={node} />
676
750
  </SidebarMenuButton>
677
- <NodeActions node={node} />
751
+ <NodeActions node={node} expandable={s.expandable} workspaceId={s.workspaceId} />
678
752
  {chevron}
679
753
  {sub}
680
754
  </SidebarMenuItem>
@@ -700,7 +774,7 @@ function NavMenuNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
700
774
  <ChevronRight className="ml-auto" />
701
775
  ))}
702
776
  </SidebarMenuButton>
703
- <NodeActions node={node} />
777
+ <NodeActions node={node} expandable={s.expandable} workspaceId={s.workspaceId} />
704
778
  {sub}
705
779
  </SidebarMenuItem>
706
780
  );
@@ -769,7 +843,7 @@ function NavSubNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
769
843
  <NavBadge node={node} />
770
844
  </KumikoLink>
771
845
  </SidebarMenuSubButton>
772
- <NodeActions node={node} />
846
+ <NodeActions node={node} expandable={s.expandable} workspaceId={s.workspaceId} />
773
847
  {chevron}
774
848
  {deeper}
775
849
  </SidebarMenuSubItem>
@@ -792,7 +866,7 @@ function NavSubNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
792
866
  <NavBadge node={node} />
793
867
  </button>
794
868
  </SidebarMenuSubButton>
795
- <NodeActions node={node} />
869
+ <NodeActions node={node} expandable={s.expandable} workspaceId={s.workspaceId} />
796
870
  {chevron}
797
871
  {deeper}
798
872
  </SidebarMenuSubItem>
@@ -819,7 +893,7 @@ function NavSubNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
819
893
  </span>
820
894
  </button>
821
895
  </SidebarMenuSubButton>
822
- <NodeActions node={node} />
896
+ <NodeActions node={node} expandable={s.expandable} workspaceId={s.workspaceId} />
823
897
  {chevron}
824
898
  {deeper}
825
899
  </SidebarMenuSubItem>
@@ -25,6 +25,7 @@ import {
25
25
  type DataTableFacet,
26
26
  type DataTableProps,
27
27
  type FieldProps,
28
+ type FillContainerProps,
28
29
  type FormProps,
29
30
  type FormWidth,
30
31
  type GridCellProps,
@@ -904,6 +905,7 @@ function DefaultDataTable({
904
905
  getRowTestId,
905
906
  getCellTestId,
906
907
  chromeless,
908
+ scrollBody,
907
909
  }: DataTableProps): ReactNode {
908
910
  // One locale/translate subscription per table — not per cell (fw#2345).
909
911
  // Optional hooks: a bare DataTable outside LocaleProvider must not crash.
@@ -937,7 +939,19 @@ function DefaultDataTable({
937
939
  // sitzt auf derselben Card-Fläche wie Forms; auf Themes mit farbigem
938
940
  // Page-Background (z.B. Cream) matchen Listen sonst nicht die Cards.
939
941
  // `chromeless` drops that frame for a host with its own boundary already (a tab panel, fw#2722).
940
- <div className={cn("overflow-hidden", chromeless !== true && "rounded-lg border bg-card")}>
942
+ // `scrollBody` fills the remaining height of its flex-col ancestor
943
+ // (see DefaultForm/FormScreenShell's `fillHeight` and this table's own
944
+ // outer wrapper below) and scrolls rows internally, instead of
945
+ // `overflow-hidden` (document-flow height, grows with row count) — for
946
+ // a host where a long list must not stretch the whole page (a tab
947
+ // panel, fw#2722). `min-h-0` overrides flex's default min-height:auto,
948
+ // which would otherwise let this grow past its flex-1 share to fit content.
949
+ <div
950
+ className={cn(
951
+ scrollBody === true ? "flex-1 min-h-0 overflow-y-auto" : "overflow-hidden",
952
+ chromeless !== true && "rounded-lg border bg-card",
953
+ )}
954
+ >
941
955
  <Table data-testid={testId}>
942
956
  <TableHeader className="bg-muted">
943
957
  <TableRow className="hover:bg-transparent">
@@ -1139,7 +1153,10 @@ function DefaultDataTable({
1139
1153
  return (
1140
1154
  <div
1141
1155
  data-testid={testId !== undefined ? `${testId}-cards` : "render-list-cards"}
1142
- className="flex flex-col gap-3"
1156
+ className={cn(
1157
+ "flex flex-col gap-3",
1158
+ scrollBody === true && "flex-1 min-h-0 overflow-y-auto",
1159
+ )}
1143
1160
  >
1144
1161
  {onSortChange !== undefined && sortableColumns.length > 0 && (
1145
1162
  <select
@@ -1241,7 +1258,7 @@ function DefaultDataTable({
1241
1258
  // der Tabelle im selben Padding-Block — kein separater bg-Bar, kein Screen-
1242
1259
  // Titel (der steht im Breadcrumb der Shell).
1243
1260
  return (
1244
- <div className="flex flex-col gap-4 p-6 w-full">
1261
+ <div className={cn("flex flex-col gap-4 p-6 w-full", scrollBody === true && "flex-1 min-h-0")}>
1245
1262
  {hasToolbar && (
1246
1263
  <div
1247
1264
  data-testid={testId !== undefined ? `${testId}-toolbar` : "render-list-toolbar"}
@@ -2134,6 +2151,7 @@ function DefaultForm({
2134
2151
  width,
2135
2152
  stickyActions,
2136
2153
  headerRegion,
2154
+ fillHeight,
2137
2155
  }: FormProps): ReactNode {
2138
2156
  // Eingebettet (AuthCard etc.): nacktes <form>, gestapelte Felder mit gap —
2139
2157
  // der Container trägt Card/Titel selbst, sonst Card-in-Card.
@@ -2164,12 +2182,25 @@ function DefaultForm({
2164
2182
  // between each other, muted action footer. Shell width defaults to full
2165
2183
  // (same chrome as lists); pass width to narrow (auth-adjacent / dense).
2166
2184
  return (
2167
- <FormRoot onSubmit={onSubmit} testId={testId} className="flex flex-col w-full">
2168
- <FormScreenShell {...(width !== undefined && { maxWidth: width })}>
2185
+ <FormRoot
2186
+ onSubmit={onSubmit}
2187
+ testId={testId}
2188
+ className={cn("flex flex-col w-full", fillHeight === true && "h-full min-h-0")}
2189
+ >
2190
+ <FormScreenShell
2191
+ {...(width !== undefined && { maxWidth: width })}
2192
+ {...(fillHeight === true && { fillHeight: true })}
2193
+ >
2169
2194
  {headerRegion !== undefined && (
2170
2195
  <div className="flex flex-col gap-6 mb-8">{headerRegion}</div>
2171
2196
  )}
2172
- <div className={cn(cardSurface(), "overflow-hidden")}>
2197
+ <div
2198
+ className={cn(
2199
+ cardSurface(),
2200
+ "overflow-hidden",
2201
+ fillHeight === true && "flex-1 min-h-0 flex flex-col",
2202
+ )}
2203
+ >
2173
2204
  {(title !== undefined || subtitle !== undefined) && (
2174
2205
  <div className={cn(cardHeaderBorder, "px-6 pb-4 pt-5")}>
2175
2206
  {title !== undefined && (
@@ -2203,6 +2234,7 @@ function DefaultForm({
2203
2234
  // safe-area, fw#2528) — widen further if a wizard step's last field
2204
2235
  // ever renders visibly clipped under three or more wrapped rows.
2205
2236
  stickyActions === true && "max-sm:pb-32",
2237
+ fillHeight === true && "flex-1 min-h-0",
2206
2238
  )}
2207
2239
  >
2208
2240
  <InsideFormContext.Provider value={true}>{children}</InsideFormContext.Provider>
@@ -2257,18 +2289,30 @@ export function FormScreenShell({
2257
2289
  className,
2258
2290
  testId,
2259
2291
  maxWidth,
2292
+ fillHeight,
2260
2293
  }: {
2261
2294
  readonly children: ReactNode;
2262
2295
  readonly className?: string;
2263
2296
  readonly testId?: string;
2264
2297
  readonly maxWidth?: FormScreenShellWidth;
2298
+ /** Stacks children in a `h-full` flex column instead of normal document
2299
+ * flow, so a `flex-1 min-h-0` child can claim the remaining height below
2300
+ * the others (fw#2722 — DefaultForm's tabs+relatedList case). Default
2301
+ * false: unchanged, content-sized height. */
2302
+ readonly fillHeight?: boolean;
2265
2303
  }): ReactNode {
2266
2304
  const contextWidth = useContext(ScreenWidthContext);
2267
2305
  const width = maxWidth ?? contextWidth;
2268
2306
  return (
2269
2307
  <div
2270
2308
  data-testid={testId}
2271
- className={cn(screenPaddingClassName, "w-full", screenWidthClassName[width], className)}
2309
+ className={cn(
2310
+ screenPaddingClassName,
2311
+ "w-full",
2312
+ screenWidthClassName[width],
2313
+ fillHeight === true && "h-full flex flex-col min-h-0",
2314
+ className,
2315
+ )}
2272
2316
  >
2273
2317
  {children}
2274
2318
  </div>
@@ -2376,6 +2420,14 @@ function DefaultSection({
2376
2420
  );
2377
2421
  }
2378
2422
 
2423
+ function DefaultFillContainer({ children, testId }: FillContainerProps): ReactNode {
2424
+ return (
2425
+ <div data-testid={testId} className="flex flex-1 min-h-0 flex-col">
2426
+ {children}
2427
+ </div>
2428
+ );
2429
+ }
2430
+
2379
2431
  function DefaultGrid({ columns, children, testId, maxRows }: GridProps): ReactNode {
2380
2432
  // "auto": content-sized items in a wrapping row (e.g. a metrics band of
2381
2433
  // self-sized tiles) instead of N equal-width, container-stretched tracks.
@@ -2631,4 +2683,5 @@ export const defaultPrimitives: CorePrimitives = {
2631
2683
  StatusBadge: DefaultStatusBadge,
2632
2684
  Metric: DefaultMetric,
2633
2685
  JsonView: DefaultJsonView,
2686
+ FillContainer: DefaultFillContainer,
2634
2687
  };