import { CaretDoubleDown, CaretDoubleLeft, CaretDoubleRight, CaretDoubleUp, } from "@phosphor-icons/react"; import { useEffect, useMemo, useRef, useState, type CSSProperties, type PropsWithChildren, type ReactNode } from "react"; import { cn } from "../../lib/cn"; import { IconButton } from "../actions"; import { SearchInput } from "../inputs/search-input"; import { UhuruPanelStateProvider, useUhuruPanelState } from "../panel-context"; import { matchesUhuruPanelRoute, getUhuruPanelLocation, useUhuruPanelRoute, type UhuruPanelRouteDefinition, } from "../navigation/panel-route-context"; import { filterPanelItems, flattenPanelItems, UhuruPanelItemView, type UhuruPanelItem, type UhuruPanelItemRenderContext, } from "../navigation/panel-item"; export type PanelSide = "left" | "right" | "bottom"; export type PanelConfig = { allowResize?: boolean; ariaLabel?: string; collapsible?: boolean; collapsedHeader?: ReactNode; defaultCollapsed?: boolean; defaultSize?: number; expandOnHover?: boolean; expandIcon?: ReactNode; iconHoverAnimation?: "none" | "lift" | "slide" | "pulse"; footer?: ReactNode; footerRoutes?: PanelFooterRoute[]; header?: ReactNode; beforeFooter?: ReactNode; showRail?: boolean; maxSize?: number; minSize?: number; collapsedSize?: number; }; export type PanelFooterRoute = { disabled?: boolean; element: ReactNode; icon?: ReactNode; id: string; label: ReactNode; path: string; }; export function PanelFooterRoutes({ collapsed = false, routes, }: { collapsed?: boolean; routes?: PanelFooterRoute[]; }) { const panelRoute = useUhuruPanelRoute(); const visibleRoutes = routes ?? []; useEffect(() => { const matchingRoute = visibleRoutes.find((route) => matchesUhuruPanelRoute(route.path), ); if ( matchingRoute && panelRoute && panelRoute.activeRoute?.path !== matchingRoute.path ) { panelRoute.navigate({ element: matchingRoute.element, path: matchingRoute.path, }); } }, [panelRoute, visibleRoutes]); if (visibleRoutes.length === 0) { return null; } return ( ); } export type UhuruPanelHeaderProps = { children?: ReactNode; className?: string; contentClassName?: string; description?: ReactNode; descriptionClassName?: string; leading?: ReactNode; leadingClassName?: string; title?: ReactNode; titleClassName?: string; trailing?: ReactNode; trailingClassName?: string; }; export function UhuruPanelHeader({ children, className, contentClassName, description, descriptionClassName, leading, leadingClassName, title, titleClassName, trailing, trailingClassName, }: UhuruPanelHeaderProps) { const panelState = useUhuruPanelState(); const collapsed = panelState?.collapsed ?? false; const side = panelState?.side ?? "left"; const showRail = panelState?.showRail ?? true; const showToggle = panelState?.showToggle ?? true; const onToggleCollapsed = panelState?.onToggleCollapsed; const expandControlIcon = side === "bottom" ? : side === "left" ? : ; const collapseControlIcon = side === "bottom" ? : side === "left" ? : ; const collapseControlLabel = collapsed ? `Expand ${side} panel` : `Collapse ${side} panel`; return (
{collapsed ? (
{showRail && showToggle && onToggleCollapsed ? ( onToggleCollapsed()} tone="secondary" /> ) : null}
) : (
{children ? ( children ) : ( <> {leading ? (
{leading}
) : null} {title || description ? (
{title ? (
{title}
) : null} {description ? (
{description}
) : null}
) : null} {(showToggle && onToggleCollapsed) || trailing ? (
{showToggle && onToggleCollapsed ? ( onToggleCollapsed()} tone="secondary" /> ) : null} {trailing ? (
{trailing}
) : null}
) : null} )}
)}
); } export type UhuruPanelProps = PropsWithChildren<{ activeId?: string; ariaLabel?: string; className?: string; config?: PanelConfig; defaultRoute?: string; emptyState?: ReactNode; loading?: boolean; onItemSelect?: (item: UhuruPanelItem) => void; renderItem?: (item: UhuruPanelItem, context: UhuruPanelItemRenderContext) => ReactNode; routes?: UhuruPanelRouteItem[]; searchable?: boolean; searchPlaceholder?: string; side: PanelSide; }>; export type UhuruPanelRouteItem = UhuruPanelItem | UhuruPanelRouteDefinition; function normalizePanelRoutes(routes?: UhuruPanelRouteItem[]): UhuruPanelItem[] { return (routes ?? []).map((route, index) => { const item = route as Partial & UhuruPanelRouteDefinition; return { ...item, children: item.children ? normalizePanelRoutes(item.children as UhuruPanelRouteItem[]) : undefined, id: item.id ?? item.path ?? `route-${index}`, label: item.label ?? item.path ?? `Route ${index + 1}`, } as UhuruPanelItem; }); } function getRouteablePanelItems(items: UhuruPanelItem[]) { return flattenPanelItems(items).filter((item) => item.route !== false && item.path && item.element); } function getPanelRouteDefinitions(items: UhuruPanelItem[]): UhuruPanelRouteDefinition[] { return getRouteablePanelItems(items).map((item) => ({ element: item.element, path: item.path, }) as UhuruPanelRouteDefinition); } function UhuruPanelRouteItems({ activeId, ariaLabel, defaultRoute, emptyState, loading, onItemSelect, renderItem, routes, searchable, searchPlaceholder, }: Pick< UhuruPanelProps, | "activeId" | "ariaLabel" | "defaultRoute" | "emptyState" | "loading" | "onItemSelect" | "renderItem" | "routes" | "searchable" | "searchPlaceholder" >) { const panelState = useUhuruPanelState(); const panelRoute = useUhuruPanelRoute(); const collapsed = panelState?.collapsed ?? false; const [query, setQuery] = useState(""); const items = useMemo(() => normalizePanelRoutes(routes), [routes]); const flatItems = useMemo(() => flattenPanelItems(items), [items]); const routeDefinitions = useMemo(() => getPanelRouteDefinitions(items), [items]); const normalizedQuery = query.trim().toLowerCase(); const visibleItems = useMemo( () => (normalizedQuery ? filterPanelItems(items, normalizedQuery) : items), [items, normalizedQuery], ); const resolvedActiveId = activeId ?? flatItems.find((item) => ( item.path && panelRoute?.activeRoute?.path === item.path ))?.id; useEffect(() => { if (routeDefinitions.length === 0) { return; } const location = getUhuruPanelLocation(); const matchingRoute = routeDefinitions.find((route) => route.path === location) ?? routeDefinitions.find((route) => matchesUhuruPanelRoute(route.path, location)); const fallbackRoute = defaultRoute ? routeDefinitions.find((route) => route.path === defaultRoute) : routeDefinitions[0]; const route = matchingRoute ?? fallbackRoute; if (route && panelRoute?.activeRoute?.path !== route.path) { panelRoute?.navigate(route); } }, [defaultRoute, panelRoute, routeDefinitions]); if (!loading && items.length === 0) { return null; } function selectItem(item: UhuruPanelItem) { if (item.route !== false && item.path && item.element) { panelRoute?.navigate({ element: item.element, path: item.path }); } onItemSelect?.(item); } return ( ); } /** * The standalone panel shell owns sizing, collapse, rails, and resize behavior. * When rendered inside UhuruAppLayout, the app layout already owns those * concerns, so UhuruPanel must not create a second competing panel shell. */ function UhuruPanelShell({ activeId, ariaLabel, children, className, config, defaultRoute, emptyState, loading, onItemSelect, renderItem, routes, searchable, searchPlaceholder, side, }: UhuruPanelProps) { const isBottom = side === "bottom"; const defaultSize = config?.defaultSize ?? (isBottom ? 240 : 280); const minSize = config?.minSize ?? (isBottom ? 140 : 220); const maxSize = config?.maxSize ?? (isBottom ? 640 : 480); const collapsedSize = config?.collapsedSize ?? (isBottom ? 56 : 64); const showRail = config?.showRail ?? true; const [size, setSize] = useState(Math.min(maxSize, Math.max(minSize, defaultSize))); const [collapsed, setCollapsed] = useState(config?.defaultCollapsed ?? false); const [isResizing, setIsResizing] = useState(false); const draggingRef = useRef(false); const panelRoute = useUhuruPanelRoute(); useEffect(() => { if (routes?.length || !config?.footerRoutes?.length) { return; } const allRoutes = (config?.footerRoutes ?? []).map(({ element, path }) => ({ element, path })); const location = getUhuruPanelLocation(); const matchingRoute = allRoutes.find((route) => route.path === location) ?? allRoutes.find((route) => matchesUhuruPanelRoute(route.path, location)); const fallbackRoute = defaultRoute ? allRoutes.find((route) => route.path === defaultRoute) : allRoutes[0]; const route = matchingRoute ?? fallbackRoute; if (route && panelRoute?.activeRoute?.path !== route.path) { panelRoute?.navigate(route); } }, [config?.footerRoutes, defaultRoute, panelRoute, routes?.length]); useEffect(() => { if (!config?.allowResize) { return; } function handleMove(event: PointerEvent) { if (!draggingRef.current || collapsed) { return; } const nextSize = isBottom ? window.innerHeight - event.clientY : side === "left" ? event.clientX : window.innerWidth - event.clientX; setSize(Math.min(maxSize, Math.max(minSize, nextSize))); } function handleUp() { draggingRef.current = false; setIsResizing(false); document.body.style.cursor = ""; document.body.style.userSelect = ""; } window.addEventListener("pointermove", handleMove); window.addEventListener("pointerup", handleUp); return () => { window.removeEventListener("pointermove", handleMove); window.removeEventListener("pointerup", handleUp); }; }, [collapsed, config?.allowResize, isBottom, maxSize, minSize, side]); const panelStyle: CSSProperties = isBottom ? collapsed ? { height: collapsedSize } : { height: size } : { width: collapsed ? (showRail ? collapsedSize : 0) : size }; const hideRailCompletely = collapsed && !showRail && config?.expandOnHover && !isBottom; const hasHeader = Boolean(config?.header || config?.collapsedHeader || config?.collapsible); return (
{hideRailCompletely ? (
); } function UhuruPanelContent({ activeId, ariaLabel, children, className, config, defaultRoute, emptyState, loading, onItemSelect, renderItem, routes, searchable, searchPlaceholder, }: UhuruPanelProps) { return (
{children}
); } /** * A reusable panel frame for side rails and bottom work areas. * * Inside UhuruAppLayout, the parent panel owns PanelConfig behavior and this * component becomes a full-size content frame, matching UhuruGroupedPanel. */ export function UhuruPanel(props: UhuruPanelProps) { const parentPanelState = useUhuruPanelState(); return parentPanelState ? : ; } export function Panel({ children, className, title, }: PropsWithChildren<{ className?: string; title?: string }>) { return (
{title ? (
{title}
) : null}
{children}
); }