UNPKG

@medalsocial/meda

Version:

Shared Meda UI shell and runtime package.

140 lines (139 loc) 10.9 kB
'use client'; import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; /** * ContextRail — spec §10 * * Renders a resizable, collapsible, persisted context navigation rail. * Width is stored per-(workspaceId, appId) via useShellLayoutState through * the MedaShellProvider context. * * Resize integration note (Phase 9 / Pattern B): * The rail uses its own pointer-events resize handle on the right edge instead * of wrapping in <ResizableShell> (Pattern A). Pattern A requires migrating * <AppShellBody> to a PanelGroup layout — that is Phase 11 territory. * TODO(phase-11): replace pointer-events handle with <ResizableShellPanel> * once <AppShellBody> ships as a ResizableShell Group. */ import { ChevronLeft, ChevronRight } from 'lucide-react'; import { Fragment, useId, useRef, useState } from 'react'; import { cn } from '../lib/utils.js'; import { useMedaShell } from './shell-provider.js'; import { useShellViewport } from './use-shell-viewport.js'; // --------------------------------------------------------------------------- // Constants — spec §10 dimensions // --------------------------------------------------------------------------- const MIN_WIDTH = 240; const MAX_WIDTH = 420; function ContextRailSeam({ railId, currentWidth, onResize, onCommit }) { const ctx = useMedaShell(); const collapsed = ctx.contextRail.collapsed; const Icon = collapsed ? ChevronRight : ChevronLeft; const startWidthRef = useRef(currentWidth); const startXRef = useRef(0); const draggingRef = useRef(false); const handlePointerDown = (e) => { if (collapsed) return; // no resize while collapsed // setPointerCapture may not be available in all test environments (jsdom) 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; const next = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidthRef.current + (e.clientX - startXRef.current))); onResize(next); }; const handlePointerUp = (e) => { if (!draggingRef.current) return; draggingRef.current = false; const next = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidthRef.current + (e.clientX - startXRef.current))); onCommit(next); }; // Grip reveal: invisible until the rail is hovered (expanded); faint and // reachable when collapsed so the rail can always be reopened. const reveal = collapsed ? 'pointer-events-auto opacity-55' : 'pointer-events-none opacity-0 group-hover/seam:pointer-events-auto group-hover/seam:opacity-100'; // ONE element: a full-height col-resize separator straddling the boundary, // carrying the single seam line (brand + glow on hover/focus) and the grip // pill. Dragging the seam resizes; clicking the grip (which stops pointer // propagation) collapses — no separate resize handle, so only one line. return (_jsxs("div", { "data-testid": "context-rail-seam", "data-collapsed": collapsed ? 'true' : 'false', onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, className: cn('group/seam absolute top-0 right-0 bottom-0 z-20 w-3 translate-x-1/2', collapsed ? 'cursor-default' : 'cursor-col-resize'), children: [_jsx("span", { role: "separator", "aria-orientation": "vertical", "aria-label": "Resize context rail", "aria-valuenow": currentWidth, "aria-valuemin": MIN_WIDTH, "aria-valuemax": MAX_WIDTH, className: cn('pointer-events-none absolute inset-y-0 left-1/2 w-[2px] -translate-x-1/2 transition-[background-color,box-shadow] duration-150', collapsed ? 'bg-shell-border' : 'bg-transparent', 'group-hover/seam:bg-[var(--color-brand-400)] group-hover/seam:shadow-[0_0_14px_-1px_var(--color-brand-400)]', 'group-focus-visible/seam:bg-[var(--color-brand-400)] group-focus-visible/seam:shadow-[0_0_14px_-1px_var(--color-brand-400)]') }), _jsx("button", { type: "button", onPointerDown: (e) => e.stopPropagation(), onClick: () => ctx.contextRail.setCollapsed(!collapsed), "aria-label": collapsed ? 'Expand sidebar' : 'Collapse sidebar', "aria-expanded": !collapsed, "aria-controls": railId, "data-testid": "context-rail-toggle", className: cn('absolute top-1/2 left-1/2 grid h-12 w-[21px] -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border bg-card text-[var(--color-brand-400)]', 'border-[color-mix(in_oklab,var(--color-brand-400)_26%,transparent)]', 'transition-[opacity,transform,box-shadow,border-color] duration-150', collapsed ? 'scale-90' : 'scale-[.82]', reveal, 'group-hover/seam:scale-100 group-hover/seam:border-[var(--color-brand-400)] group-hover/seam:opacity-100 group-hover/seam:shadow-[0_6px_18px_-4px_color-mix(in_oklab,var(--color-brand-400)_65%,transparent)]', 'focus-visible:scale-100 focus-visible:border-[var(--color-brand-400)] focus-visible:opacity-100 focus-visible:outline-none focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--color-brand-400)_35%,transparent)]'), children: _jsx(Icon, { size: 14, "aria-hidden": true }) })] })); } // --------------------------------------------------------------------------- // ContextRail // --------------------------------------------------------------------------- export function ContextRail({ appId, module, hidden = false, collapsible = true, activeItemId, renderLink, header = 'auto', scroll = 'auto', className, }) { const band = useShellViewport(); const ctx = useMedaShell(); // collapsible={false} means the rail must always render expanded — even if // the persisted layout state has collapsed: true (e.g. user collapsed the // rail before the prop flipped). Without this guard, the rail would stay // hidden forever with no in-component way to recover. const collapsed = collapsible ? ctx.contextRail.collapsed : false; // Per-instance id so multiple <ContextRail>s in one document don't clash on // duplicate `id="..."` (HTML invalid + breaks aria-controls relationships). const reactId = useId(); const railId = `meda-context-rail-${reactId}`; // Local display width tracks pointer-move updates; ctx.contextRail.setWidth // is called on pointerUp to persist via useShellLayoutState. const [displayWidth, setDisplayWidth] = useState(null); const width = displayWidth ?? ctx.contextRail.width; const items = module?.items ?? []; const hasRender = typeof module?.render === 'function'; const showHeader = header === 'visible' || (header === 'auto' && items.length > 0 && !hasRender); if (band === 'mobile') return null; if (hidden) { return _jsx("div", { "aria-hidden": "true", className: "hidden", "data-testid": "context-rail-hidden" }); } if (!module || (items.length === 0 && !module.render)) { return _jsx("div", { "aria-hidden": "true", className: "hidden", "data-testid": "context-rail-empty" }); } const handleResize = (w) => { setDisplayWidth(w); }; const handleCommit = (w) => { setDisplayWidth(null); ctx.contextRail.setWidth(w); }; // Only animate width during collapse/expand toggles, not during manual // pointer drag on the ResizeHandle. displayWidth is non-null only while // the user is mid-drag — using it as the drag signal suppresses the // transition then so the rail snaps to the cursor instead of lagging // behind it. const isDragging = displayWidth !== null; return (_jsxs("aside", { id: railId, "data-testid": "context-rail", "aria-label": module.label, className: cn('group/rail relative h-full shrink-0 bg-shell-context', !isDragging && 'transition-[width] duration-200 ease-in-out motion-reduce:transition-none', collapsed && 'w-0', className), style: { width: collapsed ? 0 : width }, children: [collapsible && (_jsx(ContextRailSeam, { railId: railId, currentWidth: width, onResize: handleResize, onCommit: handleCommit })), _jsxs("div", { className: "flex h-full min-w-0 flex-col overflow-hidden", "aria-hidden": collapsed, inert: collapsed || undefined, children: [showHeader && (_jsxs("div", { className: "shrink-0 border-b border-shell-border px-4 py-3", children: [_jsx("h2", { className: "text-sm font-semibold text-foreground", children: module.label }), module.description && (_jsx("p", { className: "mt-0.5 text-xs text-muted-foreground", children: module.description }))] })), _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", { "aria-label": `${module.label} navigation`, className: "flex flex-col gap-0.5 p-2", children: items.map((item) => { const isActive = item.id === activeItemId; const klass = cn('flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors', isActive ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:bg-accent hover:text-foreground'); const IconComp = item.icon; const inner = (_jsxs(_Fragment, { children: [_jsx(IconComp, { size: 16, "aria-hidden": "true", className: "shrink-0" }), _jsx("span", { className: "truncate", children: item.label }), item.shortcut && (_jsx("kbd", { className: "ml-auto font-mono text-[10px] text-muted-foreground", children: item.shortcut }))] })); const linkProps = { href: item.to, 'aria-current': isActive ? 'page' : undefined, className: klass, children: inner, }; if (renderLink) { return (_jsx(Fragment, { children: renderLink({ item, isActive, className: klass, children: inner, linkProps, }) }, item.id)); } return _jsx("a", { ...linkProps }, item.id); }) })), module.render?.({ workspaceId: ctx.workspace.id, appId })] })] })] })); }