@cosmicdrift/kumiko-renderer-web 0.247.0 → 0.249.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/layout/nav-tree.tsx +102 -28
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.249.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.249.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.249.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.249.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.249.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
|
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>
|