@cosmicdrift/kumiko-renderer-web 0.2.3 → 0.4.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.
@@ -0,0 +1,398 @@
1
+ // @runtime client
2
+ // VisualTree — Top-Level-Component für `r.workspace({ navigation: "tree" })`-
3
+ // Workspaces. Ersetzt VisualTreeStub aus Phase 0 Schicht 3.
4
+ //
5
+ // **Pflichten** (visual-tree.md V.1.1-A):
6
+ // 1. Provider-Iteration via `useTreeProviders()`-Hook
7
+ // 2. Subscribe-Wiring pro Provider mit unsubscribe-Cleanup beim Unmount
8
+ // 3. Top-Level-Reihenfolge alphabetisch nach featureName
9
+ // 4. Children-Lazy-Load (TreeNodeRenderer subscribed wenn Knoten ausgeklappt)
10
+ // 5. localStorage-Persistenz für expanded-Set pro Workspace
11
+ //
12
+ // **Empty-State**: keine Provider registriert → Hint statt leerem
13
+ // `<aside>` (Memory `[Sicherheit > Convenience]`: explicit empty statt
14
+ // silent fallback). Apps die navigation:"tree" deklarieren aber keinen
15
+ // clientFeatures.treeProvider liefern, kriegen eine sichtbare Diagnose.
16
+ //
17
+ // **Tenant-Source**: Provider sind session-bound; Backend liest tenantId
18
+ // aus session bei jedem fetch/dispatch. V.1.1 hatte ein TreeContext-Arg
19
+ // mit hardcoded pinned tenantId, das vom einzigen Consumer (text-content)
20
+ // ignoriert wurde. SR2-Rip 2026-05-18: Dead-API entfernt. Tenant-aware
21
+ // Provider re-introduce wenn realer Bedarf (siehe tree-node.ts comment).
22
+ //
23
+ // Siehe visual-tree.md V.1.1-A.
24
+
25
+ import type { TreeChildrenSubscribe, TreeNode } from "@cosmicdrift/kumiko-framework/engine";
26
+ import { useLiveEvents } from "@cosmicdrift/kumiko-renderer";
27
+ import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
28
+ import { useTreeEntities, useTreeProviders } from "../app/tree-providers-context";
29
+ import { TreeNodeRenderer } from "./tree-node-renderer";
30
+
31
+ const EXPANDED_STORAGE_PREFIX = "kumiko:visual-tree:expanded:";
32
+
33
+ // Stable-reference empty-list — vermeidet useEffect-deps-Trigger durch
34
+ // jedes Render (sonst würde `[].length === 0` short-circuit doch der
35
+ // Identity-Vergleich subscribeLive-effect destabilisieren).
36
+ const EMPTY_ENTITY_LIST: readonly string[] = [];
37
+
38
+ export type VisualTreeProps = {
39
+ /** Workspace-ID des aktiven `navigation:"tree"`-Workspaces. Wird als
40
+ * Schlüssel für localStorage-Persistenz verwendet (separates
41
+ * Expand-State pro Workspace, weil ein User mehrere Visual-Workspaces
42
+ * haben kann). */
43
+ readonly workspaceId: string;
44
+ };
45
+
46
+ export function VisualTree({ workspaceId }: VisualTreeProps): ReactNode {
47
+ const providers = useTreeProviders();
48
+ const treeEntities = useTreeEntities();
49
+ const sortedProviders = useMemo(
50
+ () => [...providers.entries()].sort(([a], [b]) => a.localeCompare(b)),
51
+ [providers],
52
+ );
53
+
54
+ const [expanded, setExpanded] = useState<ReadonlySet<string>>(() => loadExpanded(workspaceId));
55
+
56
+ // V.1.6c Roving-tabindex: nur ein treeitem hat tabIndex=0, Tab cyclt
57
+ // damit aus dem Tree heraus statt durch alle Items. focusedPath
58
+ // tracked welches item current-focused ist; Arrow-Keys updaten es via
59
+ // native onFocus-event (siehe TreeNodeRenderer).
60
+ const [focusedPath, setFocusedPath] = useState<string | undefined>(undefined);
61
+ const asideRef = useRef<HTMLElement | null>(null);
62
+
63
+ // Beim ersten Render (oder content-Change) ist focusedPath
64
+ // undefined → ALLE Rows hätten tabIndex=-1 und Tab könnte Tree nicht
65
+ // betreten. Post-mount setzen wir das erste sichtbare treeitem als
66
+ // initial focus. Plus: wenn focusedPath einen Pfad zeigt der nicht
67
+ // mehr existiert (Tree-Refresh), fall back to first.
68
+ useEffect(() => {
69
+ if (asideRef.current === null) return;
70
+ const items = asideRef.current.querySelectorAll<HTMLElement>('[role="treeitem"]');
71
+ if (items.length === 0) return;
72
+ if (focusedPath === undefined) {
73
+ const firstPath = items[0]?.dataset["kumikoTreePath"];
74
+ if (firstPath !== undefined) setFocusedPath(firstPath);
75
+ return;
76
+ }
77
+ const stillExists = Array.from(items).some(
78
+ (el) => el.dataset["kumikoTreePath"] === focusedPath,
79
+ );
80
+ if (!stillExists) {
81
+ const firstPath = items[0]?.dataset["kumikoTreePath"];
82
+ if (firstPath !== undefined) setFocusedPath(firstPath);
83
+ }
84
+ });
85
+
86
+ // localStorage-Persistenz: jeder Toggle persistiert sofort. Workspace-
87
+ // Switch lädt anderen Set neu (siehe 2nd useEffect).
88
+ useEffect(() => {
89
+ saveExpanded(workspaceId, expanded);
90
+ }, [workspaceId, expanded]);
91
+
92
+ // Workspace-Switch: expanded-Set neu laden (User hat anderen Tree-
93
+ // Workspace ausgewählt, dort gilt anderer Set).
94
+ useEffect(() => {
95
+ setExpanded(loadExpanded(workspaceId));
96
+ }, [workspaceId]);
97
+
98
+ const handleToggle = (path: string): void => {
99
+ setExpanded((prev) => {
100
+ const next = new Set(prev);
101
+ if (next.has(path)) {
102
+ next.delete(path);
103
+ } else {
104
+ next.add(path);
105
+ }
106
+ return next;
107
+ });
108
+ };
109
+
110
+ if (sortedProviders.length === 0) {
111
+ return <EmptyState />;
112
+ }
113
+
114
+ // V.1.5a ARIA-Tree-Keyboard-Nav: arrow-keys navigieren zwischen
115
+ // sichtbaren treeitems via DOM-query (Source of Truth = was im DOM
116
+ // sichtbar ist, inkl. expand/collapse-State). Tab kommt aus dem Tree
117
+ // heraus (kein Trap); innerhalb wird Arrow-Key erwartet.
118
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLElement>): void => {
119
+ const target = e.target as HTMLElement;
120
+ if (target.getAttribute("role") !== "treeitem") return;
121
+
122
+ const items = Array.from(e.currentTarget.querySelectorAll<HTMLElement>('[role="treeitem"]'));
123
+ const idx = items.indexOf(target);
124
+ if (idx < 0) return;
125
+ const path = target.dataset["kumikoTreePath"];
126
+ const hasChildren = target.dataset["kumikoTreeHasChildren"] === "true";
127
+ const isExpanded = target.getAttribute("aria-expanded") === "true";
128
+
129
+ switch (e.key) {
130
+ case "ArrowDown": {
131
+ if (idx < items.length - 1) {
132
+ e.preventDefault();
133
+ items[idx + 1]?.focus();
134
+ }
135
+ break;
136
+ }
137
+ case "ArrowUp": {
138
+ if (idx > 0) {
139
+ e.preventDefault();
140
+ items[idx - 1]?.focus();
141
+ }
142
+ break;
143
+ }
144
+ case "ArrowRight": {
145
+ if (hasChildren && !isExpanded && path !== undefined) {
146
+ e.preventDefault();
147
+ handleToggle(path);
148
+ } else if (hasChildren && isExpanded && idx < items.length - 1) {
149
+ // Already expanded → move to first child (next visible item
150
+ // ist by DOM-order der erste child).
151
+ e.preventDefault();
152
+ items[idx + 1]?.focus();
153
+ }
154
+ break;
155
+ }
156
+ case "ArrowLeft": {
157
+ if (hasChildren && isExpanded && path !== undefined) {
158
+ e.preventDefault();
159
+ handleToggle(path);
160
+ }
161
+ // V.1.5a: kein parent-jump (würde flat-list-traversal brauchen,
162
+ // ARIA-Tree-Pattern would expect that — geht V.1.5b mit roving-
163
+ // tabindex). Aktuell ArrowLeft auf collapsed-item: no-op.
164
+ break;
165
+ }
166
+ case "Home": {
167
+ e.preventDefault();
168
+ items[0]?.focus();
169
+ break;
170
+ }
171
+ case "End": {
172
+ e.preventDefault();
173
+ items[items.length - 1]?.focus();
174
+ break;
175
+ }
176
+ }
177
+ };
178
+
179
+ return (
180
+ <aside
181
+ ref={asideRef}
182
+ aria-label="Visual Tree"
183
+ data-kumiko-layout="visual-tree"
184
+ className="flex flex-col text-sm overflow-y-auto"
185
+ // biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: ARIA-tree pattern requires role=tree on container; <aside> is the right semantic outer element for a sidebar
186
+ role="tree"
187
+ onKeyDown={handleKeyDown}
188
+ onFocus={(e) => {
189
+ // Native focus auf einem treeitem (via Mouse-Click oder Arrow-
190
+ // Key's focus()) → setze focusedPath. Bubble-up vom child-row
191
+ // landet hier, e.target ist das treeitem.
192
+ const target = e.target as HTMLElement;
193
+ if (target.getAttribute("role") !== "treeitem") return;
194
+ const path = target.dataset["kumikoTreePath"];
195
+ if (path !== undefined && path !== focusedPath) setFocusedPath(path);
196
+ }}
197
+ >
198
+ {sortedProviders.map(([featureName, provider]) => (
199
+ <ProviderBranch
200
+ key={featureName}
201
+ featureName={featureName}
202
+ provider={provider}
203
+ entities={treeEntities.get(featureName) ?? EMPTY_ENTITY_LIST}
204
+ expanded={expanded}
205
+ onToggle={handleToggle}
206
+ focusedPath={focusedPath}
207
+ />
208
+ ))}
209
+ </aside>
210
+ );
211
+ }
212
+
213
+ // ProviderBranch — eine Sub-Section pro registrertem TreeProvider.
214
+ // Jeder Provider liefert eine readonly TreeNode[] über Subscribe; jeder
215
+ // Top-Level-Knoten wird via TreeNodeRenderer gerendert.
216
+ //
217
+ // **V.1.4 Subscribe-Error-Handling**: drei Error-Surfaces sind abgedeckt:
218
+ // 1. provider() throws synchron → try/catch im useEffect setzt error
219
+ // 2. subscribe(emit) throws synchron → same try/catch
220
+ // 3. Provider-interner SSE/fetch fail't async → Provider muss
221
+ // `emit({ error: ... })` rufen statt empty-emit. Heutige Convention:
222
+ // Provider können einen Marker-TreeNode mit state="error" emitten
223
+ // (siehe text-content client-plugin: catch + emit([])). Recovery-
224
+ // Pfad ist Retry-Button im error-banner, der `attempt` increments
225
+ // → useEffect re-fires (deps haben attempt drin) → provider neu
226
+ // aufgerufen.
227
+ function ProviderBranch({
228
+ featureName,
229
+ provider,
230
+ entities,
231
+ expanded,
232
+ onToggle,
233
+ focusedPath,
234
+ }: {
235
+ readonly featureName: string;
236
+ readonly provider: TreeChildrenSubscribe;
237
+ readonly entities: readonly string[];
238
+ readonly expanded: ReadonlySet<string>;
239
+ readonly onToggle: (path: string) => void;
240
+ readonly focusedPath: string | undefined;
241
+ }): ReactNode {
242
+ // null = noch nicht emitted (initial-Loading). Ein Provider der
243
+ // niemals emittet bleibt damit sichtbar als „lädt …" und nicht
244
+ // unsichtbar — Memory `[Sicherheit > Convenience]`.
245
+ const [nodes, setNodes] = useState<readonly TreeNode[] | null>(null);
246
+ const [error, setError] = useState<string | null>(null);
247
+ const [attempt, setAttempt] = useState(0);
248
+
249
+ // V.1.5b SSE-Tree-Refresh: subscribe live-events für die gelisteten
250
+ // entities, increment attempt → useEffect re-fires → provider re-mountet.
251
+ // Gleiche Mechanik wie der retry-button, daher single trigger.
252
+ const subscribeLive = useLiveEvents();
253
+ useEffect(() => {
254
+ if (entities.length === 0) return;
255
+ const unsubs = entities.map((entityName) =>
256
+ subscribeLive(entityName, () => setAttempt((n) => n + 1)),
257
+ );
258
+ return () => {
259
+ for (const u of unsubs) u();
260
+ };
261
+ }, [entities, subscribeLive]);
262
+
263
+ // `attempt` ist absichtlich in den deps: Retry-Button increments
264
+ // attempt → useEffect re-fires → provider neu aufgerufen. Biome's
265
+ // static-analysis sieht attempt nicht im body und meldet es als
266
+ // unnecessary — semantisch ist es der Trigger.
267
+ // biome-ignore lint/correctness/useExhaustiveDependencies: attempt triggers retry
268
+ useEffect(() => {
269
+ setError(null);
270
+ setNodes(null);
271
+ try {
272
+ const subscribe = provider();
273
+ try {
274
+ const unsubscribe = subscribe(setNodes, (e) =>
275
+ setError(e instanceof Error ? e.message : String(e)),
276
+ );
277
+ return unsubscribe;
278
+ } catch (e) {
279
+ setError(e instanceof Error ? e.message : "Subscribe fehlgeschlagen.");
280
+ }
281
+ } catch (e) {
282
+ setError(e instanceof Error ? e.message : "Provider-Init fehlgeschlagen.");
283
+ }
284
+ return undefined;
285
+ }, [provider, attempt]);
286
+
287
+ if (error !== null) {
288
+ return (
289
+ <div
290
+ data-kumiko-tree-branch={featureName}
291
+ data-kumiko-tree-state="error"
292
+ className="flex items-center gap-2 px-2 py-1 text-xs text-destructive"
293
+ >
294
+ <span className="flex-1">
295
+ {featureName}: {error}
296
+ </span>
297
+ <button
298
+ type="button"
299
+ onClick={() => setAttempt((n) => n + 1)}
300
+ className="rounded border border-destructive/40 px-2 py-0.5 hover:bg-destructive/10"
301
+ >
302
+ Neu laden
303
+ </button>
304
+ </div>
305
+ );
306
+ }
307
+
308
+ if (nodes === null) {
309
+ return (
310
+ <div
311
+ data-kumiko-tree-branch={featureName}
312
+ data-kumiko-tree-state="loading"
313
+ className="text-xs text-muted-foreground italic px-2 py-1"
314
+ >
315
+ {featureName}: lädt …
316
+ </div>
317
+ );
318
+ }
319
+
320
+ return (
321
+ <div data-kumiko-tree-branch={featureName}>
322
+ {nodes.map((node, idx) => {
323
+ // Selbe idx-Disambiguator-Logik wie TreeNodeRenderer.ChildrenView.
324
+ const nodePath = `${featureName}/${idx}-${node.label}`;
325
+ return (
326
+ <TreeNodeRenderer
327
+ key={nodePath}
328
+ node={node}
329
+ path={nodePath}
330
+ expanded={expanded}
331
+ onToggle={onToggle}
332
+ depth={0}
333
+ focusedPath={focusedPath}
334
+ />
335
+ );
336
+ })}
337
+ </div>
338
+ );
339
+ }
340
+
341
+ function EmptyState(): ReactNode {
342
+ // <section> + aria-label + tabIndex=0 macht den Empty-State per Tab
343
+ // erreichbar — sonst wäre die Diagnose-Message für Keyboard-Nutzer
344
+ // unsichtbar/nicht-fokussierbar. <section> ist semantisch korrekt für
345
+ // „informational region", Biome akzeptiert tabIndex hier (im Gegensatz
346
+ // zu <aside> oder bare <div role="region">).
347
+ // TODO V.1.2: Volle Arrow-Key-Navigation zwischen Tree-Siblings (siehe
348
+ // ARIA-tree-Pattern). Heute nur Tab-Reach + native button-Tastatur.
349
+ return (
350
+ <section
351
+ aria-label="Visual Tree (no providers)"
352
+ data-kumiko-layout="visual-tree-empty"
353
+ className="p-4 text-sm text-muted-foreground"
354
+ // biome-ignore lint/a11y/noNoninteractiveTabindex: Empty-State ist eine Diagnose-Region die Keyboard-Nutzer per Tab erreichen können müssen
355
+ tabIndex={0}
356
+ >
357
+ <p className="m-0 font-semibold">Keine Tree-Provider aktiv</p>
358
+ <p className="mt-2">
359
+ Dieser Workspace deklariert <code>navigation: "tree"</code>, aber kein registriertes
360
+ Client-Feature liefert einen <code>treeProvider</code>. Siehe{" "}
361
+ <code>
362
+ createKumikoApp({"{"}clientFeatures: [...]{"}"})
363
+ </code>
364
+ .
365
+ </p>
366
+ </section>
367
+ );
368
+ }
369
+
370
+ // localStorage-Helpers. Stille Failure bei Storage-Errors (Quota,
371
+ // Privacy-Mode) — Tree funktioniert dann ohne Persistenz, was ok ist.
372
+
373
+ function storageKey(workspaceId: string): string {
374
+ return `${EXPANDED_STORAGE_PREFIX}${workspaceId}`;
375
+ }
376
+
377
+ function loadExpanded(workspaceId: string): ReadonlySet<string> {
378
+ if (typeof window === "undefined") return new Set();
379
+ try {
380
+ const raw = window.localStorage.getItem(storageKey(workspaceId));
381
+ if (raw === null) return new Set();
382
+ const parsed = JSON.parse(raw) as unknown;
383
+ if (!Array.isArray(parsed)) return new Set();
384
+ return new Set(parsed.filter((p): p is string => typeof p === "string"));
385
+ } catch {
386
+ return new Set();
387
+ }
388
+ }
389
+
390
+ function saveExpanded(workspaceId: string, expanded: ReadonlySet<string>): void {
391
+ if (typeof window === "undefined") return;
392
+ try {
393
+ window.localStorage.setItem(storageKey(workspaceId), JSON.stringify([...expanded]));
394
+ } catch {
395
+ // Privacy-Mode / Quota-Errors → ignorieren, Tree läuft ohne
396
+ // Persistenz weiter.
397
+ }
398
+ }
@@ -33,10 +33,13 @@ import type { AccessRule } from "@cosmicdrift/kumiko-framework/ui-types";
33
33
  import type { AppSchema, FeatureSchema, WorkspaceSchema } from "@cosmicdrift/kumiko-renderer";
34
34
  import { qualifyNavId, toAppSchema, useNav } from "@cosmicdrift/kumiko-renderer";
35
35
  import { type ReactNode, useCallback, useLayoutEffect, useMemo } from "react";
36
+ import { useResolvers } from "../app/resolvers-context";
36
37
  import { AppLayout } from "./app-layout";
38
+ import { EditorPanel } from "./editor-panel";
37
39
  import { lastSegment, NavTree } from "./nav-tree";
38
40
  import { Sidebar } from "./sidebar";
39
41
  import { Topbar } from "./topbar";
42
+ import { VisualTree } from "./visual-tree";
40
43
  import { WorkspaceSwitcher } from "./workspace-switcher";
41
44
 
42
45
  export type WorkspaceShellUser = {
@@ -182,20 +185,42 @@ export function WorkspaceShell({
182
185
  <WorkspaceSwitcher workspaces={visible} activeId={activeId} onSelect={handleSelect} />
183
186
  );
184
187
 
188
+ // Sidebar-Content-Switch: workspace mit `navigation: "tree"` (opt-in)
189
+ // mountet die VisualTree-Component statt NavTree. Default (kein
190
+ // navigation gesetzt oder navigation="nav") behält das existing
191
+ // NavTree-Verhalten — kein Breaking-Change für Apps die Visual-Tree
192
+ // nicht aktivieren.
193
+ //
194
+ // VisualTree konsumiert TreeProviders via Context (siehe
195
+ // app/tree-providers-context.tsx) — App-Author registriert seine
196
+ // clientFeatures.treeProvider via createKumikoApp, kein zusätzlicher
197
+ // Prop hier nötig. Workspace-ID wird durchgereicht für die
198
+ // localStorage-Persistenz des expanded-Set.
199
+ // Siehe docs/plans/architecture/visual-tree.md A1 + V.1.1-A.
200
+ const resolvers = useResolvers();
201
+ const isTreeMode = activeWorkspace?.definition.navigation === "tree";
202
+
203
+ const sidebarContent =
204
+ activeWorkspace?.definition.navigation === "tree" ? (
205
+ <VisualTree workspaceId={activeWorkspace.definition.id} />
206
+ ) : (
207
+ <NavTree
208
+ schema={app}
209
+ {...(user !== undefined && { user })}
210
+ {...(allowedNavQns !== undefined && { allowedNavQns })}
211
+ />
212
+ );
213
+
185
214
  return (
186
215
  <AppLayout
187
216
  topbar={<Topbar start={brand} center={switcher || undefined} end={topbarActions} />}
188
217
  sidebar={
189
218
  <Sidebar {...(sidebarFooter !== undefined && { footer: sidebarFooter })}>
190
- <NavTree
191
- schema={app}
192
- {...(user !== undefined && { user })}
193
- {...(allowedNavQns !== undefined && { allowedNavQns })}
194
- />
219
+ {sidebarContent}
195
220
  </Sidebar>
196
221
  }
197
222
  >
198
- {children}
223
+ {isTreeMode ? <EditorPanel resolvers={resolvers} /> : children}
199
224
  </AppLayout>
200
225
  );
201
226
  }