@medalsocial/meda
Version:
Shared Meda UI shell and runtime package.
161 lines (160 loc) • 9.71 kB
JavaScript
'use client';
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
/**
* RightPanel — spec §12
*
* Four-mode right panel: closed (0px), panel (resizable 300–520px),
* expanded (~60vw), fullscreen (100vw/100vh takeover).
*
* Mode state lives in MedaShellProvider (panel.mode / panel.setMode).
* Width is persisted per-(workspaceId, appId) via panel.width / panel.setWidth.
*
* Resize note (Pattern B):
* Uses its own pointer-event resize handle on the LEFT edge instead of
* wrapping in <ResizableShell>. Pattern A requires migrating <AppShellBody>
* to a PanelGroup layout.
* TODO(phase-15-refactor): swap to ResizableShell once AppShellBody is a PanelGroup
*/
import { Maximize2, Minimize2, X } from 'lucide-react';
import { Fragment, useEffect, useRef, useState } from 'react';
import { cn } from '../lib/utils.js';
import { useResolvedPanelViews } from './panel-views-provider.js';
import { useMedaShell } from './shell-provider.js';
import { useShellViewport } from './use-shell-viewport.js';
// ---------------------------------------------------------------------------
// Constants — spec §12 dimensions
// ---------------------------------------------------------------------------
const MIN_WIDTH = 300;
const MAX_WIDTH = 520;
const EMPTY_PANEL_VIEWS = [];
function ResizeHandle({ currentWidth, onResize, onCommit }) {
const startWidthRef = useRef(currentWidth);
const startXRef = useRef(0);
const draggingRef = useRef(false);
const handlePointerDown = (e) => {
try {
e.currentTarget.setPointerCapture(e.pointerId);
}
catch {
// ignore — drag still functions via draggingRef
}
startWidthRef.current = currentWidth;
startXRef.current = e.clientX;
draggingRef.current = true;
};
const handlePointerMove = (e) => {
if (!draggingRef.current)
return;
// Left edge: drag left (clientX decreasing) widens panel
const delta = startXRef.current - e.clientX;
const next = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidthRef.current + delta));
onResize(next);
};
const handlePointerUp = (e) => {
if (!draggingRef.current)
return;
draggingRef.current = false;
const delta = startXRef.current - e.clientX;
const next = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidthRef.current + delta));
onCommit(next);
};
return (_jsx("div", { role: "separator", "aria-orientation": "vertical", "aria-label": "Resize panel", "aria-valuenow": currentWidth, "aria-valuemin": MIN_WIDTH, "aria-valuemax": MAX_WIDTH, tabIndex: 0, className: cn('absolute top-0 left-0 h-full w-1 cursor-col-resize', 'opacity-0 transition-opacity hover:opacity-100 hover:bg-ring'), onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp }));
}
// ---------------------------------------------------------------------------
// RightPanel
// ---------------------------------------------------------------------------
export function RightPanel({ panelViews = EMPTY_PANEL_VIEWS, defaultView, modes = ['panel', 'expanded', 'fullscreen'], renderTab, className, }) {
const band = useShellViewport();
const ctx = useMedaShell();
const { mode, activeView, width, setMode, setActiveView, setWidth } = ctx.panel;
const { panelViews: resolvedPanelViews, defaultView: resolvedDefaultView } = useResolvedPanelViews(panelViews, defaultView);
const hydratedDefaultRef = useRef(null);
// Hydrate a default when static or route-registered views become available.
useEffect(() => {
const defaultExists = resolvedDefaultView != null &&
resolvedPanelViews.some((view) => view.id === resolvedDefaultView);
if (!defaultExists)
return;
const source = resolvedDefaultView !== defaultView ? 'registered' : 'static';
const canReplaceStaticDefault = activeView === hydratedDefaultRef.current?.id &&
hydratedDefaultRef.current.source === 'static' &&
source === 'registered' &&
activeView !== resolvedDefaultView;
if (!activeView || canReplaceStaticDefault) {
hydratedDefaultRef.current = { id: resolvedDefaultView, source };
setActiveView(resolvedDefaultView);
}
}, [activeView, defaultView, resolvedDefaultView, resolvedPanelViews, setActiveView]);
// Local display width tracks pointer-move updates live;
// setWidth (persist) is called on pointerUp.
const [displayWidth, setDisplayWidth] = useState(null);
const resolvedWidth = displayWidth ?? width;
// On mobile, the right panel renders as a drawer via <MobileDrawers > PanelsDrawer />.
// The desktop-shaped <RightPanel> hides; consumers must mount <MobileDrawers> to expose
// panel views on mobile.
if (band === 'mobile')
return null;
const renderCtx = {
workspaceId: ctx.workspace.id,
appId: ctx.activeAppId,
};
// Cycle through allowed open modes only (skip 'closed')
const cycleOpenMode = () => {
if (mode === 'closed')
return;
const openModes = modes.filter((m) => m !== 'closed');
const idx = openModes.indexOf(mode);
const next = openModes[(idx + 1) % openModes.length] ?? mode;
setMode(next);
};
// Mode → inline width style
const widthStyle = (() => {
switch (mode) {
case 'closed':
return { width: 0, pointerEvents: 'none' };
case 'panel':
return { width: `${resolvedWidth}px` };
case 'expanded':
return { width: '60vw' };
case 'fullscreen':
return { width: '100vw' };
}
})();
// Z-index escalation: fullscreen covers header + rails
const zIndexClass = mode === 'fullscreen' ? 'z-[var(--z-shell-fullscreen)]' : 'z-[var(--z-shell-panel)]';
const activePanelView = resolvedPanelViews.find((v) => v.id === activeView);
let cycleAriaLabel;
if (mode === 'panel') {
cycleAriaLabel = 'Expand panel';
}
else if (mode === 'expanded') {
cycleAriaLabel = 'Maximize panel';
}
else {
cycleAriaLabel = 'Restore panel';
}
const handleResize = (w) => setDisplayWidth(w);
const handleCommit = (w) => {
setDisplayWidth(null);
setWidth(w);
};
return (_jsx("aside", { "data-meda-panel-mode": mode, "aria-hidden": mode === 'closed' ? 'true' : undefined, className: cn('relative h-full shrink-0 overflow-hidden border-l border-shell-border bg-shell-panel', 'transition-[width] ease-[var(--motion-ease)] duration-[var(--motion-panel)]', mode === 'fullscreen' && 'fixed inset-0 h-screen w-screen border-none', zIndexClass, className), style: widthStyle, children: mode !== 'closed' && (_jsxs("div", { className: "flex h-full flex-col", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-shell-border px-3 py-2", children: [_jsx("div", { className: "flex items-center gap-1", children: resolvedPanelViews.map((view) => {
const isActive = view.id === activeView;
const Icon = view.icon;
const children = (_jsxs(_Fragment, { children: [_jsx(Icon, { size: 14, "aria-hidden": "true" }), _jsx("span", { children: view.label })] }));
const buttonProps = {
type: 'button',
'aria-current': isActive ? 'true' : undefined,
'data-active': isActive || undefined,
onClick: () => setActiveView(view.id),
className: cn('inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium transition-colors', isActive
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-foreground'),
children,
};
if (renderTab) {
return (_jsx(Fragment, { children: renderTab({ view, isActive, buttonProps, children }) }, view.id));
}
return _jsx("button", { ...buttonProps }, view.id);
}) }), _jsxs("div", { className: "flex items-center gap-1", children: [modes.length > 1 && (_jsx("button", { type: "button", "aria-label": cycleAriaLabel, onClick: cycleOpenMode, className: "inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground", children: mode === 'fullscreen' ? (_jsx(Minimize2, { size: 14, "aria-hidden": "true" })) : (_jsx(Maximize2, { size: 14, "aria-hidden": "true" })) })), _jsx("button", { type: "button", "aria-label": "Close panel", onClick: () => setMode('closed'), className: "inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground", children: _jsx(X, { size: 14, "aria-hidden": "true" }) })] })] }), _jsx("div", { className: "flex-1 overflow-y-auto", children: activePanelView != null ? (activePanelView.render(renderCtx)) : (_jsx("div", { className: "p-4 text-muted-foreground text-sm", children: "No panel view selected" })) }), mode === 'panel' && (_jsx(ResizeHandle, { currentWidth: resolvedWidth, onResize: handleResize, onCommit: handleCommit }))] })) }));
}