@cosmicdrift/kumiko-renderer-web 0.130.1 → 0.130.2

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.130.1",
3
+ "version": "0.130.2",
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.130.1",
20
- "@cosmicdrift/kumiko-headless": "0.130.1",
21
- "@cosmicdrift/kumiko-renderer": "0.130.1",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.130.2",
20
+ "@cosmicdrift/kumiko-headless": "0.130.2",
21
+ "@cosmicdrift/kumiko-renderer": "0.130.2",
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",
@@ -241,6 +241,30 @@ describe("NavTree", () => {
241
241
  expect(container.querySelectorAll("svg").length).toBe(2);
242
242
  });
243
243
 
244
+ test("server, mail, download, rocket lösen auf ein Icon auf (Config-/SMTP-Nav)", () => {
245
+ // Config-Settings-Hub leitet Child-Nav-Icons aus dem mask.icon der
246
+ // ConfigKey ab (smtp-host="server", from="mail", subscription="rocket");
247
+ // fehlten sie in NAV_ICONS, rendert das Nav blank statt Icon.
248
+ const schema = {
249
+ featureName: "app",
250
+ entities: {},
251
+ screens: [
252
+ { id: "a", type: "entityList", entity: "x", columns: [] },
253
+ { id: "b", type: "entityList", entity: "x", columns: [] },
254
+ { id: "c", type: "entityList", entity: "x", columns: [] },
255
+ { id: "d", type: "entityList", entity: "x", columns: [] },
256
+ ],
257
+ navs: [
258
+ { id: "a", label: "SMTP", screen: "a", order: 10, icon: "server" },
259
+ { id: "b", label: "From", screen: "b", order: 20, icon: "mail" },
260
+ { id: "c", label: "Export", screen: "c", order: 30, icon: "download" },
261
+ { id: "d", label: "Billing", screen: "d", order: 40, icon: "rocket" },
262
+ ],
263
+ } as FeatureSchema;
264
+ const { container } = render(<NavTree schema={schema} />);
265
+ expect(container.querySelectorAll("svg").length).toBe(4);
266
+ });
267
+
244
268
  test("palette, link und share rendern Lucide-Icons (Share-/Branding-Nav)", () => {
245
269
  const schema = {
246
270
  featureName: "app",
@@ -542,3 +566,65 @@ describe("NavTree dynamic provider nodes", () => {
542
566
  expect(active()).toBe(0); // Unmount baut alles ab → kein Leak
543
567
  });
544
568
  });
569
+
570
+ describe("NavTree Suchfeld", () => {
571
+ // Das Suchfeld ist das einzige <input> im Baum; über die Rolle statt den
572
+ // i18n-Placeholder gefunden (i18n-Bundle resolvt im Worktree gegen den
573
+ // Haupt-Checkout, wo der neue Key noch fehlt — in CI greift er).
574
+
575
+ test("Filter blendet Nicht-Treffer aus, hält Treffer + deren Ancestors", () => {
576
+ render(<NavTree schema={makeSchema()} />);
577
+ const input = screen.getByRole("textbox") as HTMLInputElement;
578
+
579
+ fireEvent.change(input, { target: { value: "active" } });
580
+
581
+ // Treffer selbst sichtbar (case-insensitiv).
582
+ expect(screen.getByText("Active")).toBeTruthy();
583
+ // Nicht-Treffer-Geschwister weg.
584
+ expect(screen.queryByText("Backlog")).toBeNull();
585
+ // Ancestors bleiben, damit der Treffer erreichbar bleibt.
586
+ expect(screen.getByText("Items")).toBeTruthy();
587
+ expect(screen.getByText("Data")).toBeTruthy();
588
+ });
589
+
590
+ test("Filter ohne Treffer leert den Baum; Leeren stellt alles wieder her", () => {
591
+ render(<NavTree schema={makeSchema()} />);
592
+ const input = screen.getByRole("textbox") as HTMLInputElement;
593
+
594
+ fireEvent.change(input, { target: { value: "zzz-nichts" } });
595
+ expect(screen.queryByText("Items")).toBeNull();
596
+ expect(screen.queryByText("Data")).toBeNull();
597
+
598
+ fireEvent.change(input, { target: { value: "" } });
599
+ expect(screen.getByText("Backlog")).toBeTruthy();
600
+ expect(screen.getByText("Data")).toBeTruthy();
601
+ });
602
+ });
603
+
604
+ describe("NavTree folder-open Icon", () => {
605
+ test("Folder-Knoten zeigt folder-open wenn expanded, folder wenn collapsed", async () => {
606
+ const provider: TreeChildrenSubscribe = () => (emit) => {
607
+ emit([{ label: "page", icon: "folder", children: [pageLeaf("hero")] }]);
608
+ return () => {};
609
+ };
610
+ let container: HTMLElement | undefined;
611
+ await act(async () => {
612
+ container = renderDynamic({
613
+ schema: dynamicSchema(),
614
+ providers: new Map([["cms:nav:content", provider]]),
615
+ }).container;
616
+ });
617
+ // Default-expanded → offener Ordner, kein geschlossener.
618
+ expect(container?.querySelector(".lucide-folder-open")).toBeTruthy();
619
+ expect(container?.querySelector(".lucide-folder")).toBeNull();
620
+
621
+ // Der einzige aria-gelabelte Chevron gehört dem "page"-Sub-Ordner
622
+ // (der Provider-Container toggelt inline, ohne aria-Label).
623
+ const chevron = screen.getByRole("button", { name: /Expand|Collapse/ });
624
+ await act(async () => {
625
+ fireEvent.click(chevron);
626
+ });
627
+ expect(container?.querySelector(".lucide-folder")).toBeTruthy();
628
+ expect(container?.querySelector(".lucide-folder-open")).toBeNull();
629
+ });
630
+ });
@@ -33,9 +33,12 @@ import {
33
33
  ChevronRight,
34
34
  Coins,
35
35
  CreditCard,
36
+ Download,
36
37
  FileText,
37
38
  Folder,
39
+ FolderOpen,
38
40
  Gauge,
41
+ Hash,
39
42
  Home,
40
43
  KeyRound,
41
44
  Layers,
@@ -43,11 +46,15 @@ import {
43
46
  LineChart,
44
47
  Link,
45
48
  List,
49
+ Lock,
50
+ Mail,
46
51
  Palette,
47
52
  PiggyBank,
48
53
  Plus,
49
54
  Receipt,
55
+ Rocket,
50
56
  Search,
57
+ Server,
51
58
  Settings,
52
59
  Share2,
53
60
  Shield,
@@ -76,6 +83,7 @@ import {
76
83
  SidebarGroup,
77
84
  SidebarGroupContent,
78
85
  SidebarGroupLabel,
86
+ SidebarInput,
79
87
  SidebarMenu,
80
88
  SidebarMenuAction,
81
89
  SidebarMenuButton,
@@ -113,6 +121,7 @@ const NAV_ICONS: Readonly<Record<string, typeof Folder>> = {
113
121
  calendar: CalendarDays,
114
122
  file: FileText,
115
123
  folder: Folder,
124
+ "folder-open": FolderOpen,
116
125
  home: Home,
117
126
  bell: Bell,
118
127
  shield: Shield,
@@ -125,6 +134,12 @@ const NAV_ICONS: Readonly<Record<string, typeof Folder>> = {
125
134
  link: Link,
126
135
  palette: Palette,
127
136
  share: Share2,
137
+ server: Server,
138
+ mail: Mail,
139
+ lock: Lock,
140
+ hash: Hash,
141
+ download: Download,
142
+ rocket: Rocket,
128
143
  };
129
144
 
130
145
  export type NavTreeProps = {
@@ -149,6 +164,22 @@ export type NavTreeProps = {
149
164
  const EMPTY_BADGES: ReadonlyMap<string, ReactNode> = new Map();
150
165
  const NavBadgesContext = createContext<ReadonlyMap<string, ReactNode>>(EMPTY_BADGES);
151
166
 
167
+ // Sidebar-Filter: `q` leer = inaktiv (matches akzeptiert alles). `matches`
168
+ // bekommt das ROHE Label (i18n-Key oder Klartext) und übersetzt intern — so
169
+ // filtert das Suchfeld auf den sichtbaren Text, nicht den Key.
170
+ type NavFilter = { readonly q: string; readonly matches: (rawLabel: string) => boolean };
171
+ const NavFilterContext = createContext<NavFilter>({ q: "", matches: () => true });
172
+
173
+ // Ein Knoten überlebt den Filter, wenn er selbst oder ein Nachfahre matcht.
174
+ // Provider-Kinder sind zur Filterzeit schon materialisiert (Provider-Knoten
175
+ // sind default-expanded → eager geladen), darum reicht die statische
176
+ // children-Rekursion. ponytail: ein zur Filterzeit noch NICHT geladener
177
+ // (user-zugeklappter) Provider wird über den force-expand in useNavNodeState
178
+ // aufgeklappt, lädt und filtert dann mit.
179
+ function subtreeMatches(node: NavNode, matches: (rawLabel: string) => boolean): boolean {
180
+ return matches(node.label) || node.children.some((c) => subtreeMatches(c, matches));
181
+ }
182
+
152
183
  export function NavTree({
153
184
  schema,
154
185
  user,
@@ -175,25 +206,47 @@ export function NavTree({
175
206
  });
176
207
  }, []);
177
208
 
209
+ const t = useTranslation();
210
+ const [filter, setFilter] = useState("");
211
+ const q = filter.trim().toLowerCase();
212
+ const matches = useCallback(
213
+ (raw: string): boolean => {
214
+ const label = raw.includes(".") ? t(raw) : raw;
215
+ return label.toLowerCase().includes(q);
216
+ },
217
+ [t, q],
218
+ );
219
+ const navFilter = useMemo<NavFilter>(() => ({ q, matches }), [q, matches]);
220
+
178
221
  return (
179
- <NavBadgesContext.Provider value={navBadges ?? EMPTY_BADGES}>
180
- <div data-testid={testId} data-kumiko-layout="nav-tree" className="flex w-full flex-col">
181
- {tree.map((node) =>
182
- isPureSection(node) ? (
183
- <NavSection
184
- key={node.qualifiedName}
185
- node={node}
186
- collapsed={collapsed}
187
- onToggle={onToggle}
222
+ <NavFilterContext.Provider value={navFilter}>
223
+ <NavBadgesContext.Provider value={navBadges ?? EMPTY_BADGES}>
224
+ <div data-testid={testId} data-kumiko-layout="nav-tree" className="flex w-full flex-col">
225
+ <div className="px-2 pt-2 pb-1">
226
+ <SidebarInput
227
+ value={filter}
228
+ onChange={(e) => setFilter(e.target.value)}
229
+ placeholder={t("kumiko.nav.search")}
230
+ aria-label={t("kumiko.nav.search")}
188
231
  />
189
- ) : (
190
- <SidebarMenu key={node.qualifiedName} className="px-2 py-1">
191
- <NavMenuNode node={node} collapsed={collapsed} onToggle={onToggle} />
192
- </SidebarMenu>
193
- ),
194
- )}
195
- </div>
196
- </NavBadgesContext.Provider>
232
+ </div>
233
+ {tree.map((node) =>
234
+ isPureSection(node) ? (
235
+ <NavSection
236
+ key={node.qualifiedName}
237
+ node={node}
238
+ collapsed={collapsed}
239
+ onToggle={onToggle}
240
+ />
241
+ ) : (
242
+ <SidebarMenu key={node.qualifiedName} className="px-2 py-1">
243
+ <NavMenuNode node={node} collapsed={collapsed} onToggle={onToggle} />
244
+ </SidebarMenu>
245
+ ),
246
+ )}
247
+ </div>
248
+ </NavBadgesContext.Provider>
249
+ </NavFilterContext.Provider>
197
250
  );
198
251
  }
199
252
 
@@ -245,8 +298,17 @@ function useLabel(node: NavNode): string {
245
298
 
246
299
  // Icon-or-Dot: bekannter icon-Key → Lucide-Icon, sonst ein dezenter Dot.
247
300
  // Aktiv = accent-foreground, inaktiv = gedimmt.
248
- function NavLeadingIcon({ node, active }: { node: NavNode; active: boolean }): ReactNode {
249
- const NavIcon = node.icon !== undefined ? NAV_ICONS[node.icon] : undefined;
301
+ function NavLeadingIcon({
302
+ node,
303
+ active,
304
+ expanded = false,
305
+ }: {
306
+ node: NavNode;
307
+ active: boolean;
308
+ expanded?: boolean;
309
+ }): ReactNode {
310
+ const iconKey = expanded && node.icon === "folder" ? "folder-open" : node.icon;
311
+ const NavIcon = iconKey !== undefined ? NAV_ICONS[iconKey] : undefined;
250
312
  if (NavIcon !== undefined) return <NavIcon aria-hidden="true" className="shrink-0" />;
251
313
  return (
252
314
  <span
@@ -366,6 +428,7 @@ type NavNodeState = {
366
428
  readonly isExpanded: boolean;
367
429
  readonly active: boolean;
368
430
  readonly childNodes: readonly NavNode[];
431
+ readonly hidden: boolean;
369
432
  readonly providerLoading: boolean;
370
433
  readonly providerError: string | null;
371
434
  readonly workspaceId: string | undefined;
@@ -377,10 +440,14 @@ type NavNodeState = {
377
440
  // in den shadcn-Primitives, nicht in dieser Logik.
378
441
  function useNavNodeState(node: NavNode, collapsed: ReadonlySet<string>): NavNodeState {
379
442
  const nav = useNav();
443
+ const { q: filterQ, matches } = useContext(NavFilterContext);
444
+ const filterActive = filterQ !== "";
380
445
  const displayLabel = useLabel(node);
381
446
  const isCollapsed = collapsed.has(node.qualifiedName);
382
447
  const expandable = node.children.length > 0 || node.provider === true;
383
- const isExpanded = expandable && !isCollapsed;
448
+ // Aktiver Filter klappt jeden Container auf, damit Treffer unter zugeklappten
449
+ // Ordnern sichtbar werden (und Provider-Kinder nachladen).
450
+ const isExpanded = expandable && (!isCollapsed || filterActive);
384
451
  const { nodes: providerChildren, error: providerError } = useNavProviderChildren(
385
452
  node.qualifiedName,
386
453
  node.provider === true && isExpanded,
@@ -393,13 +460,19 @@ function useNavNodeState(node: NavNode, collapsed: ReadonlySet<string>): NavNode
393
460
  node.screen !== undefined && nav.route?.screenId === lastSegment(node.screen);
394
461
  const targetActive = node.target !== undefined && targetsEqual(node.target, activeTarget);
395
462
  const childNodes = node.provider === true ? (providerChildren ?? []) : node.children;
463
+ const visibleChildNodes = filterActive
464
+ ? childNodes.filter((c) => subtreeMatches(c, matches))
465
+ : childNodes;
396
466
  return {
397
467
  displayLabel,
398
468
  isCollapsed,
399
469
  expandable,
400
470
  isExpanded,
401
471
  active: screenActive || targetActive,
402
- childNodes,
472
+ childNodes: visibleChildNodes,
473
+ // Ein gefilterter Knoten verschwindet nur, wenn weder er selbst noch ein
474
+ // sichtbar gebliebenes Kind matcht — sonst bliebe ein leerer Ordner-Header.
475
+ hidden: filterActive && !matches(node.label) && visibleChildNodes.length === 0,
403
476
  providerLoading: node.provider === true && isExpanded && providerChildren === null,
404
477
  providerError,
405
478
  workspaceId: nav.route?.workspaceId,
@@ -488,6 +561,16 @@ function ProviderStatus({
488
561
  // gehört auf Items MIT children, nicht auf die Section selbst.
489
562
  function NavSection({ node, collapsed, onToggle }: NavSubProps): ReactNode {
490
563
  const displayLabel = useLabel(node);
564
+ const { q, matches } = useContext(NavFilterContext);
565
+ // Provider-Kinder (statisch leer bis geladen) halten die Section offen — ihr
566
+ // Match lässt sich hier nicht statisch prüfen; der Knoten selbst self-hidet.
567
+ if (
568
+ q !== "" &&
569
+ !matches(node.label) &&
570
+ !node.children.some((c) => c.provider === true || subtreeMatches(c, matches))
571
+ ) {
572
+ return null;
573
+ }
491
574
  return (
492
575
  <SidebarGroup className="py-1">
493
576
  <SidebarGroupLabel>{displayLabel}</SidebarGroupLabel>
@@ -518,6 +601,7 @@ function NavMenuNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
518
601
  const t = useTranslation();
519
602
  const dispatch = useDispatchTarget();
520
603
  const s = useNavNodeState(node, collapsed);
604
+ if (s.hidden) return null;
521
605
 
522
606
  const sub = s.isExpanded ? (
523
607
  <SidebarMenuSub>
@@ -593,7 +677,7 @@ function NavMenuNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
593
677
  }}
594
678
  {...(s.expandable && { "aria-expanded": !s.isCollapsed })}
595
679
  >
596
- <NavLeadingIcon node={node} active={false} />
680
+ <NavLeadingIcon node={node} active={false} expanded={s.isExpanded} />
597
681
  <span className="truncate">{s.displayLabel}</span>
598
682
  {s.expandable &&
599
683
  (s.isCollapsed ? (
@@ -617,9 +701,14 @@ function NavSubNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
617
701
  const t = useTranslation();
618
702
  const dispatch = useDispatchTarget();
619
703
  const s = useNavNodeState(node, collapsed);
704
+ if (s.hidden) return null;
620
705
 
706
+ // Kinder in ein eigenes SidebarMenuSub (<ul>) — ein Sub-Item ist ein <li>,
707
+ // verschachtelte <li> ohne <ul> dazwischen sind invalides HTML (nested
708
+ // Content-Ordner: Marketing/ > Block). Der <ul> trägt zugleich die
709
+ // Einrück-/Border-Stufe pro Tiefe.
621
710
  const deeper = s.isExpanded ? (
622
- <>
711
+ <SidebarMenuSub>
623
712
  <ProviderStatus loading={s.providerLoading} error={s.providerError} />
624
713
  {s.childNodes.map((child) => (
625
714
  <NavSubNode
@@ -629,7 +718,7 @@ function NavSubNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
629
718
  onToggle={onToggle}
630
719
  />
631
720
  ))}
632
- </>
721
+ </SidebarMenuSub>
633
722
  ) : null;
634
723
 
635
724
  const chevron = s.expandable ? (
@@ -695,7 +784,7 @@ function NavSubNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
695
784
  if (s.expandable) onToggle(node.qualifiedName);
696
785
  }}
697
786
  >
698
- <NavLeadingIcon node={node} active={false} />
787
+ <NavLeadingIcon node={node} active={false} expanded={s.isExpanded} />
699
788
  <span className="truncate">{s.displayLabel}</span>
700
789
  </button>
701
790
  </SidebarMenuSubButton>