"use client"; import { Fragment, isValidElement, useEffect, useRef, useState, type ButtonHTMLAttributes, type ReactNode, } from "react"; import { ChevronLeft, Loader2, Plus, Search, Trash2 } from "lucide-react"; import { cn } from "@/lib/utils"; export interface PageHeaderBreadcrumb { label?: ReactNode; suffix?: ReactNode; onClick?: () => void; 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; className?: string; 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; interface PageHeaderProps { children?: ReactNode; actions?: PageHeaderAction[]; actionGroups?: PageHeaderAction[][]; align?: "center" | "start"; shrink?: boolean; className?: string; actionGap?: "sm" | "md" | "lg"; breadcrumbs?: PageHeaderBreadcrumb[]; } const actionGapClassName = { 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, }: PageHeaderProps) { const headerContent = breadcrumbs?.length ? ( ) : ( children ); const actionItems = actions?.filter(Boolean) ?? []; const groupedActionItems = actionGroups ?.map((group) => group.filter(Boolean)) .filter((group) => group.length > 0) ?? (actionItems.length > 0 ? [actionItems] : []); return (
{headerContent} {groupedActionItems.length > 0 && (
{groupedActionItems.map((group, groupIndex) => (
{group.map((action, index) => ( ))}
))}
)}
); } function PageHeaderActionRenderer({ action }: { action: PageHeaderAction }) { if (!isPageHeaderActionObject(action)) return <>{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, }: { action: PageHeaderButtonAction; }) { const iconOnly = action.iconOnly ?? !action.label; return (
{action.icon} {action.label} {action.tooltip && (
{action.tooltip}
)}
); } function PageHeaderNewActionControl({ action, }: { action: PageHeaderNewAction; }) { const title = action.title ?? "New"; return ( {action.loading ? ( ) : ( )} ); } function PageHeaderDeleteActionControl({ action, }: { action: PageHeaderDeleteAction; }) { const title = action.title ?? "Delete"; return ( {action.loading ? ( ) : ( )} ); } function PageHeaderSearchActionControl({ action, }: { action: PageHeaderSearchAction; }) { 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)} iconOnly title={placeholder} aria-label={placeholder} > )}
); } type PageHeaderActionButtonProps = ButtonHTMLAttributes & { variant?: "default" | "danger"; iconOnly?: boolean; }; type PageHeaderActionControlClassNameOptions = { variant?: "default" | "danger"; iconOnly?: boolean; disabled?: boolean; className?: string; }; function pageHeaderActionControlClassName({ variant = "default", iconOnly = 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" : "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, className, variant = "default", iconOnly = false, disabled, ...props }: PageHeaderActionButtonProps) { return ( ); } function PageHeaderBreadcrumbs({ items }: { items: PageHeaderBreadcrumb[] }) { const current = items[items.length - 1]; const parent = [...items] .slice(0, -1) .reverse() .find((item) => item.onClick); return (
{parent?.onClick && ( )}
{items.map((item, index) => ( ))}
{current ? ( ) : null}
); } function BreadcrumbItem({ item, current, showSuffix, }: { item: PageHeaderBreadcrumb; current: boolean; showSuffix: boolean; }) { const content = item.loading ? (
) : ( <> {item.label} {showSuffix && item.suffix} ); const className = cn( "min-w-0 truncate transition-colors", current ? "text-gray-900" : item.onClick ? "text-gray-500 hover:text-gray-700" : "text-gray-500", ); return ( <> {current ? ( {content} ) : item.onClick ? ( ) : ( {content} )} {!current && } ); }