@medalsocial/meda 1.6.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,25 @@ 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
+
114
+ ## Foundation primitives
115
+
116
+ Meda includes small foundation primitives for repeated loading, empty, and filtering surfaces:
117
+
118
+ ```tsx
119
+ import { EmptyState, FilterRail, Skeleton } from '@medalsocial/meda';
120
+ ```
121
+
122
+ `Skeleton` mirrors shadcn's simple loading placeholder shape. `EmptyState` standardizes zero/error states across panels and content areas. `FilterRail` provides a dense filter surface while leaving selected values, URL syncing, and query logic in the consuming app.
123
+
124
+ The same primitives are available from the shadcn-compatible registry when an app wants local source ownership:
125
+
126
+ ```bash
127
+ npx shadcn add https://meda.medalsocial.com/r/meda-skeleton.json
128
+ npx shadcn add https://meda.medalsocial.com/r/meda-empty-state.json
129
+ npx shadcn add https://meda.medalsocial.com/r/meda-filter-rail.json
130
+ ```
112
131
  For app-scoped brand tokens:
113
132
 
114
133
  ```ts
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@ export * from './brand/public.js';
3
3
  export * from './chat/public.js';
4
4
  export * from './marketing/public.js';
5
5
  export * from './panel/public.js';
6
+ export * from './primitives/index.js';
6
7
  export * from './shell/index.js';
7
8
  export * from './theme/index.js';
8
9
  export * from './timeline/public.js';
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ export * from './brand/public.js';
3
3
  export * from './chat/public.js';
4
4
  export * from './marketing/public.js';
5
5
  export * from './panel/public.js';
6
+ export * from './primitives/index.js';
6
7
  export * from './shell/index.js'; // v2 surface
7
8
  export * from './theme/index.js';
8
9
  export * from './timeline/public.js';
@@ -0,0 +1,11 @@
1
+ import type { LucideIcon } from 'lucide-react';
2
+ import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
3
+ export type EmptyStateVariant = 'default' | 'panel' | 'inline';
4
+ export interface EmptyStateProps extends Omit<ComponentPropsWithoutRef<'div'>, 'title'> {
5
+ icon?: LucideIcon | ReactNode;
6
+ title: ReactNode;
7
+ description?: ReactNode;
8
+ action?: ReactNode;
9
+ variant?: EmptyStateVariant;
10
+ }
11
+ export declare function EmptyState({ icon, title, description, action, variant, className, ...props }: EmptyStateProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,23 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { isValidElement } from 'react';
4
+ import { cn } from '../lib/utils.js';
5
+ function isIconComponent(icon) {
6
+ if (typeof icon === 'function')
7
+ return true;
8
+ if (typeof icon !== 'object')
9
+ return false;
10
+ return '$$typeof' in icon && !isValidElement(icon);
11
+ }
12
+ function renderIcon(icon) {
13
+ if (!icon)
14
+ return null;
15
+ if (isIconComponent(icon)) {
16
+ const Icon = icon;
17
+ return _jsx(Icon, { "data-testid": "empty-state-icon", className: "size-10", "aria-hidden": "true" });
18
+ }
19
+ return (_jsx("span", { "data-testid": "empty-state-icon", "aria-hidden": "true", className: "inline-flex", children: icon }));
20
+ }
21
+ export function EmptyState({ icon, title, description, action, variant = 'default', className, ...props }) {
22
+ return (_jsxs("div", { "data-slot": "empty-state", "data-variant": variant, className: cn('flex flex-col items-center justify-center text-center', variant === 'default' && 'px-6 py-16', variant === 'panel' && 'px-4 py-10', variant === 'inline' && 'px-3 py-6', className), ...props, children: [icon ? (_jsx("div", { "data-slot": "empty-state-icon", className: cn('mb-4 inline-flex items-center justify-center rounded-md text-muted-foreground', variant === 'inline' ? 'size-9' : 'size-12'), children: renderIcon(icon) })) : null, _jsx("h3", { "data-slot": "empty-state-title", className: cn('font-semibold text-foreground', variant === 'default' && 'text-lg', variant === 'panel' && 'text-base', variant === 'inline' && 'text-sm'), children: title }), description ? (_jsx("p", { "data-slot": "empty-state-description", className: cn('mt-1 max-w-sm text-muted-foreground', variant === 'inline' ? 'text-xs' : 'text-sm'), children: description })) : null, action ? (_jsx("div", { "data-slot": "empty-state-action", className: "mt-5", children: action })) : null] }));
23
+ }
@@ -0,0 +1,20 @@
1
+ import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
2
+ export interface FilterRailProps extends Omit<ComponentPropsWithoutRef<'aside'>, 'title'> {
3
+ title?: ReactNode;
4
+ description?: ReactNode;
5
+ search?: ReactNode;
6
+ actions?: ReactNode;
7
+ footer?: ReactNode;
8
+ children?: ReactNode;
9
+ }
10
+ export interface FilterRailGroupProps extends Omit<ComponentPropsWithoutRef<'fieldset'>, 'title'> {
11
+ title?: ReactNode;
12
+ description?: ReactNode;
13
+ children?: ReactNode;
14
+ }
15
+ declare function FilterRailGroup({ title, description, children, className, ...props }: FilterRailGroupProps): import("react/jsx-runtime").JSX.Element;
16
+ declare function FilterRailRoot({ title, description, search, actions, footer, children, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, ...props }: FilterRailProps): import("react/jsx-runtime").JSX.Element;
17
+ export declare const FilterRail: typeof FilterRailRoot & {
18
+ Group: typeof FilterRailGroup;
19
+ };
20
+ export {};
@@ -0,0 +1,16 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useId } from 'react';
4
+ import { cn } from '../lib/utils.js';
5
+ function FilterRailGroup({ title, description, children, className, ...props }) {
6
+ return (_jsxs("fieldset", { "data-slot": "filter-rail-group", className: cn('space-y-2 border-0 p-0', className), ...props, children: [title ? (_jsx("legend", { "data-slot": "filter-rail-group-title", className: "text-xs font-semibold text-foreground", children: title })) : null, description ? (_jsx("p", { "data-slot": "filter-rail-group-description", className: "text-xs text-muted-foreground", children: description })) : null, _jsx("div", { "data-slot": "filter-rail-group-content", className: "space-y-1.5", children: children })] }));
7
+ }
8
+ function FilterRailRoot({ title, description, search, actions, footer, children, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, ...props }) {
9
+ const titleId = useId();
10
+ const label = ariaLabel ?? (ariaLabelledBy ? undefined : typeof title === 'string' ? title : undefined);
11
+ const labelledBy = ariaLabelledBy ?? (label ? undefined : title ? titleId : undefined);
12
+ return (_jsxs("aside", { "data-slot": "filter-rail", "aria-label": label, "aria-labelledby": labelledBy, className: cn('flex min-h-0 w-full flex-col border-border bg-card text-card-foreground', className), ...props, children: [title || description || actions ? (_jsxs("div", { "data-slot": "filter-rail-header", className: "flex shrink-0 items-start justify-between gap-3 border-b border-border px-4 py-3", children: [_jsxs("div", { className: "min-w-0", children: [title ? (_jsx("h2", { id: titleId, "data-slot": "filter-rail-title", className: "text-sm font-semibold text-foreground", children: title })) : null, description ? (_jsx("p", { "data-slot": "filter-rail-description", className: "mt-0.5 text-xs text-muted-foreground", children: description })) : null] }), actions ? (_jsx("div", { "data-slot": "filter-rail-actions", className: "shrink-0", children: actions })) : null] })) : null, search ? (_jsx("div", { "data-slot": "filter-rail-search", className: "shrink-0 border-b border-border p-3", children: search })) : null, _jsx("div", { "data-slot": "filter-rail-content", className: "min-h-0 flex-1 space-y-5 overflow-y-auto p-4", children: children }), footer ? (_jsx("div", { "data-slot": "filter-rail-footer", className: "shrink-0 border-t border-border p-3", children: footer })) : null] }));
13
+ }
14
+ export const FilterRail = Object.assign(FilterRailRoot, {
15
+ Group: FilterRailGroup,
16
+ });
@@ -0,0 +1,6 @@
1
+ export type { EmptyStateProps, EmptyStateVariant } from './empty-state.js';
2
+ export { EmptyState } from './empty-state.js';
3
+ export type { FilterRailGroupProps, FilterRailProps } from './filter-rail.js';
4
+ export { FilterRail } from './filter-rail.js';
5
+ export type { SkeletonProps } from './skeleton.js';
6
+ export { Skeleton } from './skeleton.js';
@@ -0,0 +1,3 @@
1
+ export { EmptyState } from './empty-state.js';
2
+ export { FilterRail } from './filter-rail.js';
3
+ export { Skeleton } from './skeleton.js';
@@ -0,0 +1,3 @@
1
+ import type { ComponentProps } from 'react';
2
+ export type SkeletonProps = ComponentProps<'div'>;
3
+ export declare function Skeleton({ className, 'aria-hidden': ariaHidden, ...props }: SkeletonProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,6 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { cn } from '../lib/utils.js';
4
+ export function Skeleton({ className, 'aria-hidden': ariaHidden = true, ...props }) {
5
+ return (_jsx("div", { "data-slot": "skeleton", "aria-hidden": ariaHidden, className: cn('animate-pulse rounded-md bg-muted', className), ...props }));
6
+ }
@@ -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.6.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": {