@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
|
@@ -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
|
-
}
|