@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 +5 -5
- package/src/__tests__/nav-tree.test.tsx +151 -0
- package/src/__tests__/primitives.test.tsx +68 -0
- package/src/layout/nav-tree.tsx +102 -28
- package/src/primitives/index.tsx +60 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "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.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "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.
|
|
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", () => {
|
package/src/layout/nav-tree.tsx
CHANGED
|
@@ -493,11 +493,87 @@ function ActionGlyph({ icon }: { readonly icon: string }): ReactNode {
|
|
|
493
493
|
);
|
|
494
494
|
}
|
|
495
495
|
|
|
496
|
-
//
|
|
497
|
-
//
|
|
498
|
-
//
|
|
499
|
-
|
|
500
|
-
|
|
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
|
|
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
|
-
<
|
|
515
|
-
|
|
516
|
-
|
|
592
|
+
<TreeActionControl
|
|
593
|
+
action={create}
|
|
594
|
+
ariaLabel={label(create.label)}
|
|
517
595
|
className={btn}
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
dispatch(create.target);
|
|
521
|
-
}}
|
|
596
|
+
dispatch={dispatch}
|
|
597
|
+
workspaceId={workspaceId}
|
|
522
598
|
>
|
|
523
599
|
<Plus className="size-3.5" />
|
|
524
|
-
</
|
|
600
|
+
</TreeActionControl>
|
|
525
601
|
)}
|
|
526
602
|
{actions.map((a: TreeAction) => (
|
|
527
|
-
<
|
|
603
|
+
<TreeActionControl
|
|
528
604
|
key={a.label}
|
|
529
|
-
|
|
530
|
-
|
|
605
|
+
action={a}
|
|
606
|
+
ariaLabel={label(a.label)}
|
|
531
607
|
className={cn(btn, hover)}
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
dispatch(a.target);
|
|
535
|
-
}}
|
|
608
|
+
dispatch={dispatch}
|
|
609
|
+
workspaceId={workspaceId}
|
|
536
610
|
>
|
|
537
611
|
<ActionGlyph icon={a.icon} />
|
|
538
|
-
</
|
|
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>
|
package/src/primitives/index.tsx
CHANGED
|
@@ -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
|
-
|
|
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=
|
|
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
|
|
2168
|
-
|
|
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
|
|
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(
|
|
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
|
};
|