@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.
Files changed (47) hide show
  1. package/package.json +6 -4
  2. package/src/__tests__/form-action-bar.test.tsx +50 -15
  3. package/src/__tests__/nav-tree.test.tsx +250 -16
  4. package/src/__tests__/primitives.test.tsx +9 -6
  5. package/src/__tests__/render-edit.test.tsx +6 -6
  6. package/src/__tests__/test-utils.tsx +21 -0
  7. package/src/__tests__/workspace-shell.test.tsx +21 -66
  8. package/src/app/__tests__/qualify-nav-provider-key.test.ts +27 -0
  9. package/src/app/client-plugin.tsx +12 -27
  10. package/src/app/create-app.tsx +31 -19
  11. package/src/app/nav-providers-context.tsx +56 -0
  12. package/src/index.ts +8 -0
  13. package/src/layout/app-layout.tsx +9 -2
  14. package/src/layout/default-app-shell.tsx +116 -33
  15. package/src/layout/nav-tree.tsx +491 -125
  16. package/src/layout/sidebar-brand.tsx +40 -0
  17. package/src/layout/sidebar-user.tsx +46 -0
  18. package/src/layout/sidebar.tsx +1 -1
  19. package/src/layout/target-resolver-stub.tsx +3 -5
  20. package/src/layout/workspace-shell.tsx +32 -34
  21. package/src/primitives/file-upload.tsx +118 -0
  22. package/src/primitives/index.tsx +314 -175
  23. package/src/styles.css +76 -50
  24. package/src/ui/avatar.tsx +110 -0
  25. package/src/ui/badge.tsx +49 -0
  26. package/src/ui/breadcrumb.tsx +110 -0
  27. package/src/ui/button.tsx +65 -0
  28. package/src/ui/card.tsx +93 -0
  29. package/src/ui/checkbox.tsx +33 -0
  30. package/src/ui/collapsible.tsx +34 -0
  31. package/src/ui/dropdown-menu.tsx +258 -0
  32. package/src/ui/input.tsx +22 -0
  33. package/src/ui/label.tsx +25 -0
  34. package/src/ui/select.tsx +191 -0
  35. package/src/ui/separator.tsx +29 -0
  36. package/src/ui/sheet.tsx +144 -0
  37. package/src/ui/sidebar.tsx +727 -0
  38. package/src/ui/skeleton.tsx +14 -0
  39. package/src/ui/table.tsx +117 -0
  40. package/src/ui/textarea.tsx +19 -0
  41. package/src/ui/tooltip.tsx +58 -0
  42. package/src/ui/use-mobile.ts +20 -0
  43. package/src/__tests__/visual-tree-integration.test.tsx +0 -314
  44. package/src/app/tree-providers-context.tsx +0 -68
  45. package/src/layout/__tests__/visual-tree.test.tsx +0 -303
  46. package/src/layout/tree-node-renderer.tsx +0 -386
  47. package/src/layout/visual-tree.tsx +0 -398
@@ -1,303 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import type { TreeChildrenSubscribe, TreeNode } from "@cosmicdrift/kumiko-framework/engine";
3
- import { NavProvider } from "@cosmicdrift/kumiko-renderer";
4
- import { act, fireEvent, render, screen } from "@testing-library/react";
5
- import type { ReactNode } from "react";
6
- import { useBrowserNavApi } from "../../app/nav";
7
- import { TreeProvidersProvider } from "../../app/tree-providers-context";
8
- import { setDispatchListener } from "../target-resolver-stub";
9
- import { VisualTree } from "../visual-tree";
10
-
11
- // Mock-Provider-Helper. emit wird einmal initial gerufen mit den
12
- // gegebenen Nodes; cleanup ist no-op. Subscriptions können später
13
- // auch dynamisch sein (siehe `makeMutableProvider`).
14
- function makeStaticProvider(nodes: readonly TreeNode[]): TreeChildrenSubscribe {
15
- return () => (emit) => {
16
- emit(nodes);
17
- return () => {};
18
- };
19
- }
20
-
21
- // Mutable-Provider — Test kann via `emitFn` einen weiteren Emit
22
- // auslösen um Subscribe-Update zu beweisen. Returnt ein Tupel
23
- // [provider, controls].
24
- function makeMutableProvider(initial: readonly TreeNode[]): {
25
- readonly provider: TreeChildrenSubscribe;
26
- emit(nodes: readonly TreeNode[]): void;
27
- } {
28
- let listener: ((nodes: readonly TreeNode[]) => void) | undefined;
29
- const provider: TreeChildrenSubscribe = () => (emit) => {
30
- listener = emit;
31
- emit(initial);
32
- return () => {
33
- listener = undefined;
34
- };
35
- };
36
- return {
37
- provider,
38
- emit(nodes) {
39
- if (listener !== undefined) listener(nodes);
40
- },
41
- };
42
- }
43
-
44
- async function renderTree(
45
- providers: ReadonlyMap<string, TreeChildrenSubscribe>,
46
- ): Promise<ReturnType<typeof render>> {
47
- function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
48
- // V.1.4b: TreeNodeRenderer + ActionButton nutzen useDispatchTarget,
49
- // das useNav greift — Tests brauchen NavProvider. Browser-nav reset
50
- // erfolgt im beforeEach (window.history.replaceState).
51
- const nav = useBrowserNavApi();
52
- return (
53
- <NavProvider value={nav}>
54
- <TreeProvidersProvider value={providers}>{children}</TreeProvidersProvider>
55
- </NavProvider>
56
- );
57
- }
58
- const result = render(<VisualTree workspaceId="test-ws" />, { wrapper: Wrapper });
59
- // Asynchrone React-Effects (useEffect mit setTimeout/Promise) in
60
- // act() abfangen. Ohne das feuern State-Updates außerhalb von act
61
- // und produzieren "not wrapped in act"-Warnungen.
62
- await act(async () => {});
63
- return result;
64
- }
65
-
66
- // vitest+Bun-Runtime liefert nur ein partielles localStorage (kein
67
- // `clear`/`removeItem`). Wir installieren pro Test einen frischen
68
- // Map-basierten Mock, damit Standard-API-Methoden funktionieren und
69
- // Test-Isolation sauber ist. Production-Code nutzt nur die Standard-
70
- // Schnittstelle, daher transparent.
71
- beforeEach(() => {
72
- // V.1.4b: URL-State leakt sonst zwischen Tests (useBrowserNavApi
73
- // liest window.location). Plus localStorage-Mock unten.
74
- window.history.replaceState(null, "", "/");
75
- const store = new Map<string, string>();
76
- Object.defineProperty(window, "localStorage", {
77
- configurable: true,
78
- value: {
79
- getItem: (key: string): string | null => store.get(key) ?? null,
80
- setItem: (key: string, value: string): void => {
81
- store.set(key, value);
82
- },
83
- removeItem: (key: string): void => {
84
- store.delete(key);
85
- },
86
- clear: (): void => store.clear(),
87
- get length(): number {
88
- return store.size;
89
- },
90
- key: (i: number): string | null => Array.from(store.keys())[i] ?? null,
91
- },
92
- });
93
- });
94
-
95
- describe("VisualTree — Empty-State", () => {
96
- test("ohne registrierte Provider rendert sichtbaren Empty-Hint", async () => {
97
- await renderTree(new Map());
98
- expect(screen.getByLabelText("Visual Tree (no providers)")).toBeTruthy();
99
- expect(screen.getByText(/Keine Tree-Provider aktiv/)).toBeTruthy();
100
- });
101
-
102
- test("Provider-Map vorhanden + emittet leere TreeNode[]: kein Empty-State, kein NavTree-Fallback", async () => {
103
- const providers = new Map([["text-content", makeStaticProvider([])]]);
104
- await renderTree(providers);
105
- // Nicht im Empty-State (es gibt einen registrierten Provider, der
106
- // hat nur kein Knoten emittet). Stattdessen rendert die ProviderBranch
107
- // mit dem featureName als Label im aria-tree.
108
- expect(screen.queryByLabelText("Visual Tree (no providers)")).toBeNull();
109
- expect(screen.getByLabelText("Visual Tree")).toBeTruthy();
110
- });
111
- });
112
-
113
- describe("VisualTree — Provider-Iteration", () => {
114
- test("Single-Provider mit static-children rendert Top-Level-Knoten", async () => {
115
- const providers = new Map([
116
- ["text-content", makeStaticProvider([{ label: "Marketing" }, { label: "Legal" }])],
117
- ]);
118
- await renderTree(providers);
119
- expect(screen.getByText("Marketing")).toBeTruthy();
120
- expect(screen.getByText("Legal")).toBeTruthy();
121
- });
122
-
123
- test("Multi-Provider alphabetisch sortiert nach featureName", async () => {
124
- // legal-pages kommt alphabetisch vor text-content
125
- const providers = new Map<string, TreeChildrenSubscribe>([
126
- ["text-content", makeStaticProvider([{ label: "Marketing" }])],
127
- ["legal-pages", makeStaticProvider([{ label: "Imprint" }])],
128
- ]);
129
- await renderTree(providers);
130
- const branches = document.querySelectorAll("[data-kumiko-tree-branch]");
131
- expect(branches[0]?.getAttribute("data-kumiko-tree-branch")).toBe("legal-pages");
132
- expect(branches[1]?.getAttribute("data-kumiko-tree-branch")).toBe("text-content");
133
- });
134
-
135
- test("Provider der nicht emittet bleibt im loading-State sichtbar", async () => {
136
- // Provider ruft emit nie auf (z.B. async-fetch noch im Flug)
137
- const noopProvider: TreeChildrenSubscribe = () => () => () => {};
138
- const providers = new Map([["slow-feature", noopProvider]]);
139
- await renderTree(providers);
140
- expect(screen.getByText("slow-feature: lädt …")).toBeTruthy();
141
- });
142
-
143
- test("Subscribe-Update: zweiter Emit re-rendert die Liste", async () => {
144
- const { provider, emit } = makeMutableProvider([{ label: "Hero" }]);
145
- const providers = new Map([["text-content", provider]]);
146
- await renderTree(providers);
147
-
148
- expect(screen.getByText("Hero")).toBeTruthy();
149
-
150
- // Provider emittet aktualisierte Liste — Tree muss re-rendern.
151
- // act() wrapped den state-Update damit React synchronisiert flushed.
152
- act(() => {
153
- emit([{ label: "Hero" }, { label: "Pricing" }]);
154
- });
155
- expect(screen.getByText("Pricing")).toBeTruthy();
156
- expect(screen.getByText("Hero")).toBeTruthy();
157
- });
158
-
159
- test("Provider-Unsubscribe wird beim Unmount gecallt", async () => {
160
- let unsubscribed = false;
161
- const provider: TreeChildrenSubscribe = () => (emit) => {
162
- emit([{ label: "Foo" }]);
163
- return () => {
164
- unsubscribed = true;
165
- };
166
- };
167
- const providers = new Map([["test", provider]]);
168
- const result = await renderTree(providers);
169
-
170
- expect(unsubscribed).toBe(false);
171
- result.unmount();
172
- expect(unsubscribed).toBe(true);
173
- });
174
- });
175
-
176
- describe("VisualTree — Click-Dispatch", () => {
177
- let cleanup: (() => void) | undefined;
178
- afterEach(() => {
179
- cleanup?.();
180
- cleanup = undefined;
181
- });
182
-
183
- test("Click auf Knoten mit target ruft dispatchTarget mit dem TargetRef", async () => {
184
- const dispatched: unknown[] = [];
185
- cleanup = setDispatchListener((target) => {
186
- dispatched.push(target);
187
- });
188
-
189
- const providers = new Map([
190
- [
191
- "text-content",
192
- makeStaticProvider([
193
- {
194
- label: "Hero",
195
- target: { featureId: "text-content", action: "edit", args: { slug: "hero" } },
196
- },
197
- ]),
198
- ],
199
- ]);
200
- await renderTree(providers);
201
-
202
- fireEvent.click(screen.getByText("Hero"));
203
-
204
- expect(dispatched).toEqual([
205
- { featureId: "text-content", action: "edit", args: { slug: "hero" } },
206
- ]);
207
- });
208
-
209
- test('Skeleton-Affordance: state="empty" + createAction rendert + Button und dispatcht createAction.target', async () => {
210
- // D3-Validation aus visual-tree.md V.1.1-Decisions: Provider-explizit
211
- // createAction-Field auf TreeNode mit state="empty" → Tree-Component
212
- // zeigt automatisch ein "+"-Icon und dispatcht createAction.target
213
- // beim Klick (NICHT node.target — das wäre die Row-onClick-Action).
214
- const dispatched: unknown[] = [];
215
- cleanup = setDispatchListener((target) => {
216
- dispatched.push(target);
217
- });
218
-
219
- const providers = new Map([
220
- [
221
- "sections",
222
- makeStaticProvider([
223
- {
224
- label: "Sections",
225
- state: "empty",
226
- createAction: {
227
- icon: "plus",
228
- label: "Add section",
229
- target: { featureId: "sections", action: "create" },
230
- },
231
- },
232
- ]),
233
- ],
234
- ]);
235
- await renderTree(providers);
236
-
237
- // + Button greifbar via aria-label aus createAction.label
238
- const addButton = screen.getByLabelText("Add section");
239
- fireEvent.click(addButton);
240
-
241
- expect(dispatched).toEqual([{ featureId: "sections", action: "create" }]);
242
- });
243
-
244
- test("Click auf Container-Knoten (mit children) toggled expand statt Dispatch", async () => {
245
- const dispatched: unknown[] = [];
246
- cleanup = setDispatchListener((target) => {
247
- dispatched.push(target);
248
- });
249
-
250
- const providers = new Map([
251
- [
252
- "text-content",
253
- makeStaticProvider([
254
- {
255
- label: "Marketing",
256
- children: [{ label: "Hero" }],
257
- },
258
- ]),
259
- ],
260
- ]);
261
- await renderTree(providers);
262
-
263
- // Initial collapsed: Hero nicht sichtbar
264
- expect(screen.queryByText("Hero")).toBeNull();
265
- fireEvent.click(screen.getByText("Marketing"));
266
- // Nach Click expanded: Hero sichtbar, kein Dispatch passiert
267
- expect(screen.getByText("Hero")).toBeTruthy();
268
- expect(dispatched).toEqual([]);
269
- });
270
- });
271
-
272
- describe("VisualTree — localStorage-Persistenz", () => {
273
- test("Toggle persistiert expanded-Set ins localStorage pro Workspace", async () => {
274
- const providers = new Map([
275
- ["text-content", makeStaticProvider([{ label: "Marketing", children: [{ label: "Hero" }] }])],
276
- ]);
277
- await renderTree(providers);
278
-
279
- fireEvent.click(screen.getByText("Marketing"));
280
-
281
- const stored = window.localStorage.getItem("kumiko:visual-tree:expanded:test-ws");
282
- expect(stored).not.toBeNull();
283
- const parsed = JSON.parse(stored ?? "[]") as string[];
284
- expect(parsed.length).toBe(1);
285
- expect(parsed[0]).toContain("Marketing");
286
- });
287
-
288
- test("Re-mount restored expanded-Set aus localStorage", async () => {
289
- // Setup: persistierter expanded-Set für test-ws-Workspace
290
- window.localStorage.setItem(
291
- "kumiko:visual-tree:expanded:test-ws",
292
- JSON.stringify(["text-content/0-Marketing"]),
293
- );
294
-
295
- const providers = new Map([
296
- ["text-content", makeStaticProvider([{ label: "Marketing", children: [{ label: "Hero" }] }])],
297
- ]);
298
- await renderTree(providers);
299
-
300
- // Marketing ist expandiert → Hero sichtbar ohne Click
301
- expect(screen.getByText("Hero")).toBeTruthy();
302
- });
303
- });
@@ -1,386 +0,0 @@
1
- // TreeNodeRenderer — recursive Pro-Knoten-Component für den Visual-Tree.
2
- //
3
- // **Pflichten** (visual-tree.md V.1.1-C):
4
- // 1. Render `[icon] [label] [actions]` row mit State-abhängigen Klassen
5
- // 2. Click-Dispatch: onClick mit gesetztem `node.target` → dispatchTarget
6
- // 3. Hover-Actions rechts (CSS-only, hover-visible)
7
- // 4. Children rekursiv: static-Array direkt, TreeChildrenSubscribe lazy
8
- // mit subscribe/unsubscribe an expand/collapse
9
- // 5. Skeleton-Affordance: state="empty" + createAction → automatic
10
- // "+"-Icon, dispatcht createAction.target
11
- //
12
- // **Expand-State** lebt nicht hier sondern im VisualTree (Top-Level)
13
- // damit localStorage-Persistenz pro Workspace eine Stelle hat. Renderer
14
- // kriegt `expanded: Set<path>` + `onToggle(path)` als Props.
15
- //
16
- // **Path** = Workspace-eindeutiger String (parent-path + child-index oder
17
- // node.label-segment). Stable über Re-Renders, eindeutig pro Knoten.
18
- //
19
- // Siehe visual-tree.md V.1.1-C + A4 (TreeNode-Type-Definition).
20
-
21
- import type {
22
- TargetRef,
23
- TreeAction,
24
- TreeChildrenSubscribe,
25
- TreeNode,
26
- TreeNodeState,
27
- } from "@cosmicdrift/kumiko-framework/engine";
28
- import { useNav } from "@cosmicdrift/kumiko-renderer";
29
- import { ChevronDown, ChevronRight, File, Folder, Plus } from "lucide-react";
30
- import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
31
- import { cn } from "../lib/cn";
32
- import { useDispatchTarget } from "./target-resolver-stub";
33
- import { parseTargetFromSearchParams } from "./target-url";
34
-
35
- // Icon-Registry (V.1.2-Stub): Provider liefern symbolische String-Keys
36
- // (`node.icon = "folder"`), Renderer mappt auf das lucide-Component.
37
- // Unknown Keys → kein Render (sauber leerer Slot, kein plain-string-
38
- // Overlap im 14px-Container). V.1.3+ erweitert Registry um App-
39
- // erweiterbare Custom-Icons; aktuelles Set deckt Tree-Folder/File-Bedarf
40
- // vom V.1.2-Consumer (text-content groupBlocksBySlugPrefix → "folder")
41
- // und legal-pages-Slugs (no icon set).
42
- const NODE_ICONS: Readonly<Record<string, typeof Folder>> = {
43
- folder: Folder,
44
- file: File,
45
- };
46
-
47
- // State → Tailwind-Klassen-Mapping. „filled" ist no-op (default-text).
48
- // Restliche Werte signalisieren visuell: stub = leise, empty = stark
49
- // gedimmt + italic, loading = pulse-animation, error = destruktiv-Farbe.
50
- const STATE_CLASSES: Readonly<Record<TreeNodeState, string>> = {
51
- filled: "",
52
- stub: "opacity-55",
53
- empty: "opacity-50 italic",
54
- loading: "animate-pulse",
55
- error: "text-destructive",
56
- };
57
-
58
- // TypeGuard für TreeChildrenSubscribe-Form. Nach `Array.isArray()`-check
59
- // kann TS den Function-Branch nicht automatisch narrowen, daher dieser
60
- // explizite Guard statt `as`-Cast (siehe Memory `[Type Assertions]` und
61
- // build-target.ts:isArgsObject als Vorbild).
62
- function isSubscribeFn(c: readonly TreeNode[] | TreeChildrenSubscribe): c is TreeChildrenSubscribe {
63
- return typeof c === "function";
64
- }
65
-
66
- // V.1.5c Target-Vergleich: TreeNode ist „active" wenn sein target dem
67
- // aktiven Target aus der URL entspricht. Vergleich ist deep-shallow:
68
- // featureId + action exakt, args als flat-Record mit shallow-equal
69
- // (args sind heute nur primitives). null/undefined-tolerant.
70
- function targetsEqual(a: TargetRef, b: TargetRef | undefined): boolean {
71
- if (b === undefined) return false;
72
- if (a.featureId !== b.featureId) return false;
73
- if (a.action !== b.action) return false;
74
- const aKeys = a.args ? Object.keys(a.args) : [];
75
- const bKeys = b.args ? Object.keys(b.args) : [];
76
- if (aKeys.length !== bKeys.length) return false;
77
- for (const k of aKeys) {
78
- if (a.args?.[k] !== b.args?.[k]) return false;
79
- }
80
- return true;
81
- }
82
-
83
- export type TreeNodeRendererProps = {
84
- readonly node: TreeNode;
85
- readonly path: string;
86
- readonly expanded: ReadonlySet<string>;
87
- readonly onToggle: (path: string) => void;
88
- readonly depth?: number;
89
- /** V.1.6c Roving-tabindex: nur das focused-Treeitem hat tabIndex=0,
90
- * alle anderen tabIndex=-1. Wenn undefined → kein item focused
91
- * (transient bei mount, VisualTree useEffect setzt's). */
92
- readonly focusedPath?: string;
93
- };
94
-
95
- export function TreeNodeRenderer({
96
- node,
97
- path,
98
- expanded,
99
- onToggle,
100
- depth = 0,
101
- focusedPath,
102
- }: TreeNodeRendererProps): ReactNode {
103
- const isExpanded = expanded.has(path);
104
- const hasChildren = node.children !== undefined;
105
-
106
- // Dynamic-Children-Subscribe: nur wenn ausgeklappt UND Function-Form.
107
- // null = noch nicht emitted (zeige loading), Array = letzter Emit.
108
- //
109
- // **Identity-Assumption** (TODO V.1.2 verifizieren mit echtem Provider):
110
- // node.children muss als Function-Reference stabil über Re-Renders sein,
111
- // sonst trigger der useEffect ständig unsubscribe+resubscribe ("re-
112
- // subscribe-Storm"). Recommended-Pattern für Provider-Authors: Function
113
- // als top-level-const oder useMemo'd, NICHT inline-Closure pro Emit.
114
- // Bei first violation in V.1.2 entweder useMemo hier oder path-Cache
115
- // im VisualTree-Top-Level — Entscheidung wenn realer Trigger sichtbar.
116
- const [dynamicChildren, setDynamicChildren] = useState<readonly TreeNode[] | null>(null);
117
- useEffect(() => {
118
- if (!isExpanded) return;
119
- if (node.children === undefined) return;
120
- if (!isSubscribeFn(node.children)) return; // static-Array-Pfad in ChildrenView
121
- const subscribe = node.children();
122
- const unsubscribe = subscribe(setDynamicChildren);
123
- return unsubscribe;
124
- }, [isExpanded, node.children]);
125
-
126
- const stateClass = STATE_CLASSES[node.state ?? "filled"];
127
- const indentStyle = { paddingLeft: `${depth * 12 + 8}px` };
128
- const dispatch = useDispatchTarget();
129
- const nav = useNav();
130
-
131
- // V.1.5c Active-Node-Highlight: TreeItem ist „selected" wenn sein
132
- // target dem aktiven Target aus der URL entspricht. Vergleich via
133
- // featureId+action+args (deep-shallow, args ist flat-Record). Plus
134
- // scrollIntoView wenn active-Knoten nicht im Viewport ist (F5 mit
135
- // tief im Tree liegendem Target → User sieht ihn nicht ohne Scroll).
136
- const activeTarget = useMemo(
137
- () => parseTargetFromSearchParams(nav.searchParams),
138
- [nav.searchParams],
139
- );
140
- const isActive = node.target !== undefined && targetsEqual(node.target, activeTarget);
141
- const rowRef = useRef<HTMLDivElement | null>(null);
142
- useEffect(() => {
143
- if (isActive && rowRef.current) {
144
- rowRef.current.scrollIntoView({ block: "nearest", inline: "nearest" });
145
- }
146
- }, [isActive]);
147
-
148
- const handleRowClick = (): void => {
149
- if (hasChildren) {
150
- onToggle(path);
151
- return;
152
- }
153
- if (node.target !== undefined) {
154
- dispatch(node.target);
155
- }
156
- };
157
-
158
- // VS-Code-style indent-guides: ein 1px vertikaler border per
159
- // ancestor-depth. Outer wrapper ist relative + die Lines sind absolute
160
- // mit position pro depth-step (12px); top=0 bottom=0 streckt sie über
161
- // Row + alle children (rekursiv wrapper wrappt children mit drin).
162
- const indentGuides =
163
- depth > 0
164
- ? Array.from({ length: depth }, (_, i) => (
165
- <div
166
- // biome-ignore lint/suspicious/noArrayIndexKey: stable index = depth-level
167
- key={i}
168
- aria-hidden
169
- className="absolute top-0 bottom-0 w-px bg-border/60 pointer-events-none"
170
- style={{ left: `${i * 12 + 13}px` }}
171
- />
172
- ))
173
- : null;
174
-
175
- return (
176
- <div data-kumiko-tree-node={path} className="relative">
177
- {indentGuides}
178
- {/* Outer Row als <div role="treeitem">: V.1.5a ARIA-tree-Pattern.
179
- role + tabIndex=0 + aria-expanded gibt Screenreader Tree-
180
- Semantik. Arrow-Key-Navigation läuft auf dem aside-Container
181
- via querySelectorAll('[role=treeitem]') — siehe visual-tree.tsx.
182
- div+role statt native button weil nested <button> in
183
- HoverActions invalid HTML wäre. */}
184
- <div
185
- ref={rowRef}
186
- className={cn(
187
- // VS-Code-ähnlich: compact spacing, full-row click-area,
188
- // hover subtle, active filled. Active sticky über hover via
189
- // class-order. focus-ring nur bei Keyboard (focus-visible).
190
- "group flex w-full items-center gap-1.5 py-0.5 pr-2 cursor-pointer rounded-sm relative",
191
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
192
- // Explicit VS-Code-Blau für active (statt theme-`bg-accent`,
193
- // weil publicstatus's accent near-white ist → kaum sichtbar).
194
- // border-l-2 als VS-Code-Marker-Bar links.
195
- isActive
196
- ? "bg-blue-100 dark:bg-blue-900/40 border-l-2 border-l-blue-500"
197
- : cn("hover:bg-muted/60", stateClass),
198
- )}
199
- style={indentStyle}
200
- onClick={handleRowClick}
201
- onKeyDown={(e) => {
202
- if (e.key === "Enter" || e.key === " ") {
203
- e.preventDefault();
204
- handleRowClick();
205
- }
206
- }}
207
- role="treeitem"
208
- // V.1.6c Roving-tabindex: nur das focused-treeitem hat
209
- // tabIndex=0, alle anderen tabIndex=-1. focusedPath=undefined
210
- // ist transient (VisualTree useEffect setzt's post-mount auf
211
- // erstes treeitem); während dieser Phase haben alle tabIndex=-1
212
- // und Tab-Reach geht nicht durch — minimal-Window, akzeptabel.
213
- tabIndex={focusedPath === path ? 0 : -1}
214
- aria-expanded={hasChildren ? isExpanded : undefined}
215
- aria-selected={isActive ? true : undefined}
216
- data-kumiko-tree-path={path}
217
- data-kumiko-tree-has-children={hasChildren ? "true" : "false"}
218
- data-kumiko-tree-active={isActive ? "true" : undefined}
219
- >
220
- <ChevronGlyph hasChildren={hasChildren} expanded={isExpanded} />
221
- {node.icon !== undefined &&
222
- (() => {
223
- const IconComponent = NODE_ICONS[node.icon];
224
- if (IconComponent === undefined) return null;
225
- // VS-Code-typische Icon-Color: folder yellow/amber, file
226
- // muted. Plus fill für folder gibt geschlossenem-Folder-Look.
227
- const iconColor = node.icon === "folder" ? "text-amber-500" : "text-muted-foreground";
228
- return <IconComponent aria-hidden className={cn("size-3.5 shrink-0", iconColor)} />;
229
- })()}
230
- <span className="flex-1 truncate text-sm">{node.label}</span>
231
- <HoverActions
232
- actions={node.actions}
233
- createAction={node.state === "empty" ? node.createAction : undefined}
234
- />
235
- </div>
236
- {isExpanded && (
237
- <ChildrenView
238
- node={node}
239
- path={path}
240
- expanded={expanded}
241
- onToggle={onToggle}
242
- depth={depth}
243
- dynamicChildren={dynamicChildren}
244
- focusedPath={focusedPath}
245
- />
246
- )}
247
- </div>
248
- );
249
- }
250
-
251
- function ChevronGlyph({
252
- hasChildren,
253
- expanded,
254
- }: {
255
- readonly hasChildren: boolean;
256
- readonly expanded: boolean;
257
- }): ReactNode {
258
- if (!hasChildren) return <span aria-hidden className="size-3.5" />;
259
- return expanded ? (
260
- <ChevronDown aria-hidden className="size-3.5 shrink-0" />
261
- ) : (
262
- <ChevronRight aria-hidden className="size-3.5 shrink-0" />
263
- );
264
- }
265
-
266
- function HoverActions({
267
- actions,
268
- createAction,
269
- }: {
270
- readonly actions?: readonly TreeAction[];
271
- readonly createAction?: TreeAction;
272
- }): ReactNode {
273
- const has = (actions !== undefined && actions.length > 0) || createAction !== undefined;
274
- if (!has) return null;
275
- return (
276
- <span className="invisible group-hover:visible flex items-center gap-1 shrink-0">
277
- {createAction !== undefined && (
278
- <ActionButton action={createAction} icon={<Plus className="size-3.5" />} />
279
- )}
280
- {actions?.map((a) => (
281
- <ActionButton key={a.label} action={a} icon={<span aria-hidden>{a.icon}</span>} />
282
- ))}
283
- </span>
284
- );
285
- }
286
-
287
- function ActionButton({
288
- action,
289
- icon,
290
- }: {
291
- readonly action: TreeAction;
292
- readonly icon: ReactNode;
293
- }): ReactNode {
294
- const dispatch = useDispatchTarget();
295
- return (
296
- <button
297
- type="button"
298
- aria-label={action.label}
299
- className="p-0.5 hover:bg-accent rounded"
300
- onClick={(e) => {
301
- // Stop the event so the parent-row's onClick (which would
302
- // toggle / dispatch the row's own target) doesn't fire.
303
- e.stopPropagation();
304
- dispatch(action.target);
305
- }}
306
- >
307
- {icon}
308
- </button>
309
- );
310
- }
311
-
312
- function ChildrenView({
313
- node,
314
- path,
315
- expanded,
316
- onToggle,
317
- depth,
318
- dynamicChildren,
319
- focusedPath,
320
- }: {
321
- readonly node: TreeNode;
322
- readonly path: string;
323
- readonly expanded: ReadonlySet<string>;
324
- readonly onToggle: (path: string) => void;
325
- readonly depth: number;
326
- readonly dynamicChildren: readonly TreeNode[] | null;
327
- readonly focusedPath: string | undefined;
328
- }): ReactNode {
329
- // Array.isArray narrow't TS automatisch auf readonly TreeNode[] — kein
330
- // as-Cast nötig (Memory `[Type Assertions]`).
331
- if (Array.isArray(node.children)) {
332
- const children = node.children;
333
- return (
334
- <>
335
- {children.map((child, idx) => {
336
- // Path: idx als stabiler Disambiguator falls Provider doppelte
337
- // Labels liefert (Provider-Bug, aber React-Keys müssen unique
338
- // sein sonst silent state-corruption). Provider-Liefer-Order
339
- // ist stabil — idx ist hier kein „array-shift"-Risk wie bei
340
- // user-rearrangeable Lists.
341
- const childPath = `${path}/${idx}-${child.label}`;
342
- return (
343
- <TreeNodeRenderer
344
- key={childPath}
345
- node={child}
346
- path={childPath}
347
- expanded={expanded}
348
- onToggle={onToggle}
349
- depth={depth + 1}
350
- focusedPath={focusedPath}
351
- />
352
- );
353
- })}
354
- </>
355
- );
356
- }
357
- // Dynamic-children-Pfad: noch nicht emitted → Lade-Zeile, dann Liste.
358
- if (dynamicChildren === null) {
359
- return (
360
- <div
361
- className="text-xs text-muted-foreground italic py-1"
362
- style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}
363
- >
364
- Lädt …
365
- </div>
366
- );
367
- }
368
- return (
369
- <>
370
- {dynamicChildren.map((child, idx) => {
371
- // Selbe idx-Disambiguator-Logik wie ChildrenView static-Branch.
372
- const childPath = `${path}/${idx}-${child.label}`;
373
- return (
374
- <TreeNodeRenderer
375
- key={childPath}
376
- node={child}
377
- path={childPath}
378
- expanded={expanded}
379
- onToggle={onToggle}
380
- depth={depth + 1}
381
- />
382
- );
383
- })}
384
- </>
385
- );
386
- }