@cosmicdrift/kumiko-renderer-web 0.64.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__/date-input.test.tsx +2 -1
- 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__/toast.test.tsx +4 -3
- 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/date-field.tsx +3 -1
- package/src/primitives/file-upload.tsx +118 -0
- package/src/primitives/index.tsx +314 -175
- package/src/primitives/timestamp-input.tsx +3 -1
- package/src/primitives/toast.tsx +8 -4
- 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
|
@@ -772,16 +772,15 @@ describe("WorkspaceShell — AppSchema (multi-feature)", () => {
|
|
|
772
772
|
});
|
|
773
773
|
|
|
774
774
|
// ---------------------------------------------------------------------------
|
|
775
|
-
//
|
|
776
|
-
//
|
|
777
|
-
//
|
|
778
|
-
//
|
|
779
|
-
//
|
|
780
|
-
// Siehe docs/plans/architecture/visual-tree.md A1.
|
|
775
|
+
// EINE Nav (Visual-Tree-Merge). Der frühere navigation:"tree"/VisualTree-
|
|
776
|
+
// Zweig ist gefaltet: WorkspaceShell rendert IMMER NavTree. Die
|
|
777
|
+
// navigation-Property ist seitdem ein No-op (deferred removal aus
|
|
778
|
+
// WorkspaceDefinition). Im Content-Bereich ersetzt ein aktives target
|
|
779
|
+
// (aus den nav.searchParams) den RoutedScreen durch den EditorPanel.
|
|
781
780
|
// ---------------------------------------------------------------------------
|
|
782
781
|
|
|
783
|
-
describe("WorkspaceShell —
|
|
784
|
-
test('navigation: "tree"
|
|
782
|
+
describe("WorkspaceShell — EINE Nav (Visual-Tree gefaltet)", () => {
|
|
783
|
+
test('navigation: "tree" rendert NavTree (Tree-Stub ist gefaltet, kein Switch mehr)', () => {
|
|
785
784
|
const schema = {
|
|
786
785
|
featureName: "demo",
|
|
787
786
|
entities: {},
|
|
@@ -803,48 +802,19 @@ describe("WorkspaceShell — navigation-Mode (Visual-Tree opt-in)", () => {
|
|
|
803
802
|
<div>content</div>
|
|
804
803
|
</WorkspaceShell>,
|
|
805
804
|
);
|
|
806
|
-
//
|
|
807
|
-
expect(screen.getByLabelText("Visual Tree (no providers)")).toBeTruthy();
|
|
808
|
-
// NavTree wäre durch das nav-Item "List" sichtbar — darf hier NICHT
|
|
809
|
-
// rendern (Tree-Mode ersetzt NavTree komplett)
|
|
810
|
-
expect(screen.queryByText("List")).toBeNull();
|
|
811
|
-
});
|
|
812
|
-
|
|
813
|
-
test('navigation: "nav" mountet NavTree (default-äquivalent)', () => {
|
|
814
|
-
const schema = {
|
|
815
|
-
featureName: "demo",
|
|
816
|
-
entities: {},
|
|
817
|
-
screens: [],
|
|
818
|
-
navs: [{ id: "list", label: "List" }],
|
|
819
|
-
workspaces: [
|
|
820
|
-
ws("admin", {
|
|
821
|
-
label: "Admin",
|
|
822
|
-
openToAll: true,
|
|
823
|
-
isDefault: true,
|
|
824
|
-
navigation: "nav",
|
|
825
|
-
navMembers: ["demo:nav:list"],
|
|
826
|
-
}),
|
|
827
|
-
],
|
|
828
|
-
} as const;
|
|
829
|
-
|
|
830
|
-
renderShell(
|
|
831
|
-
<WorkspaceShell brand={<div>Brand</div>} schema={schema} user={{ id: "u1", roles: [] }}>
|
|
832
|
-
<div>content</div>
|
|
833
|
-
</WorkspaceShell>,
|
|
834
|
-
);
|
|
805
|
+
// NavTree rendert das nav-Item — auch bei navigation:"tree" (No-op).
|
|
835
806
|
expect(screen.getByText("List")).toBeTruthy();
|
|
807
|
+
// Der alte VisualTree-Stub existiert nicht mehr.
|
|
836
808
|
expect(screen.queryByLabelText("Visual Tree (no providers)")).toBeNull();
|
|
837
809
|
});
|
|
838
810
|
|
|
839
|
-
test("workspace ohne navigation-Property
|
|
811
|
+
test("workspace ohne navigation-Property rendert NavTree (Backwards-Compat)", () => {
|
|
840
812
|
const schema = {
|
|
841
813
|
featureName: "demo",
|
|
842
814
|
entities: {},
|
|
843
815
|
screens: [],
|
|
844
816
|
navs: [{ id: "list", label: "List" }],
|
|
845
817
|
workspaces: [
|
|
846
|
-
// Keine `navigation`-Property → existing Apps müssen exakt so
|
|
847
|
-
// weiter laufen wie vor V.1
|
|
848
818
|
ws("admin", {
|
|
849
819
|
label: "Admin",
|
|
850
820
|
openToAll: true,
|
|
@@ -860,14 +830,15 @@ describe("WorkspaceShell — navigation-Mode (Visual-Tree opt-in)", () => {
|
|
|
860
830
|
</WorkspaceShell>,
|
|
861
831
|
);
|
|
862
832
|
expect(screen.getByText("List")).toBeTruthy();
|
|
863
|
-
expect(screen.queryByLabelText("Visual Tree (no providers)")).toBeNull();
|
|
864
833
|
});
|
|
865
834
|
|
|
866
|
-
test("
|
|
867
|
-
//
|
|
868
|
-
//
|
|
869
|
-
//
|
|
870
|
-
//
|
|
835
|
+
test("aktives target in der URL rendert EditorPanel statt der screen-children", () => {
|
|
836
|
+
// Klick auf einen target-Knoten persistiert das target in den
|
|
837
|
+
// searchParams (?t=feat:action&a_*=...). WorkspaceShell rendert dann
|
|
838
|
+
// den EditorPanel statt des RoutedScreen. Voller workspace+screen-Pfad,
|
|
839
|
+
// damit der default-fill-Effect nicht replaced (sonst Verlust der
|
|
840
|
+
// searchParams). Kein Resolver registriert → EditorPanel-Fallback.
|
|
841
|
+
window.history.replaceState(null, "", "/admin/x?t=demo:edit&a_id=1");
|
|
871
842
|
const schema = {
|
|
872
843
|
featureName: "demo",
|
|
873
844
|
entities: {},
|
|
@@ -878,14 +849,8 @@ describe("WorkspaceShell — navigation-Mode (Visual-Tree opt-in)", () => {
|
|
|
878
849
|
label: "Admin",
|
|
879
850
|
openToAll: true,
|
|
880
851
|
isDefault: true,
|
|
881
|
-
navigation: "nav",
|
|
882
852
|
navMembers: ["demo:nav:list"],
|
|
883
853
|
}),
|
|
884
|
-
ws("visual", {
|
|
885
|
-
label: "Visual",
|
|
886
|
-
openToAll: true,
|
|
887
|
-
navigation: "tree",
|
|
888
|
-
}),
|
|
889
854
|
],
|
|
890
855
|
} as const;
|
|
891
856
|
|
|
@@ -894,19 +859,9 @@ describe("WorkspaceShell — navigation-Mode (Visual-Tree opt-in)", () => {
|
|
|
894
859
|
<div>content</div>
|
|
895
860
|
</WorkspaceShell>,
|
|
896
861
|
);
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
expect(screen.
|
|
901
|
-
|
|
902
|
-
// Switch zu visual-Workspace via Switcher-Click
|
|
903
|
-
fireEvent.click(screen.getByTestId("workspace-tab-visual"));
|
|
904
|
-
expect(screen.getByLabelText("Visual Tree (no providers)")).toBeTruthy();
|
|
905
|
-
expect(screen.queryByText("List")).toBeNull();
|
|
906
|
-
|
|
907
|
-
// Zurück zu admin → NavTree wieder, Stub weg
|
|
908
|
-
fireEvent.click(screen.getByTestId("workspace-tab-admin"));
|
|
909
|
-
expect(screen.getByText("List")).toBeTruthy();
|
|
910
|
-
expect(screen.queryByLabelText("Visual Tree (no providers)")).toBeNull();
|
|
862
|
+
// EditorPanel-Fallback (kein Resolver für demo:edit) — beweist target→Editor.
|
|
863
|
+
expect(screen.getByRole("heading", { name: "Editor" })).toBeTruthy();
|
|
864
|
+
// Die screen-children sind ersetzt, nicht zusätzlich gerendert.
|
|
865
|
+
expect(screen.queryByText("content")).toBeNull();
|
|
911
866
|
});
|
|
912
867
|
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { qualifyNavId } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import { qualifyNavProviderKey } from "../create-app";
|
|
4
|
+
|
|
5
|
+
// Die Cross-Feature-Brücke des Tree→Nav-Merges: ein bundled-feature-Client
|
|
6
|
+
// liefert seinen navProvider, die App besitzt den r.nav-Knoten. Findet der
|
|
7
|
+
// NavTree-Knoten (Schema-QN) seinen Provider nicht, bricht der ganze
|
|
8
|
+
// publicstatus-Pfad still post-release — daher hier gepinnt.
|
|
9
|
+
|
|
10
|
+
describe("qualifyNavProviderKey", () => {
|
|
11
|
+
test("lokale id wird mit Feature-Namen qualifiziert (self-keyed)", () => {
|
|
12
|
+
expect(qualifyNavProviderKey("cms", "content")).toBe("cms:nav:content");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("bereits qualifizierte QN geht unverändert durch (App registriert Nav für bundled-feature)", () => {
|
|
16
|
+
// text-content-Client bekommt navId = die publicstatus-QN. Darf NICHT zu
|
|
17
|
+
// "text-content:nav:publicstatus:nav:content" doppelt-qualifiziert werden.
|
|
18
|
+
expect(qualifyNavProviderKey("text-content", "publicstatus:nav:content")).toBe(
|
|
19
|
+
"publicstatus:nav:content",
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("self-keyed Fall ist konsistent mit qualifyNavId (Schema-Seite)", () => {
|
|
24
|
+
// Beide Seiten müssen denselben QN erzeugen, sonst kein Match.
|
|
25
|
+
expect(qualifyNavProviderKey("demo", "list")).toBe(qualifyNavId("demo", "list"));
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -9,11 +9,7 @@
|
|
|
9
9
|
// ganz außen gestackt, dann alle Gates nach innen. So hat jeder Gate
|
|
10
10
|
// Zugriff auf jeden Provider, egal welches Feature ihn gebracht hat.
|
|
11
11
|
|
|
12
|
-
import type {
|
|
13
|
-
TargetRef,
|
|
14
|
-
TreeActionDef,
|
|
15
|
-
TreeChildrenSubscribe,
|
|
16
|
-
} from "@cosmicdrift/kumiko-framework/engine";
|
|
12
|
+
import type { TargetRef, TreeChildrenSubscribe } from "@cosmicdrift/kumiko-framework/engine";
|
|
17
13
|
import type {
|
|
18
14
|
ColumnRendererComponent,
|
|
19
15
|
ExtensionSectionComponent,
|
|
@@ -61,28 +57,17 @@ export type ClientFeatureDefinition = {
|
|
|
61
57
|
* `{ entityName, entityId }`. Pattern wie columnRenderers — Last-Wins
|
|
62
58
|
* bei Key-Kollision über mehrere Features. */
|
|
63
59
|
readonly extensionSectionComponents?: Readonly<Record<string, ExtensionSectionComponent>>;
|
|
64
|
-
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
* via Server-Feature setup-export (FeatureDefinition.exports.handle).
|
|
76
|
-
* Identisch zur server-side `r.treeActions(...)`-Map; bundled-
|
|
77
|
-
* features liefern beide Seiten konsistent. */
|
|
78
|
-
readonly treeActions?: Readonly<Record<string, TreeActionDef>>;
|
|
79
|
-
|
|
80
|
-
/** V.1.5b SSE-Tree-Refresh: Liste der Entity-Namen die der Provider
|
|
81
|
-
* abdeckt. Bei Live-Events für eine dieser Entities (created/updated/
|
|
82
|
-
* deleted/restored) wird der Provider neu aufgerufen → Tree refresht.
|
|
83
|
-
* Optional + leer/undefined → kein SSE-Refresh (static Provider, z.B.
|
|
84
|
-
* legal-pages). Beispiel text-content: `["text-block"]`. */
|
|
85
|
-
readonly treeEntities?: readonly string[];
|
|
60
|
+
/** Nav-Provider für die EINE Nav (Visual-Tree-Merge): hängt dynamische
|
|
61
|
+
* Children an einen statischen `r.nav({ provider: true })`-Knoten. Keyed
|
|
62
|
+
* auf die LOKALE nav-id (create-app qualifiziert zu `<feature>:nav:<id>`;
|
|
63
|
+
* bereits qualifizierte QNs gehen durch). Anders als `treeProvider` (ein
|
|
64
|
+
* Top-Level-Branch pro Feature) attacht ein navProvider an einen konkreten
|
|
65
|
+
* Nav-Knoten — der idiomatic Weg seit dem Tree→Nav-Merge. */
|
|
66
|
+
readonly navProviders?: Readonly<Record<string, TreeChildrenSubscribe>>;
|
|
67
|
+
/** SSE-Entity-Listen pro nav-id für den Live-Refresh der navProviders
|
|
68
|
+
* (analog `treeEntities`). Live-Event für eine Entity → Provider des
|
|
69
|
+
* Knotens wird neu aufgerufen → neue Kinder erscheinen live. */
|
|
70
|
+
readonly navEntities?: Readonly<Record<string, readonly string[]>>;
|
|
86
71
|
|
|
87
72
|
/** Editor-Resolver-Komponenten pro featureId:action-Key. Wenn ein
|
|
88
73
|
* TreeNode mit target angeklickt wird, schlägt der EditorPanel das
|
package/src/app/create-app.tsx
CHANGED
|
@@ -39,8 +39,18 @@ import { UpdateChecker } from "../version/update-checker";
|
|
|
39
39
|
import { createBrowserLocaleResolver } from "./browser-locale";
|
|
40
40
|
import { type ClientFeatureDefinition, stackWrappers } from "./client-plugin";
|
|
41
41
|
import { useBrowserNavApi } from "./nav";
|
|
42
|
+
import { NavProvidersProvider } from "./nav-providers-context";
|
|
42
43
|
import { type ResolverComponent, ResolversProvider } from "./resolvers-context";
|
|
43
|
-
|
|
44
|
+
|
|
45
|
+
// Qualifiziert den Key eines navProviders auf seine Nav-QN. Lokale ids
|
|
46
|
+
// (z.B. "content") werden wie in r.nav mit dem Feature-Namen qualifiziert;
|
|
47
|
+
// bereits qualifizierte QNs (App registriert die Nav für ein bundled-feature
|
|
48
|
+
// und gibt die QN als navId rein, z.B. "publicstatus:nav:content") gehen
|
|
49
|
+
// unverändert durch. MUSS konsistent zu qualifyNavId bleiben, sonst findet
|
|
50
|
+
// der NavTree-Knoten (Schema-Seite) seinen Provider nicht.
|
|
51
|
+
export function qualifyNavProviderKey(feature: string, id: string): string {
|
|
52
|
+
return id.includes(":nav:") ? id : `${feature}:nav:${id}`;
|
|
53
|
+
}
|
|
44
54
|
|
|
45
55
|
// Web-Bootstrap. Mounted den ganzen Kumiko-Render-Stack im Browser:
|
|
46
56
|
// Tokens (class-based light/dark via <html>), Primitives (HTML),
|
|
@@ -214,24 +224,26 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonl
|
|
|
214
224
|
}
|
|
215
225
|
}
|
|
216
226
|
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
const
|
|
223
|
-
const
|
|
227
|
+
// Nav-Provider-Map: ein navProvider hängt dynamische Children an einen
|
|
228
|
+
// konkreten r.nav({provider:true})-Knoten (per QN). Lokale nav-ids werden
|
|
229
|
+
// wie in r.nav mit dem Feature-Namen qualifiziert; bereits qualifizierte
|
|
230
|
+
// QNs (cross-feature, z.B. App registriert Nav für ein bundled-feature)
|
|
231
|
+
// gehen unverändert durch.
|
|
232
|
+
const navProviders = new Map<string, TreeChildrenSubscribe>();
|
|
233
|
+
const navEntities = new Map<string, readonly string[]>();
|
|
224
234
|
for (const f of clientFeatures) {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
235
|
+
for (const [navId, provider] of Object.entries(f.navProviders ?? {})) {
|
|
236
|
+
const qn = qualifyNavProviderKey(f.name, navId);
|
|
237
|
+
if (navProviders.has(qn)) {
|
|
238
|
+
// biome-ignore lint/suspicious/noConsole: dev-warning für Schema-Konflikte
|
|
239
|
+
console.warn(
|
|
240
|
+
`[kumiko] navProvider for "${qn}" defined by multiple clientFeatures — last wins.`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
navProviders.set(qn, provider);
|
|
231
244
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
treeEntities.set(f.name, f.treeEntities);
|
|
245
|
+
for (const [navId, entities] of Object.entries(f.navEntities ?? {})) {
|
|
246
|
+
if (entities.length > 0) navEntities.set(qualifyNavProviderKey(f.name, navId), entities);
|
|
235
247
|
}
|
|
236
248
|
}
|
|
237
249
|
|
|
@@ -276,14 +288,14 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonl
|
|
|
276
288
|
<CustomScreensProvider value={customScreens}>
|
|
277
289
|
<ColumnRenderersProvider value={columnRenderers}>
|
|
278
290
|
<ExtensionSectionsProvider value={extensionSectionComponents}>
|
|
279
|
-
<
|
|
291
|
+
<NavProvidersProvider value={navProviders} entities={navEntities}>
|
|
280
292
|
<ResolversProvider resolvers={resolvers}>
|
|
281
293
|
<ToastProvider>
|
|
282
294
|
<UpdateChecker />
|
|
283
295
|
{stackWrappers(providers, stackWrappers(gates, screenNode))}
|
|
284
296
|
</ToastProvider>
|
|
285
297
|
</ResolversProvider>
|
|
286
|
-
</
|
|
298
|
+
</NavProvidersProvider>
|
|
287
299
|
</ExtensionSectionsProvider>
|
|
288
300
|
</ColumnRenderersProvider>
|
|
289
301
|
</CustomScreensProvider>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// NavProvidersContext — client-side Provider-Map für die EINE Nav. Anders
|
|
2
|
+
// als der alte TreeProvidersContext (keyed auf featureName, ein Top-Level-
|
|
3
|
+
// Branch pro Feature) sind diese Provider auf eine **Nav-QN** geschlüsselt:
|
|
4
|
+
// ein statischer r.nav-Knoten mit `provider: true` bekommt seine Children
|
|
5
|
+
// zur Laufzeit aus dem hier registrierten TreeChildrenSubscribe. So hängt
|
|
6
|
+
// sich ein Feature lazy an einen konkreten Nav-Knoten (z.B. "Content") statt
|
|
7
|
+
// einen eigenen Tree-Branch aufzumachen.
|
|
8
|
+
//
|
|
9
|
+
// `entities` (parallel-Context) ist die SSE-Entity-Liste pro Provider-QN:
|
|
10
|
+
// NavTree abonniert sie via useLiveEvents und re-fired den Provider bei
|
|
11
|
+
// Entity-Events — dadurch erscheinen neu erstellte Knoten live in der Nav.
|
|
12
|
+
//
|
|
13
|
+
// Default = leere Maps (Apps ohne dynamische Nav-Provider rendern nur die
|
|
14
|
+
// statischen r.nav-Knoten). Aggregiert wird in create-app aus den
|
|
15
|
+
// clientFeatures.
|
|
16
|
+
|
|
17
|
+
import type { TreeChildrenSubscribe } from "@cosmicdrift/kumiko-framework/engine";
|
|
18
|
+
import { createContext, type ReactNode, useContext } from "react";
|
|
19
|
+
|
|
20
|
+
const EMPTY_PROVIDERS: ReadonlyMap<string, TreeChildrenSubscribe> = new Map();
|
|
21
|
+
const EMPTY_ENTITIES: ReadonlyMap<string, readonly string[]> = new Map();
|
|
22
|
+
|
|
23
|
+
const NavProvidersContext =
|
|
24
|
+
createContext<ReadonlyMap<string, TreeChildrenSubscribe>>(EMPTY_PROVIDERS);
|
|
25
|
+
const NavEntitiesContext = createContext<ReadonlyMap<string, readonly string[]>>(EMPTY_ENTITIES);
|
|
26
|
+
|
|
27
|
+
export type NavProvidersProviderProps = {
|
|
28
|
+
readonly value: ReadonlyMap<string, TreeChildrenSubscribe>;
|
|
29
|
+
readonly entities?: ReadonlyMap<string, readonly string[]>;
|
|
30
|
+
readonly children: ReactNode;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function NavProvidersProvider({
|
|
34
|
+
value,
|
|
35
|
+
entities,
|
|
36
|
+
children,
|
|
37
|
+
}: NavProvidersProviderProps): ReactNode {
|
|
38
|
+
return (
|
|
39
|
+
<NavProvidersContext.Provider value={value}>
|
|
40
|
+
<NavEntitiesContext.Provider value={entities ?? EMPTY_ENTITIES}>
|
|
41
|
+
{children}
|
|
42
|
+
</NavEntitiesContext.Provider>
|
|
43
|
+
</NavProvidersContext.Provider>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Provider-Map keyed auf Nav-QN. Empty-Map-Default → kein Crash ohne
|
|
48
|
+
* registrierte Provider. */
|
|
49
|
+
export function useNavProviders(): ReadonlyMap<string, TreeChildrenSubscribe> {
|
|
50
|
+
return useContext(NavProvidersContext);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** SSE-Entity-Liste pro Provider-QN für Live-Refresh. Empty-Map-Default. */
|
|
54
|
+
export function useNavEntities(): ReadonlyMap<string, readonly string[]> {
|
|
55
|
+
return useContext(NavEntitiesContext);
|
|
56
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -91,12 +91,15 @@ export type { CreatePublicSurfaceOptions, PublicRoute } from "./app/create-publi
|
|
|
91
91
|
export { createPublicSurface } from "./app/create-public-surface";
|
|
92
92
|
export type { KumikoLinkProps } from "./app/nav";
|
|
93
93
|
export { KumikoLink, useBrowserNavApi } from "./app/nav";
|
|
94
|
+
export { useResolvers } from "./app/resolvers-context";
|
|
94
95
|
export type { AppLayoutProps } from "./layout/app-layout";
|
|
95
96
|
export { AppLayout } from "./layout/app-layout";
|
|
96
97
|
export type { AvatarProps, AvatarSize } from "./layout/avatar";
|
|
97
98
|
export { Avatar } from "./layout/avatar";
|
|
98
99
|
export type { DefaultAppShellProps } from "./layout/default-app-shell";
|
|
99
100
|
export { DefaultAppShell } from "./layout/default-app-shell";
|
|
101
|
+
export type { EditorPanelProps, ResolverComponent } from "./layout/editor-panel";
|
|
102
|
+
export { EditorPanel } from "./layout/editor-panel";
|
|
100
103
|
export type { LanguageSwitcherProps, LocaleOption } from "./layout/language-switcher";
|
|
101
104
|
export { LanguageSwitcher } from "./layout/language-switcher";
|
|
102
105
|
export type { NavTreeProps } from "./layout/nav-tree";
|
|
@@ -105,6 +108,11 @@ export type { ProfileMenuItem, ProfileMenuProps } from "./layout/profile-menu";
|
|
|
105
108
|
export { ProfileMenu } from "./layout/profile-menu";
|
|
106
109
|
export type { SidebarProps } from "./layout/sidebar";
|
|
107
110
|
export { Sidebar } from "./layout/sidebar";
|
|
111
|
+
export type { SidebarBrandProps } from "./layout/sidebar-brand";
|
|
112
|
+
export { SidebarBrand } from "./layout/sidebar-brand";
|
|
113
|
+
export type { SidebarUserProps } from "./layout/sidebar-user";
|
|
114
|
+
export { SidebarUser } from "./layout/sidebar-user";
|
|
115
|
+
export { parseTargetFromSearchParams } from "./layout/target-url";
|
|
108
116
|
export type { ThemeToggleProps } from "./layout/theme-toggle";
|
|
109
117
|
export { ThemeToggle } from "./layout/theme-toggle";
|
|
110
118
|
export type { TopbarProps } from "./layout/topbar";
|
|
@@ -42,13 +42,20 @@ export function AppLayout({
|
|
|
42
42
|
data-kumiko-layout="app"
|
|
43
43
|
{...(fill === true && { "data-kumiko-fill": "true" })}
|
|
44
44
|
className={cn(
|
|
45
|
-
|
|
45
|
+
// new-york inset-Frame: gedämpfter Rail-Background, die Sidebar sitzt
|
|
46
|
+
// transparent darauf, der Main-Bereich schwebt als eigenes Panel.
|
|
47
|
+
"flex flex-row bg-muted/40 text-foreground",
|
|
46
48
|
fill === true ? "h-screen" : "min-h-screen",
|
|
47
49
|
className,
|
|
48
50
|
)}
|
|
49
51
|
>
|
|
50
52
|
{sidebar}
|
|
51
|
-
<div
|
|
53
|
+
<div
|
|
54
|
+
className={cn(
|
|
55
|
+
"flex flex-1 min-w-0 flex-col overflow-hidden rounded-xl border bg-background shadow-sm m-2 md:ml-0",
|
|
56
|
+
fill === true && "min-h-0",
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
52
59
|
{topbar}
|
|
53
60
|
{/* main hat KEIN Padding — Screens (Form, Liste, Demo-Pages)
|
|
54
61
|
managen ihr Padding selber, damit Action-Bars edge-to-edge
|
|
@@ -1,23 +1,36 @@
|
|
|
1
|
-
// DefaultAppShell —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// DefaultAppShell — die 90%-App-Shell auf Basis der vendored shadcn-Sidebar
|
|
2
|
+
// (src/ui/sidebar.tsx). SidebarProvider stellt den Context (Mobile-Sheet,
|
|
3
|
+
// Rail, Cmd-B), die Sidebar trägt die Chrome-Identität, SidebarInset rendert
|
|
4
|
+
// den schwebenden Content-Panel mit einem Header (Trigger + Breadcrumb).
|
|
4
5
|
//
|
|
5
6
|
// Sidebar-Slots:
|
|
6
|
-
// 1. brand — Workspace-Identity oben (
|
|
7
|
-
// 2. sidebarActions — Icon-Row
|
|
7
|
+
// 1. brand — Workspace-Identity oben (Logo + Name)
|
|
8
|
+
// 2. sidebarActions — Icon-Row (Search/Theme/Tenant-Switch)
|
|
8
9
|
// 3. NavTree — automatisch aus dem Schema
|
|
9
10
|
// 4. sidebarFooter — Bottom-Slot für Profile/Help/Plan-Banner
|
|
10
11
|
//
|
|
11
|
-
// Apps die feingranulare Kontrolle wollen, gehen direkt auf
|
|
12
|
-
//
|
|
13
|
-
// für Multi-Workspace mit Switcher in der Topbar), nutzt
|
|
14
|
-
// <WorkspaceShell> oder baut den Shell selber.
|
|
12
|
+
// Apps die feingranulare Kontrolle wollen, gehen direkt auf die ui/-Teile.
|
|
13
|
+
// Wer ein Topbar mit Workspace-Switcher braucht, nutzt <WorkspaceShell>.
|
|
15
14
|
|
|
15
|
+
import type { NavNode } from "@cosmicdrift/kumiko-headless";
|
|
16
|
+
import { resolveNavigation } from "@cosmicdrift/kumiko-headless";
|
|
16
17
|
import type { AppSchema, FeatureSchema } from "@cosmicdrift/kumiko-renderer";
|
|
17
|
-
import
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
18
|
+
import { toAppSchema, useNav, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
19
|
+
import { type ReactNode, useMemo } from "react";
|
|
20
|
+
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from "../ui/breadcrumb";
|
|
21
|
+
import { Separator } from "../ui/separator";
|
|
22
|
+
import {
|
|
23
|
+
Sidebar,
|
|
24
|
+
SidebarContent,
|
|
25
|
+
SidebarFooter,
|
|
26
|
+
SidebarGroup,
|
|
27
|
+
SidebarHeader,
|
|
28
|
+
SidebarInset,
|
|
29
|
+
SidebarProvider,
|
|
30
|
+
SidebarRail,
|
|
31
|
+
SidebarTrigger,
|
|
32
|
+
} from "../ui/sidebar";
|
|
33
|
+
import { buildNavRegistrySliceForApp, lastSegment, NavTree } from "./nav-tree";
|
|
21
34
|
|
|
22
35
|
export type DefaultAppShellUser = {
|
|
23
36
|
readonly id: string;
|
|
@@ -25,8 +38,8 @@ export type DefaultAppShellUser = {
|
|
|
25
38
|
};
|
|
26
39
|
|
|
27
40
|
export type DefaultAppShellProps = {
|
|
28
|
-
/** Header-Slot oben in der Sidebar — Workspace-Identity (Logo,
|
|
29
|
-
*
|
|
41
|
+
/** Header-Slot oben in der Sidebar — Workspace-Identity (Logo, Name,
|
|
42
|
+
* Plan-Tag). Caller liefert frei. */
|
|
30
43
|
readonly brand: ReactNode;
|
|
31
44
|
/** Schema (AppSchema oder legacy FeatureSchema) wird an NavTree
|
|
32
45
|
* durchgereicht; Sidebar-Einträge bauen sich automatisch aus
|
|
@@ -35,20 +48,18 @@ export type DefaultAppShellProps = {
|
|
|
35
48
|
/** Current user — drives nav role-gating. Ohne user-prop werden
|
|
36
49
|
* role-gated nav-einträge (`access: { roles: [...] }`) komplett
|
|
37
50
|
* ausgeblendet (resolveNavigation behandelt undefined-user als
|
|
38
|
-
* anonymous).
|
|
51
|
+
* anonymous). */
|
|
39
52
|
readonly user?: DefaultAppShellUser;
|
|
40
53
|
/** Icon-Row zwischen Brand und NavTree — typisch Search-Trigger,
|
|
41
|
-
* ThemeToggle, TenantSwitcher.
|
|
42
|
-
* horizontalen Reihe. */
|
|
54
|
+
* ThemeToggle, TenantSwitcher. */
|
|
43
55
|
readonly sidebarActions?: ReactNode;
|
|
44
56
|
/** Footer-Slot unten in der Sidebar — Profile-Row, Help-Link,
|
|
45
|
-
* Plan-Banner.
|
|
57
|
+
* Plan-Banner. */
|
|
46
58
|
readonly sidebarFooter?: ReactNode;
|
|
47
|
-
/** Viewport-fit Shell
|
|
48
|
-
*
|
|
49
|
-
* Default false (klassischer Seiten-Scroll). Siehe AppLayout.fill. */
|
|
59
|
+
/** Viewport-fit Shell. true → fixe Viewport-Höhe (`h-svh`), der Content
|
|
60
|
+
* scrollt INNEN statt der ganzen Seite. Default false (Seiten-Scroll). */
|
|
50
61
|
readonly fill?: boolean;
|
|
51
|
-
/** Screen-Content der
|
|
62
|
+
/** Screen-Content der im SidebarInset gerendert wird. */
|
|
52
63
|
readonly children: ReactNode;
|
|
53
64
|
};
|
|
54
65
|
|
|
@@ -62,15 +73,87 @@ export function DefaultAppShell({
|
|
|
62
73
|
children,
|
|
63
74
|
}: DefaultAppShellProps): ReactNode {
|
|
64
75
|
return (
|
|
65
|
-
<
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
76
|
+
<SidebarProvider {...(fill === true && { className: "h-svh" })}>
|
|
77
|
+
{/* sidebar-07-Muster: Standard-Variante (border-r, flush content) +
|
|
78
|
+
collapsible="icon" — der Rail klappt auf Icon-Breite zu (Trigger/Rail). */}
|
|
79
|
+
<Sidebar collapsible="icon">
|
|
80
|
+
<SidebarHeader data-kumiko-layout="sidebar-header">{brand}</SidebarHeader>
|
|
81
|
+
{sidebarActions !== undefined && (
|
|
82
|
+
<SidebarGroup
|
|
83
|
+
data-kumiko-layout="sidebar-actions"
|
|
84
|
+
className="flex-row items-center gap-1 py-0"
|
|
85
|
+
>
|
|
86
|
+
{sidebarActions}
|
|
87
|
+
</SidebarGroup>
|
|
88
|
+
)}
|
|
89
|
+
<SidebarContent data-kumiko-layout="sidebar-nav">
|
|
90
|
+
<NavTree schema={schema} {...(user !== undefined && { user })} />
|
|
91
|
+
</SidebarContent>
|
|
92
|
+
{sidebarFooter !== undefined && (
|
|
93
|
+
<SidebarFooter data-kumiko-layout="sidebar-footer">{sidebarFooter}</SidebarFooter>
|
|
94
|
+
)}
|
|
95
|
+
<SidebarRail />
|
|
96
|
+
</Sidebar>
|
|
97
|
+
<SidebarInset className={fill === true ? "min-h-0" : undefined}>
|
|
98
|
+
<ShellHeader schema={schema} {...(user !== undefined && { user })} />
|
|
99
|
+
<main className={fill === true ? "min-h-0 flex-1 overflow-auto" : "flex-1 overflow-auto"}>
|
|
100
|
+
{children}
|
|
101
|
+
</main>
|
|
102
|
+
</SidebarInset>
|
|
103
|
+
</SidebarProvider>
|
|
75
104
|
);
|
|
76
105
|
}
|
|
106
|
+
|
|
107
|
+
// Inset-Header: hostet den SidebarTrigger (öffnet das Mobile-Sheet / toggled
|
|
108
|
+
// die Rail) und einen Breadcrumb mit dem aktiven Screen. Höhe == Sidebar-
|
|
109
|
+
// Header (h-12), damit Rail und Panel oben bündig sind.
|
|
110
|
+
function ShellHeader({
|
|
111
|
+
schema,
|
|
112
|
+
user,
|
|
113
|
+
}: {
|
|
114
|
+
readonly schema: AppSchema | FeatureSchema;
|
|
115
|
+
readonly user?: DefaultAppShellUser;
|
|
116
|
+
}): ReactNode {
|
|
117
|
+
const nav = useNav();
|
|
118
|
+
const t = useTranslation();
|
|
119
|
+
const tree = useMemo(() => {
|
|
120
|
+
const source = buildNavRegistrySliceForApp(toAppSchema(schema));
|
|
121
|
+
return resolveNavigation({ source, ...(user !== undefined && { user }) });
|
|
122
|
+
}, [schema, user]);
|
|
123
|
+
|
|
124
|
+
const screenId = nav.route?.screenId;
|
|
125
|
+
const label = screenId !== undefined ? activeNavLabel(tree, screenId, (k) => t(k)) : undefined;
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<header className="flex h-16 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
|
129
|
+
<div className="flex items-center gap-2 px-4">
|
|
130
|
+
<SidebarTrigger className="-ml-1" />
|
|
131
|
+
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
|
132
|
+
{label !== undefined && (
|
|
133
|
+
<Breadcrumb>
|
|
134
|
+
<BreadcrumbList>
|
|
135
|
+
<BreadcrumbItem>
|
|
136
|
+
<BreadcrumbPage>{label}</BreadcrumbPage>
|
|
137
|
+
</BreadcrumbItem>
|
|
138
|
+
</BreadcrumbList>
|
|
139
|
+
</Breadcrumb>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
</header>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function activeNavLabel(
|
|
147
|
+
nodes: readonly NavNode[],
|
|
148
|
+
screenId: string,
|
|
149
|
+
t: (key: string) => string,
|
|
150
|
+
): string | undefined {
|
|
151
|
+
for (const node of nodes) {
|
|
152
|
+
if (node.screen !== undefined && lastSegment(node.screen) === screenId) {
|
|
153
|
+
return node.label.includes(".") ? t(node.label) : node.label;
|
|
154
|
+
}
|
|
155
|
+
const child = activeNavLabel(node.children, screenId, t);
|
|
156
|
+
if (child !== undefined) return child;
|
|
157
|
+
}
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|