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 (
);
}
/**
* 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}
);
}