@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.
Files changed (52) hide show
  1. package/package.json +6 -4
  2. package/src/__tests__/date-input.test.tsx +2 -1
  3. package/src/__tests__/form-action-bar.test.tsx +50 -15
  4. package/src/__tests__/nav-tree.test.tsx +250 -16
  5. package/src/__tests__/primitives.test.tsx +9 -6
  6. package/src/__tests__/render-edit.test.tsx +6 -6
  7. package/src/__tests__/test-utils.tsx +21 -0
  8. package/src/__tests__/toast.test.tsx +4 -3
  9. package/src/__tests__/workspace-shell.test.tsx +21 -66
  10. package/src/app/__tests__/qualify-nav-provider-key.test.ts +27 -0
  11. package/src/app/client-plugin.tsx +12 -27
  12. package/src/app/create-app.tsx +31 -19
  13. package/src/app/nav-providers-context.tsx +56 -0
  14. package/src/index.ts +8 -0
  15. package/src/layout/app-layout.tsx +9 -2
  16. package/src/layout/default-app-shell.tsx +116 -33
  17. package/src/layout/nav-tree.tsx +491 -125
  18. package/src/layout/sidebar-brand.tsx +40 -0
  19. package/src/layout/sidebar-user.tsx +46 -0
  20. package/src/layout/sidebar.tsx +1 -1
  21. package/src/layout/target-resolver-stub.tsx +3 -5
  22. package/src/layout/workspace-shell.tsx +32 -34
  23. package/src/primitives/date-field.tsx +3 -1
  24. package/src/primitives/file-upload.tsx +118 -0
  25. package/src/primitives/index.tsx +314 -175
  26. package/src/primitives/timestamp-input.tsx +3 -1
  27. package/src/primitives/toast.tsx +8 -4
  28. package/src/styles.css +76 -50
  29. package/src/ui/avatar.tsx +110 -0
  30. package/src/ui/badge.tsx +49 -0
  31. package/src/ui/breadcrumb.tsx +110 -0
  32. package/src/ui/button.tsx +65 -0
  33. package/src/ui/card.tsx +93 -0
  34. package/src/ui/checkbox.tsx +33 -0
  35. package/src/ui/collapsible.tsx +34 -0
  36. package/src/ui/dropdown-menu.tsx +258 -0
  37. package/src/ui/input.tsx +22 -0
  38. package/src/ui/label.tsx +25 -0
  39. package/src/ui/select.tsx +191 -0
  40. package/src/ui/separator.tsx +29 -0
  41. package/src/ui/sheet.tsx +144 -0
  42. package/src/ui/sidebar.tsx +727 -0
  43. package/src/ui/skeleton.tsx +14 -0
  44. package/src/ui/table.tsx +117 -0
  45. package/src/ui/textarea.tsx +19 -0
  46. package/src/ui/tooltip.tsx +58 -0
  47. package/src/ui/use-mobile.ts +20 -0
  48. package/src/__tests__/visual-tree-integration.test.tsx +0 -314
  49. package/src/app/tree-providers-context.tsx +0 -68
  50. package/src/layout/__tests__/visual-tree.test.tsx +0 -303
  51. package/src/layout/tree-node-renderer.tsx +0 -386
  52. package/src/layout/visual-tree.tsx +0 -398
@@ -0,0 +1,40 @@
1
+ // SidebarBrand — der TeamSwitcher-Header aus shadcns sidebar-07: Logo-Kachel +
2
+ // Name + Plan/Tagline + ChevronsUpDown. App-Author reicht ihn als `brand` an
3
+ // DefaultAppShell. Reiner Look (kein Team-Switch-Dropdown) — eine App hat
4
+ // meist EINE Identität; wer wechseln will, baut den Dropdown selbst drumrum.
5
+
6
+ import { ChevronsUpDown } from "lucide-react";
7
+ import type { ReactNode } from "react";
8
+ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar";
9
+
10
+ export type SidebarBrandProps = {
11
+ /** Workspace-/App-Name (fett, erste Zeile). */
12
+ readonly name: string;
13
+ /** Zweite Zeile — Plan, Tagline, Tenant (klein, gedimmt). */
14
+ readonly plan?: string;
15
+ /** Logo in der Kachel — typisch ein Lucide-Icon. Fehlt es, steht der
16
+ * erste Buchstabe des Namens. */
17
+ readonly logo?: ReactNode;
18
+ };
19
+
20
+ export function SidebarBrand({ name, plan, logo }: SidebarBrandProps): ReactNode {
21
+ return (
22
+ <SidebarMenu>
23
+ <SidebarMenuItem>
24
+ <SidebarMenuButton
25
+ size="lg"
26
+ className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
27
+ >
28
+ <div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
29
+ {logo ?? <span className="text-sm font-semibold">{name.charAt(0)}</span>}
30
+ </div>
31
+ <div className="grid flex-1 text-left text-sm leading-tight">
32
+ <span className="truncate font-semibold">{name}</span>
33
+ {plan !== undefined && <span className="truncate text-xs">{plan}</span>}
34
+ </div>
35
+ <ChevronsUpDown className="ml-auto" />
36
+ </SidebarMenuButton>
37
+ </SidebarMenuItem>
38
+ </SidebarMenu>
39
+ );
40
+ }
@@ -0,0 +1,46 @@
1
+ // SidebarUser — der NavUser-Footer aus shadcns sidebar-07: Avatar + Name +
2
+ // Email + ChevronsUpDown. App-Author reicht ihn als `sidebarFooter` an
3
+ // DefaultAppShell. Reiner Look (kein Account-Dropdown) — wer ein User-Menü
4
+ // will, wrappt ihn in einen DropdownMenu.
5
+
6
+ import { ChevronsUpDown } from "lucide-react";
7
+ import type { ReactNode } from "react";
8
+ import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
9
+ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar";
10
+
11
+ export type SidebarUserProps = {
12
+ readonly name: string;
13
+ readonly email?: string;
14
+ /** Avatar-URL. Fehlt sie, stehen die Initialen aus dem Namen. */
15
+ readonly avatar?: string;
16
+ };
17
+
18
+ function initials(name: string): string {
19
+ const parts = name.trim().split(/\s+/);
20
+ const first = parts[0]?.charAt(0) ?? "";
21
+ const last = parts.length > 1 ? (parts[parts.length - 1]?.charAt(0) ?? "") : "";
22
+ return (first + last).toUpperCase() || name.slice(0, 2).toUpperCase();
23
+ }
24
+
25
+ export function SidebarUser({ name, email, avatar }: SidebarUserProps): ReactNode {
26
+ return (
27
+ <SidebarMenu>
28
+ <SidebarMenuItem>
29
+ <SidebarMenuButton
30
+ size="lg"
31
+ className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
32
+ >
33
+ <Avatar className="size-8 rounded-lg">
34
+ {avatar !== undefined && <AvatarImage src={avatar} alt={name} />}
35
+ <AvatarFallback className="rounded-lg">{initials(name)}</AvatarFallback>
36
+ </Avatar>
37
+ <div className="grid flex-1 text-left text-sm leading-tight">
38
+ <span className="truncate font-semibold">{name}</span>
39
+ {email !== undefined && <span className="truncate text-xs">{email}</span>}
40
+ </div>
41
+ <ChevronsUpDown className="ml-auto size-4" />
42
+ </SidebarMenuButton>
43
+ </SidebarMenuItem>
44
+ </SidebarMenu>
45
+ );
46
+ }
@@ -28,7 +28,7 @@ export function Sidebar({ header, actions, children, footer, testId }: SidebarPr
28
28
  <aside
29
29
  data-testid={testId}
30
30
  data-kumiko-layout="sidebar"
31
- className="w-60 flex-shrink-0 border-r bg-muted/30 flex flex-col text-sm"
31
+ className="hidden md:flex w-60 flex-shrink-0 bg-transparent flex-col text-sm"
32
32
  >
33
33
  {header !== undefined && (
34
34
  <div
@@ -12,11 +12,9 @@
12
12
  // (damit unhandled Klicks sichtbar bleiben).
13
13
  //
14
14
  // **useDispatchTarget (V.1.4b)** ist der empfohlene Production-Pfad.
15
- // TreeNodeRenderer ruft den Hook in seinem Click-Handler — er schreibt
16
- // URL via nav.setSearchParams UND ruft den globalen dispatchTarget für
15
+ // NavTree ruft den Hook im Click-Handler eines target-Knotens — er schreibt
16
+ // die URL via nav.setSearchParams UND ruft den globalen dispatchTarget für
17
17
  // Test-Listener-Kompatibilität.
18
- //
19
- // Siehe visual-tree.md V.1.2 + V.1.4b.
20
18
 
21
19
  import type { TargetRef } from "@cosmicdrift/kumiko-framework/engine";
22
20
  import { useNav } from "@cosmicdrift/kumiko-renderer";
@@ -40,7 +38,7 @@ export function dispatchTarget(target: TargetRef): void {
40
38
  return;
41
39
  }
42
40
  // biome-ignore lint/suspicious/noConsole: fallback wenn kein Subscriber registered
43
- console.debug("[VisualTree] target dispatched (unhandled)", target);
41
+ console.debug("[NavTree] target dispatched (unhandled)", target);
44
42
  }
45
43
 
46
44
  /** Test-Hook: Exklusiver Spy. Returnt cleanup. */
@@ -34,12 +34,13 @@ import type { AppSchema, FeatureSchema, WorkspaceSchema } from "@cosmicdrift/kum
34
34
  import { qualifyNavId, toAppSchema, useNav } from "@cosmicdrift/kumiko-renderer";
35
35
  import { type ReactNode, useCallback, useLayoutEffect, useMemo } from "react";
36
36
  import { useResolvers } from "../app/resolvers-context";
37
+ import { SidebarProvider } from "../ui/sidebar";
37
38
  import { AppLayout } from "./app-layout";
38
39
  import { EditorPanel } from "./editor-panel";
39
40
  import { lastSegment, NavTree } from "./nav-tree";
40
41
  import { Sidebar } from "./sidebar";
42
+ import { parseTargetFromSearchParams } from "./target-url";
41
43
  import { Topbar } from "./topbar";
42
- import { VisualTree } from "./visual-tree";
43
44
  import { WorkspaceSwitcher } from "./workspace-switcher";
44
45
 
45
46
  export type WorkspaceShellUser = {
@@ -185,43 +186,40 @@ export function WorkspaceShell({
185
186
  <WorkspaceSwitcher workspaces={visible} activeId={activeId} onSelect={handleSelect} />
186
187
  );
187
188
 
188
- // Sidebar-Content-Switch: workspace mit `navigation: "tree"` (opt-in)
189
- // mountet die VisualTree-Component statt NavTree. Default (kein
190
- // navigation gesetzt oder navigation="nav") behält das existing
191
- // NavTree-Verhalten kein Breaking-Change für Apps die Visual-Tree
192
- // nicht aktivieren.
193
- //
194
- // VisualTree konsumiert TreeProviders via Context (siehe
195
- // app/tree-providers-context.tsx) — App-Author registriert seine
196
- // clientFeatures.treeProvider via createKumikoApp, kein zusätzlicher
197
- // Prop hier nötig. Workspace-ID wird durchgereicht für die
198
- // localStorage-Persistenz des expanded-Set.
199
- // Siehe docs/plans/architecture/visual-tree.md A1 + V.1.1-A.
189
+ // EINE Nav: immer NavTree. Dynamische Knoten (r.nav({ provider: true }))
190
+ // ziehen ihre Children client-seitig aus den registrierten navProviders —
191
+ // der frühere navigation:"tree"/VisualTree-Zweig ist hier eingefaltet.
192
+ // Content-Bereich: ein aktives target (aus den nav.searchParams, gesetzt
193
+ // beim Klick auf einen target-Knoten) rendert den EditorPanel statt des
194
+ // RoutedScreen — sonst die normalen screen-children.
195
+ // (WorkspaceDefinition.navigation ist seitdem ein No-op; deferred removal.)
200
196
  const resolvers = useResolvers();
201
- const isTreeMode = activeWorkspace?.definition.navigation === "tree";
197
+ const activeTarget = parseTargetFromSearchParams(nav.searchParams);
202
198
 
203
- const sidebarContent =
204
- activeWorkspace?.definition.navigation === "tree" ? (
205
- <VisualTree workspaceId={activeWorkspace.definition.id} />
206
- ) : (
207
- <NavTree
208
- schema={app}
209
- {...(user !== undefined && { user })}
210
- {...(allowedNavQns !== undefined && { allowedNavQns })}
211
- />
212
- );
199
+ const sidebarContent = (
200
+ <NavTree
201
+ schema={app}
202
+ {...(user !== undefined && { user })}
203
+ {...(allowedNavQns !== undefined && { allowedNavQns })}
204
+ />
205
+ );
213
206
 
207
+ // SidebarProvider: NavTree rendert vendored shadcn-SidebarMenuButtons, die
208
+ // useSidebar() rufen. WorkspaceShell behält sein eigenes Topbar-Layout
209
+ // (AppLayout + Sidebar), braucht aber den Context-Wrapper drumherum.
214
210
  return (
215
- <AppLayout
216
- topbar={<Topbar start={brand} center={switcher || undefined} end={topbarActions} />}
217
- sidebar={
218
- <Sidebar {...(sidebarFooter !== undefined && { footer: sidebarFooter })}>
219
- {sidebarContent}
220
- </Sidebar>
221
- }
222
- >
223
- {isTreeMode ? <EditorPanel resolvers={resolvers} /> : children}
224
- </AppLayout>
211
+ <SidebarProvider>
212
+ <AppLayout
213
+ topbar={<Topbar start={brand} center={switcher || undefined} end={topbarActions} />}
214
+ sidebar={
215
+ <Sidebar {...(sidebarFooter !== undefined && { footer: sidebarFooter })}>
216
+ {sidebarContent}
217
+ </Sidebar>
218
+ }
219
+ >
220
+ {activeTarget !== undefined ? <EditorPanel resolvers={resolvers} /> : children}
221
+ </AppLayout>
222
+ </SidebarProvider>
225
223
  );
226
224
  }
227
225
 
@@ -7,6 +7,7 @@
7
7
  // Datums-Teil neben dem Uhrzeit-Input. So teilen `date` und `timestamp`
8
8
  // dieselbe Tipp-/Navigations-UX statt zweier divergenter Primitives (#369).
9
9
 
10
+ import { useTranslation } from "@cosmicdrift/kumiko-renderer";
10
11
  import { type ReactNode, useState } from "react";
11
12
  import { cn } from "../lib/cn";
12
13
  import { CalendarPopover } from "./calendar-popover";
@@ -47,6 +48,7 @@ export function DateField({
47
48
  min,
48
49
  max,
49
50
  }: DateFieldProps): ReactNode {
51
+ const t = useTranslation();
50
52
  const resolvedLocale = locale ?? guessLocale();
51
53
  const selected = parseIso(value);
52
54
 
@@ -101,7 +103,7 @@ export function DateField({
101
103
  {...(maxDate !== undefined && { max: maxDate })}
102
104
  {...(disabled !== undefined && { disabled })}
103
105
  {...(hasError !== undefined && { hasError })}
104
- triggerLabel="Kalender öffnen"
106
+ triggerLabel={t("kumiko.field.open-calendar")}
105
107
  />
106
108
  </div>
107
109
  );
@@ -0,0 +1,118 @@
1
+ // File/Image-Upload-Widget für die Auto-Edit-Form. Lädt eine Datei per
2
+ // multipart-POST an /api/files (vom Stack gemountet wenn ein storageProvider
3
+ // gesetzt ist), bekommt die FileRef-UUID zurück und gibt sie als Field-Wert
4
+ // hoch. Image → runde Avatar-Preview (GET /api/files/:id), file → Dateiname.
5
+
6
+ import { CSRF_HEADER_NAME, readCsrfToken } from "@cosmicdrift/kumiko-dispatcher-live";
7
+ import { ImageIcon, Loader2, Upload } from "lucide-react";
8
+ import { type ChangeEvent, type ReactNode, useRef, useState } from "react";
9
+ import { Button as UiButton } from "../ui/button";
10
+
11
+ export type FileUploadInputProps = {
12
+ readonly kind: "file" | "image";
13
+ readonly id: string;
14
+ readonly value: string | null;
15
+ readonly onChange: (fileId: string | null) => void;
16
+ readonly accept?: readonly string[];
17
+ readonly disabled?: boolean;
18
+ readonly entityType?: string;
19
+ readonly fieldName?: string;
20
+ };
21
+
22
+ // "jpg" → ".jpg", "image/png" bleibt. Leere Liste → kein accept-Attribut.
23
+ function toAcceptAttr(accept?: readonly string[]): string | undefined {
24
+ if (accept === undefined || accept.length === 0) return undefined;
25
+ return accept.map((a) => (a.startsWith(".") || a.includes("/") ? a : `.${a}`)).join(",");
26
+ }
27
+
28
+ export function FileUploadInput({
29
+ kind,
30
+ id,
31
+ value,
32
+ onChange,
33
+ accept,
34
+ disabled,
35
+ entityType,
36
+ fieldName,
37
+ }: FileUploadInputProps): ReactNode {
38
+ const inputRef = useRef<HTMLInputElement>(null);
39
+ const [uploading, setUploading] = useState(false);
40
+ const [error, setError] = useState<string | null>(null);
41
+
42
+ async function onPick(e: ChangeEvent<HTMLInputElement>): Promise<void> {
43
+ const file = e.target.files?.[0];
44
+ if (file === undefined) return;
45
+ setUploading(true);
46
+ setError(null);
47
+ try {
48
+ const fd = new FormData();
49
+ fd.append("file", file);
50
+ if (entityType !== undefined) fd.append("entityType", entityType);
51
+ if (fieldName !== undefined) fd.append("fieldName", fieldName);
52
+ // Double-Submit-CSRF wie der Dispatcher: kumiko_csrf-Cookie → Header.
53
+ const csrf = readCsrfToken();
54
+ const res = await fetch("/api/files", {
55
+ method: "POST",
56
+ body: fd,
57
+ ...(csrf !== undefined && { headers: { [CSRF_HEADER_NAME]: csrf } }),
58
+ });
59
+ const json = (await res.json().catch(() => ({}))) as { id?: string; error?: string };
60
+ if (!res.ok || json.id === undefined) {
61
+ setError(json.error ?? "upload_failed");
62
+ return;
63
+ }
64
+ onChange(json.id);
65
+ } catch {
66
+ setError("upload_failed");
67
+ } finally {
68
+ setUploading(false);
69
+ }
70
+ }
71
+
72
+ const acceptAttr = toAcceptAttr(accept);
73
+
74
+ return (
75
+ <div className="flex items-center gap-4">
76
+ {kind === "image" &&
77
+ (value !== null ? (
78
+ <img
79
+ src={`/api/files/${value}`}
80
+ alt=""
81
+ className="size-16 rounded-full border object-cover"
82
+ />
83
+ ) : (
84
+ <div className="bg-muted text-muted-foreground flex size-16 items-center justify-center rounded-full border">
85
+ <ImageIcon className="size-6" />
86
+ </div>
87
+ ))}
88
+ {kind === "file" && value !== null && (
89
+ <span className="text-muted-foreground text-sm">File attached</span>
90
+ )}
91
+ <input
92
+ ref={inputRef}
93
+ id={id}
94
+ type="file"
95
+ className="hidden"
96
+ {...(acceptAttr !== undefined && { accept: acceptAttr })}
97
+ onChange={(e) => void onPick(e)}
98
+ disabled={disabled}
99
+ />
100
+ <UiButton
101
+ type="button"
102
+ variant="outline"
103
+ size="sm"
104
+ disabled={disabled === true || uploading}
105
+ onClick={() => inputRef.current?.click()}
106
+ >
107
+ {uploading ? <Loader2 className="size-4 animate-spin" /> : <Upload className="size-4" />}
108
+ {value !== null ? "Change" : "Upload"}
109
+ </UiButton>
110
+ {value !== null && (
111
+ <UiButton type="button" variant="ghost" size="sm" onClick={() => onChange(null)}>
112
+ Remove
113
+ </UiButton>
114
+ )}
115
+ {error !== null && <span className="text-destructive text-xs">{error}</span>}
116
+ </div>
117
+ );
118
+ }