@cosmicdrift/kumiko-renderer-web 0.65.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.
- package/package.json +6 -4
- package/src/__tests__/form-action-bar.test.tsx +50 -15
- package/src/__tests__/nav-tree.test.tsx +250 -16
- package/src/__tests__/primitives.test.tsx +9 -6
- package/src/__tests__/render-edit.test.tsx +6 -6
- package/src/__tests__/test-utils.tsx +21 -0
- package/src/__tests__/workspace-shell.test.tsx +21 -66
- package/src/app/__tests__/qualify-nav-provider-key.test.ts +27 -0
- package/src/app/client-plugin.tsx +12 -27
- package/src/app/create-app.tsx +31 -19
- package/src/app/nav-providers-context.tsx +56 -0
- package/src/index.ts +8 -0
- package/src/layout/app-layout.tsx +9 -2
- package/src/layout/default-app-shell.tsx +116 -33
- package/src/layout/nav-tree.tsx +491 -125
- package/src/layout/sidebar-brand.tsx +40 -0
- package/src/layout/sidebar-user.tsx +46 -0
- package/src/layout/sidebar.tsx +1 -1
- package/src/layout/target-resolver-stub.tsx +3 -5
- package/src/layout/workspace-shell.tsx +32 -34
- package/src/primitives/file-upload.tsx +118 -0
- package/src/primitives/index.tsx +314 -175
- package/src/styles.css +76 -50
- package/src/ui/avatar.tsx +110 -0
- package/src/ui/badge.tsx +49 -0
- package/src/ui/breadcrumb.tsx +110 -0
- package/src/ui/button.tsx +65 -0
- package/src/ui/card.tsx +93 -0
- package/src/ui/checkbox.tsx +33 -0
- package/src/ui/collapsible.tsx +34 -0
- package/src/ui/dropdown-menu.tsx +258 -0
- package/src/ui/input.tsx +22 -0
- package/src/ui/label.tsx +25 -0
- package/src/ui/select.tsx +191 -0
- package/src/ui/separator.tsx +29 -0
- package/src/ui/sheet.tsx +144 -0
- package/src/ui/sidebar.tsx +727 -0
- package/src/ui/skeleton.tsx +14 -0
- package/src/ui/table.tsx +117 -0
- package/src/ui/textarea.tsx +19 -0
- package/src/ui/tooltip.tsx +58 -0
- package/src/ui/use-mobile.ts +20 -0
- package/src/__tests__/visual-tree-integration.test.tsx +0 -314
- package/src/app/tree-providers-context.tsx +0 -68
- package/src/layout/__tests__/visual-tree.test.tsx +0 -303
- package/src/layout/tree-node-renderer.tsx +0 -386
- package/src/layout/visual-tree.tsx +0 -398
package/src/layout/nav-tree.tsx
CHANGED
|
@@ -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 {
|
|
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
|
|
121
|
-
{tree.map((node) =>
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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="
|
|
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
|
-
<
|
|
595
|
+
<SidebarMenuSubItem>
|
|
596
|
+
<SidebarMenuSubButton asChild isActive={s.active}>
|
|
213
597
|
<KumikoLink
|
|
214
|
-
to={{ ...(workspaceId !== undefined && { workspaceId }), screenId }}
|
|
215
|
-
|
|
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
|
-
{
|
|
222
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
{
|
|
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
|
-
|
|
630
|
+
<SidebarMenuSubItem>
|
|
631
|
+
<SidebarMenuSubButton asChild>
|
|
260
632
|
<button
|
|
261
633
|
type="button"
|
|
262
|
-
onClick={() =>
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
<
|
|
268
|
-
{
|
|
638
|
+
<NavLeadingIcon node={node} active={false} />
|
|
639
|
+
<span className="truncate">{s.displayLabel}</span>
|
|
269
640
|
</button>
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|