@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.247.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.247.0",
20
- "@cosmicdrift/kumiko-headless": "0.247.0",
21
- "@cosmicdrift/kumiko-renderer": "0.247.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.247.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
@@ -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>