UNPKG

@medalsocial/meda

Version:

Shared Meda UI shell and runtime package.

188 lines (187 loc) 13.6 kB
'use client'; import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime"; import { Monitor, Moon, Sun } from 'lucide-react'; import { cloneElement, createElement, Fragment, isValidElement, useEffect, } from 'react'; import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, } from '../../components/ui/drawer.js'; import { cn } from '../../lib/utils.js'; import { useMedaShell } from '../shell-provider.js'; import { useTheme } from '../theme.js'; /** * Renders all four mobile drawer slots (Menu / Module / Panels / AI) plus * any custom-content drawers. Mount once near the AppShell root; drawers * open/close via `ctx.mobileDrawer.open` provider state. */ /* v8 ignore next — v8 phantom duplicate function record for MobileDrawers */ export function MobileDrawers({ menuItems = [], menuActiveId, menuRenderLink, workspaceMenuItems, workspaceMenuFooter, module, moduleAppId, moduleActiveItemId, moduleRenderLink, moduleHeader, moduleScroll, sectionTabs, panelViews = [], defaultView, customContent = {}, }) { const ctx = useMedaShell(); const open = ctx.mobileDrawer.open; const setOpen = ctx.mobileDrawer.setOpen; const close = () => setOpen(null); const renderCtx = { workspaceId: ctx.workspace.id, appId: ctx.activeAppId, }; const moduleRenderCtx = { workspaceId: ctx.workspace.id, appId: moduleAppId ?? ctx.activeAppId, }; return (_jsxs(_Fragment, { children: [_jsx(MenuDrawer, { open: open === 'menu-drawer', onClose: close, items: menuItems, activeId: menuActiveId, renderLink: menuRenderLink, workspaceItems: workspaceMenuItems, workspaceFooter: workspaceMenuFooter, sectionTabs: sectionTabs }), _jsx(ModuleDrawer, { open: open === 'module-drawer', onClose: close, module: module, activeItemId: moduleActiveItemId, renderCtx: moduleRenderCtx, renderLink: moduleRenderLink, 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)))] })); } // --------------------------------------------------------------------------- // Internal sub-drawers // --------------------------------------------------------------------------- function MenuDrawer({ open, onClose, items, activeId, renderLink, workspaceItems, workspaceFooter, sectionTabs, }) { const hasIconItems = items.length > 0; const hasWorkspaceItems = Array.isArray(workspaceItems) && workspaceItems.length > 0; const hasSectionTabs = sectionTabs != null; // The desktop "leading" section tabs (e.g. Home / Inbox) live under the menu // on mobile. The tabs are router Links; clicking one navigates client-side, // so close the drawer when any anchor inside is activated. const handleSectionTabsClick = (event) => { if (event.target.closest('a')) { onClose(); } }; /* v8 ignore next — v8 phantom duplicate return statement for MenuDrawer */ return (_jsx(Drawer, { open: open, onOpenChange: (o) => !o && onClose(), direction: "left", children: _jsxs(DrawerContent, { children: [_jsxs(DrawerHeader, { children: [_jsx(DrawerTitle, { children: "Menu" }), _jsx(DrawerDescription, { className: "sr-only", children: "Switch between primary app areas." })] }), _jsxs("div", { className: "flex flex-col gap-2 p-2", children: [hasSectionTabs && (_jsxs(_Fragment, { children: [_jsx("div", { "data-meda-mobile-section-tabs": "", className: "flex h-9 items-stretch overflow-x-auto px-1", onClick: handleSectionTabsClick, children: sectionTabs }), _jsx("div", { className: "h-px bg-border" })] })), hasIconItems && (_jsx("nav", { "aria-label": "Primary navigation", className: "flex flex-col gap-0.5", children: items.map((item) => (_jsx(MenuDrawerItem, { item: item, isActive: item.id === activeId, onClose: onClose, renderLink: renderLink }, item.id))) })), (hasIconItems || hasWorkspaceItems) && _jsx("div", { className: "h-px bg-border" }), _jsxs("nav", { "aria-label": "Workspace menu", className: "flex flex-col gap-0.5", children: [workspaceItems?.map((item) => (_jsxs(Fragment, { children: [_jsx(WorkspaceMenuDrawerItem, { item: item, onClose: onClose }), item.separatorAfter && _jsx("div", { className: "my-1 h-px bg-border" })] }, item.id))), _jsx(MobileThemeMenuItem, { onClose: onClose })] }), workspaceFooter] })] }) })); } const menuItemClassName = 'flex items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground'; function MenuDrawerItem({ item, isActive, onClose, renderLink, }) { const Icon = item.icon; const children = (_jsxs(_Fragment, { children: [_jsx(Icon, { size: 18, "aria-hidden": "true" }), _jsx("span", { children: item.label })] })); const className = cn(menuItemClassName, isActive && 'bg-accent text-foreground'); const linkProps = { href: item.to, className, children, }; if (renderLink) { return closeAfterLinkClick(renderLink({ item, isActive, className, children, linkProps, }), onClose); } return closeAfterLinkClick(_jsx("a", { ...linkProps }), onClose); } function closeAfterLinkClick(link, onClose) { if (!isValidElement(link)) { return link; } const originalOnClick = link.props.onClick; return cloneElement(link, { onClick: (event) => { originalOnClick?.(event); onClose(); }, }); } function renderWorkspaceIcon(icon) { if (icon == null) return null; if (isValidElement(icon)) return icon; if (typeof icon === 'function') { return createElement(icon, { size: 18, 'aria-hidden': true }); } if (typeof icon === 'object') { const candidate = icon; if (candidate.$$typeof != null) { return createElement(icon, { size: 18, 'aria-hidden': true }); } } return icon; } function WorkspaceMenuDrawerItem({ item, onClose, }) { const children = (_jsxs(_Fragment, { children: [renderWorkspaceIcon(item.icon), _jsx("span", { children: item.label })] })); const className = cn(menuItemClassName, item.variant === 'destructive' && 'text-destructive hover:text-destructive'); const handleClick = () => { item.onClick?.(); onClose(); }; if (item.href != null) { return closeAfterLinkClick(_jsx("a", { href: item.href, className: className, "data-variant": item.variant ?? 'default', onClick: () => item.onClick?.(), children: children }), onClose); } return (_jsx("button", { type: "button", "data-variant": item.variant ?? 'default', className: className, onClick: handleClick, children: children })); } const NEXT_THEME = { light: 'dark', dark: 'system', system: 'light' }; const THEME_ICON = { light: Sun, dark: Moon, system: Monitor }; const THEME_LABEL = { light: 'Switch to dark theme', dark: 'Switch to system theme', system: 'Switch to light theme', }; function MobileThemeMenuItem({ onClose }) { const { theme, setTheme } = useTheme(); const Icon = THEME_ICON[theme]; return (_jsxs("button", { type: "button", "aria-label": THEME_LABEL[theme], className: menuItemClassName, onClick: () => { setTheme(NEXT_THEME[theme]); onClose(); }, children: [_jsx(Icon, { size: 18, "aria-hidden": "true" }), _jsx("span", { children: THEME_LABEL[theme] })] })); } function ModuleDrawer({ open, onClose, module, activeItemId, renderCtx, renderLink, header = 'auto', scroll = 'auto', }) { const items = module?.items ?? []; if (!module || (items.length === 0 && !module.render)) return null; const hasRender = typeof module.render === 'function'; const showHeader = header === 'visible' || (header === 'auto' && items.length > 0 && !hasRender); 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 }))] })); /* v8 ignore next — v8 phantom duplicate return statement for ModuleDrawer */ 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) => { const Icon = item.icon; const isActive = item.id === activeItemId; const className = cn('flex items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground', isActive && 'bg-accent text-foreground'); const children = (_jsxs(_Fragment, { children: [_jsx(Icon, { size: 16, "aria-hidden": "true" }), _jsx("span", { children: item.label })] })); const linkProps = { href: item.to, 'aria-current': isActive ? 'page' : undefined, className, children, }; if (renderLink) { return (_jsx(Fragment, { children: closeAfterLinkClick(renderLink({ item, isActive, className, children, linkProps }), onClose) }, item.id)); } return (_jsx(Fragment, { children: closeAfterLinkClick(_jsx("a", { ...linkProps }), onClose) }, item.id)); }) })), module.render?.(renderCtx)] })] }) })); } function PanelsDrawer({ open, onClose, panelViews, defaultView, renderCtx, }) { const ctx = useMedaShell(); const activeView = ctx.panel.activeView; const setActiveView = ctx.panel.setActiveView; // Hydrate ctx.panel.activeView from defaultView when the drawer opens — matches // desktop RightPanel behavior so the tab highlight (aria-current) and provider // state stay in sync across viewports. Without this, mobile shows the right // content but no tab is marked active. useEffect(() => { if (open && activeView == null && defaultView && panelViews.some((v) => v.id === defaultView)) { setActiveView(defaultView); } }, [open, activeView, defaultView, panelViews, setActiveView]); // Resolution order: explicit user selection → consumer-provided defaultView // → first view. Keeps mobile parity with desktop where defaultView is honored. const active = panelViews.find((v) => v.id === activeView) ?? (defaultView ? panelViews.find((v) => v.id === defaultView) : undefined) ?? panelViews[0]; /* v8 ignore next — v8 phantom duplicate return statement for PanelsDrawer */ return (_jsx(Drawer, { open: open, onOpenChange: (o) => !o && onClose(), direction: "bottom", children: _jsxs(DrawerContent, { children: [_jsxs(DrawerHeader, { children: [_jsx(DrawerTitle, { children: active?.label ?? 'Panels' }), _jsx(DrawerDescription, { className: "sr-only", children: "Contextual panels for the current module." })] }), panelViews.length > 1 && (_jsx("div", { className: "flex items-center gap-1 border-b border-border px-3 py-2", children: panelViews.map((view) => (_jsx("button", { type: "button", onClick: () => ctx.panel.setActiveView(view.id), "aria-current": view.id === activeView ? 'true' : undefined, className: cn('rounded-md px-2 py-1 text-sm', view.id === activeView ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-accent'), children: view.label }, view.id))) })), _jsx("div", { className: "flex-1 overflow-y-auto p-3", children: active?.render(renderCtx) })] }) })); } function AiDrawer({ open, onClose, panelViews, renderCtx, }) { const ctx = useMedaShell(); const aiView = panelViews.find((v) => v.id === 'ai'); const setActiveView = ctx.panel.setActiveView; // Pin to ai view when drawer opens // biome-ignore lint/correctness/useExhaustiveDependencies: setActiveView is stable (from useMemo in provider) useEffect(() => { if (open && aiView) { setActiveView('ai'); } }, [open, aiView]); if (!aiView) return null; /* v8 ignore next — v8 phantom duplicate return statement for AiDrawer */ return (_jsx(Drawer, { open: open, onOpenChange: (o) => !o && onClose(), direction: "bottom", children: _jsxs(DrawerContent, { children: [_jsxs(DrawerHeader, { children: [_jsx(DrawerTitle, { children: aiView.label }), _jsx(DrawerDescription, { className: "sr-only", children: "Open the AI assistant panel." })] }), _jsx("div", { className: "flex-1 overflow-y-auto p-3", children: aiView.render(renderCtx) })] }) })); }