@medalsocial/meda
Version:
Shared Meda UI shell and runtime package.
230 lines (229 loc) • 9.77 kB
JavaScript
'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) });
}