@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.
@@ -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, rightPanel);
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 && rightPanel && (_jsx(RightPanel, { panelViews: rightPanel.panelViews, defaultView: rightPanel.defaultView }))] }), isMobile && hasDrawerContent && _jsx(MobileBottomNav, { items: navItems }), isMobile && hasDrawerContent && (_jsx(MobileDrawers, { menuItems: mobileMenuItems, module: contextRail?.module, panelViews: rightPanel?.panelViews ?? [], defaultView: rightPanel?.defaultView }))] }));
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, rightPanel) {
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 (rightPanel && rightPanel.panelViews.length > 0) {
53
+ if (panelViews.length > 0) {
49
54
  items.push({ id: 'panels', label: 'Panels', icon: PanelTop, opens: 'panels-drawer' });
50
- if (rightPanel.panelViews.some((v) => v.id === 'ai')) {
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
- 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 }), cmd.shortcut && _jsx(CommandShortcut, { children: cmd.shortcut })] }, cmd.id));
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: _appId, module, hidden, collapsible, activeItemId, renderLink, className, }: ContextRailProps): import("react/jsx-runtime").JSX.Element | null;
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: _appId, module, hidden = false, collapsible = true, activeItemId, renderLink, className, }) {
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 || module.items.length === 0) {
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: module.items.map((item) => {
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
  }
@@ -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';
@@ -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
- return (_jsxs(_Fragment, { children: [_jsx(MenuDrawer, { open: open === 'menu-drawer', onClose: close, items: menuItems }), _jsx(ModuleDrawer, { open: open === 'module-drawer', onClose: close, module: module }), _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)))] }));
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
- const Icon = item.icon;
29
- 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: 18, "aria-hidden": "true" }), _jsx("span", { children: item.label })] }, item.id));
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
- if (!module)
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: module.items.map((item) => {
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 = [], defaultView, modes = ['panel', 'expanded', 'fullscreen'], className, }) {
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
- // Hydrate defaultView on mount if no active view set
69
- // biome-ignore lint/correctness/useExhaustiveDependencies: intentional one-time mount effect
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
- if (!activeView && defaultView) {
72
- setActiveView(defaultView);
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 = panelViews.find((v) => v.id === activeView);
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: panelViews.map((view) => {
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
  ]);
@@ -14,7 +14,8 @@ export interface ContextModule {
14
14
  id: string;
15
15
  label: string;
16
16
  description?: string;
17
- items: ContextItem[];
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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medalsocial/meda",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Shared Meda UI shell and runtime package.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {