@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
@@ -0,0 +1,14 @@
1
+ // @ts-nocheck — vendored shadcn, regenerate via scripts/sync-shadcn.ts
2
+ import { cn } from "../lib/cn"
3
+
4
+ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
5
+ return (
6
+ <div
7
+ data-slot="skeleton"
8
+ className={cn("animate-pulse rounded-md bg-accent", className)}
9
+ {...props}
10
+ />
11
+ )
12
+ }
13
+
14
+ export { Skeleton }
@@ -0,0 +1,117 @@
1
+ // @ts-nocheck — vendored shadcn, regenerate via scripts/sync-shadcn.ts
2
+ "use client"
3
+
4
+ import * as React from "react"
5
+
6
+ import { cn } from "../lib/cn"
7
+
8
+ function Table({ className, ...props }: React.ComponentProps<"table">) {
9
+ return (
10
+ <div
11
+ data-slot="table-container"
12
+ className="relative w-full overflow-x-auto"
13
+ >
14
+ <table
15
+ data-slot="table"
16
+ className={cn("w-full caption-bottom text-sm", className)}
17
+ {...props}
18
+ />
19
+ </div>
20
+ )
21
+ }
22
+
23
+ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
24
+ return (
25
+ <thead
26
+ data-slot="table-header"
27
+ className={cn("[&_tr]:border-b", className)}
28
+ {...props}
29
+ />
30
+ )
31
+ }
32
+
33
+ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
34
+ return (
35
+ <tbody
36
+ data-slot="table-body"
37
+ className={cn("[&_tr:last-child]:border-0", className)}
38
+ {...props}
39
+ />
40
+ )
41
+ }
42
+
43
+ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
44
+ return (
45
+ <tfoot
46
+ data-slot="table-footer"
47
+ className={cn(
48
+ "border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
49
+ className
50
+ )}
51
+ {...props}
52
+ />
53
+ )
54
+ }
55
+
56
+ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
57
+ return (
58
+ <tr
59
+ data-slot="table-row"
60
+ className={cn(
61
+ "border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
62
+ className
63
+ )}
64
+ {...props}
65
+ />
66
+ )
67
+ }
68
+
69
+ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
70
+ return (
71
+ <th
72
+ data-slot="table-head"
73
+ className={cn(
74
+ "h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
75
+ className
76
+ )}
77
+ {...props}
78
+ />
79
+ )
80
+ }
81
+
82
+ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
83
+ return (
84
+ <td
85
+ data-slot="table-cell"
86
+ className={cn(
87
+ "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
88
+ className
89
+ )}
90
+ {...props}
91
+ />
92
+ )
93
+ }
94
+
95
+ function TableCaption({
96
+ className,
97
+ ...props
98
+ }: React.ComponentProps<"caption">) {
99
+ return (
100
+ <caption
101
+ data-slot="table-caption"
102
+ className={cn("mt-4 text-sm text-muted-foreground", className)}
103
+ {...props}
104
+ />
105
+ )
106
+ }
107
+
108
+ export {
109
+ Table,
110
+ TableHeader,
111
+ TableBody,
112
+ TableFooter,
113
+ TableHead,
114
+ TableRow,
115
+ TableCell,
116
+ TableCaption,
117
+ }
@@ -0,0 +1,19 @@
1
+ // @ts-nocheck — vendored shadcn, regenerate via scripts/sync-shadcn.ts
2
+ import * as React from "react"
3
+
4
+ import { cn } from "../lib/cn"
5
+
6
+ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
7
+ return (
8
+ <textarea
9
+ data-slot="textarea"
10
+ className={cn(
11
+ "flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
12
+ className
13
+ )}
14
+ {...props}
15
+ />
16
+ )
17
+ }
18
+
19
+ export { Textarea }
@@ -0,0 +1,58 @@
1
+ // @ts-nocheck — vendored shadcn, regenerate via scripts/sync-shadcn.ts
2
+ "use client"
3
+
4
+ import * as React from "react"
5
+ import { Tooltip as TooltipPrimitive } from "radix-ui"
6
+
7
+ import { cn } from "../lib/cn"
8
+
9
+ function TooltipProvider({
10
+ delayDuration = 0,
11
+ ...props
12
+ }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
13
+ return (
14
+ <TooltipPrimitive.Provider
15
+ data-slot="tooltip-provider"
16
+ delayDuration={delayDuration}
17
+ {...props}
18
+ />
19
+ )
20
+ }
21
+
22
+ function Tooltip({
23
+ ...props
24
+ }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
25
+ return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
26
+ }
27
+
28
+ function TooltipTrigger({
29
+ ...props
30
+ }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
31
+ return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
32
+ }
33
+
34
+ function TooltipContent({
35
+ className,
36
+ sideOffset = 0,
37
+ children,
38
+ ...props
39
+ }: React.ComponentProps<typeof TooltipPrimitive.Content>) {
40
+ return (
41
+ <TooltipPrimitive.Portal>
42
+ <TooltipPrimitive.Content
43
+ data-slot="tooltip-content"
44
+ sideOffset={sideOffset}
45
+ className={cn(
46
+ "z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
47
+ className
48
+ )}
49
+ {...props}
50
+ >
51
+ {children}
52
+ <TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
53
+ </TooltipPrimitive.Content>
54
+ </TooltipPrimitive.Portal>
55
+ )
56
+ }
57
+
58
+ export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
@@ -0,0 +1,20 @@
1
+ // @ts-nocheck — vendored shadcn, regenerate via scripts/sync-shadcn.ts
2
+ import * as React from "react"
3
+
4
+ const MOBILE_BREAKPOINT = 768
5
+
6
+ export function useIsMobile() {
7
+ const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
8
+
9
+ React.useEffect(() => {
10
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
11
+ const onChange = () => {
12
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
13
+ }
14
+ mql.addEventListener("change", onChange)
15
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
16
+ return () => mql.removeEventListener("change", onChange)
17
+ }, [])
18
+
19
+ return !!isMobile
20
+ }
@@ -1,314 +0,0 @@
1
- //
2
- // V.1.1-D Integration-Test — End-to-End-Beweis für die Schleife
3
- // `clientFeatures.treeProvider → useTreeProviders → VisualTree →
4
- // TreeNodeRenderer → Click → dispatchTarget`.
5
- //
6
- // Im Gegensatz zum visual-tree.test.tsx (isolierte VisualTree-Component
7
- // mit Mock-Providers) mountet dieser Test den vollen WorkspaceShell mit
8
- // zwei Workspaces (nav + tree) und zwei Tree-Provider-clientFeatures.
9
- // Bewert wird:
10
- // 1. Provider-Iteration: beide Top-Level-Knoten landen im DOM
11
- // (alphabetisch nach featureName)
12
- // 2. Subscribe-Update: zweiter Emit eines Providers re-rendert die
13
- // Sidebar ohne Workspace-Switch
14
- // 3. Stub-Dispatch: Click auf Knoten mit target ruft dispatch mit
15
- // richtigem TargetRef
16
- // 4. Workspace-Switch: nav-Workspace zeigt NavTree, tree-Workspace
17
- // zeigt VisualTree mit beiden Provider-Beiträgen
18
- //
19
- // **Memory `[Kein Fake-Dispatcher]`-Note**: V.1.1 hat keine HTTP-Calls
20
- // (Tree-Provider sind reine Client-Functions ohne Args). Echtes
21
- // setupTestStack kommt mit V.1.2 wenn text-content's Slug-Liste durch
22
- // die Server-Pipeline geht.
23
-
24
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
25
- import type { TreeChildrenSubscribe, TreeNode } from "@cosmicdrift/kumiko-framework/engine";
26
- import type { FeatureSchema, WorkspaceSchema } from "@cosmicdrift/kumiko-renderer";
27
- import {
28
- createStaticLocaleResolver,
29
- LocaleProvider,
30
- NavProvider,
31
- PrimitivesProvider,
32
- } from "@cosmicdrift/kumiko-renderer";
33
- import { act, fireEvent, render, screen } from "@testing-library/react";
34
- import type { ReactNode } from "react";
35
- import { useBrowserNavApi } from "../app/nav";
36
- import { TreeProvidersProvider } from "../app/tree-providers-context";
37
- import { setDispatchListener } from "../layout/target-resolver-stub";
38
- import { WorkspaceShell } from "../layout/workspace-shell";
39
- import { defaultPrimitives } from "../primitives";
40
-
41
- // localStorage-Mock (vitest+Bun-Runtime liefert nur partielles
42
- // localStorage). Pro Test frische Map damit Tests sauber isoliert sind.
43
- beforeEach(() => {
44
- const store = new Map<string, string>();
45
- Object.defineProperty(window, "localStorage", {
46
- configurable: true,
47
- value: {
48
- getItem: (key: string): string | null => store.get(key) ?? null,
49
- setItem: (key: string, value: string): void => {
50
- store.set(key, value);
51
- },
52
- removeItem: (key: string): void => {
53
- store.delete(key);
54
- },
55
- clear: (): void => store.clear(),
56
- get length(): number {
57
- return store.size;
58
- },
59
- key: (i: number): string | null => Array.from(store.keys())[i] ?? null,
60
- },
61
- });
62
- window.history.replaceState(null, "", "/");
63
- });
64
-
65
- function ws(
66
- id: string,
67
- options: {
68
- label: string;
69
- isDefault?: boolean;
70
- navigation?: "nav" | "tree";
71
- navMembers?: readonly string[];
72
- },
73
- ): WorkspaceSchema {
74
- return {
75
- definition: {
76
- id,
77
- label: options.label,
78
- access: { openToAll: true },
79
- ...(options.isDefault === true && { default: true }),
80
- ...(options.navigation !== undefined && { navigation: options.navigation }),
81
- },
82
- navMembers: options.navMembers ?? [],
83
- };
84
- }
85
-
86
- function makeStaticProvider(nodes: readonly TreeNode[]): TreeChildrenSubscribe {
87
- return () => (emit) => {
88
- emit(nodes);
89
- return () => {};
90
- };
91
- }
92
-
93
- function makeMutableProvider(initial: readonly TreeNode[]): {
94
- readonly provider: TreeChildrenSubscribe;
95
- emit(nodes: readonly TreeNode[]): void;
96
- } {
97
- let listener: ((nodes: readonly TreeNode[]) => void) | undefined;
98
- const provider: TreeChildrenSubscribe = () => (emit) => {
99
- listener = emit;
100
- emit(initial);
101
- return () => {
102
- listener = undefined;
103
- };
104
- };
105
- return {
106
- provider,
107
- emit(nodes) {
108
- if (listener !== undefined) listener(nodes);
109
- },
110
- };
111
- }
112
-
113
- function renderShellWithTreeProviders(
114
- schema: FeatureSchema,
115
- treeProviders: ReadonlyMap<string, TreeChildrenSubscribe>,
116
- ): ReturnType<typeof render> {
117
- function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
118
- const nav = useBrowserNavApi({ hasWorkspaces: true });
119
- return (
120
- <LocaleProvider resolver={createStaticLocaleResolver()}>
121
- <PrimitivesProvider value={defaultPrimitives}>
122
- <NavProvider value={nav}>
123
- <TreeProvidersProvider value={treeProviders}>{children}</TreeProvidersProvider>
124
- </NavProvider>
125
- </PrimitivesProvider>
126
- </LocaleProvider>
127
- );
128
- }
129
- return render(
130
- <WorkspaceShell brand={<div>Brand</div>} schema={schema} user={{ id: "u1", roles: [] }}>
131
- <div>content</div>
132
- </WorkspaceShell>,
133
- { wrapper: Wrapper },
134
- );
135
- }
136
-
137
- describe("V.1.1-D Integration — Provider-Iteration + Subscribe + Dispatch", () => {
138
- let cleanup: (() => void) | undefined;
139
- afterEach(() => {
140
- cleanup?.();
141
- cleanup = undefined;
142
- });
143
-
144
- test("Provider-Iteration: beide Features landen im DOM, alphabetisch sortiert", () => {
145
- const schema = {
146
- featureName: "demo",
147
- entities: {},
148
- screens: [],
149
- navs: [],
150
- workspaces: [
151
- ws("visual", {
152
- label: "Visual",
153
- isDefault: true,
154
- navigation: "tree",
155
- }),
156
- ],
157
- } as const;
158
-
159
- const treeProviders = new Map<string, TreeChildrenSubscribe>([
160
- ["text-content", makeStaticProvider([{ label: "Marketing" }])],
161
- ["legal-pages", makeStaticProvider([{ label: "Imprint" }])],
162
- ]);
163
-
164
- renderShellWithTreeProviders(schema, treeProviders);
165
-
166
- expect(screen.getByText("Marketing")).toBeTruthy();
167
- expect(screen.getByText("Imprint")).toBeTruthy();
168
-
169
- // Reihenfolge: legal-pages (l < t) kommt vor text-content
170
- const branches = document.querySelectorAll("[data-kumiko-tree-branch]");
171
- expect(branches[0]?.getAttribute("data-kumiko-tree-branch")).toBe("legal-pages");
172
- expect(branches[1]?.getAttribute("data-kumiko-tree-branch")).toBe("text-content");
173
- });
174
-
175
- test("Subscribe-Update ohne Workspace-Switch: zweiter Emit re-rendert", () => {
176
- const { provider, emit } = makeMutableProvider([{ label: "Hero" }]);
177
-
178
- const schema = {
179
- featureName: "demo",
180
- entities: {},
181
- screens: [],
182
- navs: [],
183
- workspaces: [ws("visual", { label: "Visual", isDefault: true, navigation: "tree" })],
184
- } as const;
185
-
186
- const treeProviders = new Map([["text-content", provider]]);
187
-
188
- renderShellWithTreeProviders(schema, treeProviders);
189
- expect(screen.getByText("Hero")).toBeTruthy();
190
-
191
- act(() => {
192
- emit([{ label: "Hero" }, { label: "Pricing" }, { label: "Footer" }]);
193
- });
194
-
195
- expect(screen.getByText("Pricing")).toBeTruthy();
196
- expect(screen.getByText("Footer")).toBeTruthy();
197
- });
198
-
199
- test("Stub-Dispatch: Click → setDispatchListener-Spy bekommt richtige TargetRef", () => {
200
- const dispatched: unknown[] = [];
201
- cleanup = setDispatchListener((target) => {
202
- dispatched.push(target);
203
- });
204
-
205
- const schema = {
206
- featureName: "demo",
207
- entities: {},
208
- screens: [],
209
- navs: [],
210
- workspaces: [ws("visual", { label: "Visual", isDefault: true, navigation: "tree" })],
211
- } as const;
212
-
213
- const treeProviders = new Map<string, TreeChildrenSubscribe>([
214
- [
215
- "text-content",
216
- makeStaticProvider([
217
- {
218
- label: "Imprint",
219
- target: {
220
- featureId: "text-content",
221
- action: "edit",
222
- args: { slug: "imprint" },
223
- },
224
- },
225
- ]),
226
- ],
227
- ]);
228
-
229
- renderShellWithTreeProviders(schema, treeProviders);
230
- fireEvent.click(screen.getByText("Imprint"));
231
-
232
- expect(dispatched).toEqual([
233
- {
234
- featureId: "text-content",
235
- action: "edit",
236
- args: { slug: "imprint" },
237
- },
238
- ]);
239
- });
240
-
241
- test("Workspace-Switch: nav-Workspace zeigt NavTree, tree-Workspace zeigt VisualTree-Provider", () => {
242
- const schema = {
243
- featureName: "demo",
244
- entities: {},
245
- screens: [],
246
- navs: [{ id: "list", label: "Builder-List" }],
247
- workspaces: [
248
- ws("admin", {
249
- label: "Admin",
250
- isDefault: true,
251
- navigation: "nav",
252
- navMembers: ["demo:nav:list"],
253
- }),
254
- ws("visual", {
255
- label: "Visual",
256
- navigation: "tree",
257
- }),
258
- ],
259
- } as const;
260
-
261
- const treeProviders = new Map<string, TreeChildrenSubscribe>([
262
- ["text-content", makeStaticProvider([{ label: "Marketing" }])],
263
- ]);
264
-
265
- renderShellWithTreeProviders(schema, treeProviders);
266
-
267
- // Initial: admin-Workspace → NavTree mit "Builder-List"
268
- expect(screen.getByText("Builder-List")).toBeTruthy();
269
- expect(screen.queryByText("Marketing")).toBeNull();
270
-
271
- // Switch zu visual → VisualTree mit Provider-Beitrag
272
- fireEvent.click(screen.getByTestId("workspace-tab-visual"));
273
- expect(screen.getByText("Marketing")).toBeTruthy();
274
- expect(screen.queryByText("Builder-List")).toBeNull();
275
-
276
- // Zurück zu admin → NavTree wieder
277
- fireEvent.click(screen.getByTestId("workspace-tab-admin"));
278
- expect(screen.getByText("Builder-List")).toBeTruthy();
279
- expect(screen.queryByText("Marketing")).toBeNull();
280
- });
281
-
282
- test("localStorage-Persistenz: Toggle persistiert pro Workspace, Re-Mount restored", () => {
283
- const schema = {
284
- featureName: "demo",
285
- entities: {},
286
- screens: [],
287
- navs: [],
288
- workspaces: [ws("visual", { label: "Visual", isDefault: true, navigation: "tree" })],
289
- } as const;
290
-
291
- const treeProviders = new Map<string, TreeChildrenSubscribe>([
292
- ["text-content", makeStaticProvider([{ label: "Marketing", children: [{ label: "Hero" }] }])],
293
- ]);
294
-
295
- const result = renderShellWithTreeProviders(schema, treeProviders);
296
-
297
- // Initial collapsed: Hero nicht sichtbar
298
- expect(screen.queryByText("Hero")).toBeNull();
299
-
300
- // Toggle: Marketing ausklappen
301
- fireEvent.click(screen.getByText("Marketing"));
302
- expect(screen.getByText("Hero")).toBeTruthy();
303
-
304
- // localStorage hat den Persisted-State
305
- const stored = window.localStorage.getItem("kumiko:visual-tree:expanded:visual");
306
- expect(stored).not.toBeNull();
307
- expect(JSON.parse(stored ?? "[]")).toContain("text-content/0-Marketing");
308
-
309
- // Re-Mount mit gleichem Workspace → expanded restored
310
- result.unmount();
311
- renderShellWithTreeProviders(schema, treeProviders);
312
- expect(screen.getByText("Hero")).toBeTruthy();
313
- });
314
- });
@@ -1,68 +0,0 @@
1
- // TreeProvidersContext — React-Context für die client-side TreeProvider-
2
- // Map. Wird von createKumikoApp aus den `clientFeatures[].treeProvider`-
3
- // Feldern aggregiert und steht damit allen Layout-Komponenten zur
4
- // Verfügung (insbesondere VisualTree im WorkspaceShell).
5
- //
6
- // **Warum Context, nicht Prop**: WorkspaceShell ist bereits multi-Prop
7
- // (brand, schema, user, sidebarFooter, ...). Provider-Map wäre der
8
- // nächste Required-when-tree-mode-Prop und würde das Interface aufweichen
9
- // (welche Props sind Pflicht wann?). Context-Pattern ist konsistent zu
10
- // existing kumiko-renderer-Mechanik (NavProvider, LocaleProvider,
11
- // PrimitivesProvider, LiveEventsProvider) — alle für „cross-component
12
- // state aus app-level".
13
- //
14
- // **Default-Wert** ist eine leere Map. Apps ohne Tree-Workspace mounten
15
- // die App ohne `clientFeatures` mit `treeProvider`, und VisualTree
16
- // rendert eine empty-state-Sicht. Memory `[Sicherheit > Convenience]`:
17
- // kein silent-fallback, sondern explicit empty.
18
- //
19
- // Siehe visual-tree.md V.1.1-Distribution-Mechanismus.
20
-
21
- import type { TreeChildrenSubscribe } from "@cosmicdrift/kumiko-framework/engine";
22
- import { createContext, type ReactNode, useContext } from "react";
23
-
24
- const EMPTY_PROVIDERS: ReadonlyMap<string, TreeChildrenSubscribe> = new Map();
25
- const EMPTY_ENTITIES: ReadonlyMap<string, readonly string[]> = new Map();
26
-
27
- const TreeProvidersContext =
28
- createContext<ReadonlyMap<string, TreeChildrenSubscribe>>(EMPTY_PROVIDERS);
29
-
30
- // V.1.5b separater Context für SSE-Entity-Lists pro Provider. Parallel-
31
- // Context statt Entry-Tuple weil bestehende TreeProvidersProvider-Konsumenten
32
- // (tests, integration) sonst alle Migrations-Effort hätten.
33
- const TreeEntitiesContext = createContext<ReadonlyMap<string, readonly string[]>>(EMPTY_ENTITIES);
34
-
35
- export type TreeProvidersProviderProps = {
36
- readonly value: ReadonlyMap<string, TreeChildrenSubscribe>;
37
- /** Optional: pro Provider die Entity-Liste für SSE-Live-Refresh.
38
- * Default: leere Map → kein Provider refresht via SSE. */
39
- readonly entities?: ReadonlyMap<string, readonly string[]>;
40
- readonly children: ReactNode;
41
- };
42
-
43
- export function TreeProvidersProvider({
44
- value,
45
- entities,
46
- children,
47
- }: TreeProvidersProviderProps): ReactNode {
48
- return (
49
- <TreeProvidersContext.Provider value={value}>
50
- <TreeEntitiesContext.Provider value={entities ?? EMPTY_ENTITIES}>
51
- {children}
52
- </TreeEntitiesContext.Provider>
53
- </TreeProvidersContext.Provider>
54
- );
55
- }
56
-
57
- /** Hook für TreeProvider-Map-Konsumenten (VisualTree im WorkspaceShell).
58
- * Returnt eine empty Map wenn die App keine clientFeatures mit
59
- * treeProvider registriert hat — sicheres Default-Verhalten ohne
60
- * Crash. */
61
- export function useTreeProviders(): ReadonlyMap<string, TreeChildrenSubscribe> {
62
- return useContext(TreeProvidersContext);
63
- }
64
-
65
- /** V.1.5b SSE-Entity-Map pro Provider. Empty Map default. */
66
- export function useTreeEntities(): ReadonlyMap<string, readonly string[]> {
67
- return useContext(TreeEntitiesContext);
68
- }