@cosmicdrift/kumiko-renderer-web 0.64.0 → 0.66.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.
Files changed (52) hide show
  1. package/package.json +6 -4
  2. package/src/__tests__/date-input.test.tsx +2 -1
  3. package/src/__tests__/form-action-bar.test.tsx +50 -15
  4. package/src/__tests__/nav-tree.test.tsx +250 -16
  5. package/src/__tests__/primitives.test.tsx +9 -6
  6. package/src/__tests__/render-edit.test.tsx +6 -6
  7. package/src/__tests__/test-utils.tsx +21 -0
  8. package/src/__tests__/toast.test.tsx +4 -3
  9. package/src/__tests__/workspace-shell.test.tsx +21 -66
  10. package/src/app/__tests__/qualify-nav-provider-key.test.ts +27 -0
  11. package/src/app/client-plugin.tsx +12 -27
  12. package/src/app/create-app.tsx +31 -19
  13. package/src/app/nav-providers-context.tsx +56 -0
  14. package/src/index.ts +8 -0
  15. package/src/layout/app-layout.tsx +9 -2
  16. package/src/layout/default-app-shell.tsx +116 -33
  17. package/src/layout/nav-tree.tsx +491 -125
  18. package/src/layout/sidebar-brand.tsx +40 -0
  19. package/src/layout/sidebar-user.tsx +46 -0
  20. package/src/layout/sidebar.tsx +1 -1
  21. package/src/layout/target-resolver-stub.tsx +3 -5
  22. package/src/layout/workspace-shell.tsx +32 -34
  23. package/src/primitives/date-field.tsx +3 -1
  24. package/src/primitives/file-upload.tsx +118 -0
  25. package/src/primitives/index.tsx +314 -175
  26. package/src/primitives/timestamp-input.tsx +3 -1
  27. package/src/primitives/toast.tsx +8 -4
  28. package/src/styles.css +76 -50
  29. package/src/ui/avatar.tsx +110 -0
  30. package/src/ui/badge.tsx +49 -0
  31. package/src/ui/breadcrumb.tsx +110 -0
  32. package/src/ui/button.tsx +65 -0
  33. package/src/ui/card.tsx +93 -0
  34. package/src/ui/checkbox.tsx +33 -0
  35. package/src/ui/collapsible.tsx +34 -0
  36. package/src/ui/dropdown-menu.tsx +258 -0
  37. package/src/ui/input.tsx +22 -0
  38. package/src/ui/label.tsx +25 -0
  39. package/src/ui/select.tsx +191 -0
  40. package/src/ui/separator.tsx +29 -0
  41. package/src/ui/sheet.tsx +144 -0
  42. package/src/ui/sidebar.tsx +727 -0
  43. package/src/ui/skeleton.tsx +14 -0
  44. package/src/ui/table.tsx +117 -0
  45. package/src/ui/textarea.tsx +19 -0
  46. package/src/ui/tooltip.tsx +58 -0
  47. package/src/ui/use-mobile.ts +20 -0
  48. package/src/__tests__/visual-tree-integration.test.tsx +0 -314
  49. package/src/app/tree-providers-context.tsx +0 -68
  50. package/src/layout/__tests__/visual-tree.test.tsx +0 -303
  51. package/src/layout/tree-node-renderer.tsx +0 -386
  52. package/src/layout/visual-tree.tsx +0 -398
@@ -11,11 +11,18 @@
11
11
  // auf/zu. State lebt lokal im NavTree (useState); Default expanded
12
12
  // für alles, Caller kann später localStorage-Persistenz drüberlegen.
13
13
 
14
+ import type { TargetRef, TreeAction, TreeNode } from "@cosmicdrift/kumiko-framework/engine";
14
15
  import type { NavDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
15
16
  import type { NavNode, NavRegistrySlice } from "@cosmicdrift/kumiko-headless";
16
17
  import { resolveNavigation } from "@cosmicdrift/kumiko-headless";
17
18
  import type { AppSchema, FeatureSchema } from "@cosmicdrift/kumiko-renderer";
18
- import { lastSegment, toAppSchema, useNav, useTranslation } from "@cosmicdrift/kumiko-renderer";
19
+ import {
20
+ lastSegment,
21
+ toAppSchema,
22
+ useLiveEvents,
23
+ useNav,
24
+ useTranslation,
25
+ } from "@cosmicdrift/kumiko-renderer";
19
26
  import {
20
27
  BarChart3,
21
28
  Bell,
@@ -33,6 +40,7 @@ import {
33
40
  LineChart,
34
41
  List,
35
42
  PiggyBank,
43
+ Plus,
36
44
  Receipt,
37
45
  Search,
38
46
  Settings,
@@ -45,9 +53,24 @@ import {
45
53
  Wallet,
46
54
  Wand2,
47
55
  } from "lucide-react";
48
- import { type ReactNode, useCallback, useMemo, useState } from "react";
56
+ import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
49
57
  import { KumikoLink } from "../app/nav";
58
+ import { useNavEntities, useNavProviders } from "../app/nav-providers-context";
50
59
  import { cn } from "../lib/cn";
60
+ import {
61
+ SidebarGroup,
62
+ SidebarGroupContent,
63
+ SidebarGroupLabel,
64
+ SidebarMenu,
65
+ SidebarMenuAction,
66
+ SidebarMenuButton,
67
+ SidebarMenuItem,
68
+ SidebarMenuSub,
69
+ SidebarMenuSubButton,
70
+ SidebarMenuSubItem,
71
+ } from "../ui/sidebar";
72
+ import { useDispatchTarget } from "./target-resolver-stub";
73
+ import { parseTargetFromSearchParams } from "./target-url";
51
74
 
52
75
  // Nav-Icon-Registry: ein Nav-Eintrag setzt `icon: "<key>"` (im r.nav-Decl),
53
76
  // der Renderer mappt den symbolischen Key auf ein lucide-Component. Unknown
@@ -117,166 +140,509 @@ export function NavTree({ schema, user, testId, allowedNavQns }: NavTreeProps):
117
140
  }, []);
118
141
 
119
142
  return (
120
- <div data-testid={testId} data-kumiko-layout="nav-tree" className="flex flex-col gap-0.5">
121
- {tree.map((node) => (
122
- <NavNodeItem
123
- key={node.qualifiedName}
124
- node={node}
125
- depth={0}
126
- collapsed={collapsed}
127
- onToggle={onToggle}
128
- />
129
- ))}
143
+ <div data-testid={testId} data-kumiko-layout="nav-tree" className="flex w-full flex-col">
144
+ {tree.map((node) =>
145
+ isPureSection(node) ? (
146
+ <NavSection
147
+ key={node.qualifiedName}
148
+ node={node}
149
+ collapsed={collapsed}
150
+ onToggle={onToggle}
151
+ />
152
+ ) : (
153
+ <SidebarMenu key={node.qualifiedName} className="px-2 py-1">
154
+ <NavMenuNode node={node} collapsed={collapsed} onToggle={onToggle} />
155
+ </SidebarMenu>
156
+ ),
157
+ )}
130
158
  </div>
131
159
  );
132
160
  }
133
161
 
134
- type NavNodeItemProps = {
162
+ // Pure section = ein Gruppen-Header ohne eigenen Screen, der nur seine
163
+ // children gruppiert. Auf Top-Level wird daraus eine SidebarGroup mit
164
+ // togglebarem GroupLabel; ein Node mit Screen (auch wenn er children hat)
165
+ // ist dagegen ein klickbarer Menu-Eintrag.
166
+ function isPureSection(node: NavNode): boolean {
167
+ return (
168
+ node.screen === undefined &&
169
+ node.target === undefined &&
170
+ node.provider !== true &&
171
+ node.createAction === undefined &&
172
+ node.children.length > 0
173
+ );
174
+ }
175
+
176
+ type NavSubProps = {
135
177
  readonly node: NavNode;
136
- readonly depth: number;
137
178
  readonly collapsed: ReadonlySet<string>;
138
179
  readonly onToggle: (qn: string) => void;
139
180
  };
140
181
 
141
- function NavNodeItem({ node, depth, collapsed, onToggle }: NavNodeItemProps): ReactNode {
142
- const nav = useNav();
182
+ // i18n-Key Konvention: enthält das label einen Punkt, durchs t() laufen
183
+ // lassen (Bundle kennt den Key → übersetzt, sonst bleibt der Key sichtbar
184
+ // = vergessene Übersetzung). Reine String-Labels bleiben unangetastet.
185
+ function useLabel(node: NavNode): string {
143
186
  const t = useTranslation();
144
- const active = node.screen !== undefined && nav.route?.screenId === lastSegment(node.screen);
187
+ return node.label.includes(".") ? t(node.label) : node.label;
188
+ }
145
189
 
190
+ // Icon-or-Dot: bekannter icon-Key → Lucide-Icon, sonst ein dezenter Dot.
191
+ // Aktiv = accent-foreground, inaktiv = gedimmt.
192
+ function NavLeadingIcon({ node, active }: { node: NavNode; active: boolean }): ReactNode {
146
193
  const NavIcon = node.icon !== undefined ? NAV_ICONS[node.icon] : undefined;
147
- const hasChildren = node.children.length > 0;
194
+ if (NavIcon !== undefined) return <NavIcon aria-hidden="true" className="shrink-0" />;
195
+ return (
196
+ <span
197
+ aria-hidden="true"
198
+ className={cn(
199
+ "inline-block size-1.5 rounded-full",
200
+ active ? "bg-sidebar-accent-foreground" : "bg-sidebar-foreground/40",
201
+ )}
202
+ />
203
+ );
204
+ }
205
+
206
+ // Stable-reference empty-list — sonst destabilisiert ein neues `[]` pro
207
+ // Render die useEffect-deps der SSE-Subscription.
208
+ const EMPTY_ENTITY_LIST: readonly string[] = [];
209
+
210
+ // Active-Target-Vergleich: featureId + action exakt, args shallow-equal
211
+ // (heute nur primitives). Gespiegelt aus dem alten TreeNodeRenderer.
212
+ function targetsEqual(a: TargetRef, b: TargetRef | undefined): boolean {
213
+ if (b === undefined) return false;
214
+ if (a.featureId !== b.featureId || a.action !== b.action) return false;
215
+ const aKeys = a.args ? Object.keys(a.args) : [];
216
+ const bKeys = b.args ? Object.keys(b.args) : [];
217
+ if (aKeys.length !== bKeys.length) return false;
218
+ for (const k of aKeys) {
219
+ if (a.args?.[k] !== b.args?.[k]) return false;
220
+ }
221
+ return true;
222
+ }
223
+
224
+ // Adapter: provider-emittierte TreeNodes → renderbare NavNodes. Synthetische
225
+ // QN aus parent-QN + idx-label (stabil über Emit-Order). Statische children
226
+ // rekursiv. ponytail: eine TreeChildrenSubscribe-Function als children wird
227
+ // als Leaf behandelt — nested-dynamic-Provider nutzt heute kein Consumer;
228
+ // upgrade-path = synthetische QN registrieren wenn einer sie emittet.
229
+ function treeNodeToNavNode(tn: TreeNode, parentQn: string, idx: number): NavNode {
230
+ const qualifiedName = `${parentQn}/${idx}-${tn.label}`;
231
+ const children = Array.isArray(tn.children)
232
+ ? tn.children.map((c, i) => treeNodeToNavNode(c, qualifiedName, i))
233
+ : [];
234
+ return {
235
+ qualifiedName,
236
+ label: tn.label,
237
+ order: idx,
238
+ children,
239
+ ...(tn.icon !== undefined && { icon: tn.icon }),
240
+ ...(tn.target !== undefined && { target: tn.target }),
241
+ ...(tn.actions !== undefined && { actions: tn.actions }),
242
+ ...(tn.createAction !== undefined && { createAction: tn.createAction }),
243
+ };
244
+ }
245
+
246
+ // Lazy provider-children + SSE-Live-Refresh für einen `provider: true`-Knoten.
247
+ // Subscribe läuft erst wenn der Knoten ausgeklappt ist (enabled); die
248
+ // treeEntities-Liste (per QN) re-fired den Provider bei Entity-Events → neu
249
+ // erstellte Knoten erscheinen live. Logik gespiegelt aus VisualTree.ProviderBranch.
250
+ function useNavProviderChildren(
251
+ qn: string,
252
+ enabled: boolean,
253
+ ): { readonly nodes: readonly NavNode[] | null; readonly error: string | null } {
254
+ const providers = useNavProviders();
255
+ const entitiesMap = useNavEntities();
256
+ const subscribeLive = useLiveEvents();
257
+ const provider = providers.get(qn);
258
+ const entities = entitiesMap.get(qn) ?? EMPTY_ENTITY_LIST;
259
+ const [raw, setRaw] = useState<readonly TreeNode[] | null>(null);
260
+ const [error, setError] = useState<string | null>(null);
261
+ const [attempt, setAttempt] = useState(0);
262
+
263
+ useEffect(() => {
264
+ if (!enabled || entities.length === 0) return;
265
+ const unsubs = entities.map((e) => subscribeLive(e, () => setAttempt((n) => n + 1)));
266
+ return () => {
267
+ for (const u of unsubs) u();
268
+ };
269
+ }, [enabled, entities, subscribeLive]);
270
+
271
+ // `attempt` triggert SSE-Refresh + erneutes Subscribe — Biome sieht es
272
+ // nicht im body, semantisch ist es der re-fire-Trigger.
273
+ // biome-ignore lint/correctness/useExhaustiveDependencies: attempt = re-fire trigger
274
+ useEffect(() => {
275
+ if (!enabled) {
276
+ setRaw(null);
277
+ setError(null);
278
+ return;
279
+ }
280
+ if (provider === undefined) {
281
+ setError("Kein nav-provider registriert.");
282
+ return;
283
+ }
284
+ setError(null);
285
+ setRaw(null);
286
+ try {
287
+ const subscribe = provider();
288
+ try {
289
+ return subscribe(setRaw, (e) => setError(e instanceof Error ? e.message : String(e)));
290
+ } catch (e) {
291
+ setError(e instanceof Error ? e.message : "Subscribe fehlgeschlagen.");
292
+ }
293
+ } catch (e) {
294
+ setError(e instanceof Error ? e.message : "Provider-Init fehlgeschlagen.");
295
+ }
296
+ return undefined;
297
+ }, [enabled, provider, attempt]);
298
+
299
+ const nodes = useMemo(
300
+ () => (raw === null ? null : raw.map((tn, i) => treeNodeToNavNode(tn, qn, i))),
301
+ [raw, qn],
302
+ );
303
+ return { nodes, error };
304
+ }
305
+
306
+ type NavNodeState = {
307
+ readonly displayLabel: string;
308
+ readonly isCollapsed: boolean;
309
+ readonly expandable: boolean;
310
+ readonly isExpanded: boolean;
311
+ readonly active: boolean;
312
+ readonly childNodes: readonly NavNode[];
313
+ readonly providerLoading: boolean;
314
+ readonly providerError: string | null;
315
+ readonly workspaceId: string | undefined;
316
+ };
317
+
318
+ // Geteilte Knoten-Logik für Top-Level- (NavMenuNode) und Sub-Knoten
319
+ // (NavSubNode): Label, Expand/Collapse, active-State (screen ODER target),
320
+ // und die lazy Provider-Children. Die zwei Renderer unterscheiden sich nur
321
+ // in den shadcn-Primitives, nicht in dieser Logik.
322
+ function useNavNodeState(node: NavNode, collapsed: ReadonlySet<string>): NavNodeState {
323
+ const nav = useNav();
324
+ const displayLabel = useLabel(node);
148
325
  const isCollapsed = collapsed.has(node.qualifiedName);
149
- const indent = { paddingLeft: `${0.5 + depth * 1}rem` };
150
-
151
- // i18n-Key Konvention: wenn label einen Punkt enthält, durchs t()
152
- // laufen lassen — wenn Bundle den Key kennt, wird übersetzt, sonst
153
- // bleibt der key selbst stehen (und der App-Dev sieht dass er eine
154
- // Übersetzung vergessen hat). Reine String-Labels ("Dashboard")
155
- // bleiben unangetastet durch das Mapping.
156
- const displayLabel = node.label.includes(".") ? t(node.label) : node.label;
157
-
158
- // In workspace mode the URL is /<ws>/<screen> — sidebar links must
159
- // carry the active workspaceId, otherwise navigate({ screenId }) would
160
- // produce /<screen> and the parser would interpret <screen> as a
161
- // workspace id. Pulled from useNav().route so the link tracks switches.
162
- const workspaceId = nav.route?.workspaceId;
163
-
164
- // Chevron-Icon — nur wenn Node children hat. Rechts neben dem Label
165
- // angeordnet; ein Click auf den Chevron alleine toggled die Section
166
- // ohne zu navigieren (stopPropagation auf dem KumikoLink-Wrapper).
167
- const chevron = hasChildren ? (
326
+ const expandable = node.children.length > 0 || node.provider === true;
327
+ const isExpanded = expandable && !isCollapsed;
328
+ const { nodes: providerChildren, error: providerError } = useNavProviderChildren(
329
+ node.qualifiedName,
330
+ node.provider === true && isExpanded,
331
+ );
332
+ const activeTarget = useMemo(
333
+ () => parseTargetFromSearchParams(nav.searchParams),
334
+ [nav.searchParams],
335
+ );
336
+ const screenActive =
337
+ node.screen !== undefined && nav.route?.screenId === lastSegment(node.screen);
338
+ const targetActive = node.target !== undefined && targetsEqual(node.target, activeTarget);
339
+ const childNodes = node.provider === true ? (providerChildren ?? []) : node.children;
340
+ return {
341
+ displayLabel,
342
+ isCollapsed,
343
+ expandable,
344
+ isExpanded,
345
+ active: screenActive || targetActive,
346
+ childNodes,
347
+ providerLoading: node.provider === true && isExpanded && providerChildren === null,
348
+ providerError,
349
+ workspaceId: nav.route?.workspaceId,
350
+ };
351
+ }
352
+
353
+ // Action-Icon-Lookup: bekannter NAV_ICONS-Key → Lucide, sonst der rohe
354
+ // String als Text (Provider-Konvention, kein Boot-Fail bei unknown).
355
+ function ActionGlyph({ icon }: { readonly icon: string }): ReactNode {
356
+ const Icon = NAV_ICONS[icon];
357
+ if (Icon !== undefined) return <Icon aria-hidden className="size-3.5" />;
358
+ return (
359
+ <span aria-hidden className="text-xs">
360
+ {icon}
361
+ </span>
362
+ );
363
+ }
364
+
365
+ // Hover-Actions + „+"-Affordance, absolut rechts (links vom Chevron via
366
+ // right-7). createAction ist persistent (User-sichtbares „+"), übrige
367
+ // Actions erst bei Hover. stopPropagation, damit der Row-Click (toggle/
368
+ // dispatch) nicht zusätzlich feuert.
369
+ function NodeActions({ node }: { node: NavNode }): ReactNode {
370
+ const dispatch = useDispatchTarget();
371
+ const t = useTranslation();
372
+ const create = node.createAction;
373
+ const actions = node.actions ?? [];
374
+ if (create === undefined && actions.length === 0) return null;
375
+ const label = (s: string): string => (s.includes(".") ? t(s) : s);
376
+ const btn =
377
+ "flex size-5 items-center justify-center rounded-md text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground";
378
+ const hover =
379
+ "opacity-0 group-hover/menu-item:opacity-100 group-hover/menu-sub-item:opacity-100 group-focus-within/menu-item:opacity-100";
380
+ return (
381
+ <div className="absolute top-1 right-7 flex items-center gap-0.5">
382
+ {create !== undefined && (
383
+ <button
384
+ type="button"
385
+ aria-label={label(create.label)}
386
+ className={btn}
387
+ onClick={(e) => {
388
+ e.stopPropagation();
389
+ dispatch(create.target);
390
+ }}
391
+ >
392
+ <Plus className="size-3.5" />
393
+ </button>
394
+ )}
395
+ {actions.map((a: TreeAction) => (
396
+ <button
397
+ key={a.label}
398
+ type="button"
399
+ aria-label={label(a.label)}
400
+ className={cn(btn, hover)}
401
+ onClick={(e) => {
402
+ e.stopPropagation();
403
+ dispatch(a.target);
404
+ }}
405
+ >
406
+ <ActionGlyph icon={a.icon} />
407
+ </button>
408
+ ))}
409
+ </div>
410
+ );
411
+ }
412
+
413
+ // Status-Zeilen innerhalb einer Provider-Sub-Liste (lazy-loading / error).
414
+ function ProviderStatus({
415
+ loading,
416
+ error,
417
+ }: {
418
+ readonly loading: boolean;
419
+ readonly error: string | null;
420
+ }): ReactNode {
421
+ if (error !== null) {
422
+ return <li className="px-2 py-1 text-xs text-destructive">{error}</li>;
423
+ }
424
+ if (loading) {
425
+ return <li className="px-2 py-1 text-xs text-sidebar-foreground/60 italic">Lädt …</li>;
426
+ }
427
+ return null;
428
+ }
429
+
430
+ // Top-Level-Section: SidebarGroup mit STATISCHEM Label (sidebar-07-Muster —
431
+ // "Platform"/"Projects" sind feste Überschriften, keine Toggles). Collapse
432
+ // gehört auf Items MIT children, nicht auf die Section selbst.
433
+ function NavSection({ node, collapsed, onToggle }: NavSubProps): ReactNode {
434
+ const displayLabel = useLabel(node);
435
+ return (
436
+ <SidebarGroup className="py-1">
437
+ <SidebarGroupLabel>{displayLabel}</SidebarGroupLabel>
438
+ <SidebarGroupContent>
439
+ <SidebarMenu>
440
+ {node.children.map((child) => (
441
+ <NavMenuNode
442
+ key={child.qualifiedName}
443
+ node={child}
444
+ collapsed={collapsed}
445
+ onToggle={onToggle}
446
+ />
447
+ ))}
448
+ </SidebarMenu>
449
+ </SidebarGroupContent>
450
+ </SidebarGroup>
451
+ );
452
+ }
453
+
454
+ // Ein Menu-Eintrag (Top-Level-Item oder Section-Child). Drei Klick-Modi am
455
+ // EINEN Knoten: `screen` → Route-Link (KumikoLink), `target` → EditorPanel-
456
+ // Dispatch (useDispatchTarget), keins → Container/Provider-Knoten der nur
457
+ // auf-/zuklappt. Provider-Knoten laden ihre Children lazy + SSE-live. Der
458
+ // Collapse-Chevron sitzt als SidebarMenuAction daneben (separater Button,
459
+ // sonst <button> im <a> = invalides HTML); createAction/actions als
460
+ // absolute NodeActions links davon.
461
+ function NavMenuNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
462
+ const t = useTranslation();
463
+ const dispatch = useDispatchTarget();
464
+ const s = useNavNodeState(node, collapsed);
465
+
466
+ const sub = s.isExpanded ? (
467
+ <SidebarMenuSub>
468
+ <ProviderStatus loading={s.providerLoading} error={s.providerError} />
469
+ {s.childNodes.map((child) => (
470
+ <NavSubNode
471
+ key={child.qualifiedName}
472
+ node={child}
473
+ collapsed={collapsed}
474
+ onToggle={onToggle}
475
+ />
476
+ ))}
477
+ </SidebarMenuSub>
478
+ ) : null;
479
+
480
+ const chevron = s.expandable ? (
481
+ <SidebarMenuAction
482
+ aria-label={t(s.isCollapsed ? "kumiko.nav.expand" : "kumiko.nav.collapse")}
483
+ aria-expanded={!s.isCollapsed}
484
+ onClick={() => onToggle(node.qualifiedName)}
485
+ >
486
+ {s.isCollapsed ? <ChevronRight /> : <ChevronDown />}
487
+ </SidebarMenuAction>
488
+ ) : null;
489
+
490
+ if (node.screen !== undefined) {
491
+ const screenId = lastSegment(node.screen);
492
+ return (
493
+ <SidebarMenuItem>
494
+ {/* tooltip = Label, das shadcn nur im collapsed-Icon-State einblendet. */}
495
+ <SidebarMenuButton asChild isActive={s.active} tooltip={s.displayLabel}>
496
+ <KumikoLink
497
+ to={{ ...(s.workspaceId !== undefined && { workspaceId: s.workspaceId }), screenId }}
498
+ {...(s.active && { "aria-current": "page" })}
499
+ >
500
+ <NavLeadingIcon node={node} active={s.active} />
501
+ <span className="truncate">{s.displayLabel}</span>
502
+ </KumikoLink>
503
+ </SidebarMenuButton>
504
+ <NodeActions node={node} />
505
+ {chevron}
506
+ {sub}
507
+ </SidebarMenuItem>
508
+ );
509
+ }
510
+
511
+ if (node.target !== undefined) {
512
+ const target = node.target;
513
+ return (
514
+ <SidebarMenuItem>
515
+ <SidebarMenuButton
516
+ isActive={s.active}
517
+ tooltip={s.displayLabel}
518
+ onClick={() => dispatch(target)}
519
+ >
520
+ <NavLeadingIcon node={node} active={s.active} />
521
+ <span className="truncate">{s.displayLabel}</span>
522
+ </SidebarMenuButton>
523
+ <NodeActions node={node} />
524
+ {chevron}
525
+ {sub}
526
+ </SidebarMenuItem>
527
+ );
528
+ }
529
+
530
+ return (
531
+ <SidebarMenuItem>
532
+ <SidebarMenuButton
533
+ onClick={() => {
534
+ if (s.expandable) onToggle(node.qualifiedName);
535
+ }}
536
+ {...(s.expandable && { "aria-expanded": !s.isCollapsed })}
537
+ >
538
+ <NavLeadingIcon node={node} active={false} />
539
+ <span className="truncate">{s.displayLabel}</span>
540
+ {s.expandable &&
541
+ (s.isCollapsed ? (
542
+ <ChevronRight className="ml-auto" />
543
+ ) : (
544
+ <ChevronDown className="ml-auto" />
545
+ ))}
546
+ </SidebarMenuButton>
547
+ <NodeActions node={node} />
548
+ {sub}
549
+ </SidebarMenuItem>
550
+ );
551
+ }
552
+
553
+ // Verschachtelte Einträge (innerhalb SidebarMenuSub). Gleiche drei Klick-Modi
554
+ // wie NavMenuNode, nur mit den Sub-Primitives. Folder-/Container-Knoten (kein
555
+ // screen/target) klappen auf — Provider-Children + tiefere Statics rekursiv.
556
+ // Non-Link-Klickziele brauchen asChild+<button> (SidebarMenuSubButton ist ein
557
+ // <a>); der Chevron sitzt als eigener absoluter Button (kein Button-im-<a>).
558
+ function NavSubNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
559
+ const t = useTranslation();
560
+ const dispatch = useDispatchTarget();
561
+ const s = useNavNodeState(node, collapsed);
562
+
563
+ const deeper = s.isExpanded ? (
564
+ <>
565
+ <ProviderStatus loading={s.providerLoading} error={s.providerError} />
566
+ {s.childNodes.map((child) => (
567
+ <NavSubNode
568
+ key={child.qualifiedName}
569
+ node={child}
570
+ collapsed={collapsed}
571
+ onToggle={onToggle}
572
+ />
573
+ ))}
574
+ </>
575
+ ) : null;
576
+
577
+ const chevron = s.expandable ? (
168
578
  <button
169
579
  type="button"
170
- aria-label={t(isCollapsed ? "kumiko.nav.expand" : "kumiko.nav.collapse")}
171
- aria-expanded={!isCollapsed}
580
+ aria-label={t(s.isCollapsed ? "kumiko.nav.expand" : "kumiko.nav.collapse")}
581
+ aria-expanded={!s.isCollapsed}
172
582
  onClick={(e) => {
173
- e.preventDefault();
174
583
  e.stopPropagation();
175
584
  onToggle(node.qualifiedName);
176
585
  }}
177
- className="ml-auto flex size-4 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-accent-foreground"
586
+ className="absolute top-1 right-1 flex size-5 items-center justify-center rounded-md text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
178
587
  >
179
- {isCollapsed ? <ChevronRight className="size-3" /> : <ChevronDown className="size-3" />}
588
+ {s.isCollapsed ? <ChevronRight className="size-3.5" /> : <ChevronDown className="size-3.5" />}
180
589
  </button>
181
590
  ) : null;
182
591
 
183
- const children =
184
- hasChildren && !isCollapsed
185
- ? node.children.map((child) => (
186
- <NavNodeItem
187
- key={child.qualifiedName}
188
- node={child}
189
- depth={depth + 1}
190
- collapsed={collapsed}
191
- onToggle={onToggle}
192
- />
193
- ))
194
- : null;
195
-
196
- // Variante 1: Node hat einen Screen → KumikoLink. Wenn das Item auch
197
- // children hat, sitzt der Chevron als Geschwister rechts NEBEN dem
198
- // Link (nicht IM Link) — sonst würde ein <button> im <a> für invalid
199
- // HTML sorgen. Wrapper-Div bekommt das hover/active-Styling, Link
200
- // selbst ist nur die Klick-Fläche.
201
592
  if (node.screen !== undefined) {
202
593
  const screenId = lastSegment(node.screen);
203
- const rowClass = cn(
204
- "flex h-7 items-center gap-2 rounded-md text-[13px] transition-colors",
205
- "hover:bg-accent/60 hover:text-foreground",
206
- active
207
- ? "bg-accent text-foreground font-medium"
208
- : "text-muted-foreground hover:text-foreground",
209
- );
210
594
  return (
211
- <>
212
- <div style={indent} className={rowClass}>
595
+ <SidebarMenuSubItem>
596
+ <SidebarMenuSubButton asChild isActive={s.active}>
213
597
  <KumikoLink
214
- to={{ ...(workspaceId !== undefined && { workspaceId }), screenId }}
215
- className={cn(
216
- "flex flex-1 min-w-0 items-center gap-2 px-2 h-full",
217
- hasChildren && "pr-0",
218
- )}
219
- {...(active && { "aria-current": "page" })}
598
+ to={{ ...(s.workspaceId !== undefined && { workspaceId: s.workspaceId }), screenId }}
599
+ {...(s.active && { "aria-current": "page" })}
220
600
  >
221
- {NavIcon !== undefined ? (
222
- <NavIcon
223
- aria-hidden="true"
224
- className={cn(
225
- "size-4 shrink-0",
226
- active ? "text-foreground" : "text-muted-foreground",
227
- )}
228
- />
229
- ) : (
230
- <span
231
- aria-hidden="true"
232
- className={cn(
233
- "inline-block size-1.5 rounded-full",
234
- active ? "bg-accent-foreground" : "bg-muted-foreground/40",
235
- )}
236
- />
237
- )}
238
- <span className="truncate">{displayLabel}</span>
601
+ <NavLeadingIcon node={node} active={s.active} />
602
+ <span className="truncate">{s.displayLabel}</span>
239
603
  </KumikoLink>
240
- {chevron !== null && <div className="pr-2">{chevron}</div>}
241
- </div>
242
- {children}
243
- </>
604
+ </SidebarMenuSubButton>
605
+ <NodeActions node={node} />
606
+ {chevron}
607
+ {deeper}
608
+ </SidebarMenuSubItem>
609
+ );
610
+ }
611
+
612
+ if (node.target !== undefined) {
613
+ const target = node.target;
614
+ return (
615
+ <SidebarMenuSubItem>
616
+ <SidebarMenuSubButton asChild isActive={s.active}>
617
+ <button type="button" onClick={() => dispatch(target)}>
618
+ <NavLeadingIcon node={node} active={s.active} />
619
+ <span className="truncate">{s.displayLabel}</span>
620
+ </button>
621
+ </SidebarMenuSubButton>
622
+ <NodeActions node={node} />
623
+ {chevron}
624
+ {deeper}
625
+ </SidebarMenuSubItem>
244
626
  );
245
627
  }
246
628
 
247
- // Variante 2: Node ist ein Section-Header (kein Screen). Mit children
248
- // wird das Label zum Toggle-Button — Click klappt die ganze Section
249
- // auf/zu. Chevron rendert hier als Span (kein nested button), weil
250
- // der äußere Button schon das Toggle-Target ist. Ohne children
251
- // rendert ein dezenter Section-Header (uppercase).
252
- const chevronSpan = hasChildren ? (
253
- <span aria-hidden="true" className="ml-auto flex size-4 items-center justify-center">
254
- {isCollapsed ? <ChevronRight className="size-3" /> : <ChevronDown className="size-3" />}
255
- </span>
256
- ) : null;
257
629
  return (
258
- <>
259
- {hasChildren ? (
630
+ <SidebarMenuSubItem>
631
+ <SidebarMenuSubButton asChild>
260
632
  <button
261
633
  type="button"
262
- onClick={() => onToggle(node.qualifiedName)}
263
- aria-expanded={!isCollapsed}
264
- style={indent}
265
- className="flex h-7 items-center gap-2 rounded-md px-2 pt-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 hover:text-foreground transition-colors text-left"
634
+ onClick={() => {
635
+ if (s.expandable) onToggle(node.qualifiedName);
636
+ }}
266
637
  >
267
- <span className="truncate">{displayLabel}</span>
268
- {chevronSpan}
638
+ <NavLeadingIcon node={node} active={false} />
639
+ <span className="truncate">{s.displayLabel}</span>
269
640
  </button>
270
- ) : (
271
- <div
272
- style={indent}
273
- className="px-2 pt-3 pb-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70"
274
- >
275
- {displayLabel}
276
- </div>
277
- )}
278
- {children}
279
- </>
641
+ </SidebarMenuSubButton>
642
+ <NodeActions node={node} />
643
+ {chevron}
644
+ {deeper}
645
+ </SidebarMenuSubItem>
280
646
  );
281
647
  }
282
648