@medalsocial/meda 1.2.0 → 1.3.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/dist/shell/app-shell-workspace.js +11 -6
- package/dist/shell/command-palette.js +2 -1
- package/dist/shell/context-rail.d.ts +1 -1
- package/dist/shell/context-rail.js +5 -4
- package/dist/shell/index.d.ts +2 -0
- package/dist/shell/index.js +1 -0
- package/dist/shell/internal/mobile-drawers.d.ts +8 -2
- package/dist/shell/internal/mobile-drawers.js +40 -12
- package/dist/shell/panel-views-provider.d.ts +15 -0
- package/dist/shell/panel-views-provider.js +56 -0
- package/dist/shell/right-panel.js +21 -8
- package/dist/shell/shell-provider.d.ts +14 -1
- package/dist/shell/shell-provider.js +64 -2
- package/dist/shell/types.d.ts +5 -1
- package/package.json +1 -1
|
@@ -6,18 +6,22 @@ import { IconRail } from './icon-rail.js';
|
|
|
6
6
|
import { MobileBottomNav } from './internal/mobile-bottom-nav.js';
|
|
7
7
|
import { MobileDrawers } from './internal/mobile-drawers.js';
|
|
8
8
|
import { MobileHeader } from './internal/mobile-header.js';
|
|
9
|
+
import { useResolvedPanelViews } from './panel-views-provider.js';
|
|
9
10
|
import { RightPanel } from './right-panel.js';
|
|
10
11
|
import { ShellHeader } from './shell-header.js';
|
|
11
12
|
import { ShellMain } from './shell-main.js';
|
|
12
13
|
import { useShellViewport } from './use-shell-viewport.js';
|
|
14
|
+
const EMPTY_PANEL_VIEWS = [];
|
|
13
15
|
export function AppShellWorkspace({ iconRail, contextRail, rightPanel, globalActions, children, }) {
|
|
14
16
|
const viewport = useShellViewport();
|
|
15
17
|
const isMobile = viewport === 'mobile';
|
|
18
|
+
const staticPanelViews = rightPanel?.panelViews ?? EMPTY_PANEL_VIEWS;
|
|
19
|
+
const resolvedRightPanel = useResolvedPanelViews(staticPanelViews, rightPanel?.defaultView);
|
|
16
20
|
// Derive the bottom-nav items from the variant config so each button maps
|
|
17
21
|
// to a drawer that actually has content. Without this filter, partial
|
|
18
22
|
// configs (e.g. iconRail only) would show Module/Panels/AI buttons that
|
|
19
23
|
// dispatch into the void.
|
|
20
|
-
const navItems = buildMobileNavItems(iconRail, contextRail,
|
|
24
|
+
const navItems = buildMobileNavItems(iconRail, contextRail, resolvedRightPanel.panelViews);
|
|
21
25
|
const hasDrawerContent = navItems.length > 0;
|
|
22
26
|
// Mobile menu drawer needs both main and utility items — desktop IconRail
|
|
23
27
|
// shows both, so dropping utilityItems here would orphan items like Help/
|
|
@@ -33,21 +37,22 @@ export function AppShellWorkspace({ iconRail, contextRail, rightPanel, globalAct
|
|
|
33
37
|
// rendered without an explicit-height ancestor (tests, direct imports). The
|
|
34
38
|
// <AppShell> wrapper already enforces h-screen for the workspace variant, so
|
|
35
39
|
// nested viewport-height divs collapse cleanly — no double-scroll.
|
|
36
|
-
return (_jsxs("div", { className: "flex h-screen flex-col", children: [isMobile ? (_jsx(MobileHeader, { globalActions: globalActions })) : (_jsx(ShellHeader, { globalActions: globalActions })), _jsxs("div", { className: "relative flex flex-1 overflow-hidden", children: [!isMobile && iconRail && (_jsx(IconRail, { mainItems: iconRail.mainItems, utilityItems: iconRail.utilityItems, footer: iconRail.footer, activeId: iconRail.activeId })), !isMobile && contextRail && (_jsx(ContextRail, { appId: contextRail.appId, module: contextRail.module, activeItemId: contextRail.activeItemId })), _jsx(ShellMain, { layout: "workspace", children: children }), !isMobile &&
|
|
40
|
+
return (_jsxs("div", { className: "flex h-screen flex-col", children: [isMobile ? (_jsx(MobileHeader, { globalActions: globalActions })) : (_jsx(ShellHeader, { globalActions: globalActions })), _jsxs("div", { className: "relative flex flex-1 overflow-hidden", children: [!isMobile && iconRail && (_jsx(IconRail, { mainItems: iconRail.mainItems, utilityItems: iconRail.utilityItems, footer: iconRail.footer, activeId: iconRail.activeId, renderLink: iconRail.renderLink })), !isMobile && contextRail && (_jsx(ContextRail, { appId: contextRail.appId, module: contextRail.module, activeItemId: contextRail.activeItemId })), _jsx(ShellMain, { layout: "workspace", children: children }), !isMobile && resolvedRightPanel.panelViews.length > 0 && (_jsx(RightPanel, { panelViews: staticPanelViews, defaultView: rightPanel?.defaultView }))] }), isMobile && hasDrawerContent && _jsx(MobileBottomNav, { items: navItems }), isMobile && hasDrawerContent && (_jsx(MobileDrawers, { menuItems: mobileMenuItems, menuActiveId: iconRail?.activeId, menuRenderLink: iconRail?.renderLink, module: contextRail?.module, moduleAppId: contextRail?.appId, panelViews: resolvedRightPanel.panelViews, defaultView: resolvedRightPanel.defaultView }))] }));
|
|
37
41
|
}
|
|
38
|
-
function buildMobileNavItems(iconRail, contextRail,
|
|
42
|
+
function buildMobileNavItems(iconRail, contextRail, panelViews) {
|
|
39
43
|
const items = [];
|
|
40
44
|
if (iconRail) {
|
|
41
45
|
items.push({ id: 'menu', label: 'Menu', icon: Menu, opens: 'menu-drawer' });
|
|
42
46
|
}
|
|
43
|
-
if (contextRail?.module
|
|
47
|
+
if (contextRail?.module &&
|
|
48
|
+
((contextRail.module.items ?? []).length > 0 || Boolean(contextRail.module.render))) {
|
|
44
49
|
items.push({ id: 'module', label: 'Module', icon: LayoutGrid, opens: 'module-drawer' });
|
|
45
50
|
}
|
|
46
51
|
// Panels button only when there's an actual view to render — empty
|
|
47
52
|
// panelViews would open an empty drawer (dead-end tap).
|
|
48
|
-
if (
|
|
53
|
+
if (panelViews.length > 0) {
|
|
49
54
|
items.push({ id: 'panels', label: 'Panels', icon: PanelTop, opens: 'panels-drawer' });
|
|
50
|
-
if (
|
|
55
|
+
if (panelViews.some((v) => v.id === 'ai')) {
|
|
51
56
|
items.push({ id: 'ai', label: 'AI', icon: Sparkles, opens: 'ai-drawer' });
|
|
52
57
|
}
|
|
53
58
|
}
|
|
@@ -109,7 +109,8 @@ export function CommandPalette({ children }) {
|
|
|
109
109
|
// -------------------------------------------------------------------------
|
|
110
110
|
return (_jsxs(CommandRegistryContext.Provider, { value: registry, children: [children, _jsx(CommandDialog, { open: open, onOpenChange: setOpen, children: _jsxs(Command, { children: [_jsx(CommandInput, { placeholder: "Search commands\u2026" }), _jsxs(CommandList, { children: [_jsx(CommandEmpty, { children: "No results." }), grouped.map((group) => (_jsx(CommandGroup, { heading: group.label, children: group.commands.map((cmd) => {
|
|
111
111
|
const Icon = cmd.icon;
|
|
112
|
-
|
|
112
|
+
const shortcut = cmd.shortcut ?? cmd.hotkey;
|
|
113
|
+
return (_jsxs(CommandItem, { value: `${group.label} ${cmd.label}`, onSelect: () => handleSelect(cmd), children: [Icon && _jsx(Icon, { size: 16, "aria-hidden": "true" }), _jsx("span", { children: cmd.label }), shortcut && _jsx(CommandShortcut, { children: shortcut })] }, cmd.id));
|
|
113
114
|
}) }, group.id)))] })] }) })] }));
|
|
114
115
|
}
|
|
115
116
|
// ---------------------------------------------------------------------------
|
|
@@ -14,4 +14,4 @@ export interface ContextRailProps {
|
|
|
14
14
|
renderLink?: (args: ShellLinkRenderArgs) => ReactNode;
|
|
15
15
|
className?: string;
|
|
16
16
|
}
|
|
17
|
-
export declare function ContextRail({ appId
|
|
17
|
+
export declare function ContextRail({ appId, module, hidden, collapsible, activeItemId, renderLink, className, }: ContextRailProps): import("react/jsx-runtime").JSX.Element | null;
|
|
@@ -75,7 +75,7 @@ function ContextRailToggle({ railId }) {
|
|
|
75
75
|
// ---------------------------------------------------------------------------
|
|
76
76
|
// ContextRail
|
|
77
77
|
// ---------------------------------------------------------------------------
|
|
78
|
-
export function ContextRail({ appId
|
|
78
|
+
export function ContextRail({ appId, module, hidden = false, collapsible = true, activeItemId, renderLink, className, }) {
|
|
79
79
|
const band = useShellViewport();
|
|
80
80
|
const ctx = useMedaShell();
|
|
81
81
|
// collapsible={false} means the rail must always render expanded — even if
|
|
@@ -91,12 +91,13 @@ export function ContextRail({ appId: _appId, module, hidden = false, collapsible
|
|
|
91
91
|
// is called on pointerUp to persist via useShellLayoutState.
|
|
92
92
|
const [displayWidth, setDisplayWidth] = useState(null);
|
|
93
93
|
const width = displayWidth ?? ctx.contextRail.width;
|
|
94
|
+
const items = module?.items ?? [];
|
|
94
95
|
if (band === 'mobile')
|
|
95
96
|
return null;
|
|
96
97
|
if (hidden) {
|
|
97
98
|
return _jsx("div", { "aria-hidden": "true", className: "hidden", "data-testid": "context-rail-hidden" });
|
|
98
99
|
}
|
|
99
|
-
if (!module ||
|
|
100
|
+
if (!module || (items.length === 0 && !module.render)) {
|
|
100
101
|
return _jsx("div", { "aria-hidden": "true", className: "hidden", "data-testid": "context-rail-empty" });
|
|
101
102
|
}
|
|
102
103
|
const handleResize = (w) => {
|
|
@@ -112,7 +113,7 @@ export function ContextRail({ appId: _appId, module, hidden = false, collapsible
|
|
|
112
113
|
// transition then so the rail snaps to the cursor instead of lagging
|
|
113
114
|
// behind it.
|
|
114
115
|
const isDragging = displayWidth !== null;
|
|
115
|
-
return (_jsxs("aside", { id: railId, "data-testid": "context-rail", "aria-label": module.label, className: cn('relative h-full shrink-0 border-r border-shell-border bg-shell-context', !isDragging && 'transition-[width] duration-200 ease-in-out motion-reduce:transition-none', collapsed && 'w-0', className), style: { width: collapsed ? 0 : width }, children: [collapsible && _jsx(ContextRailToggle, { railId: railId }), _jsxs("div", { className: "h-full overflow-hidden", "aria-hidden": collapsed, inert: collapsed || undefined, children: [_jsxs("div", { className: "border-b border-shell-border px-4 py-3", children: [_jsx("h2", { className: "text-sm font-semibold text-foreground", children: module.label }), module.description && (_jsx("p", { className: "mt-0.5 text-xs text-muted-foreground", children: module.description }))] }), _jsx("nav", { "aria-label": `${module.label} navigation`, className: "flex flex-col gap-0.5 p-2", children:
|
|
116
|
+
return (_jsxs("aside", { id: railId, "data-testid": "context-rail", "aria-label": module.label, className: cn('relative h-full shrink-0 border-r border-shell-border bg-shell-context', !isDragging && 'transition-[width] duration-200 ease-in-out motion-reduce:transition-none', collapsed && 'w-0', className), style: { width: collapsed ? 0 : width }, children: [collapsible && _jsx(ContextRailToggle, { railId: railId }), _jsxs("div", { className: "h-full overflow-hidden", "aria-hidden": collapsed, inert: collapsed || undefined, children: [_jsxs("div", { className: "border-b border-shell-border px-4 py-3", children: [_jsx("h2", { className: "text-sm font-semibold text-foreground", children: module.label }), module.description && (_jsx("p", { className: "mt-0.5 text-xs text-muted-foreground", children: module.description }))] }), items.length > 0 && (_jsx("nav", { "aria-label": `${module.label} navigation`, className: "flex flex-col gap-0.5 p-2", children: items.map((item) => {
|
|
116
117
|
const isActive = item.id === activeItemId;
|
|
117
118
|
const klass = cn('flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors', isActive
|
|
118
119
|
? 'bg-primary/10 text-primary'
|
|
@@ -123,5 +124,5 @@ export function ContextRail({ appId: _appId, module, hidden = false, collapsible
|
|
|
123
124
|
return renderLink({ item, isActive, className: klass, children: inner });
|
|
124
125
|
}
|
|
125
126
|
return (_jsx("a", { href: item.to, "aria-current": isActive ? 'page' : undefined, className: klass, children: inner }, item.id));
|
|
126
|
-
}) })] }), !collapsed && (_jsx(ResizeHandle, { currentWidth: width, onResize: handleResize, onCommit: handleCommit }))] }));
|
|
127
|
+
}) })), module.render?.({ workspaceId: ctx.workspace.id, appId })] }), !collapsed && (_jsx(ResizeHandle, { currentWidth: width, onResize: handleResize, onCommit: handleCommit }))] }));
|
|
127
128
|
}
|
package/dist/shell/index.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export { IconRail, RailDivider } from './icon-rail.js';
|
|
|
9
9
|
export type { ShellStorageAdapter } from './layout-state.js';
|
|
10
10
|
export { createLocalStorageAdapter } from './layout-state.js';
|
|
11
11
|
export { motion } from './motion.js';
|
|
12
|
+
export type { PanelViewsProviderProps } from './panel-views-provider.js';
|
|
13
|
+
export { PanelViewsProvider } from './panel-views-provider.js';
|
|
12
14
|
export type { RailDropSlotProps, RailDropSlotState } from './rail-drop-slot.js';
|
|
13
15
|
export { RailDropSlot } from './rail-drop-slot.js';
|
|
14
16
|
export type { RailDropZonesProps } from './rail-drop-zones.js';
|
package/dist/shell/index.js
CHANGED
|
@@ -18,6 +18,7 @@ export { IconRail, RailDivider } from './icon-rail.js';
|
|
|
18
18
|
export { createLocalStorageAdapter } from './layout-state.js';
|
|
19
19
|
// Hooks + tokens
|
|
20
20
|
export { motion } from './motion.js';
|
|
21
|
+
export { PanelViewsProvider } from './panel-views-provider.js';
|
|
21
22
|
// Resize primitives
|
|
22
23
|
export { RailDropSlot } from './rail-drop-slot.js';
|
|
23
24
|
export { RailDropZones } from './rail-drop-zones.js';
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
|
-
import type { IconRailItem } from '../icon-rail.js';
|
|
2
|
+
import type { IconRailItem, IconRailProps } from '../icon-rail.js';
|
|
3
3
|
import type { ContextModule, PanelView } from '../types.js';
|
|
4
4
|
export interface MobileDrawersProps {
|
|
5
5
|
/** Menu drawer source (icon-rail items). */
|
|
6
6
|
menuItems?: IconRailItem[];
|
|
7
|
+
/** Active menu item id, sourced from icon rail config. */
|
|
8
|
+
menuActiveId?: string;
|
|
9
|
+
/** Custom menu link renderer, sourced from icon rail config. */
|
|
10
|
+
menuRenderLink?: IconRailProps['renderLink'];
|
|
7
11
|
/** Module drawer source (current app's context-rail module). */
|
|
8
12
|
module?: ContextModule;
|
|
13
|
+
/** App id used when rendering module custom content. */
|
|
14
|
+
moduleAppId?: string;
|
|
9
15
|
/** Panels drawer source. */
|
|
10
16
|
panelViews?: PanelView[];
|
|
11
17
|
/**
|
|
@@ -23,4 +29,4 @@ export interface MobileDrawersProps {
|
|
|
23
29
|
* any custom-content drawers. Mount once near the AppShell root; drawers
|
|
24
30
|
* open/close via `ctx.mobileDrawer.open` provider state.
|
|
25
31
|
*/
|
|
26
|
-
export declare function MobileDrawers({ menuItems, module, panelViews, defaultView, customContent, }: MobileDrawersProps): import("react/jsx-runtime").JSX.Element;
|
|
32
|
+
export declare function MobileDrawers({ menuItems, menuActiveId, menuRenderLink, module, moduleAppId, panelViews, defaultView, customContent, }: MobileDrawersProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useEffect } from 'react';
|
|
3
|
+
import { cloneElement, isValidElement, useEffect } from 'react';
|
|
4
4
|
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, } from '../../components/ui/drawer.js';
|
|
5
5
|
import { cn } from '../../lib/utils.js';
|
|
6
6
|
import { useMedaShell } from '../shell-provider.js';
|
|
@@ -9,7 +9,7 @@ import { useMedaShell } from '../shell-provider.js';
|
|
|
9
9
|
* any custom-content drawers. Mount once near the AppShell root; drawers
|
|
10
10
|
* open/close via `ctx.mobileDrawer.open` provider state.
|
|
11
11
|
*/
|
|
12
|
-
export function MobileDrawers({ menuItems = [], module, panelViews = [], defaultView, customContent = {}, }) {
|
|
12
|
+
export function MobileDrawers({ menuItems = [], menuActiveId, menuRenderLink, module, moduleAppId, panelViews = [], defaultView, customContent = {}, }) {
|
|
13
13
|
const ctx = useMedaShell();
|
|
14
14
|
const open = ctx.mobileDrawer.open;
|
|
15
15
|
const setOpen = ctx.mobileDrawer.setOpen;
|
|
@@ -18,24 +18,52 @@ export function MobileDrawers({ menuItems = [], module, panelViews = [], default
|
|
|
18
18
|
workspaceId: ctx.workspace.id,
|
|
19
19
|
appId: ctx.activeAppId,
|
|
20
20
|
};
|
|
21
|
-
|
|
21
|
+
const moduleRenderCtx = {
|
|
22
|
+
workspaceId: ctx.workspace.id,
|
|
23
|
+
appId: moduleAppId ?? ctx.activeAppId,
|
|
24
|
+
};
|
|
25
|
+
return (_jsxs(_Fragment, { children: [_jsx(MenuDrawer, { open: open === 'menu-drawer', onClose: close, items: menuItems, activeId: menuActiveId, renderLink: menuRenderLink }), _jsx(ModuleDrawer, { open: open === 'module-drawer', onClose: close, module: module, renderCtx: moduleRenderCtx }), _jsx(PanelsDrawer, { open: open === 'panels-drawer', onClose: close, panelViews: panelViews, defaultView: defaultView, renderCtx: renderCtx }), _jsx(AiDrawer, { open: open === 'ai-drawer', onClose: close, panelViews: panelViews, renderCtx: renderCtx }), Object.entries(customContent).map(([id, renderFn]) => (_jsx(Drawer, { open: open === id, onOpenChange: (o) => !o && close(), direction: "bottom", children: _jsx(DrawerContent, { children: renderFn(close) }) }, id)))] }));
|
|
22
26
|
}
|
|
23
27
|
// ---------------------------------------------------------------------------
|
|
24
28
|
// Internal sub-drawers
|
|
25
29
|
// ---------------------------------------------------------------------------
|
|
26
|
-
function MenuDrawer({ open, onClose, items, }) {
|
|
27
|
-
return (_jsx(Drawer, { open: open, onOpenChange: (o) => !o && onClose(), direction: "left", children: _jsxs(DrawerContent, { children: [_jsxs(DrawerHeader, { children: [_jsx(DrawerTitle, { children: "Menu" }), _jsx(DrawerDescription, { className: "sr-only", children: "Switch between primary app areas." })] }), _jsx("nav", { className: "flex flex-col gap-0.5 p-2", children: items.map((item) => {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
function MenuDrawer({ open, onClose, items, activeId, renderLink, }) {
|
|
31
|
+
return (_jsx(Drawer, { open: open, onOpenChange: (o) => !o && onClose(), direction: "left", children: _jsxs(DrawerContent, { children: [_jsxs(DrawerHeader, { children: [_jsx(DrawerTitle, { children: "Menu" }), _jsx(DrawerDescription, { className: "sr-only", children: "Switch between primary app areas." })] }), _jsx("nav", { className: "flex flex-col gap-0.5 p-2", children: items.map((item) => (_jsx(MenuDrawerItem, { item: item, isActive: item.id === activeId, onClose: onClose, renderLink: renderLink }, item.id))) })] }) }));
|
|
32
|
+
}
|
|
33
|
+
const menuItemClassName = 'flex items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground';
|
|
34
|
+
function MenuDrawerItem({ item, isActive, onClose, renderLink, }) {
|
|
35
|
+
const Icon = item.icon;
|
|
36
|
+
const children = (_jsxs(_Fragment, { children: [_jsx(Icon, { size: 18, "aria-hidden": "true" }), _jsx("span", { children: item.label })] }));
|
|
37
|
+
if (renderLink) {
|
|
38
|
+
return closeAfterLinkClick(renderLink({
|
|
39
|
+
item,
|
|
40
|
+
isActive,
|
|
41
|
+
className: cn(menuItemClassName, isActive && 'bg-accent text-foreground'),
|
|
42
|
+
children,
|
|
43
|
+
}), onClose);
|
|
44
|
+
}
|
|
45
|
+
return (_jsx("a", { href: item.to, onClick: onClose, className: menuItemClassName, children: children }));
|
|
46
|
+
}
|
|
47
|
+
function closeAfterLinkClick(link, onClose) {
|
|
48
|
+
if (!isValidElement(link)) {
|
|
49
|
+
return link;
|
|
50
|
+
}
|
|
51
|
+
const originalOnClick = link.props.onClick;
|
|
52
|
+
return cloneElement(link, {
|
|
53
|
+
onClick: (event) => {
|
|
54
|
+
originalOnClick?.(event);
|
|
55
|
+
onClose();
|
|
56
|
+
},
|
|
57
|
+
});
|
|
31
58
|
}
|
|
32
|
-
function ModuleDrawer({ open, onClose, module, }) {
|
|
33
|
-
|
|
59
|
+
function ModuleDrawer({ open, onClose, module, renderCtx, }) {
|
|
60
|
+
const items = module?.items ?? [];
|
|
61
|
+
if (!module || (items.length === 0 && !module.render))
|
|
34
62
|
return null;
|
|
35
|
-
return (_jsx(Drawer, { open: open, onOpenChange: (o) => !o && onClose(), direction: "left", children: _jsxs(DrawerContent, { children: [_jsxs(DrawerHeader, { children: [_jsx(DrawerTitle, { children: module.label }), module.description && (_jsx(DrawerDescription, { className: "text-muted-foreground text-xs", children: module.description }))] }), _jsx("nav", { className: "flex flex-col gap-0.5 p-2", children:
|
|
63
|
+
return (_jsx(Drawer, { open: open, onOpenChange: (o) => !o && onClose(), direction: "left", children: _jsxs(DrawerContent, { children: [_jsxs(DrawerHeader, { children: [_jsx(DrawerTitle, { children: module.label }), module.description && (_jsx(DrawerDescription, { className: "text-muted-foreground text-xs", children: module.description }))] }), items.length > 0 && (_jsx("nav", { className: "flex flex-col gap-0.5 p-2", children: items.map((item) => {
|
|
36
64
|
const Icon = item.icon;
|
|
37
65
|
return (_jsxs("a", { href: item.to, onClick: onClose, className: "flex items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground", children: [_jsx(Icon, { size: 16, "aria-hidden": "true" }), _jsx("span", { children: item.label })] }, item.id));
|
|
38
|
-
}) })] }) }));
|
|
66
|
+
}) })), module.render?.(renderCtx)] }) }));
|
|
39
67
|
}
|
|
40
68
|
function PanelsDrawer({ open, onClose, panelViews, defaultView, renderCtx, }) {
|
|
41
69
|
const ctx = useMedaShell();
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { type PanelViewRegistration } from './shell-provider.js';
|
|
3
|
+
import type { PanelView } from './types.js';
|
|
4
|
+
export interface PanelViewsProviderProps {
|
|
5
|
+
views: PanelView[];
|
|
6
|
+
defaultView?: string;
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
}
|
|
9
|
+
export interface ResolvedPanelViews {
|
|
10
|
+
panelViews: PanelView[];
|
|
11
|
+
defaultView?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function mergePanelViews(staticViews: PanelView[] | undefined, registrations: PanelViewRegistration[], staticDefaultView?: string): ResolvedPanelViews;
|
|
14
|
+
export declare function useResolvedPanelViews(staticViews?: PanelView[], staticDefaultView?: string): ResolvedPanelViews;
|
|
15
|
+
export declare function PanelViewsProvider({ views, defaultView, children }: PanelViewsProviderProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useId, useLayoutEffect, useMemo, useRef } from 'react';
|
|
4
|
+
import { useMedaShell } from './shell-provider.js';
|
|
5
|
+
export function mergePanelViews(staticViews = [], registrations, staticDefaultView) {
|
|
6
|
+
const panelViews = [];
|
|
7
|
+
for (const view of staticViews) {
|
|
8
|
+
panelViews.push(view);
|
|
9
|
+
}
|
|
10
|
+
let defaultView = staticDefaultView;
|
|
11
|
+
for (const registration of registrations) {
|
|
12
|
+
for (const view of registration.views) {
|
|
13
|
+
const previousIndex = panelViews.findIndex((existingView) => existingView.id === view.id);
|
|
14
|
+
if (previousIndex >= 0) {
|
|
15
|
+
panelViews.splice(previousIndex, 1);
|
|
16
|
+
}
|
|
17
|
+
panelViews.push(view);
|
|
18
|
+
}
|
|
19
|
+
if (registration.defaultView !== undefined) {
|
|
20
|
+
defaultView = registration.defaultView;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return defaultView === undefined ? { panelViews } : { panelViews, defaultView };
|
|
24
|
+
}
|
|
25
|
+
export function useResolvedPanelViews(staticViews = [], staticDefaultView) {
|
|
26
|
+
const { panelViews } = useMedaShell();
|
|
27
|
+
return useMemo(() => mergePanelViews(staticViews, panelViews.registrations, staticDefaultView), [staticViews, panelViews.registrations, staticDefaultView]);
|
|
28
|
+
}
|
|
29
|
+
function getPanelViewsSignature(views) {
|
|
30
|
+
return views.map((view) => `${view.id}\u001f${view.label}`).join('\u001e');
|
|
31
|
+
}
|
|
32
|
+
export function PanelViewsProvider({ views, defaultView, children }) {
|
|
33
|
+
const { register } = useMedaShell().panelViews;
|
|
34
|
+
const registrationId = useId();
|
|
35
|
+
const latestViewsRef = useRef(views);
|
|
36
|
+
latestViewsRef.current = views;
|
|
37
|
+
const viewsSignature = getPanelViewsSignature(views);
|
|
38
|
+
const registeredViewsRef = useRef(null);
|
|
39
|
+
if (registeredViewsRef.current?.signature !== viewsSignature) {
|
|
40
|
+
registeredViewsRef.current = {
|
|
41
|
+
signature: viewsSignature,
|
|
42
|
+
views: views.map((view) => ({
|
|
43
|
+
id: view.id,
|
|
44
|
+
label: view.label,
|
|
45
|
+
icon: view.icon,
|
|
46
|
+
render: (ctx) => latestViewsRef.current.find((currentView) => currentView.id === view.id)?.render(ctx) ??
|
|
47
|
+
null,
|
|
48
|
+
})),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const registeredViews = registeredViewsRef.current.views;
|
|
52
|
+
useLayoutEffect(() => {
|
|
53
|
+
return register(registrationId, registeredViews, defaultView);
|
|
54
|
+
}, [register, registrationId, registeredViews, defaultView]);
|
|
55
|
+
return _jsx(_Fragment, { children: children });
|
|
56
|
+
}
|
|
@@ -18,6 +18,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
18
18
|
import { Maximize2, Minimize2, X } from 'lucide-react';
|
|
19
19
|
import { useEffect, useRef, useState } from 'react';
|
|
20
20
|
import { cn } from '../lib/utils.js';
|
|
21
|
+
import { useResolvedPanelViews } from './panel-views-provider.js';
|
|
21
22
|
import { useMedaShell } from './shell-provider.js';
|
|
22
23
|
import { useShellViewport } from './use-shell-viewport.js';
|
|
23
24
|
// ---------------------------------------------------------------------------
|
|
@@ -25,6 +26,7 @@ import { useShellViewport } from './use-shell-viewport.js';
|
|
|
25
26
|
// ---------------------------------------------------------------------------
|
|
26
27
|
const MIN_WIDTH = 300;
|
|
27
28
|
const MAX_WIDTH = 520;
|
|
29
|
+
const EMPTY_PANEL_VIEWS = [];
|
|
28
30
|
function ResizeHandle({ currentWidth, onResize, onCommit }) {
|
|
29
31
|
const startWidthRef = useRef(currentWidth);
|
|
30
32
|
const startXRef = useRef(0);
|
|
@@ -61,17 +63,28 @@ function ResizeHandle({ currentWidth, onResize, onCommit }) {
|
|
|
61
63
|
// ---------------------------------------------------------------------------
|
|
62
64
|
// RightPanel
|
|
63
65
|
// ---------------------------------------------------------------------------
|
|
64
|
-
export function RightPanel({ panelViews =
|
|
66
|
+
export function RightPanel({ panelViews = EMPTY_PANEL_VIEWS, defaultView, modes = ['panel', 'expanded', 'fullscreen'], className, }) {
|
|
65
67
|
const band = useShellViewport();
|
|
66
68
|
const ctx = useMedaShell();
|
|
67
69
|
const { mode, activeView, width, setMode, setActiveView, setWidth } = ctx.panel;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
+
const { panelViews: resolvedPanelViews, defaultView: resolvedDefaultView } = useResolvedPanelViews(panelViews, defaultView);
|
|
71
|
+
const hydratedDefaultRef = useRef(null);
|
|
72
|
+
// Hydrate a default when static or route-registered views become available.
|
|
70
73
|
useEffect(() => {
|
|
71
|
-
|
|
72
|
-
|
|
74
|
+
const defaultExists = resolvedDefaultView != null &&
|
|
75
|
+
resolvedPanelViews.some((view) => view.id === resolvedDefaultView);
|
|
76
|
+
if (!defaultExists)
|
|
77
|
+
return;
|
|
78
|
+
const source = resolvedDefaultView !== defaultView ? 'registered' : 'static';
|
|
79
|
+
const canReplaceStaticDefault = activeView === hydratedDefaultRef.current?.id &&
|
|
80
|
+
hydratedDefaultRef.current.source === 'static' &&
|
|
81
|
+
source === 'registered' &&
|
|
82
|
+
activeView !== resolvedDefaultView;
|
|
83
|
+
if (!activeView || canReplaceStaticDefault) {
|
|
84
|
+
hydratedDefaultRef.current = { id: resolvedDefaultView, source };
|
|
85
|
+
setActiveView(resolvedDefaultView);
|
|
73
86
|
}
|
|
74
|
-
}, []);
|
|
87
|
+
}, [activeView, defaultView, resolvedDefaultView, resolvedPanelViews, setActiveView]);
|
|
75
88
|
// Local display width tracks pointer-move updates live;
|
|
76
89
|
// setWidth (persist) is called on pointerUp.
|
|
77
90
|
const [displayWidth, setDisplayWidth] = useState(null);
|
|
@@ -109,14 +122,14 @@ export function RightPanel({ panelViews = [], defaultView, modes = ['panel', 'ex
|
|
|
109
122
|
})();
|
|
110
123
|
// Z-index escalation: fullscreen covers header + rails
|
|
111
124
|
const zIndexClass = mode === 'fullscreen' ? 'z-[var(--z-shell-fullscreen)]' : 'z-[var(--z-shell-panel)]';
|
|
112
|
-
const activePanelView =
|
|
125
|
+
const activePanelView = resolvedPanelViews.find((v) => v.id === activeView);
|
|
113
126
|
const cycleAriaLabel = mode === 'panel' ? 'Expand panel' : mode === 'expanded' ? 'Maximize panel' : 'Restore panel';
|
|
114
127
|
const handleResize = (w) => setDisplayWidth(w);
|
|
115
128
|
const handleCommit = (w) => {
|
|
116
129
|
setDisplayWidth(null);
|
|
117
130
|
setWidth(w);
|
|
118
131
|
};
|
|
119
|
-
return (_jsx("aside", { "data-meda-panel-mode": mode, "aria-hidden": mode === 'closed' ? 'true' : undefined, className: cn('relative h-full shrink-0 overflow-hidden border-l border-shell-border bg-shell-panel', 'transition-[width] ease-[var(--motion-ease)] duration-[var(--motion-panel)]', mode === 'fullscreen' && 'fixed inset-0 h-screen w-screen border-none', zIndexClass, className), style: widthStyle, children: mode !== 'closed' && (_jsxs("div", { className: "flex h-full flex-col", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-shell-border px-3 py-2", children: [_jsx("div", { className: "flex items-center gap-1", children:
|
|
132
|
+
return (_jsx("aside", { "data-meda-panel-mode": mode, "aria-hidden": mode === 'closed' ? 'true' : undefined, className: cn('relative h-full shrink-0 overflow-hidden border-l border-shell-border bg-shell-panel', 'transition-[width] ease-[var(--motion-ease)] duration-[var(--motion-panel)]', mode === 'fullscreen' && 'fixed inset-0 h-screen w-screen border-none', zIndexClass, className), style: widthStyle, children: mode !== 'closed' && (_jsxs("div", { className: "flex h-full flex-col", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-shell-border px-3 py-2", children: [_jsx("div", { className: "flex items-center gap-1", children: resolvedPanelViews.map((view) => {
|
|
120
133
|
const isActive = view.id === activeView;
|
|
121
134
|
const Icon = view.icon;
|
|
122
135
|
return (_jsxs("button", { type: "button", "aria-current": isActive ? 'true' : undefined, onClick: () => setActiveView(view.id), className: cn('inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium transition-colors', isActive
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
2
|
import { type ShellStorageAdapter } from './layout-state.js';
|
|
3
|
-
import type { AppDefinition, MobileBottomNavItem, PanelMode, ThemeAdapter, WorkspaceDefinition } from './types.js';
|
|
3
|
+
import type { AppDefinition, MobileBottomNavItem, PanelMode, PanelView, ThemeAdapter, WorkspaceDefinition } from './types.js';
|
|
4
4
|
export type MobileDrawerKind = 'menu-drawer' | 'module-drawer' | 'panels-drawer' | 'ai-drawer' | (string & {}) | null;
|
|
5
|
+
export interface PanelViewRegistration {
|
|
6
|
+
id: string;
|
|
7
|
+
views: PanelView[];
|
|
8
|
+
defaultView?: string;
|
|
9
|
+
}
|
|
5
10
|
interface MedaShellContextValue {
|
|
6
11
|
workspace: WorkspaceDefinition;
|
|
7
12
|
workspaces: WorkspaceDefinition[];
|
|
@@ -15,6 +20,9 @@ interface MedaShellContextValue {
|
|
|
15
20
|
setMode: (m: PanelMode) => void;
|
|
16
21
|
setActiveView: (v: string | null) => void;
|
|
17
22
|
setWidth: (w: number) => void;
|
|
23
|
+
open: () => void;
|
|
24
|
+
close: () => void;
|
|
25
|
+
toggle: () => void;
|
|
18
26
|
/** Opens panel + switches to viewId in one call.
|
|
19
27
|
* Sugar for app keyboard shortcuts (e.g. Cmd+J → panel.focus('ai')).
|
|
20
28
|
* If already open in 'panel', 'expanded', or 'fullscreen', the existing
|
|
@@ -26,6 +34,7 @@ interface MedaShellContextValue {
|
|
|
26
34
|
collapsed: boolean;
|
|
27
35
|
setWidth: (w: number) => void;
|
|
28
36
|
setCollapsed: (c: boolean) => void;
|
|
37
|
+
toggle: () => void;
|
|
29
38
|
};
|
|
30
39
|
mobileBottomNav: MobileBottomNavItem[];
|
|
31
40
|
mobileDrawer: {
|
|
@@ -36,6 +45,10 @@ interface MedaShellContextValue {
|
|
|
36
45
|
open: boolean;
|
|
37
46
|
setOpen: (open: boolean) => void;
|
|
38
47
|
};
|
|
48
|
+
panelViews: {
|
|
49
|
+
registrations: PanelViewRegistration[];
|
|
50
|
+
register: (id: string, views: PanelView[], defaultView?: string) => () => void;
|
|
51
|
+
};
|
|
39
52
|
commandPaletteHotkey: string;
|
|
40
53
|
/** Selection bridge between main workspace and right panel views (spec §17). */
|
|
41
54
|
selection: unknown | null;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import { LayoutGrid, Menu, PanelTop, Sparkles } from 'lucide-react';
|
|
4
|
-
import { createContext, lazy, Suspense, useContext, useMemo, useState, } from 'react';
|
|
4
|
+
import { createContext, lazy, Suspense, useCallback, useContext, useMemo, useState, } from 'react';
|
|
5
5
|
import { createLocalStorageAdapter, useShellLayoutState, } from './layout-state.js';
|
|
6
6
|
import { DefaultThemeProvider, ThemeCtx } from './theme.js';
|
|
7
|
+
import { useShellViewport } from './use-shell-viewport.js';
|
|
7
8
|
// ---------------------------------------------------------------------------
|
|
8
9
|
// Theme adapter wiring
|
|
9
10
|
// ---------------------------------------------------------------------------
|
|
@@ -78,11 +79,35 @@ export function MedaShellProvider(props) {
|
|
|
78
79
|
open: commandPaletteOpen,
|
|
79
80
|
setOpen: setCommandPaletteOpen,
|
|
80
81
|
}), [commandPaletteOpen]);
|
|
82
|
+
const [panelViewRegistrations, setPanelViewRegistrations] = useState([]);
|
|
83
|
+
const registerPanelViews = useCallback((id, views, defaultView) => {
|
|
84
|
+
setPanelViewRegistrations((prev) => {
|
|
85
|
+
const existing = prev.find((registration) => registration.id === id);
|
|
86
|
+
if (existing?.views === views && existing.defaultView === defaultView)
|
|
87
|
+
return prev;
|
|
88
|
+
const nextRegistration = defaultView === undefined ? { id, views } : { id, views, defaultView };
|
|
89
|
+
if (existing == null)
|
|
90
|
+
return [...prev, nextRegistration];
|
|
91
|
+
return prev.map((registration) => (registration.id === id ? nextRegistration : registration));
|
|
92
|
+
});
|
|
93
|
+
return () => {
|
|
94
|
+
setPanelViewRegistrations((prev) => prev.some((registration) => registration.id === id &&
|
|
95
|
+
registration.views === views &&
|
|
96
|
+
registration.defaultView === defaultView)
|
|
97
|
+
? prev.filter((registration) => registration.id !== id)
|
|
98
|
+
: prev);
|
|
99
|
+
};
|
|
100
|
+
}, []);
|
|
101
|
+
const panelViews = useMemo(() => ({
|
|
102
|
+
registrations: panelViewRegistrations,
|
|
103
|
+
register: registerPanelViews,
|
|
104
|
+
}), [panelViewRegistrations, registerPanelViews]);
|
|
81
105
|
const [layoutState, setLayoutState] = useShellLayoutState({
|
|
82
106
|
workspaceId: props.workspace.id,
|
|
83
107
|
appId: activeAppId,
|
|
84
108
|
storage,
|
|
85
109
|
});
|
|
110
|
+
const isMobile = useShellViewport() === 'mobile';
|
|
86
111
|
const panel = useMemo(() => ({
|
|
87
112
|
mode: layoutState.rightPanel.mode,
|
|
88
113
|
activeView: layoutState.rightPanel.activeView,
|
|
@@ -99,6 +124,37 @@ export function MedaShellProvider(props) {
|
|
|
99
124
|
...prev,
|
|
100
125
|
rightPanel: { ...prev.rightPanel, width },
|
|
101
126
|
})),
|
|
127
|
+
open: () => {
|
|
128
|
+
if (isMobile)
|
|
129
|
+
setMobileDrawerOpen('panels-drawer');
|
|
130
|
+
setLayoutState((prev) => ({
|
|
131
|
+
...prev,
|
|
132
|
+
rightPanel: {
|
|
133
|
+
...prev.rightPanel,
|
|
134
|
+
mode: prev.rightPanel.mode === 'closed' ? 'panel' : prev.rightPanel.mode,
|
|
135
|
+
},
|
|
136
|
+
}));
|
|
137
|
+
},
|
|
138
|
+
close: () => {
|
|
139
|
+
if (isMobile)
|
|
140
|
+
setMobileDrawerOpen((open) => (open === 'panels-drawer' ? null : open));
|
|
141
|
+
setLayoutState((prev) => ({
|
|
142
|
+
...prev,
|
|
143
|
+
rightPanel: { ...prev.rightPanel, mode: 'closed' },
|
|
144
|
+
}));
|
|
145
|
+
},
|
|
146
|
+
toggle: () => {
|
|
147
|
+
if (isMobile) {
|
|
148
|
+
setMobileDrawerOpen((open) => (open === 'panels-drawer' ? null : 'panels-drawer'));
|
|
149
|
+
}
|
|
150
|
+
setLayoutState((prev) => ({
|
|
151
|
+
...prev,
|
|
152
|
+
rightPanel: {
|
|
153
|
+
...prev.rightPanel,
|
|
154
|
+
mode: prev.rightPanel.mode === 'closed' ? 'panel' : 'closed',
|
|
155
|
+
},
|
|
156
|
+
}));
|
|
157
|
+
},
|
|
102
158
|
// focus(viewId) — opens panel + switches to view in one call.
|
|
103
159
|
// Only flips closed → panel; preserves expanded / fullscreen modes.
|
|
104
160
|
focus: (viewId) => setLayoutState((prev) => {
|
|
@@ -108,7 +164,7 @@ export function MedaShellProvider(props) {
|
|
|
108
164
|
rightPanel: { ...prev.rightPanel, mode: nextMode, activeView: viewId },
|
|
109
165
|
};
|
|
110
166
|
}),
|
|
111
|
-
}), [layoutState, setLayoutState]);
|
|
167
|
+
}), [isMobile, layoutState, setLayoutState]);
|
|
112
168
|
const contextRail = useMemo(() => ({
|
|
113
169
|
width: layoutState.contextRail.width,
|
|
114
170
|
collapsed: layoutState.contextRail.collapsed,
|
|
@@ -120,6 +176,10 @@ export function MedaShellProvider(props) {
|
|
|
120
176
|
...prev,
|
|
121
177
|
contextRail: { ...prev.contextRail, collapsed },
|
|
122
178
|
})),
|
|
179
|
+
toggle: () => setLayoutState((prev) => ({
|
|
180
|
+
...prev,
|
|
181
|
+
contextRail: { ...prev.contextRail, collapsed: !prev.contextRail.collapsed },
|
|
182
|
+
})),
|
|
123
183
|
}), [layoutState, setLayoutState]);
|
|
124
184
|
const value = useMemo(() => ({
|
|
125
185
|
workspace: props.workspace,
|
|
@@ -132,6 +192,7 @@ export function MedaShellProvider(props) {
|
|
|
132
192
|
mobileBottomNav: props.mobileBottomNav ?? defaultMobileBottomNav,
|
|
133
193
|
mobileDrawer,
|
|
134
194
|
commandPalette,
|
|
195
|
+
panelViews,
|
|
135
196
|
commandPaletteHotkey: props.commandPaletteHotkey ?? 'mod+k',
|
|
136
197
|
selection,
|
|
137
198
|
setSelection,
|
|
@@ -145,6 +206,7 @@ export function MedaShellProvider(props) {
|
|
|
145
206
|
props.mobileBottomNav,
|
|
146
207
|
mobileDrawer,
|
|
147
208
|
commandPalette,
|
|
209
|
+
panelViews,
|
|
148
210
|
props.commandPaletteHotkey,
|
|
149
211
|
selection,
|
|
150
212
|
]);
|
package/dist/shell/types.d.ts
CHANGED
|
@@ -14,7 +14,8 @@ export interface ContextModule {
|
|
|
14
14
|
id: string;
|
|
15
15
|
label: string;
|
|
16
16
|
description?: string;
|
|
17
|
-
items
|
|
17
|
+
items?: ContextItem[];
|
|
18
|
+
render?: (ctx: ShellRenderContext) => ReactNode;
|
|
18
19
|
}
|
|
19
20
|
export interface ContextItem {
|
|
20
21
|
id: string;
|
|
@@ -55,6 +56,8 @@ export interface CommandDefinition {
|
|
|
55
56
|
icon?: LucideIcon;
|
|
56
57
|
group: string;
|
|
57
58
|
shortcut?: string;
|
|
59
|
+
/** Display-only alias for `shortcut`; `shortcut` wins when both are supplied. */
|
|
60
|
+
hotkey?: string;
|
|
58
61
|
run: () => void | Promise<void>;
|
|
59
62
|
}
|
|
60
63
|
export interface ThemeAdapter {
|
|
@@ -76,6 +79,7 @@ export interface AppShellIconRailConfig {
|
|
|
76
79
|
utilityItems?: import('./icon-rail.js').IconRailItem[];
|
|
77
80
|
footer?: ReactNode;
|
|
78
81
|
activeId?: string;
|
|
82
|
+
renderLink?: import('./icon-rail.js').IconRailProps['renderLink'];
|
|
79
83
|
}
|
|
80
84
|
/** ContextRail configuration for `<AppShell variant="workspace">`. */
|
|
81
85
|
export interface AppShellContextRailConfig {
|