@medalsocial/meda 1.7.0 → 2.0.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/README.md CHANGED
@@ -109,6 +109,8 @@ Workspace shells also expose chrome-level composition slots:
109
109
 
110
110
  `workspace.menuItems`, `workspace.menuFooter`, and the theme toggle are available from the mobile Menu drawer. Use `mainLayout`/`mainClassName` when a workspace shell needs the same mobile chrome but a custom main scroll region, such as a full-bleed marketing page. `useCommands()` works from workspace descendants without manually mounting `CommandPalette`; lower-level primitive compositions can still mount `CommandPalette` directly.
111
111
 
112
+ `ContextRail` is usable for both navigation rails and custom rendered rails. Navigation rails show Meda's label header by default. Custom rendered rails hide the automatic visible header by default so consumers can render their own heading without duplication. Rail bodies scroll vertically by default; use `contextRail={{ header: "visible" }}` or `contextRail={{ scroll: "none" }}` when you need explicit control.
113
+
112
114
  ## Foundation primitives
113
115
 
114
116
  Meda includes small foundation primitives for repeated loading, empty, and filtering surfaces:
@@ -126,7 +128,6 @@ npx shadcn add https://meda.medalsocial.com/r/meda-skeleton.json
126
128
  npx shadcn add https://meda.medalsocial.com/r/meda-empty-state.json
127
129
  npx shadcn add https://meda.medalsocial.com/r/meda-filter-rail.json
128
130
  ```
129
-
130
131
  For app-scoped brand tokens:
131
132
 
132
133
  ```ts
@@ -39,7 +39,7 @@ export function AppShellWorkspace({ iconRail, contextRail, rightPanel, workspace
39
39
  // <AppShell> wrapper already enforces h-screen for the workspace variant, so
40
40
  // nested viewport-height divs collapse cleanly — no double-scroll.
41
41
  const commandRegistry = useContext(CommandRegistryContext);
42
- const shell = (_jsxs("div", { className: "flex h-screen flex-col", children: [isMobile ? (_jsx(MobileHeader, { globalActions: globalActions })) : (_jsx(ShellHeader, { globalActions: globalActions, headerCenter: headerCenter, appTabsRenderLink: appTabs?.renderLink, workspaceMenuItems: workspace?.menuItems, workspaceMenuFooter: workspace?.menuFooter })), banners ? (_jsx("div", { "data-meda-banners": "", className: "flex-shrink-0", children: banners })) : (false), _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: mainLayout ?? 'workspace', className: mainClassName, 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, workspaceMenuItems: workspace?.menuItems, workspaceMenuFooter: workspace?.menuFooter, module: contextRail?.module, moduleAppId: contextRail?.appId, panelViews: resolvedRightPanel.panelViews, defaultView: resolvedRightPanel.defaultView }))] }));
42
+ const shell = (_jsxs("div", { className: "flex h-screen flex-col", children: [isMobile ? (_jsx(MobileHeader, { globalActions: globalActions })) : (_jsx(ShellHeader, { globalActions: globalActions, headerCenter: headerCenter, appTabsRenderLink: appTabs?.renderLink, workspaceMenuItems: workspace?.menuItems, workspaceMenuFooter: workspace?.menuFooter })), banners ? (_jsx("div", { "data-meda-banners": "", className: "flex-shrink-0", children: banners })) : (false), _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, header: contextRail.header, scroll: contextRail.scroll })), _jsx(ShellMain, { layout: mainLayout ?? 'workspace', className: mainClassName, 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, workspaceMenuItems: workspace?.menuItems, workspaceMenuFooter: workspace?.menuFooter, module: contextRail?.module, moduleAppId: contextRail?.appId, moduleHeader: contextRail?.header, moduleScroll: contextRail?.scroll, panelViews: resolvedRightPanel.panelViews, defaultView: resolvedRightPanel.defaultView }))] }));
43
43
  return commandRegistry ? shell : _jsx(CommandPalette, { children: shell });
44
44
  }
45
45
  function buildMobileNavItems(contextRail, panelViews) {
@@ -1,5 +1,5 @@
1
1
  import type { ReactNode } from 'react';
2
- import type { ContextModule, ShellLinkRenderArgs } from './types.js';
2
+ import type { ContextModule, ContextRailHeader, ContextRailScroll, ShellLinkRenderArgs } from './types.js';
3
3
  export interface ContextRailProps {
4
4
  /** Drives the persistence key for layout state. */
5
5
  appId: string;
@@ -12,6 +12,8 @@ export interface ContextRailProps {
12
12
  /** Optional active item id for nav active state (preferred over useMedaShell().selection). */
13
13
  activeItemId?: string;
14
14
  renderLink?: (args: ShellLinkRenderArgs) => ReactNode;
15
+ header?: ContextRailHeader;
16
+ scroll?: ContextRailScroll;
15
17
  className?: string;
16
18
  }
17
- export declare function ContextRail({ appId, module, hidden, collapsible, activeItemId, renderLink, className, }: ContextRailProps): import("react/jsx-runtime").JSX.Element | null;
19
+ export declare function ContextRail({ appId, module, hidden, collapsible, activeItemId, renderLink, header, scroll, 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, module, hidden = false, collapsible = true, activeItemId, renderLink, className, }) {
78
+ export function ContextRail({ appId, module, hidden = false, collapsible = true, activeItemId, renderLink, header = 'auto', scroll = 'auto', className, }) {
79
79
  const band = useShellViewport();
80
80
  const ctx = useMedaShell();
81
81
  // collapsible={false} means the rail must always render expanded — even if
@@ -92,6 +92,8 @@ export function ContextRail({ appId, module, hidden = false, collapsible = true,
92
92
  const [displayWidth, setDisplayWidth] = useState(null);
93
93
  const width = displayWidth ?? ctx.contextRail.width;
94
94
  const items = module?.items ?? [];
95
+ const hasRender = typeof module?.render === 'function';
96
+ const showHeader = header === 'visible' || (header === 'auto' && items.length > 0 && !hasRender);
95
97
  if (band === 'mobile')
96
98
  return null;
97
99
  if (hidden) {
@@ -113,22 +115,28 @@ export function ContextRail({ appId, module, hidden = false, collapsible = true,
113
115
  // transition then so the rail snaps to the cursor instead of lagging
114
116
  // behind it.
115
117
  const isDragging = displayWidth !== null;
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) => {
117
- const isActive = item.id === activeItemId;
118
- const klass = cn('flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors', isActive
119
- ? 'bg-primary/10 text-primary'
120
- : 'text-muted-foreground hover:bg-accent hover:text-foreground');
121
- const IconComp = item.icon;
122
- const inner = (_jsxs(_Fragment, { children: [_jsx(IconComp, { size: 16, "aria-hidden": "true", className: "shrink-0" }), _jsx("span", { className: "truncate", children: item.label }), item.shortcut && (_jsx("kbd", { className: "ml-auto font-mono text-[10px] text-muted-foreground", children: item.shortcut }))] }));
123
- const linkProps = {
124
- href: item.to,
125
- 'aria-current': isActive ? 'page' : undefined,
126
- className: klass,
127
- children: inner,
128
- };
129
- if (renderLink) {
130
- return (_jsx(Fragment, { children: renderLink({ item, isActive, className: klass, children: inner, linkProps }) }, item.id));
131
- }
132
- return _jsx("a", { ...linkProps }, item.id);
133
- }) })), module.render?.({ workspaceId: ctx.workspace.id, appId })] }), !collapsed && (_jsx(ResizeHandle, { currentWidth: width, onResize: handleResize, onCommit: handleCommit }))] }));
118
+ 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: "flex h-full min-w-0 flex-col overflow-hidden", "aria-hidden": collapsed, inert: collapsed || undefined, children: [showHeader && (_jsxs("div", { className: "shrink-0 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 }))] })), _jsxs("div", { "data-meda-context-rail-scroll-area": "", className: cn('min-h-0 flex-1', scroll === 'auto' ? 'overflow-y-auto overflow-x-hidden' : 'overflow-hidden'), children: [items.length > 0 && (_jsx("nav", { "aria-label": `${module.label} navigation`, className: "flex flex-col gap-0.5 p-2", children: items.map((item) => {
119
+ const isActive = item.id === activeItemId;
120
+ const klass = cn('flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors', isActive
121
+ ? 'bg-primary/10 text-primary'
122
+ : 'text-muted-foreground hover:bg-accent hover:text-foreground');
123
+ const IconComp = item.icon;
124
+ const inner = (_jsxs(_Fragment, { children: [_jsx(IconComp, { size: 16, "aria-hidden": "true", className: "shrink-0" }), _jsx("span", { className: "truncate", children: item.label }), item.shortcut && (_jsx("kbd", { className: "ml-auto font-mono text-[10px] text-muted-foreground", children: item.shortcut }))] }));
125
+ const linkProps = {
126
+ href: item.to,
127
+ 'aria-current': isActive ? 'page' : undefined,
128
+ className: klass,
129
+ children: inner,
130
+ };
131
+ if (renderLink) {
132
+ return (_jsx(Fragment, { children: renderLink({
133
+ item,
134
+ isActive,
135
+ className: klass,
136
+ children: inner,
137
+ linkProps,
138
+ }) }, item.id));
139
+ }
140
+ return _jsx("a", { ...linkProps }, item.id);
141
+ }) })), module.render?.({ workspaceId: ctx.workspace.id, appId })] })] }), !collapsed && (_jsx(ResizeHandle, { currentWidth: width, onResize: handleResize, onCommit: handleCommit }))] }));
134
142
  }
@@ -27,5 +27,5 @@ export type { MedaShellProviderProps } from './shell-provider.js';
27
27
  export { MedaShellProvider, useMedaShell, useShellSelection } from './shell-provider.js';
28
28
  export { DefaultThemeProvider, ThemeToggle, useTheme } from './theme.js';
29
29
  export { NextThemesAdapter } from './theme-next-themes.js';
30
- export type { AppDefinition, AppShellAppTabsConfig, AppShellAuthBranding, AppShellAuthConfig, AppShellContextRailConfig, AppShellIconRailConfig, AppShellRightPanelConfig, AppShellVariant, AppShellWorkspaceConfig, AppTabRenderLinkArgs, CommandDefinition, ContextItem, ContextModule, MobileBottomNavItem, PanelMode, PanelView, ShellLinkRenderArgs, ShellMainLayout, ShellRenderContext, ShellViewport, ThemeAdapter, WorkspaceDefinition, WorkspaceMenuItem, } from './types.js';
30
+ export type { AppDefinition, AppShellAppTabsConfig, AppShellAuthBranding, AppShellAuthConfig, AppShellContextRailConfig, AppShellIconRailConfig, AppShellRightPanelConfig, AppShellVariant, AppShellWorkspaceConfig, AppTabRenderLinkArgs, CommandDefinition, ContextItem, ContextModule, ContextRailHeader, ContextRailScroll, MobileBottomNavItem, PanelMode, PanelView, ShellLinkRenderArgs, ShellMainLayout, ShellRenderContext, ShellViewport, ThemeAdapter, WorkspaceDefinition, WorkspaceMenuItem, } from './types.js';
31
31
  export { useShellViewport } from './use-shell-viewport.js';
@@ -1,6 +1,6 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import type { IconRailItem, IconRailProps } from '../icon-rail.js';
3
- import type { ContextModule, PanelView, WorkspaceMenuItem } from '../types.js';
3
+ import type { ContextModule, ContextRailHeader, ContextRailScroll, PanelView, WorkspaceMenuItem } from '../types.js';
4
4
  export interface MobileDrawersProps {
5
5
  /** Menu drawer source (icon-rail items). */
6
6
  menuItems?: IconRailItem[];
@@ -16,6 +16,10 @@ export interface MobileDrawersProps {
16
16
  module?: ContextModule;
17
17
  /** App id used when rendering module custom content. */
18
18
  moduleAppId?: string;
19
+ /** Header behavior mirrored from the desktop ContextRail. */
20
+ moduleHeader?: ContextRailHeader;
21
+ /** Scroll behavior mirrored from the desktop ContextRail. */
22
+ moduleScroll?: ContextRailScroll;
19
23
  /** Panels drawer source. */
20
24
  panelViews?: PanelView[];
21
25
  /**
@@ -33,4 +37,4 @@ export interface MobileDrawersProps {
33
37
  * any custom-content drawers. Mount once near the AppShell root; drawers
34
38
  * open/close via `ctx.mobileDrawer.open` provider state.
35
39
  */
36
- export declare function MobileDrawers({ menuItems, menuActiveId, menuRenderLink, workspaceMenuItems, workspaceMenuFooter, module, moduleAppId, panelViews, defaultView, customContent, }: MobileDrawersProps): import("react/jsx-runtime").JSX.Element;
40
+ export declare function MobileDrawers({ menuItems, menuActiveId, menuRenderLink, workspaceMenuItems, workspaceMenuFooter, module, moduleAppId, moduleHeader, moduleScroll, panelViews, defaultView, customContent, }: MobileDrawersProps): import("react/jsx-runtime").JSX.Element;
@@ -11,7 +11,7 @@ import { useTheme } from '../theme.js';
11
11
  * any custom-content drawers. Mount once near the AppShell root; drawers
12
12
  * open/close via `ctx.mobileDrawer.open` provider state.
13
13
  */
14
- export function MobileDrawers({ menuItems = [], menuActiveId, menuRenderLink, workspaceMenuItems, workspaceMenuFooter, module, moduleAppId, panelViews = [], defaultView, customContent = {}, }) {
14
+ export function MobileDrawers({ menuItems = [], menuActiveId, menuRenderLink, workspaceMenuItems, workspaceMenuFooter, module, moduleAppId, moduleHeader, moduleScroll, panelViews = [], defaultView, customContent = {}, }) {
15
15
  const ctx = useMedaShell();
16
16
  const open = ctx.mobileDrawer.open;
17
17
  const setOpen = ctx.mobileDrawer.setOpen;
@@ -24,7 +24,7 @@ export function MobileDrawers({ menuItems = [], menuActiveId, menuRenderLink, wo
24
24
  workspaceId: ctx.workspace.id,
25
25
  appId: moduleAppId ?? ctx.activeAppId,
26
26
  };
27
- return (_jsxs(_Fragment, { children: [_jsx(MenuDrawer, { open: open === 'menu-drawer', onClose: close, items: menuItems, activeId: menuActiveId, renderLink: menuRenderLink, workspaceItems: workspaceMenuItems, workspaceFooter: workspaceMenuFooter }), _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)))] }));
27
+ return (_jsxs(_Fragment, { children: [_jsx(MenuDrawer, { open: open === 'menu-drawer', onClose: close, items: menuItems, activeId: menuActiveId, renderLink: menuRenderLink, workspaceItems: workspaceMenuItems, workspaceFooter: workspaceMenuFooter }), _jsx(ModuleDrawer, { open: open === 'module-drawer', onClose: close, module: module, renderCtx: moduleRenderCtx, header: moduleHeader, scroll: moduleScroll }), _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)))] }));
28
28
  }
29
29
  // ---------------------------------------------------------------------------
30
30
  // Internal sub-drawers
@@ -110,14 +110,17 @@ function MobileThemeMenuItem({ onClose }) {
110
110
  onClose();
111
111
  }, children: [_jsx(Icon, { size: 18, "aria-hidden": "true" }), _jsx("span", { children: THEME_LABEL[theme] })] }));
112
112
  }
113
- function ModuleDrawer({ open, onClose, module, renderCtx, }) {
113
+ function ModuleDrawer({ open, onClose, module, renderCtx, header = 'auto', scroll = 'auto', }) {
114
114
  const items = module?.items ?? [];
115
115
  if (!module || (items.length === 0 && !module.render))
116
116
  return null;
117
- 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) => {
118
- const Icon = item.icon;
119
- 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));
120
- }) })), module.render?.(renderCtx)] }) }));
117
+ const hasRender = typeof module.render === 'function';
118
+ const showHeader = header === 'visible' || (header === 'auto' && items.length > 0 && !hasRender);
119
+ const title = (_jsxs(_Fragment, { children: [_jsx(DrawerTitle, { className: showHeader ? undefined : 'sr-only', children: module.label }), module.description && (_jsx(DrawerDescription, { className: showHeader ? 'text-muted-foreground text-xs' : 'sr-only', children: module.description }))] }));
120
+ return (_jsx(Drawer, { open: open, onOpenChange: (o) => !o && onClose(), direction: "left", children: _jsxs(DrawerContent, { children: [showHeader ? _jsx(DrawerHeader, { children: title }) : title, _jsxs("div", { "data-meda-context-rail-scroll-area": "", className: cn('min-h-0 flex-1', scroll === 'auto' ? 'overflow-y-auto overflow-x-hidden' : 'overflow-hidden'), children: [items.length > 0 && (_jsx("nav", { className: "flex flex-col gap-0.5 p-2", children: items.map((item) => {
121
+ const Icon = item.icon;
122
+ 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));
123
+ }) })), module.render?.(renderCtx)] })] }) }));
121
124
  }
122
125
  function PanelsDrawer({ open, onClose, panelViews, defaultView, renderCtx, }) {
123
126
  const ctx = useMedaShell();
@@ -107,63 +107,79 @@ export function MedaShellProvider(props) {
107
107
  storage,
108
108
  });
109
109
  const isMobile = useShellViewport() === 'mobile';
110
+ const setPanelMode = useCallback((mode) => setLayoutState((prev) => ({
111
+ ...prev,
112
+ rightPanel: { ...prev.rightPanel, mode },
113
+ })), [setLayoutState]);
114
+ const setPanelActiveView = useCallback((activeView) => setLayoutState((prev) => ({
115
+ ...prev,
116
+ rightPanel: { ...prev.rightPanel, activeView },
117
+ })), [setLayoutState]);
118
+ const setPanelWidth = useCallback((width) => setLayoutState((prev) => ({
119
+ ...prev,
120
+ rightPanel: { ...prev.rightPanel, width },
121
+ })), [setLayoutState]);
122
+ const openPanel = useCallback(() => {
123
+ if (isMobile)
124
+ setMobileDrawerOpen('panels-drawer');
125
+ setLayoutState((prev) => ({
126
+ ...prev,
127
+ rightPanel: {
128
+ ...prev.rightPanel,
129
+ mode: prev.rightPanel.mode === 'closed' ? 'panel' : prev.rightPanel.mode,
130
+ },
131
+ }));
132
+ }, [isMobile, setLayoutState]);
133
+ const closePanel = useCallback(() => {
134
+ if (isMobile)
135
+ setMobileDrawerOpen((open) => (open === 'panels-drawer' ? null : open));
136
+ setLayoutState((prev) => ({
137
+ ...prev,
138
+ rightPanel: { ...prev.rightPanel, mode: 'closed' },
139
+ }));
140
+ }, [isMobile, setLayoutState]);
141
+ const togglePanel = useCallback(() => {
142
+ if (isMobile) {
143
+ setMobileDrawerOpen((open) => (open === 'panels-drawer' ? null : 'panels-drawer'));
144
+ }
145
+ setLayoutState((prev) => ({
146
+ ...prev,
147
+ rightPanel: {
148
+ ...prev.rightPanel,
149
+ mode: prev.rightPanel.mode === 'closed' ? 'panel' : 'closed',
150
+ },
151
+ }));
152
+ }, [isMobile, setLayoutState]);
153
+ const focusPanel = useCallback((viewId) => setLayoutState((prev) => {
154
+ const nextMode = prev.rightPanel.mode === 'closed' ? 'panel' : prev.rightPanel.mode;
155
+ return {
156
+ ...prev,
157
+ rightPanel: { ...prev.rightPanel, mode: nextMode, activeView: viewId },
158
+ };
159
+ }), [setLayoutState]);
110
160
  const panel = useMemo(() => ({
111
161
  mode: layoutState.rightPanel.mode,
112
162
  activeView: layoutState.rightPanel.activeView,
113
163
  width: layoutState.rightPanel.width,
114
- setMode: (mode) => setLayoutState((prev) => ({
115
- ...prev,
116
- rightPanel: { ...prev.rightPanel, mode },
117
- })),
118
- setActiveView: (activeView) => setLayoutState((prev) => ({
119
- ...prev,
120
- rightPanel: { ...prev.rightPanel, activeView },
121
- })),
122
- setWidth: (width) => setLayoutState((prev) => ({
123
- ...prev,
124
- rightPanel: { ...prev.rightPanel, width },
125
- })),
126
- open: () => {
127
- if (isMobile)
128
- setMobileDrawerOpen('panels-drawer');
129
- setLayoutState((prev) => ({
130
- ...prev,
131
- rightPanel: {
132
- ...prev.rightPanel,
133
- mode: prev.rightPanel.mode === 'closed' ? 'panel' : prev.rightPanel.mode,
134
- },
135
- }));
136
- },
137
- close: () => {
138
- if (isMobile)
139
- setMobileDrawerOpen((open) => (open === 'panels-drawer' ? null : open));
140
- setLayoutState((prev) => ({
141
- ...prev,
142
- rightPanel: { ...prev.rightPanel, mode: 'closed' },
143
- }));
144
- },
145
- toggle: () => {
146
- if (isMobile) {
147
- setMobileDrawerOpen((open) => (open === 'panels-drawer' ? null : 'panels-drawer'));
148
- }
149
- setLayoutState((prev) => ({
150
- ...prev,
151
- rightPanel: {
152
- ...prev.rightPanel,
153
- mode: prev.rightPanel.mode === 'closed' ? 'panel' : 'closed',
154
- },
155
- }));
156
- },
157
- // focus(viewId) — opens panel + switches to view in one call.
158
- // Only flips closed → panel; preserves expanded / fullscreen modes.
159
- focus: (viewId) => setLayoutState((prev) => {
160
- const nextMode = prev.rightPanel.mode === 'closed' ? 'panel' : prev.rightPanel.mode;
161
- return {
162
- ...prev,
163
- rightPanel: { ...prev.rightPanel, mode: nextMode, activeView: viewId },
164
- };
165
- }),
166
- }), [isMobile, layoutState, setLayoutState]);
164
+ setMode: setPanelMode,
165
+ setActiveView: setPanelActiveView,
166
+ setWidth: setPanelWidth,
167
+ open: openPanel,
168
+ close: closePanel,
169
+ toggle: togglePanel,
170
+ focus: focusPanel,
171
+ }), [
172
+ layoutState.rightPanel.mode,
173
+ layoutState.rightPanel.activeView,
174
+ layoutState.rightPanel.width,
175
+ setPanelMode,
176
+ setPanelActiveView,
177
+ setPanelWidth,
178
+ openPanel,
179
+ closePanel,
180
+ togglePanel,
181
+ focusPanel,
182
+ ]);
167
183
  const contextRail = useMemo(() => ({
168
184
  width: layoutState.contextRail.width,
169
185
  collapsed: layoutState.contextRail.collapsed,
@@ -50,6 +50,8 @@ export interface PanelView {
50
50
  export type PanelMode = 'closed' | 'panel' | 'expanded' | 'fullscreen';
51
51
  export type ShellMainLayout = 'workspace' | 'centered' | 'fullbleed';
52
52
  export type ShellViewport = 'mobile' | 'tablet' | 'desktop' | 'wide' | 'ultrawide';
53
+ export type ContextRailHeader = 'auto' | 'visible' | 'hidden';
54
+ export type ContextRailScroll = 'auto' | 'none';
53
55
  export interface MobileBottomNavItem {
54
56
  id: string;
55
57
  label: string | (() => string);
@@ -103,6 +105,8 @@ export interface AppShellContextRailConfig {
103
105
  appId: string;
104
106
  module: ContextModule;
105
107
  activeItemId?: string;
108
+ header?: ContextRailHeader;
109
+ scroll?: ContextRailScroll;
106
110
  }
107
111
  /** RightPanel configuration for `<AppShell variant="workspace">`. */
108
112
  export interface AppShellRightPanelConfig {
@@ -7,11 +7,18 @@ const BREAKPOINTS = {
7
7
  wide: '(min-width: 1280px) and (max-width: 1535px)',
8
8
  ultrawide: '(min-width: 1536px)',
9
9
  };
10
- function detectViewport() {
10
+ function getMatchMedia() {
11
11
  if (typeof window === 'undefined')
12
+ return null;
13
+ if (typeof window.matchMedia !== 'function')
14
+ return null;
15
+ return window.matchMedia.bind(window);
16
+ }
17
+ function detectViewport(matchMedia = getMatchMedia()) {
18
+ if (!matchMedia)
12
19
  return 'desktop';
13
20
  for (const [band, query] of Object.entries(BREAKPOINTS)) {
14
- if (window.matchMedia(query).matches)
21
+ if (matchMedia(query).matches)
15
22
  return band;
16
23
  }
17
24
  return 'desktop';
@@ -21,9 +28,12 @@ export function useShellViewport() {
21
28
  // client's first paint — actual band resolves in the post-mount effect.
22
29
  const [viewport, setViewport] = useState('desktop');
23
30
  useEffect(() => {
24
- setViewport(detectViewport());
31
+ const matchMedia = getMatchMedia();
32
+ setViewport(detectViewport(matchMedia));
33
+ if (!matchMedia)
34
+ return;
25
35
  const cleanups = Object.entries(BREAKPOINTS).map(([band, query]) => {
26
- const mql = window.matchMedia(query);
36
+ const mql = matchMedia(query);
27
37
  const onChange = () => {
28
38
  if (mql.matches)
29
39
  setViewport(band);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medalsocial/meda",
3
- "version": "1.7.0",
3
+ "version": "2.0.0",
4
4
  "description": "Shared Meda UI shell and runtime package.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {