@cosmicdrift/kumiko-renderer-web 0.178.1 → 0.181.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 +4 -4
- package/src/index.ts +1 -0
- package/src/layout/default-app-shell.tsx +41 -30
- package/src/layout/nav-tree.tsx +54 -18
- package/src/layout/sidebar-panel.tsx +177 -0
- package/src/layout/workspace-shell.tsx +35 -24
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.181.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.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
19
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.181.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.181.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.181.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
|
-
{
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
{...(
|
|
128
|
+
{...(headerActions !== undefined && { headerActions })}
|
|
107
129
|
/>
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
}
|
package/src/layout/nav-tree.tsx
CHANGED
|
@@ -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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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">
|
|
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">
|
|
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">
|
|
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">
|
|
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
|
|
796
|
-
|
|
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
|
-
<
|
|
231
|
-
<
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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
|
}
|