UNPKG

@medalsocial/meda

Version:

Shared Meda UI shell and runtime package.

230 lines (229 loc) 9.77 kB
'use client'; import { jsx as _jsx } from "react/jsx-runtime"; import { LayoutGrid, Menu, PanelTop, Sparkles } from 'lucide-react'; import { createContext, lazy, Suspense, useCallback, useContext, useMemo, useState, } from 'react'; import { createLocalStorageAdapter, useShellLayoutState, } from './layout-state.js'; import { DefaultThemeProvider, ThemeCtx } from './theme.js'; import { useShellViewport } from './use-shell-viewport.js'; // --------------------------------------------------------------------------- // Theme adapter wiring // --------------------------------------------------------------------------- // Lazy-loaded so default-adapter consumers do not pay for the compatibility // bridge used when themeAdapter='next-themes'. const NextThemesAdapter = lazy(() => import('./theme-next-themes.js').then((m) => ({ default: m.NextThemesAdapter }))); function CustomThemeBridge({ adapter, children }) { return _jsx(ThemeCtx.Provider, { value: adapter, children: children }); } function pickTheme(themeAdapter, children) { const adapter = themeAdapter ?? 'default'; if (adapter === 'default') { return _jsx(DefaultThemeProvider, { children: children }); } if (adapter === 'next-themes') { return (_jsx(Suspense, { fallback: null, children: _jsx(NextThemesAdapter, { children: children }) })); } // Custom ThemeAdapter object return _jsx(CustomThemeBridge, { adapter: adapter, children: children }); } // --------------------------------------------------------------------------- // Default mobile bottom nav (canonical 4-item set, per spec §16) // Full icons ship here; the per-app customisation point is the mobileBottomNav // prop. For RC.1 the canonical drawer wire-up is a Phase 13 concern — only // the shape is locked here. // --------------------------------------------------------------------------- const defaultMobileBottomNav = [ { id: 'menu', label: 'Menu', icon: Menu, opens: 'menu-drawer' }, { id: 'module', label: 'Module', icon: LayoutGrid, opens: 'module-drawer' }, { id: 'panels', label: 'Panels', icon: PanelTop, opens: 'panels-drawer' }, { id: 'ai', label: 'AI', icon: Sparkles, opens: 'ai-drawer' }, ]; const Ctx = createContext(null); export function useMedaShell() { const v = useContext(Ctx); if (!v) throw new Error('useMedaShell must be used inside <MedaShellProvider>'); return v; } /** * Selection bridge between main workspace and right panel views (spec §17). * * `T` is consumer-typed: setSelection writes whatever shape you pass; the * matching getter narrows to `T | null`. The shell does not validate the * shape — coordinate types between writer and readers in your app. */ export function useShellSelection() { const ctx = useMedaShell(); return [ctx.selection, ctx.setSelection]; } // --------------------------------------------------------------------------- // Provider implementation // --------------------------------------------------------------------------- export function MedaShellProvider(props) { // Guard: apps array must not be empty if (props.apps.length === 0) { throw new Error('MedaShellProvider: apps must contain at least one AppDefinition'); } // Stable storage reference — prevents useShellLayoutState's hydration // effect from looping when an inline adapter would change identity each render. const storage = useMemo(() => props.storage ?? createLocalStorageAdapter(), [props.storage]); const [activeAppId, setActiveApp] = useState(props.defaultActiveApp ?? props.apps[0]?.id ?? ''); const [selection, setSelection] = useState(null); const [mobileDrawerOpen, setMobileDrawerOpen] = useState(null); const mobileDrawer = useMemo(() => ({ open: mobileDrawerOpen, setOpen: setMobileDrawerOpen, }), [mobileDrawerOpen]); const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); const commandPalette = useMemo(() => ({ open: commandPaletteOpen, setOpen: setCommandPaletteOpen, }), [commandPaletteOpen]); const [panelViewRegistrations, setPanelViewRegistrations] = useState([]); const registerPanelViews = useCallback((id, views, defaultView) => { setPanelViewRegistrations((prev) => { const existing = prev.find((registration) => registration.id === id); if (existing?.views === views && existing.defaultView === defaultView) return prev; const nextRegistration = defaultView === undefined ? { id, views } : { id, views, defaultView }; if (existing == null) return [...prev, nextRegistration]; return prev.map((registration) => (registration.id === id ? nextRegistration : registration)); }); return () => { setPanelViewRegistrations((prev) => prev.some((registration) => registration.id === id && registration.views === views && registration.defaultView === defaultView) ? prev.filter((registration) => registration.id !== id) : prev); }; }, []); const panelViews = useMemo(() => ({ registrations: panelViewRegistrations, register: registerPanelViews, }), [panelViewRegistrations, registerPanelViews]); const [layoutState, setLayoutState] = useShellLayoutState({ workspaceId: props.workspace.id, appId: activeAppId, storage, }); const isMobile = useShellViewport() === 'mobile'; const setPanelMode = useCallback((mode) => setLayoutState((prev) => ({ ...prev, rightPanel: { ...prev.rightPanel, mode }, })), [setLayoutState]); const setPanelActiveView = useCallback((activeView) => setLayoutState((prev) => ({ ...prev, rightPanel: { ...prev.rightPanel, activeView }, })), [setLayoutState]); const setPanelWidth = useCallback((width) => setLayoutState((prev) => ({ ...prev, rightPanel: { ...prev.rightPanel, width }, })), [setLayoutState]); const openPanel = useCallback(() => { if (isMobile) setMobileDrawerOpen('panels-drawer'); setLayoutState((prev) => ({ ...prev, rightPanel: { ...prev.rightPanel, mode: prev.rightPanel.mode === 'closed' ? 'panel' : prev.rightPanel.mode, }, })); }, [isMobile, setLayoutState]); const closePanel = useCallback(() => { if (isMobile) setMobileDrawerOpen((open) => (open === 'panels-drawer' ? null : open)); setLayoutState((prev) => ({ ...prev, rightPanel: { ...prev.rightPanel, mode: 'closed' }, })); }, [isMobile, setLayoutState]); const togglePanel = useCallback(() => { if (isMobile) { setMobileDrawerOpen((open) => (open === 'panels-drawer' ? null : 'panels-drawer')); } setLayoutState((prev) => ({ ...prev, rightPanel: { ...prev.rightPanel, mode: prev.rightPanel.mode === 'closed' ? 'panel' : 'closed', }, })); }, [isMobile, setLayoutState]); const focusPanel = useCallback((viewId) => setLayoutState((prev) => { const nextMode = prev.rightPanel.mode === 'closed' ? 'panel' : prev.rightPanel.mode; return { ...prev, rightPanel: { ...prev.rightPanel, mode: nextMode, activeView: viewId }, }; }), [setLayoutState]); const panel = useMemo(() => ({ mode: layoutState.rightPanel.mode, activeView: layoutState.rightPanel.activeView, width: layoutState.rightPanel.width, setMode: setPanelMode, setActiveView: setPanelActiveView, setWidth: setPanelWidth, open: openPanel, close: closePanel, toggle: togglePanel, focus: focusPanel, }), [ layoutState.rightPanel.mode, layoutState.rightPanel.activeView, layoutState.rightPanel.width, setPanelMode, setPanelActiveView, setPanelWidth, openPanel, closePanel, togglePanel, focusPanel, ]); const contextRail = useMemo(() => ({ width: layoutState.contextRail.width, collapsed: layoutState.contextRail.collapsed, setWidth: (width) => setLayoutState((prev) => ({ ...prev, contextRail: { ...prev.contextRail, width }, })), setCollapsed: (collapsed) => setLayoutState((prev) => ({ ...prev, contextRail: { ...prev.contextRail, collapsed }, })), toggle: () => setLayoutState((prev) => ({ ...prev, contextRail: { ...prev.contextRail, collapsed: !prev.contextRail.collapsed }, })), }), [layoutState, setLayoutState]); const value = useMemo(() => ({ workspace: props.workspace, workspaces: props.workspaces ?? [props.workspace], apps: props.apps, activeAppId, setActiveApp, panel, contextRail, mobileBottomNav: props.mobileBottomNav ?? defaultMobileBottomNav, mobileDrawer, commandPalette, panelViews, commandPaletteHotkey: props.commandPaletteHotkey ?? 'mod+k', selection, setSelection, }), [ props.workspace, props.workspaces, props.apps, activeAppId, panel, contextRail, props.mobileBottomNav, mobileDrawer, commandPalette, panelViews, props.commandPaletteHotkey, selection, ]); return _jsx(Ctx.Provider, { value: value, children: pickTheme(props.themeAdapter, props.children) }); }