@cosmicdrift/kumiko-renderer-web 0.178.0 → 0.180.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.178.0",
3
+ "version": "0.180.0",
4
4
  "description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -16,9 +16,9 @@
16
16
  "./styles.css": "./src/styles.css"
17
17
  },
18
18
  "dependencies": {
19
- "@cosmicdrift/kumiko-dispatcher-live": "0.178.0",
20
- "@cosmicdrift/kumiko-headless": "0.178.0",
21
- "@cosmicdrift/kumiko-renderer": "0.178.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.180.0",
20
+ "@cosmicdrift/kumiko-headless": "0.180.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.180.0",
22
22
  "@radix-ui/react-dialog": "^1.1.15",
23
23
  "@radix-ui/react-dropdown-menu": "^2.1.16",
24
24
  "@radix-ui/react-label": "^2.1.8",
package/src/index.ts CHANGED
@@ -114,6 +114,7 @@ export type { SidebarProps } from "./layout/sidebar";
114
114
  export { Sidebar } from "./layout/sidebar";
115
115
  export type { SidebarBrandProps } from "./layout/sidebar-brand";
116
116
  export { SidebarBrand } from "./layout/sidebar-brand";
117
+ export { SidebarPanel } from "./layout/sidebar-panel";
117
118
  export type { SidebarUserProps } from "./layout/sidebar-user";
118
119
  export { SidebarUser } from "./layout/sidebar-user";
119
120
  export { parseTargetFromSearchParams } from "./layout/target-url";
@@ -33,6 +33,7 @@ import {
33
33
  import { fillClasses } from "./fill-classes";
34
34
  import { NavTree } from "./nav-tree";
35
35
  import { ShellHeader } from "./shell-header";
36
+ import { SidebarPanelProvider, useSidebarPanelHost } from "./sidebar-panel";
36
37
 
37
38
  export type DefaultAppShellUser = {
38
39
  readonly id: string;
@@ -85,42 +86,52 @@ export function DefaultAppShell({
85
86
  children,
86
87
  }: DefaultAppShellProps): ReactNode {
87
88
  const fillCls = fillClasses(fill);
89
+ // Second column only when a screen fills it (sidebar-09 pattern) — otherwise
90
+ // every other screen would carry an empty strip next to the navigation.
91
+ const panel = useSidebarPanelHost();
88
92
  return (
89
93
  <SidebarProvider {...fillCls.provider}>
90
- {/* sidebar-07-Muster: Standard-Variante (border-r, flush content) +
94
+ <SidebarPanelProvider value={panel.value}>
95
+ {/* sidebar-07-Muster: Standard-Variante (border-r, flush content) +
91
96
  collapsible="icon" — der Rail klappt auf Icon-Breite zu (Trigger/Rail). */}
92
- <Sidebar collapsible="icon">
93
- <SidebarHeader data-kumiko-layout="sidebar-header">{brand}</SidebarHeader>
94
- {sidebarActions !== undefined && (
95
- <SidebarGroup
96
- data-kumiko-layout="sidebar-actions"
97
- className="flex-row items-center gap-1 py-0"
98
- >
99
- {sidebarActions}
100
- </SidebarGroup>
101
- )}
102
- <SidebarContent data-kumiko-layout="sidebar-nav">
103
- <NavTree
97
+ <Sidebar collapsible="icon">
98
+ <SidebarHeader data-kumiko-layout="sidebar-header">{brand}</SidebarHeader>
99
+ {sidebarActions !== undefined && (
100
+ <SidebarGroup
101
+ data-kumiko-layout="sidebar-actions"
102
+ className="flex-row items-center gap-1 py-0"
103
+ >
104
+ {sidebarActions}
105
+ </SidebarGroup>
106
+ )}
107
+ <SidebarContent data-kumiko-layout="sidebar-nav">
108
+ <NavTree
109
+ schema={schema}
110
+ {...(user !== undefined && { user })}
111
+ {...(navBadges !== undefined && { navBadges })}
112
+ />
113
+ </SidebarContent>
114
+ {sidebarFooter !== undefined && (
115
+ <SidebarFooter data-kumiko-layout="sidebar-footer">{sidebarFooter}</SidebarFooter>
116
+ )}
117
+ <SidebarRail />
118
+ </Sidebar>
119
+ <div
120
+ ref={panel.ref}
121
+ data-kumiko-layout="sidebar-panel"
122
+ className={panel.occupied ? "flex h-svh shrink-0 flex-col" : "hidden"}
123
+ />
124
+ <SidebarInset className={fillCls.inset}>
125
+ <ShellHeader
104
126
  schema={schema}
105
127
  {...(user !== undefined && { user })}
106
- {...(navBadges !== undefined && { navBadges })}
128
+ {...(headerActions !== undefined && { headerActions })}
107
129
  />
108
- </SidebarContent>
109
- {sidebarFooter !== undefined && (
110
- <SidebarFooter data-kumiko-layout="sidebar-footer">{sidebarFooter}</SidebarFooter>
111
- )}
112
- <SidebarRail />
113
- </Sidebar>
114
- <SidebarInset className={fillCls.inset}>
115
- <ShellHeader
116
- schema={schema}
117
- {...(user !== undefined && { user })}
118
- {...(headerActions !== undefined && { headerActions })}
119
- />
120
- <main className={fillCls.main}>
121
- <UserRolesProvider roles={user?.roles}>{children}</UserRolesProvider>
122
- </main>
123
- </SidebarInset>
130
+ <main className={fillCls.main}>
131
+ <UserRolesProvider roles={user?.roles}>{children}</UserRolesProvider>
132
+ </main>
133
+ </SidebarInset>
134
+ </SidebarPanelProvider>
124
135
  </SidebarProvider>
125
136
  );
126
137
  }
@@ -147,6 +147,8 @@ const NAV_ICONS: Readonly<Record<string, typeof Folder>> = {
147
147
  hash: Hash,
148
148
  download: Download,
149
149
  rocket: Rocket,
150
+ // Was imported but never registered — `icon: "plus"` silently fell back.
151
+ plus: Plus,
150
152
  };
151
153
 
152
154
  export type NavTreeProps = {
@@ -309,23 +311,44 @@ function NavLeadingIcon({
309
311
  node,
310
312
  active,
311
313
  expanded = false,
314
+ label,
312
315
  }: {
313
316
  node: NavNode;
314
317
  active: boolean;
315
318
  expanded?: boolean;
319
+ /** Only needed for the rail fallback — see below. */
320
+ label?: string;
316
321
  }): ReactNode {
317
322
  const iconKey = expanded && node.icon === "folder" ? "folder-open" : node.icon;
318
323
  const NavIcon =
319
324
  iconKey !== undefined && Object.hasOwn(NAV_ICONS, iconKey) ? NAV_ICONS[iconKey] : undefined;
320
325
  if (NavIcon !== undefined) return <NavIcon aria-hidden="true" className="shrink-0" />;
326
+ const initial = label?.trim().charAt(0).toUpperCase();
321
327
  return (
322
- <span
323
- aria-hidden="true"
324
- className={cn(
325
- "inline-block size-1.5 rounded-full",
326
- active ? "bg-sidebar-accent-foreground" : "bg-sidebar-foreground/40",
328
+ <>
329
+ {/* Expanded, a dot is enough: the label sits next to it and carries the
330
+ meaning. Collapsed the dot is worthless — a rail of identical dots says
331
+ nothing about what you are clicking. There the initial takes its place
332
+ until the app sets an icon. */}
333
+ <span
334
+ aria-hidden="true"
335
+ className={cn(
336
+ "inline-block size-1.5 shrink-0 rounded-full group-data-[collapsible=icon]:hidden",
337
+ active ? "bg-sidebar-accent-foreground" : "bg-sidebar-foreground/40",
338
+ )}
339
+ />
340
+ {initial !== undefined && initial !== "" && (
341
+ <span
342
+ aria-hidden="true"
343
+ className={cn(
344
+ "hidden size-4 shrink-0 items-center justify-center font-medium text-xs group-data-[collapsible=icon]:flex",
345
+ active ? "text-sidebar-accent-foreground" : "text-sidebar-foreground/70",
346
+ )}
347
+ >
348
+ {initial}
349
+ </span>
327
350
  )}
328
- />
351
+ </>
329
352
  );
330
353
  }
331
354
 
@@ -645,8 +668,10 @@ function NavMenuNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
645
668
  to={{ ...(s.workspaceId !== undefined && { workspaceId: s.workspaceId }), screenId }}
646
669
  {...(s.active && { "aria-current": "page" })}
647
670
  >
648
- <NavLeadingIcon node={node} active={s.active} />
649
- <span className="min-w-0 truncate">{s.displayLabel}</span>
671
+ <NavLeadingIcon node={node} active={s.active} label={s.displayLabel} />
672
+ <span className="min-w-0 truncate group-data-[collapsible=icon]:hidden">
673
+ {s.displayLabel}
674
+ </span>
650
675
  <NavBadge node={node} />
651
676
  </KumikoLink>
652
677
  </SidebarMenuButton>
@@ -666,8 +691,10 @@ function NavMenuNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
666
691
  tooltip={s.displayLabel}
667
692
  onClick={() => dispatch(target)}
668
693
  >
669
- <NavLeadingIcon node={node} active={s.active} />
670
- <span className="min-w-0 truncate">{s.displayLabel}</span>
694
+ <NavLeadingIcon node={node} active={s.active} label={s.displayLabel} />
695
+ <span className="min-w-0 truncate group-data-[collapsible=icon]:hidden">
696
+ {s.displayLabel}
697
+ </span>
671
698
  <NavBadge node={node} />
672
699
  </SidebarMenuButton>
673
700
  <NodeActions node={node} />
@@ -685,8 +712,8 @@ function NavMenuNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
685
712
  }}
686
713
  {...(s.expandable && { "aria-expanded": s.isExpanded })}
687
714
  >
688
- <NavLeadingIcon node={node} active={false} expanded={s.isExpanded} />
689
- <span className="truncate">{s.displayLabel}</span>
715
+ <NavLeadingIcon node={node} active={false} expanded={s.isExpanded} label={s.displayLabel} />
716
+ <span className="truncate group-data-[collapsible=icon]:hidden">{s.displayLabel}</span>
690
717
  {s.expandable &&
691
718
  (s.isExpanded ? (
692
719
  <ChevronDown className="ml-auto" />
@@ -753,8 +780,10 @@ function NavSubNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
753
780
  to={{ ...(s.workspaceId !== undefined && { workspaceId: s.workspaceId }), screenId }}
754
781
  {...(s.active && { "aria-current": "page" })}
755
782
  >
756
- <NavLeadingIcon node={node} active={s.active} />
757
- <span className="min-w-0 truncate">{s.displayLabel}</span>
783
+ <NavLeadingIcon node={node} active={s.active} label={s.displayLabel} />
784
+ <span className="min-w-0 truncate group-data-[collapsible=icon]:hidden">
785
+ {s.displayLabel}
786
+ </span>
758
787
  <NavBadge node={node} />
759
788
  </KumikoLink>
760
789
  </SidebarMenuSubButton>
@@ -771,8 +800,10 @@ function NavSubNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
771
800
  <SidebarMenuSubItem>
772
801
  <SidebarMenuSubButton asChild isActive={s.active}>
773
802
  <button type="button" onClick={() => dispatch(target)}>
774
- <NavLeadingIcon node={node} active={s.active} />
775
- <span className="min-w-0 truncate">{s.displayLabel}</span>
803
+ <NavLeadingIcon node={node} active={s.active} label={s.displayLabel} />
804
+ <span className="min-w-0 truncate group-data-[collapsible=icon]:hidden">
805
+ {s.displayLabel}
806
+ </span>
776
807
  <NavBadge node={node} />
777
808
  </button>
778
809
  </SidebarMenuSubButton>
@@ -792,8 +823,13 @@ function NavSubNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
792
823
  if (s.expandable) onToggle(node.qualifiedName);
793
824
  }}
794
825
  >
795
- <NavLeadingIcon node={node} active={false} expanded={s.isExpanded} />
796
- <span className="truncate">{s.displayLabel}</span>
826
+ <NavLeadingIcon
827
+ node={node}
828
+ active={false}
829
+ expanded={s.isExpanded}
830
+ label={s.displayLabel}
831
+ />
832
+ <span className="truncate group-data-[collapsible=icon]:hidden">{s.displayLabel}</span>
797
833
  </button>
798
834
  </SidebarMenuSubButton>
799
835
  <NodeActions node={node} />
@@ -0,0 +1,177 @@
1
+ // A second sidebar column for screens that need a list next to the navigation
2
+ // rather than inside the content — the shadcn `sidebar-09` pattern (mail
3
+ // client) against the `sidebar-07` the shell otherwise runs.
4
+ //
5
+ // Why a slot and not a screen layout: the area sits outside the `SidebarInset`
6
+ // and therefore above the ShellHeader. A screen renders by definition *into*
7
+ // the content, below the header, and cannot reach that spot from there. The
8
+ // slot inverts the direction: the shell holds the space, the screen fills it
9
+ // through a portal and stays an ordinary screen otherwise.
10
+ //
11
+ // With no screen filling it the shell does not render the space at all — no
12
+ // empty strip on every other screen.
13
+
14
+ import {
15
+ createContext,
16
+ type ReactNode,
17
+ type PointerEvent as ReactPointerEvent,
18
+ useCallback,
19
+ useContext,
20
+ useEffect,
21
+ useRef,
22
+ useState,
23
+ } from "react";
24
+ import { createPortal } from "react-dom";
25
+ import { cn } from "../lib/cn";
26
+
27
+ type SlotElement = HTMLElement | null;
28
+
29
+ type SidebarPanelSlot = {
30
+ readonly element: SlotElement;
31
+ /** Tells the shell state whether a screen is currently claiming the space. */
32
+ readonly setOccupied: (occupied: boolean) => void;
33
+ };
34
+
35
+ const SidebarPanelContext = createContext<SidebarPanelSlot | null>(null);
36
+
37
+ export const SidebarPanelProvider = SidebarPanelContext.Provider;
38
+
39
+ /** Shells only — screens use `SidebarPanel`. */
40
+ export function useSidebarPanelSlot(): SidebarPanelSlot | null {
41
+ return useContext(SidebarPanelContext);
42
+ }
43
+
44
+ export type SidebarPanelProps = {
45
+ readonly children: ReactNode;
46
+ /** Initial width in px. After the first drag the stored width wins. */
47
+ readonly defaultWidth?: number;
48
+ readonly minWidth?: number;
49
+ readonly maxWidth?: number;
50
+ /** localStorage key for the dragged width. Without a key nothing is stored —
51
+ * two screens with their own list should not overwrite each other's width. */
52
+ readonly storageKey?: string;
53
+ readonly className?: string;
54
+ };
55
+
56
+ const DEFAULT_WIDTH = 340;
57
+ const DEFAULT_MIN = 260;
58
+ const DEFAULT_MAX = 640;
59
+
60
+ function clamp(value: number, min: number, max: number): number {
61
+ return Math.min(Math.max(value, min), max);
62
+ }
63
+
64
+ function readStoredWidth(key: string | undefined): number | null {
65
+ if (key === undefined || typeof window === "undefined") return null;
66
+ const raw = window.localStorage.getItem(key);
67
+ if (raw === null) return null;
68
+ const parsed = Number.parseInt(raw, 10);
69
+ return Number.isFinite(parsed) ? parsed : null;
70
+ }
71
+
72
+ /** Renders its children into the second sidebar column when the shell offers
73
+ * one. When it does not (public surface, tests, an older shell) the children
74
+ * land in place — the screen stays usable instead of losing its list.
75
+ *
76
+ * The width drags like in any mail client: the handle sits on the divider and
77
+ * the result survives a reload (with `storageKey`). */
78
+ export function SidebarPanel({
79
+ children,
80
+ defaultWidth = DEFAULT_WIDTH,
81
+ minWidth = DEFAULT_MIN,
82
+ maxWidth = DEFAULT_MAX,
83
+ storageKey,
84
+ className,
85
+ }: SidebarPanelProps): ReactNode {
86
+ const slot = useSidebarPanelSlot();
87
+ const setOccupied = slot?.setOccupied;
88
+ const [width, setWidth] = useState<number>(() =>
89
+ clamp(readStoredWidth(storageKey) ?? defaultWidth, minWidth, maxWidth),
90
+ );
91
+ const dragging = useRef(false);
92
+
93
+ useEffect(() => {
94
+ if (setOccupied === undefined) return;
95
+ setOccupied(true);
96
+ return () => setOccupied(false);
97
+ }, [setOccupied]);
98
+
99
+ // Listeners on window, not on the handle: a fast drag leaves the 4px strip
100
+ // between two frames, and a handler on the element would lose the drag there.
101
+ useEffect(() => {
102
+ const onMove = (event: PointerEvent) => {
103
+ if (!dragging.current) return;
104
+ event.preventDefault();
105
+ setWidth(clamp(event.clientX, minWidth, maxWidth));
106
+ };
107
+ const onUp = () => {
108
+ if (!dragging.current) return;
109
+ dragging.current = false;
110
+ document.body.style.removeProperty("cursor");
111
+ document.body.style.removeProperty("user-select");
112
+ };
113
+ window.addEventListener("pointermove", onMove);
114
+ window.addEventListener("pointerup", onUp);
115
+ return () => {
116
+ window.removeEventListener("pointermove", onMove);
117
+ window.removeEventListener("pointerup", onUp);
118
+ };
119
+ }, [minWidth, maxWidth]);
120
+
121
+ useEffect(() => {
122
+ if (storageKey === undefined || typeof window === "undefined") return;
123
+ window.localStorage.setItem(storageKey, String(width));
124
+ }, [storageKey, width]);
125
+
126
+ const startDrag = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
127
+ event.preventDefault();
128
+ dragging.current = true;
129
+ // Set globally while dragging: otherwise the cursor flickers as soon as the
130
+ // pointer sits over the list instead of the handle, and a drag selects the
131
+ // row text on the way.
132
+ document.body.style.setProperty("cursor", "col-resize");
133
+ document.body.style.setProperty("user-select", "none");
134
+ }, []);
135
+
136
+ const content = (
137
+ <div
138
+ data-kumiko-layout="sidebar-panel-body"
139
+ className={cn(
140
+ "relative flex h-full shrink-0 flex-col border-sidebar-border border-r bg-sidebar",
141
+ className,
142
+ )}
143
+ style={{ width: `${width}px` }}
144
+ >
145
+ {children}
146
+ {/* No keyboard equivalent on purpose: the width is optional, the content
147
+ stays fully reachable without ever dragging. */}
148
+ <div
149
+ data-kumiko-layout="sidebar-panel-handle"
150
+ onPointerDown={startDrag}
151
+ className="absolute inset-y-0 right-0 w-1 cursor-col-resize bg-transparent transition-colors hover:bg-sidebar-border"
152
+ />
153
+ </div>
154
+ );
155
+
156
+ if (slot?.element == null) {
157
+ return content;
158
+ }
159
+ return createPortal(content, slot.element);
160
+ }
161
+
162
+ /** Shell-side counterpart: holds the slot element and the occupied state. Its
163
+ * own hook so both shells (WorkspaceShell, DefaultAppShell) share one wiring
164
+ * instead of getting the same thing half right twice. */
165
+ export function useSidebarPanelHost(): {
166
+ readonly occupied: boolean;
167
+ readonly value: SidebarPanelSlot;
168
+ readonly ref: (node: SlotElement) => void;
169
+ } {
170
+ const [element, setElement] = useState<SlotElement>(null);
171
+ const [occupied, setOccupied] = useState(false);
172
+ return {
173
+ occupied,
174
+ value: { element, setOccupied },
175
+ ref: setElement,
176
+ };
177
+ }
@@ -47,6 +47,7 @@ import { EditorPanel } from "./editor-panel";
47
47
  import { fillClasses } from "./fill-classes";
48
48
  import { lastSegment, NavTree } from "./nav-tree";
49
49
  import { ShellHeader } from "./shell-header";
50
+ import { SidebarPanelProvider, useSidebarPanelHost } from "./sidebar-panel";
50
51
  import { parseTargetFromSearchParams } from "./target-url";
51
52
  import { WorkspaceSwitcher } from "./workspace-switcher";
52
53
 
@@ -225,33 +226,43 @@ export function WorkspaceShell({
225
226
  // above the content. topbarActions render right inside the header row
226
227
  // instead of a separate topbar — one header line, not two.
227
228
  const fillCls = fillClasses(fill);
229
+ // Zweite Spalte nur wenn ein Screen sie fuellt (sidebar-09-Muster) — sonst
230
+ // stuende auf jedem anderen Screen ein leerer Streifen neben der Navigation.
231
+ const panel = useSidebarPanelHost();
228
232
  return (
229
233
  <SidebarProvider {...fillCls.provider}>
230
- <Sidebar collapsible="icon">
231
- <SidebarHeader data-kumiko-layout="sidebar-header">
232
- {brand}
233
- {switcher}
234
- </SidebarHeader>
235
- <SidebarContent data-kumiko-layout="sidebar-nav">{sidebarContent}</SidebarContent>
236
- {sidebarFooter !== undefined && (
237
- <SidebarFooter data-kumiko-layout="sidebar-footer">{sidebarFooter}</SidebarFooter>
238
- )}
239
- <SidebarRail />
240
- </Sidebar>
241
- <SidebarInset className={fillCls.inset}>
242
- <ShellHeader
243
- schema={app}
244
- {...(user !== undefined && { user })}
245
- {...(topbarActions !== undefined && { headerActions: topbarActions })}
246
- />
247
- <main className={fillCls.main}>
248
- {activeTarget !== undefined ? (
249
- <EditorPanel resolvers={resolvers} />
250
- ) : (
251
- <UserRolesProvider roles={user?.roles}>{children}</UserRolesProvider>
234
+ <SidebarPanelProvider value={panel.value}>
235
+ <Sidebar collapsible="icon">
236
+ <SidebarHeader data-kumiko-layout="sidebar-header">
237
+ {brand}
238
+ {switcher}
239
+ </SidebarHeader>
240
+ <SidebarContent data-kumiko-layout="sidebar-nav">{sidebarContent}</SidebarContent>
241
+ {sidebarFooter !== undefined && (
242
+ <SidebarFooter data-kumiko-layout="sidebar-footer">{sidebarFooter}</SidebarFooter>
252
243
  )}
253
- </main>
254
- </SidebarInset>
244
+ <SidebarRail />
245
+ </Sidebar>
246
+ <div
247
+ ref={panel.ref}
248
+ data-kumiko-layout="sidebar-panel"
249
+ className={panel.occupied ? "flex h-svh shrink-0 flex-col" : "hidden"}
250
+ />
251
+ <SidebarInset className={fillCls.inset}>
252
+ <ShellHeader
253
+ schema={app}
254
+ {...(user !== undefined && { user })}
255
+ {...(topbarActions !== undefined && { headerActions: topbarActions })}
256
+ />
257
+ <main className={fillCls.main}>
258
+ {activeTarget !== undefined ? (
259
+ <EditorPanel resolvers={resolvers} />
260
+ ) : (
261
+ <UserRolesProvider roles={user?.roles}>{children}</UserRolesProvider>
262
+ )}
263
+ </main>
264
+ </SidebarInset>
265
+ </SidebarPanelProvider>
255
266
  </SidebarProvider>
256
267
  );
257
268
  }