"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 && ›}
);
}