import { CaretDoubleLeft, CaretDoubleRight, DotsThree, X } from "@phosphor-icons/react"; import { createContext, isValidElement, useContext, useEffect, useRef, useState, type CSSProperties, type ReactNode, } from "react"; import { createPortal } from "react-dom"; import { cn } from "../../lib/cn"; import { IconButton, Menu, type MenuItem } from "../actions"; import { PanelFooterRoutes, UhuruPanel, type PanelConfig } from "../layout/panel"; import { UhuruPanelRouteProvider, useUhuruPanelRoute, } from "../navigation/panel-route-context"; import { UhuruPanelStateProvider } from "../panel-context"; import { Popover } from "../overlay"; import { UhuruAiPanelProvider, useAiPanelContext, type AiPanelPosition, } from "./ai-panel-context"; type ResizablePanelProps = { ariaLabel?: string; children: ReactNode; className?: string; collapsed?: boolean; collapsedWidth?: number; defaultWidth: number; enabled?: boolean; expandOnHover?: boolean; showRail?: boolean; collapsible?: boolean; iconHoverAnimation?: "none" | "lift" | "slide" | "pulse"; header?: ReactNode; collapsedHeader?: ReactNode; collapsedHeaderActions?: ReactNode; headerActions?: ReactNode; beforeFooter?: ReactNode; footer?: ReactNode; footerRoutes?: PanelConfig["footerRoutes"]; maxWidth?: number; minWidth?: number; onToggleCollapsed?: () => void; onHoverExpandedChange?: (expanded: boolean) => void; style?: CSSProperties; side: "left" | "right"; }; function clampWidth(width: number, minWidth: number, maxWidth: number) { return Math.min(maxWidth, Math.max(minWidth, width)); } function ResizablePanel({ ariaLabel, children, className, collapsed = false, collapsedWidth = 64, defaultWidth, enabled = false, expandOnHover = false, showRail = true, collapsible = false, iconHoverAnimation = "none", collapsedHeader, collapsedHeaderActions, header, headerActions, beforeFooter, footer, footerRoutes, maxWidth = 480, minWidth = 220, onToggleCollapsed, onHoverExpandedChange, style, side, }: ResizablePanelProps) { const [width, setWidth] = useState(clampWidth(defaultWidth, minWidth, maxWidth)); const [hoverExpanded, setHoverExpanded] = useState(false); const [isResizing, setIsResizing] = useState(false); const draggingRef = useRef(false); useEffect(() => { function handleMove(event: PointerEvent) { if (!draggingRef.current || collapsed) { return; } const nextWidth = side === "left" ? clampWidth(event.clientX, minWidth, maxWidth) : clampWidth(window.innerWidth - event.clientX, minWidth, maxWidth); setWidth(nextWidth); } 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, maxWidth, minWidth, side]); const railVisible = showRail; const effectiveCollapsed = collapsed && !hoverExpanded; const hideRailCompletely = effectiveCollapsed && !railVisible && expandOnHover; const railWidth = effectiveCollapsed ? (railVisible ? collapsedWidth : 0) : width; const showPanelHeader = effectiveCollapsed ? Boolean(collapsedHeader || collapsedHeaderActions || collapsible) : Boolean(header || headerActions); useEffect(() => { onHoverExpandedChange?.(hoverExpanded); }, [hoverExpanded, onHoverExpandedChange]); return (
{ if (expandOnHover && collapsed) setHoverExpanded(true); }} onMouseLeave={() => { if (expandOnHover) setHoverExpanded(false); }} style={{ width: railWidth, ...style } satisfies CSSProperties} > {hideRailCompletely ? ( ) : null}
); } export type UhuruAppLayoutProps = { aiPanelConfig?: AiPanelConfig; children?: ReactNode; className?: string; header?: ReactNode; leftPanel?: ReactNode; leftPanelConfig?: PanelConfig; bottomPanel?: ReactNode; bottomPanelConfig?: PanelConfig; rightPanel?: ReactNode; rightPanelConfig?: PanelConfig; fab?: ReactNode; scrollMode?: "content" | "page"; }; export type AiPanelConfig = PanelConfig & { content?: ReactNode; defaultOpen?: boolean; enabled?: boolean; onOpenChange?: (open: boolean) => void; open?: boolean; position?: AiPanelPosition; }; export type UhuruAppLayoutTogglePlacement = "auto" | "header" | "panel" | "none"; type UhuruAppLayoutControls = { hasLeftPanel: boolean; hasRightPanel: boolean; isMobileViewport: boolean; leftPanelCollapsed: boolean; leftPanelExpandIcon?: ReactNode; onToggleLeftPanel: () => void; onToggleRightPanel: () => void; rightPanelCollapsed: boolean; rightPanelExpandIcon?: ReactNode; }; const UhuruAppLayoutControlsContext = createContext(null); export type UhuruAppLayoutHeaderProps = { actions?: ReactNode; actionsClassName?: string; className?: string; compactBreakpoint?: number; backgroundOpacity?: number; center?: ReactNode; centerClassName?: string; contentClassName?: string; description?: ReactNode; descriptionClassName?: string; height?: number | string; leading?: ReactNode; leadingClassName?: string; leftTogglePlacement?: UhuruAppLayoutTogglePlacement; paddingBlock?: number | string; paddingInlineEnd?: number | string; paddingInlineStart?: number | string; profile?: ReactNode; profileClassName?: string; rightTogglePlacement?: UhuruAppLayoutTogglePlacement; search?: ReactNode; searchClassName?: string; style?: CSSProperties; toggleGap?: number; title?: ReactNode; trailing?: ReactNode; trailingClassName?: string; titleClassName?: string; variant?: "classic" | "standard"; mobileActionItems?: MenuItem[]; mobileActions?: ReactNode; mobileActionsLabel?: string; }; function HeaderMobileOverflow({ align, actions, items, label, search, }: { align: "left" | "right"; actions?: ReactNode; items?: MenuItem[]; label: string; search?: ReactNode; }) { const [open, setOpen] = useState(false); const resolvedItems = items?.map((item) => ({ ...item, onSelect: () => { item.onSelect?.(); setOpen(false); }, })); if (!search && !actions && !resolvedItems?.length) { return null; } return ( {search ?
{search}
: null} {actions ?
{actions}
: null} {resolvedItems?.length ? : null} } isOpen={open} onOpenChange={setOpen} trigger={ } label={label} /> } /> ); } export function UhuruAppLayoutHeader({ actions, actionsClassName, className, compactBreakpoint = 1023, backgroundOpacity = 0.98, center, centerClassName, contentClassName, description, descriptionClassName, height, leading, leadingClassName, leftTogglePlacement, paddingBlock = 8, paddingInlineEnd, paddingInlineStart, profile, profileClassName, rightTogglePlacement, search, searchClassName, style, toggleGap = 24, title, trailing, trailingClassName, titleClassName, variant = "classic", mobileActionItems, mobileActions, mobileActionsLabel = "More header actions", }: UhuruAppLayoutHeaderProps) { const controls = useContext(UhuruAppLayoutControlsContext); const [isCompactViewport, setIsCompactViewport] = useState(false); const trailingContent = trailing ?? actions; const profileContent = profile ?? leading; const searchContent = search ?? center; useEffect(() => { if (typeof window === "undefined") return; const media = window.matchMedia(`(max-width: ${compactBreakpoint}px)`); const update = () => setIsCompactViewport(media.matches); update(); media.addEventListener("change", update); return () => media.removeEventListener("change", update); }, [compactBreakpoint]); const resolvedLeftTogglePlacement = leftTogglePlacement ?? "panel"; const resolvedRightTogglePlacement = rightTogglePlacement ?? "panel"; const showLeftToggle = Boolean( controls?.hasLeftPanel && ( controls.isMobileViewport ? resolvedLeftTogglePlacement !== "none" : resolvedLeftTogglePlacement === "header" || resolvedLeftTogglePlacement === "auto" ), ); const showRightToggle = Boolean( controls?.hasRightPanel && ( controls.isMobileViewport ? resolvedRightTogglePlacement !== "none" : resolvedRightTogglePlacement === "header" || resolvedRightTogglePlacement === "auto" ), ); const headerStyle: CSSProperties = { ...style }; if (height !== undefined) { headerStyle.height = typeof height === "number" ? `${height}px` : height; } if (paddingBlock !== undefined) { headerStyle.paddingTop = paddingBlock; headerStyle.paddingBottom = paddingBlock; } headerStyle.paddingLeft = paddingInlineStart ?? (showLeftToggle ? (isCompactViewport ? 12 : 16) : 12); headerStyle.paddingRight = paddingInlineEnd ?? (showRightToggle ? (isCompactViewport ? 12 : 16) : 12); const leftToggle = showLeftToggle && controls ? ( : } label={controls.isMobileViewport || controls.leftPanelCollapsed ? "Open left panel" : "Collapse left panel"} onClick={controls.onToggleLeftPanel} /> ) : null; const rightToggle = showRightToggle && controls ? ( : } label={controls.isMobileViewport || controls.rightPanelCollapsed ? "Open right panel" : "Collapse right panel"} onClick={controls.onToggleRightPanel} /> ) : null; const identityText = title || description ? (
{title ?
{title}
: null} {description ?
{description}
: null}
) : null; const identity = (
{profileContent ?
{profileContent}
: null} {identityText}
); const standardIdentity = (
{identityText ?
{identityText}
: null} {profileContent ?
{profileContent}
: null}
); const mobileOverflow = ( ); return (
); } function UhuruAppLayoutFrame({ aiPanelConfig, children, className, header, leftPanel, leftPanelConfig, bottomPanel, bottomPanelConfig, fab, rightPanel, rightPanelConfig, scrollMode = "page", }: UhuruAppLayoutProps) { const aiPanel = useAiPanelContext(); const aiPanelOpen = Boolean(aiPanel?.enabled && aiPanel.isOpen); const aiPanelPosition = aiPanel?.position ?? aiPanelConfig?.position ?? "left"; const resolvedLeftPanel = aiPanelOpen && aiPanelPosition === "left" ? aiPanelConfig?.content : leftPanel; const resolvedRightPanel = aiPanelOpen && aiPanelPosition === "right" ? aiPanelConfig?.content : rightPanel; const resolvedLeftPanelConfig = aiPanelOpen && aiPanelPosition === "left" ? aiPanelConfig : leftPanelConfig; const resolvedRightPanelConfig = aiPanelOpen && aiPanelPosition === "right" ? aiPanelConfig : rightPanelConfig; const [aiPanelCollapsed, setAiPanelCollapsed] = useState( aiPanelConfig?.defaultCollapsed ?? false, ); const [leftPanelCollapsed, setLeftPanelCollapsed] = useState(leftPanelConfig?.defaultCollapsed ?? false); const [rightPanelCollapsed, setRightPanelCollapsed] = useState(rightPanelConfig?.defaultCollapsed ?? false); const [leftPanelHoverExpanded, setLeftPanelHoverExpanded] = useState(false); const [rightPanelHoverExpanded, setRightPanelHoverExpanded] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [mobileRightPanelOpen, setMobileRightPanelOpen] = useState(false); const [headerHeight, setHeaderHeight] = useState(0); const leftPanelContent = resolvedLeftPanel; const panelRoute = useUhuruPanelRoute(); const leftPanelResizeEnabled = resolvedLeftPanelConfig?.allowResize ?? false; const rightPanelResizeEnabled = resolvedRightPanelConfig?.allowResize ?? false; const headerRef = useRef(null); useEffect(() => { if (!mobileSidebarOpen && !mobileRightPanelOpen) { return; } const previousOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { document.body.style.overflow = previousOverflow; }; }, [mobileRightPanelOpen, mobileSidebarOpen]); function openMobileSidebar() { setMobileRightPanelOpen(false); setMobileSidebarOpen(true); } function openMobileRightPanel() { setMobileSidebarOpen(false); setMobileRightPanelOpen(true); } const [isMobileViewport, setIsMobileViewport] = useState(false); const previousAiPanelOpenRef = useRef(aiPanelOpen); useEffect(() => { if (typeof window === "undefined") { return; } const media = window.matchMedia("(max-width: 767px)"); const update = () => setIsMobileViewport(media.matches); update(); media.addEventListener("change", update); return () => media.removeEventListener("change", update); }, []); useEffect(() => { const wasOpen = previousAiPanelOpenRef.current; previousAiPanelOpenRef.current = aiPanelOpen; if (!isMobileViewport) return; if (aiPanelOpen) { if (aiPanelPosition === "left") openMobileSidebar(); else openMobileRightPanel(); return; } if (wasOpen) { setMobileSidebarOpen(false); setMobileRightPanelOpen(false); } }, [aiPanelOpen, aiPanelPosition, isMobileViewport]); useEffect(() => { const node = headerRef.current; if (!node) { setHeaderHeight(0); return; } const update = () => { setHeaderHeight(node.getBoundingClientRect().height); }; update(); if (typeof ResizeObserver === "undefined") { return; } const observer = new ResizeObserver(update); observer.observe(node); return () => observer.disconnect(); }, [header]); const headerToggleProps = isValidElement(header) ? header.props : undefined; const resolveTogglePlacement = ( placement: UhuruAppLayoutTogglePlacement | undefined, ): Exclude => { if (placement === "auto") { return "header"; } return placement ?? "panel"; }; const leftTogglePlacement = resolveTogglePlacement(headerToggleProps?.leftTogglePlacement); const rightTogglePlacement = resolveTogglePlacement(headerToggleProps?.rightTogglePlacement); const mobilePanelOpen = mobileSidebarOpen || mobileRightPanelOpen; const showHeader = Boolean(header); const stickyPanelTop = scrollMode === "page" ? headerHeight : 0; const effectiveLeftPanelCollapsed = aiPanelOpen && aiPanelPosition === "left" ? aiPanelCollapsed : leftPanelCollapsed; const effectiveRightPanelCollapsed = aiPanelOpen && aiPanelPosition === "right" ? aiPanelCollapsed : rightPanelCollapsed; function toggleLeftPanel() { if (aiPanelOpen && aiPanelPosition === "left") { setAiPanelCollapsed((current) => !current); return; } setLeftPanelCollapsed((current) => !current); } function toggleRightPanel() { if (aiPanelOpen && aiPanelPosition === "right") { setAiPanelCollapsed((current) => !current); return; } setRightPanelCollapsed((current) => !current); } const appLayoutControls: UhuruAppLayoutControls = { hasLeftPanel: Boolean( leftPanelContent && !(aiPanelOpen && aiPanelPosition === "left"), ), hasRightPanel: Boolean( resolvedRightPanel && !(aiPanelOpen && aiPanelPosition === "right"), ), isMobileViewport, leftPanelCollapsed: effectiveLeftPanelCollapsed && !leftPanelHoverExpanded, leftPanelExpandIcon: resolvedLeftPanelConfig?.expandIcon, onToggleLeftPanel: () => { if (isMobileViewport) { openMobileSidebar(); return; } toggleLeftPanel(); }, onToggleRightPanel: () => { if (isMobileViewport) { openMobileRightPanel(); return; } toggleRightPanel(); }, rightPanelCollapsed: effectiveRightPanelCollapsed && !rightPanelHoverExpanded, rightPanelExpandIcon: resolvedRightPanelConfig?.expandIcon, }; return (
{leftPanelContent || resolvedRightPanel ? (
{ setMobileSidebarOpen(false); setMobileRightPanelOpen(false); }} > {leftPanelContent ?
event.stopPropagation()} >
{resolvedLeftPanelConfig?.header}
{leftPanelContent}
{resolvedLeftPanelConfig?.beforeFooter ? (
{resolvedLeftPanelConfig.beforeFooter}
) : null} {resolvedLeftPanelConfig?.footer ? (
{resolvedLeftPanelConfig.footer}
) : null}
: null} {resolvedRightPanel ?
event.stopPropagation()} >
{resolvedRightPanelConfig?.header}
{resolvedRightPanel}
{resolvedRightPanelConfig?.beforeFooter ? (
{resolvedRightPanelConfig.beforeFooter}
) : null} {resolvedRightPanelConfig?.footer ? (
{resolvedRightPanelConfig.footer}
) : null}
: null}
) : null} {showHeader ? (
{header}
) : null}
{leftPanelContent ? {leftPanelContent} : null}
{panelRoute?.activeRoute?.element ?? children}
{bottomPanel ? ( {bottomPanel} ) : null}
{resolvedRightPanel ? (
{resolvedRightPanel}
{resolvedRightPanelConfig?.beforeFooter && !effectiveRightPanelCollapsed ? (
{resolvedRightPanelConfig.beforeFooter}
) : null} {resolvedRightPanelConfig?.footer && !effectiveRightPanelCollapsed ? (
{resolvedRightPanelConfig.footer}
) : null}
) : null}
); } export function UhuruAppLayout(props: UhuruAppLayoutProps) { const aiPanelPosition = props.aiPanelConfig?.position ?? "left"; const aiPanelMissingContent = Boolean( props.aiPanelConfig?.enabled && !props.aiPanelConfig.content, ); const aiPanelError = aiPanelMissingContent ? "The AI panel is enabled but aiPanelConfig.content is missing. Pass an AiPanel or another AI conversation surface as content." : undefined; const fabPortal = props.fab && typeof document !== "undefined" ? createPortal(
{props.fab}
, document.querySelector("[data-uhuru-root]") ?? document.body, ) : null; return ( {fabPortal} ); }