"use client"; import { Fragment, isValidElement, useEffect, useRef, useState, type ButtonHTMLAttributes, type ReactNode, } from "react"; import { createPortal } from "react-dom"; import { ChevronLeft, Loader2, Plus, Search, Trash2 } from "lucide-react"; import { usePageChrome } from "@/app/contexts/PageChromeContext"; import { cn } from "@/lib/utils"; export interface PageHeaderBreadcrumb { label?: ReactNode; onClick?: () => void; cursor?: "text"; loading?: boolean; skeletonClassName?: string; title?: string; } type PageHeaderButtonAction = { type?: "button"; icon?: ReactNode; label?: ReactNode; onClick?: () => void; disabled?: boolean; title?: string; variant?: "default" | "danger"; iconOnly?: boolean; compact?: boolean; tooltip?: ReactNode; }; type PageHeaderSearchAction = { type: "search"; value: string; onChange: (value: string) => void; placeholder?: string; }; type PageHeaderDeleteAction = { type: "delete"; onClick?: () => void; disabled?: boolean; loading?: boolean; title?: string; }; type PageHeaderNewAction = { type: "new"; onClick?: () => void; disabled?: boolean; loading?: boolean; title?: string; }; type PageHeaderCustomAction = { type: "custom"; render: ReactNode; }; export type PageHeaderAction = | PageHeaderButtonAction | PageHeaderSearchAction | PageHeaderDeleteAction | PageHeaderNewAction | PageHeaderCustomAction | ReactNode; type PageHeaderActionGap = "xs" | "sm" | "md" | "lg"; type PageHeaderActionGroup = | PageHeaderAction[] | { actions: PageHeaderAction[]; gap?: PageHeaderActionGap; }; interface PageHeaderProps { children?: ReactNode; actions?: PageHeaderAction[]; actionGroups?: PageHeaderActionGroup[]; align?: "center" | "start"; shrink?: boolean; className?: string; actionGap?: PageHeaderActionGap; breadcrumbs?: PageHeaderBreadcrumb[]; loading?: boolean; } const actionGapClassName = { xs: "gap-1", sm: "gap-2.5", md: "gap-2.5", lg: "gap-2.5", }; export function PageHeader({ children, actions, actionGroups, align = "center", shrink = false, className, actionGap = "sm", breadcrumbs, loading = false, }: PageHeaderProps) { const { mobileActionsContainer } = usePageChrome(); const headerContent = breadcrumbs?.length ? ( ) : ( children ); const actionsDisabled = loading || !!breadcrumbs?.some((item) => item.loading); const actionItems = actions?.filter(Boolean) ?? []; const groupedActionItems = ( actionGroups ?.map((group) => normalizeActionGroup(group, actionGap)) .filter((group) => group.actions.length > 0) ?? (actionItems.length > 0 ? [{ actions: actionItems, gap: actionGap }] : []) ); const hasActions = groupedActionItems.length > 0; return (
{headerContent} {hasActions && (
)} {hasActions && mobileActionsContainer && createPortal(
, mobileActionsContainer, )}
); } function PageHeaderActionGroups({ groupedActionItems, actionsDisabled, }: { groupedActionItems: { actions: PageHeaderAction[]; gap: PageHeaderActionGap; }[]; actionsDisabled: boolean; }) { return ( <> {groupedActionItems.map((group, groupIndex) => (
{group.actions.map((action, index) => ( ))}
))} ); } function normalizeActionGroup( group: PageHeaderActionGroup, fallbackGap: PageHeaderActionGap, ) { if (Array.isArray(group)) { return { actions: group.filter(Boolean), gap: fallbackGap, }; } return { actions: group.actions.filter(Boolean), gap: group.gap ?? fallbackGap, }; } function PageHeaderActionRenderer({ action, disabled, }: { action: PageHeaderAction; disabled: boolean; }) { if (!isPageHeaderActionObject(action)) { return disabled ? ( {action} ) : ( <>{action} ); } switch (action.type) { case "search": return ( ); case "delete": return ( ); case "new": return ( ); case "custom": return ( {action.render} ); case "button": default: return ( ); } } function isPageHeaderActionObject( action: PageHeaderAction, ): action is Exclude { return !!action && typeof action === "object" && !isValidElement(action); } function PageHeaderButtonActionControl({ action, disabled, }: { action: PageHeaderButtonAction; disabled: boolean; }) { const iconOnly = action.iconOnly ?? !action.label; return (
{action.icon} {action.label} {action.tooltip && (
{action.tooltip}
)}
); } function PageHeaderNewActionControl({ action, disabled, }: { action: PageHeaderNewAction; disabled: boolean; }) { const title = action.title ?? "New"; return ( {action.loading ? ( ) : ( )} ); } function PageHeaderDeleteActionControl({ action, disabled, }: { action: PageHeaderDeleteAction; disabled: boolean; }) { const title = action.title ?? "Delete"; return ( {action.loading ? ( ) : ( )} ); } function PageHeaderSearchActionControl({ action, disabled, }: { action: PageHeaderSearchAction; disabled: boolean; }) { const [open, setOpen] = useState(false); const ref = useRef(null); const placeholder = action.placeholder ?? "Search…"; useEffect(() => { function handleClick(e: MouseEvent) { if (ref.current && !ref.current.contains(e.target as Node)) { setOpen(false); action.onChange(""); } } if (open) document.addEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick); }, [open, action]); return (
{open ? (
action.onChange(e.target.value)} className="flex-1 text-sm text-gray-700 placeholder:text-gray-400 outline-none bg-transparent" />
) : ( setOpen(true)} disabled={disabled} iconOnly title={placeholder} aria-label={placeholder} > )}
); } type PageHeaderActionButtonProps = Omit< ButtonHTMLAttributes, "className" > & { variant?: "default" | "danger"; iconOnly?: boolean; compact?: boolean; }; type PageHeaderActionControlClassNameOptions = { variant?: "default" | "danger"; iconOnly?: boolean; compact?: boolean; disabled?: boolean; className?: string; }; function pageHeaderActionControlClassName({ variant = "default", iconOnly = false, compact = false, disabled = false, className, }: PageHeaderActionControlClassNameOptions = {}) { return cn( "flex h-7 items-center justify-center rounded-full text-sm transition-colors hover:bg-gray-100 active:bg-gray-100 disabled:cursor-default disabled:text-gray-300 disabled:hover:bg-transparent disabled:hover:text-gray-300", iconOnly ? "w-7" : compact ? "gap-1.5 px-2" : "gap-1.5 px-3", disabled ? "cursor-default" : "cursor-pointer", "hover:bg-gray-100 active:bg-gray-100", variant === "danger" ? "text-gray-500 hover:text-red-600" : "text-gray-500 hover:text-gray-900", className, ); } function PageHeaderActionButton({ children, variant = "default", iconOnly = false, compact = false, disabled, ...props }: PageHeaderActionButtonProps) { return ( ); } function PageHeaderBreadcrumbs({ items }: { items: PageHeaderBreadcrumb[] }) { const parent = [...items] .slice(0, -1) .reverse() .find((item) => item.onClick); return (
{parent?.onClick && ( )}
{items.map((item, index) => ( ))}
); } function BreadcrumbItem({ item, current, }: { item: PageHeaderBreadcrumb; current: boolean; }) { const content = item.loading ? (
) : ( <> {item.label} ); const className = cn( "min-w-0 truncate transition-colors", item.cursor === "text" && "cursor-text", current ? "text-gray-900" : item.onClick ? "text-gray-500 hover:text-gray-700" : "text-gray-500", ); const wrapperClassName = cn( "min-w-0 items-center gap-1.5", current ? "flex" : "hidden sm:flex", ); return ( {current ? ( {content} ) : item.onClick ? ( ) : ( {content} )} {!current && } ); }