@medalsocial/meda 2.5.2 → 2.6.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.
@@ -1,5 +1,5 @@
1
1
  import { type ReactNode } from 'react';
2
- import type { AppShellAppTabsConfig, AppShellContextRailConfig, AppShellIconRailConfig, AppShellRightPanelConfig, AppShellWorkspaceConfig, ShellMainLayout } from './types.js';
2
+ import type { AppShellAppTabsConfig, AppShellContextRailConfig, AppShellIconRailConfig, AppShellMobileNavConfig, AppShellRightPanelConfig, AppShellWorkspaceConfig, ShellMainLayout } from './types.js';
3
3
  export interface AppShellWorkspaceProps {
4
4
  iconRail?: AppShellIconRailConfig;
5
5
  contextRail?: AppShellContextRailConfig;
@@ -19,6 +19,12 @@ export interface AppShellWorkspaceProps {
19
19
  * app ships its own palette.
20
20
  */
21
21
  builtInCommandPalette?: boolean;
22
+ /**
23
+ * Opt-in mobile dock + workspace sheet. When provided, the mobile viewport
24
+ * renders a floating dock + one workspace sheet instead of the legacy
25
+ * bottom-nav + four drawers. Omit to keep the legacy mobile nav.
26
+ */
27
+ mobileNav?: AppShellMobileNavConfig;
22
28
  children: ReactNode;
23
29
  }
24
- export declare function AppShellWorkspace({ iconRail, contextRail, rightPanel, workspace, appTabs, globalActions, headerCenter, headerLeading, banners, mainLayout, mainClassName, builtInCommandPalette, children, }: AppShellWorkspaceProps): import("react/jsx-runtime").JSX.Element;
30
+ export declare function AppShellWorkspace({ iconRail, contextRail, rightPanel, workspace, appTabs, globalActions, headerCenter, headerLeading, banners, mainLayout, mainClassName, builtInCommandPalette, mobileNav, children, }: AppShellWorkspaceProps): import("react/jsx-runtime").JSX.Element;
@@ -1,20 +1,23 @@
1
1
  'use client';
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { LayoutGrid, Menu, PanelTop, Sparkles } from 'lucide-react';
4
4
  import { useContext } from 'react';
5
+ import { cn } from '../lib/utils.js';
5
6
  import { CommandPalette, CommandRegistryContext } from './command-palette.js';
6
7
  import { ContextRail } from './context-rail.js';
7
8
  import { IconRail } from './icon-rail.js';
8
9
  import { MobileBottomNav } from './internal/mobile-bottom-nav.js';
10
+ import { MobileDock } from './internal/mobile-dock.js';
9
11
  import { MobileDrawers } from './internal/mobile-drawers.js';
10
12
  import { MobileHeader } from './internal/mobile-header.js';
13
+ import { MobileWorkspaceSheet } from './internal/mobile-workspace-sheet.js';
11
14
  import { useResolvedPanelViews } from './panel-views-provider.js';
12
15
  import { RightPanel } from './right-panel.js';
13
16
  import { ShellHeader } from './shell-header.js';
14
17
  import { ShellMain } from './shell-main.js';
15
18
  import { useShellViewport } from './use-shell-viewport.js';
16
19
  const EMPTY_PANEL_VIEWS = [];
17
- export function AppShellWorkspace({ iconRail, contextRail, rightPanel, workspace, appTabs, globalActions, headerCenter, headerLeading, banners, mainLayout, mainClassName, builtInCommandPalette = true, children, }) {
20
+ export function AppShellWorkspace({ iconRail, contextRail, rightPanel, workspace, appTabs, globalActions, headerCenter, headerLeading, banners, mainLayout, mainClassName, builtInCommandPalette = true, mobileNav, children, }) {
18
21
  const viewport = useShellViewport();
19
22
  const isMobile = viewport === 'mobile';
20
23
  const staticPanelViews = rightPanel?.panelViews ?? EMPTY_PANEL_VIEWS;
@@ -39,7 +42,9 @@ export function AppShellWorkspace({ iconRail, contextRail, rightPanel, workspace
39
42
  // <AppShell> wrapper already enforces h-screen for the workspace variant, so
40
43
  // nested viewport-height divs collapse cleanly — no double-scroll.
41
44
  const commandRegistry = useContext(CommandRegistryContext);
42
- const shell = (_jsxs("div", { className: "flex h-svh flex-col", children: [isMobile ? (_jsx(MobileHeader, { globalActions: globalActions, headerCenter: headerCenter })) : (_jsx(ShellHeader, { globalActions: globalActions, headerCenter: headerCenter, headerLeading: headerLeading, appTabsRenderLink: appTabs?.renderLink, workspaceMenuItems: workspace?.menuItems, workspaceMenuFooter: workspace?.menuFooter, showPanelToggle: resolvedRightPanel.panelViews.length > 0, panelViews: resolvedRightPanel.panelViews })), 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, labelVisibility: iconRail.labelVisibility })), !isMobile && contextRail && (_jsx(ContextRail, { appId: contextRail.appId, module: contextRail.module, activeItemId: contextRail.activeItemId, renderLink: contextRail.renderLink, 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, moduleActiveItemId: contextRail?.activeItemId, moduleRenderLink: contextRail?.renderLink, moduleHeader: contextRail?.header, moduleScroll: contextRail?.scroll, sectionTabs: headerLeading, panelViews: resolvedRightPanel.panelViews, defaultView: resolvedRightPanel.defaultView }))] }));
45
+ const shell = (_jsxs("div", { className: "flex h-svh flex-col", children: [isMobile ? (_jsx(MobileHeader, { globalActions: globalActions, headerCenter: headerCenter })) : (_jsx(ShellHeader, { globalActions: globalActions, headerCenter: headerCenter, headerLeading: headerLeading, appTabsRenderLink: appTabs?.renderLink, workspaceMenuItems: workspace?.menuItems, workspaceMenuFooter: workspace?.menuFooter, showPanelToggle: resolvedRightPanel.panelViews.length > 0, panelViews: resolvedRightPanel.panelViews })), 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, labelVisibility: iconRail.labelVisibility })), !isMobile && contextRail && (_jsx(ContextRail, { appId: contextRail.appId, module: contextRail.module, activeItemId: contextRail.activeItemId, renderLink: contextRail.renderLink, header: contextRail.header, scroll: contextRail.scroll })), _jsx(ShellMain, { layout: mainLayout ?? 'workspace', className: cn(mainClassName,
46
+ // Pad the scroll area so the floating dock never covers content.
47
+ isMobile && mobileNav && 'pb-[calc(env(safe-area-inset-bottom)+80px)]'), children: children }), !isMobile && resolvedRightPanel.panelViews.length > 0 && (_jsx(RightPanel, { panelViews: staticPanelViews, defaultView: rightPanel?.defaultView }))] }), isMobile && mobileNav ? (_jsxs(_Fragment, { children: [_jsx(MobileDock, { items: mobileNav.dock, activeTo: mobileNav.activeTo, renderLink: mobileNav.renderLink }), _jsx(MobileWorkspaceSheet, { tree: mobileNav.tree, activeTo: mobileNav.activeTo, renderLink: mobileNav.renderLink, workspaceMenuItems: workspace?.menuItems, workspaceMenuFooter: workspace?.menuFooter })] })) : (_jsxs(_Fragment, { children: [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, moduleActiveItemId: contextRail?.activeItemId, moduleRenderLink: contextRail?.renderLink, moduleHeader: contextRail?.header, moduleScroll: contextRail?.scroll, sectionTabs: headerLeading, panelViews: resolvedRightPanel.panelViews, defaultView: resolvedRightPanel.defaultView }))] }))] }));
43
48
  // Backwards-compatible default: meda provides the built-in CommandPalette
44
49
  // (and its CommandRegistryContext) so consumers calling useCommands()/
45
50
  // useCommandGroup() under the workspace shell keep working. When a registry is
@@ -1,5 +1,5 @@
1
1
  import type { ReactNode } from 'react';
2
- import type { AppShellAppTabsConfig, AppShellAuthBranding, AppShellAuthConfig, AppShellContextRailConfig, AppShellIconRailConfig, AppShellRightPanelConfig, AppShellWorkspaceConfig, ShellMainLayout } from './types.js';
2
+ import type { AppShellAppTabsConfig, AppShellAuthBranding, AppShellAuthConfig, AppShellContextRailConfig, AppShellIconRailConfig, AppShellMobileNavConfig, AppShellRightPanelConfig, AppShellWorkspaceConfig, ShellMainLayout } from './types.js';
3
3
  interface AppShellBaseProps {
4
4
  children: ReactNode;
5
5
  className?: string;
@@ -58,6 +58,11 @@ export type AppShellProps = AppShellBaseProps & ({
58
58
  * host app ships its own command palette to avoid a duplicate dialog.
59
59
  */
60
60
  builtInCommandPalette?: boolean;
61
+ /**
62
+ * Opt-in mobile dock + workspace sheet (replaces the legacy bottom-nav
63
+ * + four drawers on the mobile viewport). Omit for the legacy nav.
64
+ */
65
+ mobileNav?: AppShellMobileNavConfig;
61
66
  } | {
62
67
  variant: 'chat';
63
68
  globalActions?: ReactNode;
@@ -16,7 +16,7 @@ export function AppShell(props) {
16
16
  case 'auth':
17
17
  return wrapper(_jsx(AppShellAuth, { ...resolveAuthConfig(props), children: props.children }));
18
18
  case 'workspace':
19
- return wrapper(_jsx(AppShellWorkspace, { iconRail: props.iconRail, contextRail: props.contextRail, rightPanel: props.rightPanel, workspace: props.workspace, appTabs: props.appTabs, globalActions: props.globalActions, headerCenter: props.headerCenter, headerLeading: props.headerLeading, banners: props.banners, mainLayout: props.mainLayout, mainClassName: props.mainClassName, builtInCommandPalette: props.builtInCommandPalette, children: props.children }));
19
+ return wrapper(_jsx(AppShellWorkspace, { iconRail: props.iconRail, contextRail: props.contextRail, rightPanel: props.rightPanel, workspace: props.workspace, appTabs: props.appTabs, globalActions: props.globalActions, headerCenter: props.headerCenter, headerLeading: props.headerLeading, banners: props.banners, mainLayout: props.mainLayout, mainClassName: props.mainClassName, builtInCommandPalette: props.builtInCommandPalette, mobileNav: props.mobileNav, children: props.children }));
20
20
  case 'chat':
21
21
  return wrapper(_jsx(AppShellChat, { globalActions: props.globalActions, children: props.children }));
22
22
  }
@@ -1,48 +1,76 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { ChevronDown, ChevronUp } from 'lucide-react';
4
- import { Fragment, useState } from 'react';
4
+ import { Fragment, useEffect, useState } from 'react';
5
5
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '../components/ui/tooltip.js';
6
6
  import { cn } from '../lib/utils.js';
7
7
  import { useShellViewport } from './use-shell-viewport.js';
8
8
  // ---------------------------------------------------------------------------
9
9
  // Item styling
10
10
  // ---------------------------------------------------------------------------
11
+ // Short-viewport scaling: below 850px viewport height items compact (smaller
12
+ // icon frame, tighter spacing, smaller label); below 700px labels hide so the
13
+ // rail degrades to icon-only. The rail additionally scrolls (see railClass) as
14
+ // the hard guarantee that every item stays reachable on any height.
11
15
  function itemClass(isActive, labelVisibility) {
12
16
  if (labelVisibility === 'visible') {
13
- return cn('group relative flex min-h-[4rem] w-full flex-col items-center justify-start gap-1 px-1 py-1 text-center transition-colors', isActive ? 'text-foreground' : 'text-muted-foreground hover:text-foreground');
17
+ return cn('group relative flex min-h-[4rem] w-full shrink-0 flex-col items-center justify-start gap-1 px-1 py-1 text-center transition-colors', '[@media(max-height:850px)]:min-h-[3.25rem] [@media(max-height:850px)]:gap-0.5', '[@media(max-height:700px)]:min-h-0', isActive ? 'text-foreground' : 'text-muted-foreground hover:text-foreground');
14
18
  }
15
- return cn('group relative flex h-11 w-11 items-center justify-center rounded-xl transition-colors', isActive
19
+ return cn('group relative flex h-11 w-11 shrink-0 items-center justify-center rounded-xl transition-colors', '[@media(max-height:700px)]:h-9 [@media(max-height:700px)]:w-9', isActive
16
20
  ? 'bg-primary text-primary-foreground shadow-sm ring-2 ring-primary/60'
17
21
  : 'text-muted-foreground hover:bg-accent hover:text-foreground');
18
22
  }
19
23
  function iconFrameClass(isActive) {
20
- return cn('relative inline-flex h-11 w-11 items-center justify-center rounded-xl transition-colors', isActive
24
+ return cn('relative inline-flex h-11 w-11 items-center justify-center rounded-xl transition-colors', '[@media(max-height:850px)]:h-9 [@media(max-height:850px)]:w-9', isActive
21
25
  ? 'bg-primary text-primary-foreground shadow-sm ring-2 ring-primary/60'
22
26
  : 'group-hover:bg-accent group-hover:text-foreground');
23
27
  }
24
28
  function railClass(labelVisibility, className) {
25
- return cn('flex h-full shrink-0 flex-col items-center bg-shell-rail', labelVisibility === 'visible'
26
- ? 'w-[var(--shell-rail-label-width)] px-2 py-4'
27
- : 'w-[var(--shell-rail-width)] py-3.5', className);
29
+ return cn('flex h-full shrink-0 flex-col items-center bg-shell-rail',
30
+ // Hard reachability guarantee on short viewports: the rail scrolls when the
31
+ // compact tiers still can't fit every item. Scrollbar is hidden — wheel,
32
+ // trackpad, and keyboard focus traversal still scroll the overflow.
33
+ 'min-h-0 overflow-y-auto overflow-x-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden', labelVisibility === 'visible'
34
+ ? 'w-[var(--shell-rail-label-width)] px-2 py-4 [@media(max-height:850px)]:py-2'
35
+ : 'w-[var(--shell-rail-width)] py-3.5 [@media(max-height:850px)]:py-2', className);
28
36
  }
29
37
  export function RailDivider({ pinnedBottom, onToggle }) {
30
- return (_jsx("button", { type: "button", "data-testid": "rail-divider", onClick: onToggle, "aria-label": pinnedBottom ? 'Pull utility items up' : 'Push utility items down', className: cn('my-3 flex h-6 w-8 items-center justify-center rounded-md', 'text-muted-foreground hover:bg-accent hover:text-foreground transition-colors'), children: pinnedBottom ? (_jsx(ChevronUp, { size: 14, "aria-hidden": "true" })) : (_jsx(ChevronDown, { size: 14, "aria-hidden": "true" })) }));
38
+ return (_jsx("button", { type: "button", "data-testid": "rail-divider", onClick: onToggle, "aria-label": pinnedBottom ? 'Pull utility items up' : 'Push utility items down', className: cn('my-3 flex h-6 w-8 shrink-0 items-center justify-center rounded-md', '[@media(max-height:850px)]:my-1', 'text-muted-foreground hover:bg-accent hover:text-foreground transition-colors'), children: pinnedBottom ? (_jsx(ChevronUp, { size: 14, "aria-hidden": "true" })) : (_jsx(ChevronDown, { size: 14, "aria-hidden": "true" })) }));
39
+ }
40
+ // Mirrors the [@media(max-height:700px)] CSS tier that hides visible-mode
41
+ // labels, so those items regain a tooltip fallback exactly when their label
42
+ // disappears. SSR-safe: initial false, resolves post-mount.
43
+ function useIsShortViewport() {
44
+ const [isShort, setIsShort] = useState(false);
45
+ useEffect(() => {
46
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
47
+ return;
48
+ const mql = window.matchMedia('(max-height: 700px)');
49
+ const onChange = () => setIsShort(mql.matches);
50
+ onChange();
51
+ mql.addEventListener('change', onChange);
52
+ return () => mql.removeEventListener('change', onChange);
53
+ }, []);
54
+ return isShort;
31
55
  }
32
56
  // ---------------------------------------------------------------------------
33
57
  // IconRail
34
58
  // ---------------------------------------------------------------------------
35
59
  export function IconRail({ mainItems, utilityItems = [], footer, activeId, renderLink, labelVisibility = 'tooltip', className, }) {
36
60
  const band = useShellViewport();
61
+ const isShortViewport = useIsShortViewport();
37
62
  const [pinnedBottom, setPinnedBottom] = useState(true);
38
63
  if (band === 'mobile')
39
64
  return null;
40
65
  const showLabels = labelVisibility === 'visible';
66
+ // At ≤700px the visible-mode label is hidden by CSS, so items fall back to
67
+ // tooltip labels like tooltip mode does.
68
+ const useTooltipFallback = !showLabels || isShortViewport;
41
69
  const renderItem = (item) => {
42
70
  const isActive = item.id === activeId;
43
71
  const klass = itemClass(isActive, labelVisibility);
44
72
  const IconComp = item.icon;
45
- const inner = showLabels ? (_jsxs(_Fragment, { children: [_jsxs("span", { "data-slot": "icon-rail-icon-frame", className: iconFrameClass(isActive), children: [_jsx(IconComp, { size: 26, "aria-hidden": "true" }), item.badge ? _jsx("span", { className: "absolute right-1 top-1", children: item.badge }) : null] }), _jsx("span", { "data-slot": "icon-rail-label", className: "max-w-full truncate text-[12px] leading-4", children: item.label })] })) : (_jsxs(_Fragment, { children: [_jsx(IconComp, { size: 22, "aria-hidden": "true" }), item.badge ? _jsx("span", { className: "absolute right-1 top-1", children: item.badge }) : null] }));
73
+ const inner = showLabels ? (_jsxs(_Fragment, { children: [_jsxs("span", { "data-slot": "icon-rail-icon-frame", className: iconFrameClass(isActive), children: [_jsx(IconComp, { size: 26, "aria-hidden": "true", className: "[@media(max-height:850px)]:size-5" }), item.badge ? _jsx("span", { className: "absolute right-1 top-1", children: item.badge }) : null] }), _jsx("span", { "data-slot": "icon-rail-label", className: "max-w-full truncate text-[12px] leading-4 [@media(max-height:850px)]:text-[11px] [@media(max-height:700px)]:hidden", children: item.label })] })) : (_jsxs(_Fragment, { children: [_jsx(IconComp, { size: 22, "aria-hidden": "true", className: "[@media(max-height:700px)]:size-[18px]" }), item.badge ? _jsx("span", { className: "absolute right-1 top-1", children: item.badge }) : null] }));
46
74
  const linkProps = {
47
75
  href: item.to,
48
76
  'aria-label': item.label,
@@ -54,10 +82,10 @@ export function IconRail({ mainItems, utilityItems = [], footer, activeId, rende
54
82
  // renderLink consumers receive the className so they can apply it themselves
55
83
  renderLink({ item, isActive, className: klass, children: inner, linkProps })) : (_jsx("a", { ...linkProps }));
56
84
  const trigger = (_jsx("span", { "data-testid": `icon-rail-trigger-${item.id}`, className: klass, children: linkContent }));
57
- if (showLabels) {
85
+ if (!useTooltipFallback) {
58
86
  return _jsx(Fragment, { children: trigger }, item.id);
59
87
  }
60
88
  return (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: trigger }), _jsx(TooltipContent, { side: "right", children: item.label })] }, item.id));
61
89
  };
62
- return (_jsx(TooltipProvider, { children: _jsxs("nav", { "data-testid": "icon-rail", "aria-label": "Primary", className: railClass(labelVisibility, className), children: [_jsx("div", { className: cn('flex flex-col items-center', showLabels ? 'w-full gap-1' : 'gap-1'), children: mainItems.map(renderItem) }), utilityItems.length > 0 && (_jsxs(_Fragment, { children: [_jsx(RailDivider, { pinnedBottom: pinnedBottom, onToggle: () => setPinnedBottom((prev) => !prev) }), _jsx("div", { "data-testid": "utility-items-wrapper", className: cn('flex flex-col items-center', showLabels ? 'w-full gap-1' : 'gap-1', pinnedBottom && 'mt-auto'), children: utilityItems.map(renderItem) })] })), footer && (_jsx("div", { className: cn('pt-3', pinnedBottom || utilityItems.length === 0 ? 'mt-auto' : ''), children: footer }))] }) }));
90
+ return (_jsx(TooltipProvider, { children: _jsxs("nav", { "data-testid": "icon-rail", "aria-label": "Primary", className: railClass(labelVisibility, className), children: [_jsx("div", { className: cn('flex shrink-0 flex-col items-center', showLabels ? 'w-full gap-1' : 'gap-1'), children: mainItems.map(renderItem) }), utilityItems.length > 0 && (_jsxs(_Fragment, { children: [_jsx(RailDivider, { pinnedBottom: pinnedBottom, onToggle: () => setPinnedBottom((prev) => !prev) }), _jsx("div", { "data-testid": "utility-items-wrapper", className: cn('flex shrink-0 flex-col items-center', showLabels ? 'w-full gap-1' : 'gap-1', pinnedBottom && 'mt-auto'), children: utilityItems.map(renderItem) })] })), footer && (_jsx("div", { className: cn('shrink-0 pt-3 [@media(max-height:850px)]:pt-1.5', pinnedBottom || utilityItems.length === 0 ? 'mt-auto' : ''), children: footer }))] }) }));
63
91
  }
@@ -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, ContextRailHeader, ContextRailScroll, MobileBottomNavItem, PanelMode, PanelView, ShellLinkRenderArgs, ShellMainLayout, ShellRenderContext, ShellViewport, ThemeAdapter, WorkspaceDefinition, WorkspaceMenuItem, } from './types.js';
30
+ export type { AppDefinition, AppShellAppTabsConfig, AppShellAuthBranding, AppShellAuthConfig, AppShellContextRailConfig, AppShellIconRailConfig, AppShellMobileNavConfig, AppShellRightPanelConfig, AppShellVariant, AppShellWorkspaceConfig, AppTabRenderLinkArgs, CommandDefinition, ContextItem, ContextModule, ContextRailHeader, ContextRailScroll, MobileBottomNavItem, MobileDockItem, MobileNavGroup, MobileNavItem, MobileNavLinkArgs, MobileNavTree, MobileNavView, PanelMode, PanelView, ShellLinkRenderArgs, ShellMainLayout, ShellRenderContext, ShellViewport, ThemeAdapter, WorkspaceDefinition, WorkspaceMenuItem, } from './types.js';
31
31
  export { useShellViewport } from './use-shell-viewport.js';
@@ -0,0 +1,17 @@
1
+ import { type ReactNode } from 'react';
2
+ import type { MobileDockItem, MobileNavLinkArgs } from '../types.js';
3
+ /** The key the workspace sheet listens on (reuses the single mobile-drawer state). */
4
+ export declare const WORKSPACE_SHEET_KEY = "workspace-sheet";
5
+ export interface MobileDockProps {
6
+ items: MobileDockItem[];
7
+ activeTo?: string;
8
+ renderLink?: (args: MobileNavLinkArgs) => ReactNode;
9
+ className?: string;
10
+ }
11
+ /**
12
+ * Floating mobile dock — a pill of pinned destinations plus a workspace-sheet
13
+ * trigger, with any `emphasis: 'brand'` item rendered as a standalone circle
14
+ * (e.g. Pilot) beside the pill. Replaces the bordered bottom bar. Hidden on
15
+ * non-mobile viewports and when the right panel is fullscreen.
16
+ */
17
+ export declare function MobileDock({ items, activeTo, renderLink, className }: MobileDockProps): import("react/jsx-runtime").JSX.Element | null;
@@ -0,0 +1,75 @@
1
+ 'use client';
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { createElement, isValidElement } from 'react';
4
+ import { cn } from '../../lib/utils.js';
5
+ import { useMedaShell } from '../shell-provider.js';
6
+ import { useShellViewport } from '../use-shell-viewport.js';
7
+ /** The key the workspace sheet listens on (reuses the single mobile-drawer state). */
8
+ export const WORKSPACE_SHEET_KEY = 'workspace-sheet';
9
+ function renderIcon(icon, size) {
10
+ if (icon == null)
11
+ return null;
12
+ if (isValidElement(icon))
13
+ return icon;
14
+ if (typeof icon === 'function' || typeof icon === 'object') {
15
+ // Lucide component (function) or forwardRef/memo object.
16
+ return createElement(icon, { size, 'aria-hidden': true });
17
+ }
18
+ return null;
19
+ }
20
+ /**
21
+ * Floating mobile dock — a pill of pinned destinations plus a workspace-sheet
22
+ * trigger, with any `emphasis: 'brand'` item rendered as a standalone circle
23
+ * (e.g. Pilot) beside the pill. Replaces the bordered bottom bar. Hidden on
24
+ * non-mobile viewports and when the right panel is fullscreen.
25
+ */
26
+ export function MobileDock({ items, activeTo, renderLink, className }) {
27
+ const ctx = useMedaShell();
28
+ const band = useShellViewport();
29
+ if (band !== 'mobile')
30
+ return null;
31
+ if (ctx.panel.mode === 'fullscreen')
32
+ return null;
33
+ if (items.length === 0)
34
+ return null;
35
+ const pillItems = items.filter((item) => item.emphasis !== 'brand');
36
+ const brandItems = items.filter((item) => item.emphasis === 'brand');
37
+ const runAction = (item) => {
38
+ if (item.action === 'open-sheet')
39
+ ctx.mobileDrawer.setOpen(WORKSPACE_SHEET_KEY);
40
+ else if (item.action === 'open-ai')
41
+ ctx.mobileDrawer.setOpen('ai-drawer');
42
+ else if (item.action === 'open-command-palette')
43
+ ctx.commandPalette.setOpen(true);
44
+ };
45
+ const renderSlot = (item, size) => {
46
+ const label = typeof item.label === 'function' ? item.label() : item.label;
47
+ const isActive = Boolean(item.to && activeTo && item.to === activeTo);
48
+ const icon = renderIcon(item.icon, size);
49
+ const badge = item.badge ? (_jsx("span", { className: "absolute top-0 right-0 size-2 rounded-full bg-primary ring-2 ring-card" })) : null;
50
+ // Route link
51
+ if (item.to && !item.action) {
52
+ const className = cn('relative flex items-center justify-center rounded-full p-1 transition-colors', isActive ? 'text-foreground' : 'text-muted-foreground hover:text-foreground');
53
+ const children = (_jsxs(_Fragment, { children: [icon, badge] }));
54
+ if (renderLink) {
55
+ return renderLink({
56
+ to: item.to,
57
+ isActive,
58
+ className,
59
+ children,
60
+ onNavigate: () => undefined,
61
+ linkProps: {
62
+ href: item.to,
63
+ className,
64
+ 'aria-label': label,
65
+ 'aria-current': isActive ? 'page' : undefined,
66
+ },
67
+ });
68
+ }
69
+ return (_jsx("a", { href: item.to, className: className, "aria-label": label, "aria-current": isActive ? 'page' : undefined, children: children }));
70
+ }
71
+ // Action slot
72
+ return (_jsxs("button", { type: "button", onClick: () => runAction(item), "aria-label": label, className: "relative flex items-center justify-center rounded-full p-1 text-muted-foreground transition-colors hover:text-foreground", children: [icon, badge] }));
73
+ };
74
+ return (_jsxs("div", { "data-testid": "mobile-dock", className: cn('pointer-events-none absolute inset-x-0 bottom-0 z-30 flex items-center justify-center gap-2.5 pb-[calc(env(safe-area-inset-bottom)+12px)] pt-2', className), children: [_jsx("nav", { "aria-label": "Dock", className: "pointer-events-auto flex items-center gap-[18px] rounded-full border border-border bg-card px-4 py-2.5 shadow-[0_6px_20px_rgba(20,18,60,0.14)]", children: pillItems.map((item) => (_jsx("span", { className: "flex", children: renderSlot(item, 22) }, item.id))) }), brandItems.map((item) => (_jsx("span", { className: "pointer-events-auto flex size-11 items-center justify-center rounded-full bg-[#5B2D8C] text-white shadow-[0_6px_20px_rgba(91,45,140,0.35)]", children: renderSlot(item, 22) }, item.id)))] }));
75
+ }
@@ -0,0 +1,18 @@
1
+ import { type ReactNode } from 'react';
2
+ import type { MobileNavLinkArgs, MobileNavTree, WorkspaceMenuItem } from '../types.js';
3
+ export interface MobileWorkspaceSheetProps {
4
+ tree: MobileNavTree;
5
+ activeTo?: string;
6
+ renderLink?: (args: MobileNavLinkArgs) => ReactNode;
7
+ workspaceMenuItems?: WorkspaceMenuItem[];
8
+ workspaceMenuFooter?: ReactNode;
9
+ }
10
+ /**
11
+ * The one mobile workspace sheet — a calm, scannable list of the whole nav
12
+ * tree that replaces the four drawers. Modules are light group labels,
13
+ * submodules are rows: a `›` navigates, a `⌄` reveals a slim chip row of
14
+ * preset views (accordion, one open at a time). Filters, layout, and saved
15
+ * views deliberately stay on the page, not here. Opened from the dock's
16
+ * workspace-selector slot; a workspace header on top surfaces the account menu.
17
+ */
18
+ export declare function MobileWorkspaceSheet({ tree, activeTo, renderLink, workspaceMenuItems, workspaceMenuFooter, }: MobileWorkspaceSheetProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,100 @@
1
+ 'use client';
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { ChevronDown, ChevronRight, ChevronUp, Monitor, Moon, Search, Sun, } from 'lucide-react';
4
+ import { createElement, Fragment, isValidElement, useState } from 'react';
5
+ import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, } from '../../components/ui/drawer.js';
6
+ import { cn } from '../../lib/utils.js';
7
+ import { useMedaShell } from '../shell-provider.js';
8
+ import { useTheme } from '../theme.js';
9
+ import { WORKSPACE_SHEET_KEY } from './mobile-dock.js';
10
+ function renderIcon(icon, size) {
11
+ if (icon == null)
12
+ return null;
13
+ if (isValidElement(icon))
14
+ return icon;
15
+ if (typeof icon === 'function' || typeof icon === 'object') {
16
+ return createElement(icon, { size, 'aria-hidden': true });
17
+ }
18
+ return null;
19
+ }
20
+ const rowClass = 'flex w-full items-center gap-3 rounded-[10px] px-2 py-2.5 text-left text-[13.5px] transition-colors hover:bg-accent';
21
+ /**
22
+ * The one mobile workspace sheet — a calm, scannable list of the whole nav
23
+ * tree that replaces the four drawers. Modules are light group labels,
24
+ * submodules are rows: a `›` navigates, a `⌄` reveals a slim chip row of
25
+ * preset views (accordion, one open at a time). Filters, layout, and saved
26
+ * views deliberately stay on the page, not here. Opened from the dock's
27
+ * workspace-selector slot; a workspace header on top surfaces the account menu.
28
+ */
29
+ export function MobileWorkspaceSheet({ tree, activeTo, renderLink, workspaceMenuItems, workspaceMenuFooter, }) {
30
+ const ctx = useMedaShell();
31
+ const open = ctx.mobileDrawer.open === WORKSPACE_SHEET_KEY;
32
+ const close = () => ctx.mobileDrawer.setOpen(null);
33
+ const [expandedId, setExpandedId] = useState(null);
34
+ const [accountOpen, setAccountOpen] = useState(false);
35
+ const navLink = (to, label, className, children, isActive) => {
36
+ if (renderLink) {
37
+ return renderLink({
38
+ to,
39
+ isActive,
40
+ className,
41
+ children,
42
+ onNavigate: close,
43
+ linkProps: {
44
+ href: to,
45
+ className,
46
+ 'aria-label': label,
47
+ 'aria-current': isActive ? 'page' : undefined,
48
+ onClick: close,
49
+ },
50
+ });
51
+ }
52
+ return (_jsx("a", { href: to, className: className, "aria-label": label, onClick: close, children: children }));
53
+ };
54
+ const renderRow = (item) => {
55
+ const isActive = Boolean(activeTo && item.to === activeTo);
56
+ const hasViews = Boolean(item.views && item.views.length > 0);
57
+ const icon = renderIcon(item.icon, 17);
58
+ const badge = item.badge ? (_jsx("span", { className: "rounded-full bg-accent px-1.5 py-0.5 text-[11px] text-muted-foreground", children: item.badge })) : null;
59
+ // Plain navigate row — no presets.
60
+ if (!hasViews) {
61
+ return navLink(item.to, item.label, cn(rowClass, isActive ? 'text-foreground' : 'text-foreground/90'), _jsxs(_Fragment, { children: [_jsx("span", { className: "shrink-0 text-muted-foreground", children: icon }), _jsx("span", { className: "flex-1", children: item.label }), badge, _jsx(ChevronRight, { className: "size-4 shrink-0 text-muted-foreground/50", "aria-hidden": "true" })] }), isActive);
62
+ }
63
+ // Expandable row — reveals a slim chip row of preset views.
64
+ const isOpen = expandedId === item.id;
65
+ return (_jsxs(Fragment, { children: [_jsxs("button", { type: "button", onClick: () => setExpandedId(isOpen ? null : item.id), "aria-expanded": isOpen, className: cn(rowClass, isOpen ? 'text-primary' : 'text-foreground/90'), children: [_jsx("span", { className: cn('shrink-0', isOpen ? 'text-primary' : 'text-muted-foreground'), children: icon }), _jsx("span", { className: "flex-1", children: item.label }), badge, isOpen ? (_jsx(ChevronUp, { className: "size-4 shrink-0 text-muted-foreground", "aria-hidden": "true" })) : (_jsx(ChevronDown, { className: "size-4 shrink-0 text-muted-foreground", "aria-hidden": "true" }))] }), isOpen ? (_jsx("div", { className: "flex flex-wrap gap-1.5 pt-1 pb-2 pl-[37px]", children: item.views?.map((view) => {
66
+ const viewActive = Boolean(activeTo && view.to === activeTo);
67
+ return (_jsx(Fragment, { children: navLink(view.to, `${item.label} · ${view.label}`, cn('rounded-full px-3 py-1.5 text-[12px] transition-colors', viewActive
68
+ ? 'bg-primary/12 text-primary'
69
+ : 'border border-border text-muted-foreground hover:text-foreground'), view.label, viewActive) }, view.id));
70
+ }) })) : null] }, item.id));
71
+ };
72
+ return (_jsx(Drawer, { open: open, onOpenChange: (o) => !o && close(), direction: "bottom", children: _jsxs(DrawerContent, { showOverlay: true, className: "max-h-[86vh] rounded-t-[22px] p-0 data-[vaul-drawer-direction=bottom]:mt-0 data-[vaul-drawer-direction=bottom]:max-h-[86vh]", children: [_jsxs(DrawerHeader, { className: "sr-only", children: [_jsx(DrawerTitle, { children: "Navigation" }), _jsx(DrawerDescription, { children: "Jump to any area of the workspace." })] }), _jsxs("div", { className: "min-h-0 flex-1 overflow-y-auto px-3 pt-1 pb-[calc(env(safe-area-inset-bottom)+12px)]", children: [_jsxs("button", { type: "button", onClick: () => setAccountOpen((v) => !v), "aria-expanded": accountOpen, className: "flex w-full items-center gap-2.5 rounded-[10px] px-1.5 py-2 text-left hover:bg-accent", children: [_jsx("span", { className: "inline-flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted ring-1 ring-border/70", children: ctx.workspace.icon }), _jsx("span", { className: "min-w-0 flex-1 truncate font-medium text-sm", children: ctx.workspace.name }), accountOpen ? (_jsx(ChevronUp, { className: "size-4 text-muted-foreground", "aria-hidden": "true" })) : (_jsx(ChevronDown, { className: "size-4 text-muted-foreground", "aria-hidden": "true" }))] }), accountOpen ? (_jsxs("div", { className: "mb-1 flex flex-col gap-0.5 border-b pb-2", children: [workspaceMenuItems?.map((item) => (_jsxs(Fragment, { children: [_jsx(WorkspaceMenuRow, { item: item, onClose: close }), item.separatorAfter ? _jsx("div", { className: "my-1 h-px bg-border" }) : null] }, item.id))), _jsx(ThemeRow, {}), workspaceMenuFooter] })) : null, _jsxs("button", { type: "button", onClick: () => {
73
+ close();
74
+ ctx.commandPalette.setOpen(true);
75
+ }, className: "my-2 flex w-full items-center gap-2 rounded-[10px] border px-3 py-2.5 text-left text-muted-foreground text-sm", children: [_jsx(Search, { className: "size-4", "aria-hidden": "true" }), "Search\u2026"] }), tree.groups.map((group) => (_jsxs("section", { "aria-label": group.label, children: [_jsx("p", { className: "px-2 pt-3 pb-1 text-[11px] text-muted-foreground", children: group.label }), _jsx("div", { className: "flex flex-col gap-0.5", children: group.items.map((item) => (_jsx(Fragment, { children: renderRow(item) }, item.id))) })] }, group.id))), tree.footerItems && tree.footerItems.length > 0 ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "mx-2 my-2 h-px bg-border" }), _jsx("div", { className: "flex flex-col gap-0.5", children: tree.footerItems.map((item) => (_jsx(Fragment, { children: renderRow(item) }, item.id))) })] })) : null] })] }) }));
76
+ }
77
+ function WorkspaceMenuRow({ item, onClose }) {
78
+ const icon = renderIcon(item.icon, 16);
79
+ const className = cn(rowClass, item.variant === 'destructive' && 'text-destructive');
80
+ const handleClick = () => {
81
+ item.onClick?.();
82
+ onClose();
83
+ };
84
+ if (item.href != null) {
85
+ return (_jsxs("a", { href: item.href, className: className, onClick: handleClick, children: [_jsx("span", { className: "shrink-0 text-muted-foreground", children: icon }), item.label] }));
86
+ }
87
+ return (_jsxs("button", { type: "button", className: className, onClick: handleClick, children: [_jsx("span", { className: "shrink-0 text-muted-foreground", children: icon }), item.label] }));
88
+ }
89
+ const NEXT_THEME = { light: 'dark', dark: 'system', system: 'light' };
90
+ const THEME_ICON = { light: Sun, dark: Moon, system: Monitor };
91
+ const THEME_LABEL = {
92
+ light: 'Switch to dark theme',
93
+ dark: 'Switch to system theme',
94
+ system: 'Switch to light theme',
95
+ };
96
+ function ThemeRow() {
97
+ const { theme, setTheme } = useTheme();
98
+ const Icon = THEME_ICON[theme];
99
+ return (_jsxs("button", { type: "button", className: rowClass, onClick: () => setTheme(NEXT_THEME[theme]), children: [_jsx("span", { className: "shrink-0 text-muted-foreground", children: _jsx(Icon, { size: 16, "aria-hidden": "true" }) }), THEME_LABEL[theme]] }));
100
+ }
@@ -58,6 +58,67 @@ export interface MobileBottomNavItem {
58
58
  icon: LucideIcon;
59
59
  opens: 'menu-drawer' | 'module-drawer' | 'panels-drawer' | 'ai-drawer' | ((close: () => void) => ReactNode);
60
60
  }
61
+ /** A pinned slot in the floating dock — a route link or a shell action. */
62
+ export interface MobileDockItem {
63
+ id: string;
64
+ label: string | (() => string);
65
+ icon: LucideIcon | ReactNode;
66
+ /** Route target — rendered as a nav link via `renderLink`. */
67
+ to?: string;
68
+ /** Show an unread/notification dot on the slot. */
69
+ badge?: boolean;
70
+ /** Perform a shell action instead of navigating. */
71
+ action?: 'open-sheet' | 'open-ai' | 'open-command-palette';
72
+ /** `brand` renders the standalone emphasized circle (e.g. Pilot). */
73
+ emphasis?: 'brand';
74
+ }
75
+ /** A preset view under a submodule (e.g. Deals → Won) — expands inline. */
76
+ export interface MobileNavView {
77
+ id: string;
78
+ label: string;
79
+ to: string;
80
+ count?: string | number;
81
+ }
82
+ /** A submodule row in the workspace sheet (e.g. CRM → Deals). */
83
+ export interface MobileNavItem {
84
+ id: string;
85
+ label: string;
86
+ icon?: LucideIcon | ReactNode;
87
+ to: string;
88
+ badge?: string | number;
89
+ /** Preset views that expand inline under this row (accordion). */
90
+ views?: MobileNavView[];
91
+ }
92
+ /** A module group in the workspace sheet (e.g. CRM). */
93
+ export interface MobileNavGroup {
94
+ id: string;
95
+ label: string;
96
+ icon?: LucideIcon | ReactNode;
97
+ items: MobileNavItem[];
98
+ }
99
+ /** The full nav tree shown in the workspace sheet. */
100
+ export interface MobileNavTree {
101
+ groups: MobileNavGroup[];
102
+ /** Rows below the groups (e.g. Settings). */
103
+ footerItems?: MobileNavItem[];
104
+ }
105
+ export interface MobileNavLinkArgs {
106
+ to: string;
107
+ isActive: boolean;
108
+ className: string;
109
+ children: ReactNode;
110
+ onNavigate: () => void;
111
+ linkProps: AnchorHTMLAttributes<HTMLAnchorElement>;
112
+ }
113
+ /** Opt-in mobile dock + workspace sheet configuration. */
114
+ export interface AppShellMobileNavConfig {
115
+ dock: MobileDockItem[];
116
+ tree: MobileNavTree;
117
+ /** The `to` of the currently-active row, for highlighting. */
118
+ activeTo?: string;
119
+ /** Render dock/sheet targets as router links (else plain `<a>`). */
120
+ renderLink?: (args: MobileNavLinkArgs) => ReactNode;
121
+ }
61
122
  export interface ShellRenderContext {
62
123
  workspaceId: string;
63
124
  appId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medalsocial/meda",
3
- "version": "2.5.2",
3
+ "version": "2.6.0",
4
4
  "description": "Shared Meda UI shell and runtime package.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {