import { createContext, useContext, useMemo, type ComponentProps, type ReactNode } from "react"; import { cn } from "../lib/cn"; /** * One header for every page: title, one-line description, primary action and * optional tabs. Title 20/28 semibold, description 14/20 muted, actions pinned * right with `shrink-0` (they never push past the column), and 16px of space * below. No hairline by default: the title and the first section breathe * instead (pass `divider` to draw one). A `tabs` slot brings its own rule. * * Variants: * - `default`: 20px title, optional 16px brand icon (Capabilities style). * - `large`: 24px title and never an icon (the older settings style). * * Whether the icon shows is also a page-level choice: settings shells can wrap * their pages in `` so sub-pages drop the * icon while main-rail pages keep it, without touching each call site. * * The layout responds to its own width (container query), so it stacks the * actions under the title in a phone, a sheet or a narrow preview alike. */ export type PageHeaderVariant = "default" | "large"; export type PageHeaderIconMode = "show" | "hide"; interface PageHeaderStyle { variant?: PageHeaderVariant | undefined; icon?: PageHeaderIconMode | undefined; } const PageHeaderStyleContext = createContext({}); /** Sets the header style for every PageHeader below it (props still win). */ export function PageHeaderStyleProvider({ variant, icon, children, }: PageHeaderStyle & { children: ReactNode }) { const parent = useContext(PageHeaderStyleContext); const value = useMemo( () => ({ variant: variant ?? parent.variant, icon: icon ?? parent.icon }), [icon, parent.icon, parent.variant, variant], ); return ( {children} ); } /** The header style in effect here, after providers and defaults. */ export function usePageHeaderStyle(): Required { const style = useContext(PageHeaderStyleContext); const variant = style.variant ?? "default"; return { variant, icon: variant === "large" ? "hide" : (style.icon ?? "show") }; } export interface PageHeaderProps extends Omit, "title"> { /** The page name. Same noun as the rail entry and the route. */ title: ReactNode; /** One line, 90 characters or less. */ description?: ReactNode | undefined; /** A 16px lucide icon, the same one as the page's rail entry. */ icon?: ReactNode | undefined; /** A 12px line above the title, for organization scope or a back link. */ context?: ReactNode | undefined; /** Inline after the title, for example a scope chip. */ meta?: ReactNode | undefined; /** Right-aligned actions. Keep one primary action. */ actions?: ReactNode | undefined; /** A tab row (LineTabsList or LineTabsNav). Replaces the hairline. */ tabs?: ReactNode | undefined; /** Overrides the provider. */ variant?: PageHeaderVariant | undefined; /** Overrides the provider: force the icon on or off. */ showIcon?: boolean | undefined; /** A hairline under the header when there are no tabs. Default false. */ divider?: boolean | undefined; } export function PageHeader({ title, description, icon, context, meta, actions, tabs, variant: variantProp, showIcon, divider = false, className, ...props }: PageHeaderProps) { const style = useContext(PageHeaderStyleContext); const variant = variantProp ?? style.variant ?? "default"; const large = variant === "large"; const iconVisible = Boolean(icon) && !large && (showIcon ?? style.icon !== "hide"); const ruled = divider && !tabs; return (
{context ? (
{context}
) : null}
{iconVisible ? ( ) : null}

{title}

{meta ? ( {meta} ) : null}
{description ? (

{description}

) : null}
{actions ? (
{actions}
) : null}
{tabs ?
{tabs}
: null}
); }