2026-06-06 15:48:47 +08:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
|
Fragment,
|
|
|
|
|
|
isValidElement,
|
|
|
|
|
|
useEffect,
|
|
|
|
|
|
useRef,
|
|
|
|
|
|
useState,
|
|
|
|
|
|
type ButtonHTMLAttributes,
|
|
|
|
|
|
type ReactNode,
|
|
|
|
|
|
} from "react";
|
2026-06-11 22:43:13 +08:00
|
|
|
|
import { createPortal } from "react-dom";
|
2026-06-06 15:48:47 +08:00
|
|
|
|
import { ChevronLeft, Loader2, Plus, Search, Trash2 } from "lucide-react";
|
2026-06-11 22:43:13 +08:00
|
|
|
|
import { usePageChrome } from "@/app/contexts/PageChromeContext";
|
2026-06-06 15:48:47 +08:00
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
|
|
|
|
|
|
|
export interface PageHeaderBreadcrumb {
|
|
|
|
|
|
label?: ReactNode;
|
|
|
|
|
|
onClick?: () => void;
|
2026-06-11 21:50:58 +08:00
|
|
|
|
cursor?: "text";
|
2026-06-06 15:48:47 +08:00
|
|
|
|
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;
|
2026-06-11 22:43:13 +08:00
|
|
|
|
compact?: boolean;
|
2026-06-06 15:48:47 +08:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-06-11 21:50:58 +08:00
|
|
|
|
type PageHeaderActionGap = "xs" | "sm" | "md" | "lg";
|
|
|
|
|
|
type PageHeaderActionGroup =
|
|
|
|
|
|
| PageHeaderAction[]
|
|
|
|
|
|
| {
|
|
|
|
|
|
actions: PageHeaderAction[];
|
|
|
|
|
|
gap?: PageHeaderActionGap;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-06 15:48:47 +08:00
|
|
|
|
interface PageHeaderProps {
|
|
|
|
|
|
children?: ReactNode;
|
|
|
|
|
|
actions?: PageHeaderAction[];
|
2026-06-11 21:50:58 +08:00
|
|
|
|
actionGroups?: PageHeaderActionGroup[];
|
2026-06-06 15:48:47 +08:00
|
|
|
|
align?: "center" | "start";
|
|
|
|
|
|
shrink?: boolean;
|
|
|
|
|
|
className?: string;
|
2026-06-11 21:50:58 +08:00
|
|
|
|
actionGap?: PageHeaderActionGap;
|
2026-06-06 15:48:47 +08:00
|
|
|
|
breadcrumbs?: PageHeaderBreadcrumb[];
|
2026-06-11 21:50:58 +08:00
|
|
|
|
loading?: boolean;
|
2026-06-06 15:48:47 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const actionGapClassName = {
|
2026-06-11 21:50:58 +08:00
|
|
|
|
xs: "gap-1",
|
2026-06-06 15:48:47 +08:00
|
|
|
|
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,
|
2026-06-11 21:50:58 +08:00
|
|
|
|
loading = false,
|
2026-06-06 15:48:47 +08:00
|
|
|
|
}: PageHeaderProps) {
|
2026-06-11 22:43:13 +08:00
|
|
|
|
const { mobileActionsContainer } = usePageChrome();
|
2026-06-06 15:48:47 +08:00
|
|
|
|
const headerContent = breadcrumbs?.length ? (
|
|
|
|
|
|
<PageHeaderBreadcrumbs items={breadcrumbs} />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
children
|
|
|
|
|
|
);
|
2026-06-11 21:50:58 +08:00
|
|
|
|
const actionsDisabled =
|
|
|
|
|
|
loading || !!breadcrumbs?.some((item) => item.loading);
|
2026-06-06 15:48:47 +08:00
|
|
|
|
const actionItems = actions?.filter(Boolean) ?? [];
|
2026-06-11 21:50:58 +08:00
|
|
|
|
const groupedActionItems = (
|
2026-06-06 15:48:47 +08:00
|
|
|
|
actionGroups
|
2026-06-11 21:50:58 +08:00
|
|
|
|
?.map((group) => normalizeActionGroup(group, actionGap))
|
|
|
|
|
|
.filter((group) => group.actions.length > 0) ??
|
|
|
|
|
|
(actionItems.length > 0
|
|
|
|
|
|
? [{ actions: actionItems, gap: actionGap }]
|
|
|
|
|
|
: [])
|
|
|
|
|
|
);
|
2026-06-11 22:43:13 +08:00
|
|
|
|
const hasActions = groupedActionItems.length > 0;
|
2026-06-06 15:48:47 +08:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"flex justify-between",
|
|
|
|
|
|
align === "start" ? "items-start" : "items-center",
|
|
|
|
|
|
"px-4 md:px-10",
|
2026-06-11 22:43:13 +08:00
|
|
|
|
"min-h-[76px] pb-4 pt-5.5",
|
2026-06-06 15:48:47 +08:00
|
|
|
|
shrink && "shrink-0",
|
|
|
|
|
|
className,
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{headerContent}
|
2026-06-11 22:43:13 +08:00
|
|
|
|
{hasActions && (
|
|
|
|
|
|
<div className="ml-4 hidden shrink-0 items-center gap-3 md:flex">
|
|
|
|
|
|
<PageHeaderActionGroups
|
|
|
|
|
|
groupedActionItems={groupedActionItems}
|
|
|
|
|
|
actionsDisabled={actionsDisabled}
|
|
|
|
|
|
/>
|
2026-06-06 15:48:47 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-06-11 22:43:13 +08:00
|
|
|
|
{hasActions &&
|
|
|
|
|
|
mobileActionsContainer &&
|
|
|
|
|
|
createPortal(
|
|
|
|
|
|
<div className="flex min-w-0 items-center justify-end gap-3 overflow-visible py-2 -my-2">
|
|
|
|
|
|
<PageHeaderActionGroups
|
|
|
|
|
|
groupedActionItems={groupedActionItems}
|
|
|
|
|
|
actionsDisabled={actionsDisabled}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>,
|
|
|
|
|
|
mobileActionsContainer,
|
|
|
|
|
|
)}
|
2026-06-06 15:48:47 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-11 22:43:13 +08:00
|
|
|
|
function PageHeaderActionGroups({
|
|
|
|
|
|
groupedActionItems,
|
|
|
|
|
|
actionsDisabled,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
groupedActionItems: {
|
|
|
|
|
|
actions: PageHeaderAction[];
|
|
|
|
|
|
gap: PageHeaderActionGap;
|
|
|
|
|
|
}[];
|
|
|
|
|
|
actionsDisabled: boolean;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{groupedActionItems.map((group, groupIndex) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={groupIndex}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"flex shrink-0 items-center",
|
|
|
|
|
|
actionGapClassName[group.gap],
|
|
|
|
|
|
"rounded-full border border-white/70 bg-white px-1 py-1 shadow-[0_8px_24px_rgba(15,23,42,0.06)] backdrop-blur-2xl",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{group.actions.map((action, index) => (
|
|
|
|
|
|
<Fragment key={index}>
|
|
|
|
|
|
<PageHeaderActionRenderer
|
|
|
|
|
|
action={action}
|
|
|
|
|
|
disabled={actionsDisabled}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Fragment>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-11 21:50:58 +08:00
|
|
|
|
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 ? (
|
|
|
|
|
|
<span className="inline-flex h-7 items-center opacity-40 pointer-events-none">
|
|
|
|
|
|
{action}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>{action}</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-06-06 15:48:47 +08:00
|
|
|
|
|
|
|
|
|
|
switch (action.type) {
|
|
|
|
|
|
case "search":
|
2026-06-11 21:50:58 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<PageHeaderSearchActionControl
|
|
|
|
|
|
action={action}
|
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
2026-06-06 15:48:47 +08:00
|
|
|
|
case "delete":
|
2026-06-11 21:50:58 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<PageHeaderDeleteActionControl
|
|
|
|
|
|
action={action}
|
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
2026-06-06 15:48:47 +08:00
|
|
|
|
case "new":
|
2026-06-11 21:50:58 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<PageHeaderNewActionControl
|
|
|
|
|
|
action={action}
|
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
2026-06-06 15:48:47 +08:00
|
|
|
|
case "custom":
|
2026-06-11 21:50:58 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<span
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"inline-flex h-7 items-center",
|
|
|
|
|
|
disabled && "pointer-events-none opacity-40",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{action.render}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
);
|
2026-06-06 15:48:47 +08:00
|
|
|
|
case "button":
|
|
|
|
|
|
default:
|
2026-06-11 21:50:58 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<PageHeaderButtonActionControl
|
|
|
|
|
|
action={action}
|
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
2026-06-06 15:48:47 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isPageHeaderActionObject(
|
|
|
|
|
|
action: PageHeaderAction,
|
|
|
|
|
|
): action is Exclude<PageHeaderAction, ReactNode> {
|
|
|
|
|
|
return !!action && typeof action === "object" && !isValidElement(action);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function PageHeaderButtonActionControl({
|
|
|
|
|
|
action,
|
2026-06-11 21:50:58 +08:00
|
|
|
|
disabled,
|
2026-06-06 15:48:47 +08:00
|
|
|
|
}: {
|
|
|
|
|
|
action: PageHeaderButtonAction;
|
2026-06-11 21:50:58 +08:00
|
|
|
|
disabled: boolean;
|
2026-06-06 15:48:47 +08:00
|
|
|
|
}) {
|
|
|
|
|
|
const iconOnly = action.iconOnly ?? !action.label;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className={action.tooltip ? "relative group" : undefined}>
|
|
|
|
|
|
<PageHeaderActionButton
|
|
|
|
|
|
onClick={action.onClick}
|
2026-06-11 21:50:58 +08:00
|
|
|
|
disabled={disabled || action.disabled}
|
2026-06-06 15:48:47 +08:00
|
|
|
|
title={action.title}
|
|
|
|
|
|
aria-label={action.title}
|
|
|
|
|
|
variant={action.variant}
|
|
|
|
|
|
iconOnly={iconOnly}
|
2026-06-11 22:43:13 +08:00
|
|
|
|
compact={action.compact}
|
2026-06-06 15:48:47 +08:00
|
|
|
|
>
|
|
|
|
|
|
{action.icon}
|
|
|
|
|
|
{action.label}
|
|
|
|
|
|
</PageHeaderActionButton>
|
|
|
|
|
|
{action.tooltip && (
|
|
|
|
|
|
<div className="pointer-events-none absolute right-0 top-full mt-1.5 z-10 hidden items-center whitespace-nowrap rounded-lg bg-gray-900 px-2.5 py-1.5 text-xs text-white shadow-lg group-hover:flex">
|
|
|
|
|
|
{action.tooltip}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function PageHeaderNewActionControl({
|
|
|
|
|
|
action,
|
2026-06-11 21:50:58 +08:00
|
|
|
|
disabled,
|
2026-06-06 15:48:47 +08:00
|
|
|
|
}: {
|
|
|
|
|
|
action: PageHeaderNewAction;
|
2026-06-11 21:50:58 +08:00
|
|
|
|
disabled: boolean;
|
2026-06-06 15:48:47 +08:00
|
|
|
|
}) {
|
|
|
|
|
|
const title = action.title ?? "New";
|
|
|
|
|
|
return (
|
|
|
|
|
|
<PageHeaderActionButton
|
|
|
|
|
|
onClick={action.onClick}
|
2026-06-11 21:50:58 +08:00
|
|
|
|
disabled={disabled || action.disabled || action.loading}
|
2026-06-06 15:48:47 +08:00
|
|
|
|
title={title}
|
|
|
|
|
|
aria-label={title}
|
|
|
|
|
|
iconOnly
|
|
|
|
|
|
>
|
|
|
|
|
|
{action.loading ? (
|
|
|
|
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Plus className="h-4 w-4" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</PageHeaderActionButton>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function PageHeaderDeleteActionControl({
|
|
|
|
|
|
action,
|
2026-06-11 21:50:58 +08:00
|
|
|
|
disabled,
|
2026-06-06 15:48:47 +08:00
|
|
|
|
}: {
|
|
|
|
|
|
action: PageHeaderDeleteAction;
|
2026-06-11 21:50:58 +08:00
|
|
|
|
disabled: boolean;
|
2026-06-06 15:48:47 +08:00
|
|
|
|
}) {
|
|
|
|
|
|
const title = action.title ?? "Delete";
|
|
|
|
|
|
return (
|
|
|
|
|
|
<PageHeaderActionButton
|
|
|
|
|
|
onClick={action.onClick}
|
2026-06-11 21:50:58 +08:00
|
|
|
|
disabled={disabled || action.disabled || action.loading}
|
2026-06-06 15:48:47 +08:00
|
|
|
|
title={title}
|
|
|
|
|
|
aria-label={title}
|
|
|
|
|
|
iconOnly
|
|
|
|
|
|
variant="danger"
|
|
|
|
|
|
>
|
|
|
|
|
|
{action.loading ? (
|
|
|
|
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Trash2 className="h-4 w-4" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</PageHeaderActionButton>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function PageHeaderSearchActionControl({
|
|
|
|
|
|
action,
|
2026-06-11 21:50:58 +08:00
|
|
|
|
disabled,
|
2026-06-06 15:48:47 +08:00
|
|
|
|
}: {
|
|
|
|
|
|
action: PageHeaderSearchAction;
|
2026-06-11 21:50:58 +08:00
|
|
|
|
disabled: boolean;
|
2026-06-06 15:48:47 +08:00
|
|
|
|
}) {
|
|
|
|
|
|
const [open, setOpen] = useState(false);
|
|
|
|
|
|
const ref = useRef<HTMLDivElement>(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 (
|
|
|
|
|
|
<div ref={ref} className="relative flex items-center">
|
|
|
|
|
|
{open ? (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
pageHeaderActionControlClassName({
|
|
|
|
|
|
className:
|
|
|
|
|
|
"cursor-text justify-start gap-2 px-3 text-gray-700 hover:text-gray-700",
|
|
|
|
|
|
}),
|
|
|
|
|
|
"w-56 bg-gray-100 sm:w-80",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Search className="h-3.5 w-3.5 text-gray-400 shrink-0" />
|
|
|
|
|
|
<input
|
|
|
|
|
|
autoFocus
|
2026-06-11 21:50:58 +08:00
|
|
|
|
disabled={disabled}
|
2026-06-06 15:48:47 +08:00
|
|
|
|
type="text"
|
|
|
|
|
|
placeholder={placeholder}
|
|
|
|
|
|
value={action.value}
|
|
|
|
|
|
onChange={(e) => action.onChange(e.target.value)}
|
|
|
|
|
|
className="flex-1 text-sm text-gray-700 placeholder:text-gray-400 outline-none bg-transparent"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<PageHeaderActionButton
|
|
|
|
|
|
onClick={() => setOpen(true)}
|
2026-06-11 21:50:58 +08:00
|
|
|
|
disabled={disabled}
|
2026-06-06 15:48:47 +08:00
|
|
|
|
iconOnly
|
|
|
|
|
|
title={placeholder}
|
|
|
|
|
|
aria-label={placeholder}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Search className="h-4 w-4" />
|
|
|
|
|
|
</PageHeaderActionButton>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-11 21:50:58 +08:00
|
|
|
|
type PageHeaderActionButtonProps = Omit<
|
|
|
|
|
|
ButtonHTMLAttributes<HTMLButtonElement>,
|
|
|
|
|
|
"className"
|
|
|
|
|
|
> & {
|
2026-06-06 15:48:47 +08:00
|
|
|
|
variant?: "default" | "danger";
|
|
|
|
|
|
iconOnly?: boolean;
|
2026-06-11 22:43:13 +08:00
|
|
|
|
compact?: boolean;
|
2026-06-06 15:48:47 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
type PageHeaderActionControlClassNameOptions = {
|
|
|
|
|
|
variant?: "default" | "danger";
|
|
|
|
|
|
iconOnly?: boolean;
|
2026-06-11 22:43:13 +08:00
|
|
|
|
compact?: boolean;
|
2026-06-06 15:48:47 +08:00
|
|
|
|
disabled?: boolean;
|
|
|
|
|
|
className?: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function pageHeaderActionControlClassName({
|
|
|
|
|
|
variant = "default",
|
|
|
|
|
|
iconOnly = false,
|
2026-06-11 22:43:13 +08:00
|
|
|
|
compact = false,
|
2026-06-06 15:48:47 +08:00
|
|
|
|
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",
|
2026-06-11 22:43:13 +08:00
|
|
|
|
iconOnly ? "w-7" : compact ? "gap-1.5 px-2" : "gap-1.5 px-3",
|
2026-06-06 15:48:47 +08:00
|
|
|
|
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,
|
2026-06-11 22:43:13 +08:00
|
|
|
|
compact = false,
|
2026-06-06 15:48:47 +08:00
|
|
|
|
disabled,
|
|
|
|
|
|
...props
|
|
|
|
|
|
}: PageHeaderActionButtonProps) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
|
className={pageHeaderActionControlClassName({
|
|
|
|
|
|
variant,
|
|
|
|
|
|
iconOnly,
|
2026-06-11 22:43:13 +08:00
|
|
|
|
compact,
|
2026-06-06 15:48:47 +08:00
|
|
|
|
disabled,
|
|
|
|
|
|
})}
|
|
|
|
|
|
{...props}
|
|
|
|
|
|
>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function PageHeaderBreadcrumbs({ items }: { items: PageHeaderBreadcrumb[] }) {
|
|
|
|
|
|
const parent = [...items]
|
|
|
|
|
|
.slice(0, -1)
|
|
|
|
|
|
.reverse()
|
|
|
|
|
|
.find((item) => item.onClick);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex min-w-0 items-center gap-1.5 text-2xl font-medium font-serif">
|
|
|
|
|
|
{parent?.onClick && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={parent.onClick}
|
|
|
|
|
|
className="shrink-0 text-gray-400 transition-colors hover:text-gray-600 sm:hidden"
|
|
|
|
|
|
title={parent.title ?? "Back"}
|
|
|
|
|
|
aria-label={parent.title ?? "Back"}
|
|
|
|
|
|
>
|
|
|
|
|
|
<ChevronLeft className="h-5 w-5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
2026-06-11 22:43:13 +08:00
|
|
|
|
<div className="flex min-w-0 items-center gap-1.5">
|
2026-06-06 15:48:47 +08:00
|
|
|
|
{items.map((item, index) => (
|
|
|
|
|
|
<BreadcrumbItem
|
|
|
|
|
|
key={index}
|
|
|
|
|
|
item={item}
|
|
|
|
|
|
current={index === items.length - 1}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function BreadcrumbItem({
|
|
|
|
|
|
item,
|
|
|
|
|
|
current,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
item: PageHeaderBreadcrumb;
|
|
|
|
|
|
current: boolean;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const content = item.loading ? (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"h-6 rounded bg-gray-100 animate-pulse",
|
|
|
|
|
|
item.skeletonClassName ?? "w-32",
|
|
|
|
|
|
)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
2026-06-11 21:50:58 +08:00
|
|
|
|
<span
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"truncate",
|
|
|
|
|
|
item.cursor === "text" && "cursor-text",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{item.label}
|
|
|
|
|
|
</span>
|
2026-06-06 15:48:47 +08:00
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const className = cn(
|
|
|
|
|
|
"min-w-0 truncate transition-colors",
|
2026-06-11 21:50:58 +08:00
|
|
|
|
item.cursor === "text" && "cursor-text",
|
2026-06-06 15:48:47 +08:00
|
|
|
|
current
|
|
|
|
|
|
? "text-gray-900"
|
|
|
|
|
|
: item.onClick
|
|
|
|
|
|
? "text-gray-500 hover:text-gray-700"
|
|
|
|
|
|
: "text-gray-500",
|
|
|
|
|
|
);
|
2026-06-11 22:43:13 +08:00
|
|
|
|
const wrapperClassName = cn(
|
|
|
|
|
|
"min-w-0 items-center gap-1.5",
|
|
|
|
|
|
current ? "flex" : "hidden sm:flex",
|
|
|
|
|
|
);
|
2026-06-06 15:48:47 +08:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-06-11 22:43:13 +08:00
|
|
|
|
<span className={wrapperClassName}>
|
2026-06-06 15:48:47 +08:00
|
|
|
|
{current ? (
|
|
|
|
|
|
<span className={className}>{content}</span>
|
|
|
|
|
|
) : item.onClick ? (
|
|
|
|
|
|
<button onClick={item.onClick} className={className}>
|
|
|
|
|
|
{content}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span className={className}>{content}</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{!current && <span className="shrink-0 text-gray-300">›</span>}
|
2026-06-11 22:43:13 +08:00
|
|
|
|
</span>
|
2026-06-06 15:48:47 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|