'use client';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { cn, prefersReducedMotion, round, useInView, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Breadcrumbs, type Crumb } from '@/components/ui/breadcrumbs';
import { DropdownMenu, type MenuItem } from '@/components/ui/dropdown-menu';
import { NumberTicker } from '@/components/ui/number-ticker';
import { Parallax } from '@/components/ui/motion';
/*
* The head of an inner page: where you are, what this page is and what you
* can do here. Six layouts for apps and websites:
*
* actions list pages: breadcrumbs, title with a count, actions on the right
* detail a single record: back link, status, facts, copy link and a menu
* with-tabs sections of one record as tabs that stick to the top
* with-stats a title over a row of key figures with a period switch
* cover profiles and projects: a picture band, an avatar, actions
* centered inner pages of a website: kicker, title, lead and a search
*
* Motion, server-rendered in its final state: the parts rise one after
* another when the header comes into view, the tab marker glides, figures
* count up as they scroll in, the cover moves slower than the page, and app
* headers hand over to a compact bar at the top once they scroll away.
*/
export type PageHeaderVariant = 'actions' | 'detail' | 'with-tabs' | 'with-stats' | 'cover' | 'centered';
export interface PageHeaderAction {
label: string;
href?: string;
icon?: IconName;
/** primary for the one main action, the rest default to outline */
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
/** Makes the action a toggle, the label it shows while pressed (Follow → Following) */
pressedLabel?: string;
/** Start pressed */
pressed?: boolean;
}
export interface PageHeaderTab {
label: string;
href?: string;
count?: number;
active?: boolean;
}
export interface PageHeaderStat {
label: string;
/** One value per period, or a single value */
values: number[];
format?: Intl.NumberFormatOptions;
/** Change against the period before, one per period, e.g. "+12 %" */
changes?: string[];
/** Direction of the change, one per period. up is good, down is bad, flat is neutral. */
trends?: ('up' | 'down' | 'flat')[];
}
export interface PageHeaderMeta {
icon?: IconName;
label: string;
value?: string;
}
export type PageHeaderBadgeTone = 'neutral' | 'accent' | 'success' | 'warning';
export interface PageHeaderProps {
variant?: PageHeaderVariant;
title: React.ReactNode;
description?: React.ReactNode;
/** A short line above the title (centered, cover) */
eyebrow?: string;
breadcrumbs?: Crumb[];
/** detail: a back link instead of breadcrumbs */
back?: { label: string; href: string };
/** A number next to the title, e.g. the entries of a list */
count?: number;
badge?: { label: string; tone?: PageHeaderBadgeTone };
meta?: PageHeaderMeta[];
/** The last primary action sits at the far right */
actions?: PageHeaderAction[];
/** More actions behind a ⋯ button */
menu?: MenuItem[];
/** detail: a button that copies this link */
copyLink?: string;
tabs?: PageHeaderTab[];
onTabChange?: (index: number) => void;
/** with-stats */
stats?: PageHeaderStat[];
periods?: string[];
defaultPeriod?: number;
/** cover: the picture band and the avatar or logo */
cover?: React.ReactNode;
avatar?: React.ReactNode;
/** centered: start sets kicker, title, lead and content flush left, for inner pages whose content runs left. Default center. */
align?: 'center' | 'start';
/** centered: a search field with suggestions */
search?: { placeholder: string; action?: string; suggestions?: string[] };
onSearch?: (query: string) => void;
/** App variants: a compact bar with title and main action takes over at the top once the header scrolls away. Default true for actions and detail. with-tabs keeps its tab row at the top instead, with the main action at its end. */
condense?: boolean;
/** with-tabs: the tab row sticks to the top of the page. Default true. */
stickyTabs?: boolean;
/** Content under the header, e.g. filters or a toolbar */
children?: React.ReactNode;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: { more: 'More actions', copy: 'Copy link', copied: 'Link copied', tabs: 'Sections', periods: 'Period', search: 'Search', suggestions: 'Popular searches', vs: 'vs. previous period' },
de: { more: 'Weitere Aktionen', copy: 'Link kopieren', copied: 'Link kopiert', tabs: 'Bereiche', periods: 'Zeitraum', search: 'Suchen', suggestions: 'Oft gesucht', vs: 'zum Vorzeitraum' },
};
type Texts = (typeof TEXTS)['en'];
const BADGE: Record<PageHeaderBadgeTone, string> = {
neutral: 'bg-muted text-muted-foreground',
accent: 'bg-[color-mix(in_oklab,var(--primary)_13%,transparent)] text-[color-mix(in_oklab,var(--primary)_78%,var(--foreground))]',
success: 'bg-[color-mix(in_oklab,var(--success)_15%,transparent)] text-success-foreground',
warning: 'bg-[color-mix(in_oklab,var(--warning)_18%,transparent)] text-warning-foreground',
};
const STYLES = `
.kph-in>*{animation:kit-rise .7s var(--ease-kit) both;animation-delay:calc(var(--kph-i,0)*70ms)}
@media (prefers-reduced-motion:reduce){.kph-in>*{animation:none}}
`;
const useIsoLayout = typeof window === 'undefined' ? useEffect : useLayoutEffect;
/* ------------------------------------------------------------------ */
/* Parts */
/* ------------------------------------------------------------------ */
/** Children rise one after another once the header is in view (only if it was out of view first) */
function Rise({ as: Tag = 'div', className, children }: { as?: 'div' | 'header'; className?: string; children: React.ReactNode }) {
const [ref, inView] = useInView<HTMLDivElement>();
const waited = useRef(false);
if (!inView) waited.current = true;
return (
<Tag ref={ref as never} className={cn(!inView && '[&>*]:opacity-0', inView && waited.current && 'kph-in', className)}>
{children}
</Tag>
);
}
function StatusBadge({ badge }: { badge: NonNullable<PageHeaderProps['badge']> }) {
return (
<span className={cn('inline-flex h-6 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium', BADGE[badge.tone ?? 'neutral'])}>
<span className="relative flex size-1.5" aria-hidden>
{badge.tone !== 'neutral' && <span className="absolute inset-0 animate-kit-pulse rounded-full bg-current" />}
<span className="relative size-1.5 rounded-full bg-current" />
</span>
{badge.label}
</span>
);
}
function ActionButton({ a, size = 'md', iconOnPhone, className }: { a: PageHeaderAction; size?: 'sm' | 'md'; /** On phones only the icon shows, the label stays for screen readers */ iconOnPhone?: boolean; className?: string }) {
const [pressed, setPressed] = useState(!!a.pressed);
const toggle = a.pressedLabel !== undefined;
const variant = toggle && pressed ? 'outline' : (a.variant ?? 'outline');
const label = toggle && pressed ? a.pressedLabel : a.label;
const icon = toggle && pressed ? 'check' : a.icon;
const compact = iconOnPhone && !!icon;
const cls = cn(compact && 'max-sm:w-11 max-sm:gap-0 max-sm:px-0', className);
if (a.href && !toggle)
return (
<Button href={a.href} variant={variant} size={size} iconLeft={icon} className={cls}>
<span className={cn(compact && 'max-sm:sr-only')}>{label}</span>
</Button>
);
return (
<Button variant={variant} size={size} iconLeft={icon} aria-pressed={toggle ? pressed : undefined} onClick={() => toggle && setPressed((p) => !p)} className={cls}>
<span key={label} className={cn(toggle && 'kit-rise [--kit-delay:0ms]', compact && 'max-sm:sr-only')}>
{label}
</span>
</Button>
);
}
function MoreMenu({ items, t, className }: { items: MenuItem[]; t: Texts; className?: string }) {
return (
<DropdownMenu
align="end"
items={items}
trigger={
<button
type="button"
aria-label={t.more}
className={cn(
'group/more grid size-11 shrink-0 place-items-center rounded-lg border border-border-strong bg-background text-foreground shadow-[var(--shadow-sm)] transition-colors hover:bg-accent aria-expanded:bg-accent',
className,
)}
>
<Icon name="more-horizontal" size={18} className="transition-transform duration-300 ease-[var(--ease-kit)] group-aria-expanded/more:rotate-90 motion-reduce:transition-none" />
</button>
}
/>
);
}
function CopyLink({ value, t }: { value: string; t: Texts }) {
const [done, setDone] = useState(false);
useEffect(() => {
if (!done) return;
const id = window.setTimeout(() => setDone(false), 1800);
return () => window.clearTimeout(id);
}, [done]);
return (
<button
type="button"
onClick={() => {
navigator.clipboard?.writeText(value).catch(() => {});
setDone(true);
}}
aria-label={done ? t.copied : t.copy}
className="relative grid size-11 shrink-0 place-items-center rounded-lg border border-border-strong bg-background text-foreground shadow-[var(--shadow-sm)] transition-colors hover:bg-accent"
>
<Icon name="link" size={18} className={cn('transition-[opacity,transform] duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none', done && 'scale-50 opacity-0')} />
<Icon name="check" size={18} className={cn('absolute text-success-foreground transition-[opacity,transform] duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none', !done && 'scale-50 opacity-0')} />
<span className="sr-only" aria-live="polite">
{done ? t.copied : ''}
</span>
</button>
);
}
/** Actions in a row. On phones the main action takes the room, the others shrink to icons where they have one. */
function Actions({ actions = [], menu, copyLink, t, className, fill = true }: { actions?: PageHeaderAction[]; menu?: MenuItem[]; copyLink?: string; t: Texts; className?: string; fill?: boolean }) {
if (!actions.length && !menu?.length && !copyLink) return null;
// More than three things in a phone row: the side actions shrink to their icons
const crowded = actions.length + (menu?.length ? 1 : 0) + (copyLink ? 1 : 0) > 3;
return (
<div className={cn('flex w-full items-center gap-2 sm:w-auto sm:shrink-0', className)}>
{copyLink && <CopyLink value={copyLink} t={t} />}
{menu?.length ? <MoreMenu items={menu} t={t} /> : null}
{actions.map((a, i) => {
const main = (a.variant ?? 'outline') === 'primary' || i === actions.length - 1;
return <ActionButton key={a.label} a={a} iconOnPhone={!main && crowded} className={cn(fill && main && 'max-sm:flex-1', !main && !a.icon && crowded && 'max-sm:hidden')} />;
})}
</div>
);
}
function Title({ title, count, badge, as: Tag = 'h1', className }: { title: React.ReactNode; count?: number; badge?: PageHeaderProps['badge']; as?: 'h1' | 'h2'; className?: string }) {
const locale = useLocale();
return (
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2">
<Tag className={cn('min-w-0 text-balance text-[clamp(1.625rem,1.3rem+1.1vw,2.125rem)] font-semibold leading-[1.15] tracking-[-0.025em] text-foreground', className)}>{title}</Tag>
{count !== undefined && (
<span className="inline-flex h-6 items-center rounded-md border border-border bg-surface px-2 text-[13px] font-medium tabular-nums text-muted-foreground">{count.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')}</span>
)}
{badge && <StatusBadge badge={badge} />}
</div>
);
}
function MetaList({ meta, className }: { meta: PageHeaderMeta[]; className?: string }) {
return (
<dl className={cn('grid grid-cols-2 gap-x-4 gap-y-3 text-[13px] sm:flex sm:flex-wrap sm:gap-x-6 sm:gap-y-2.5', className)}>
{meta.map((m) => (
<div key={m.label} className="flex min-w-0 items-center gap-2">
{m.icon && <Icon name={m.icon} size={15} className="shrink-0 text-subtle-foreground" />}
<dt className={cn('text-muted-foreground', m.value && 'max-sm:sr-only')}>{m.label}</dt>
{m.value && <dd className="truncate font-medium text-foreground">{m.value}</dd>}
</div>
))}
</dl>
);
}
/**
* A compact bar at the top of the window that takes over once the header has
* scrolled away. Portaled into the body, slides in with transform only.
*/
function CondensedBar({ watch, title, action }: { watch: React.RefObject<HTMLElement | null>; title: React.ReactNode; action?: PageHeaderAction }) {
const [show, setShow] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
const el = watch.current;
if (!el || typeof IntersectionObserver === 'undefined') return;
const io = new IntersectionObserver(([e]) => setShow(!e.isIntersecting && e.boundingClientRect.top < 0), { threshold: 0 });
io.observe(el);
return () => io.disconnect();
}, [watch]);
if (!mounted) return null;
return createPortal(
<div
aria-hidden={!show}
inert={!show}
className={cn(
'fixed inset-x-0 top-0 z-40 border-b border-border bg-[color-mix(in_oklab,var(--background)_86%,transparent)] backdrop-blur-md transition-[transform,opacity] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none',
show ? 'translate-y-0 opacity-100' : '-translate-y-full opacity-0',
)}
>
<div className="kit-container flex h-14 items-center justify-between gap-4">
<p className="min-w-0 truncate text-[15px] font-semibold tracking-[-0.015em] text-foreground">{title}</p>
{action && <ActionButton a={action} size="sm" />}
</div>
</div>,
document.body,
);
}
/* ------------------------------------------------------------------ */
/* Tabs with a gliding marker */
/* ------------------------------------------------------------------ */
function HeaderTabs({ tabs, onTabChange, sticky, bleed, action, t }: { tabs: PageHeaderTab[]; onTabChange?: (i: number) => void; sticky: boolean; /** Full width band with the content width inside (with-tabs), else a row inside the container */ bleed?: boolean; /** Shown at the end of the row while it sticks (tablet and desktop) */ action?: PageHeaderAction; t: Texts }) {
const locale = useLocale();
const [active, setActive] = useState(() => Math.max(0, tabs.findIndex((x) => x.active)));
const row = useRef<HTMLDivElement>(null);
const [bar, setBar] = useState<{ x: number; w: number; glide: boolean } | null>(null);
const sentinel = useRef<HTMLDivElement>(null);
const [stuck, setStuck] = useState(false);
const [fade, setFade] = useState('');
const measure = useCallback(() => {
const r = row.current;
const el = r?.querySelector<HTMLElement>('[aria-current="page"]');
if (!r || !el) return;
setBar((b) => ({ x: round(el.offsetLeft), w: round(el.offsetWidth), glide: b !== null }));
const max = r.scrollWidth - r.clientWidth;
setFade(max <= 1 ? '' : r.scrollLeft <= 1 ? 'kit-fade-end' : r.scrollLeft >= max - 1 ? 'kit-fade-start' : 'kit-fade-x');
}, []);
useIsoLayout(() => {
measure();
}, [active, measure]);
useEffect(() => {
const r = row.current;
window.addEventListener('resize', measure);
r?.addEventListener('scroll', measure, { passive: true });
document.fonts?.ready.then(measure).catch(() => {});
return () => {
window.removeEventListener('resize', measure);
r?.removeEventListener('scroll', measure);
};
}, [measure]);
useEffect(() => {
const el = sentinel.current;
if (!sticky || !el || typeof IntersectionObserver === 'undefined') return;
const io = new IntersectionObserver(([e]) => setStuck(!e.isIntersecting && e.boundingClientRect.top < 0));
io.observe(el);
return () => io.disconnect();
}, [sticky]);
const pick = (i: number) => {
setActive(i);
onTabChange?.(i);
row.current?.querySelectorAll<HTMLElement>('a')[i]?.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
};
return (
<>
<div ref={sentinel} aria-hidden className="h-px" />
<div className={cn('z-30 border-b border-border transition-shadow duration-300', sticky && 'sticky top-0 bg-[color-mix(in_oklab,var(--background)_88%,transparent)] backdrop-blur-md', stuck && 'shadow-[var(--shadow-md)]')}>
<nav aria-label={t.tabs} className={cn('relative', bleed && 'kit-container')}>
<div ref={row} className={cn('kit-no-scrollbar relative -mx-3 -mb-px flex gap-1 overflow-x-auto [&_a:focus-visible]:outline-offset-[-2px] [&_button:focus-visible]:outline-offset-[-2px]', fade)}>
{tabs.map((tab, i) => (
<a
key={tab.label}
href={tab.href ?? '#'}
aria-current={i === active ? 'page' : undefined}
onClick={(e) => {
if (!tab.href || tab.href === '#') e.preventDefault();
pick(i);
}}
className={cn(
'group/tab relative flex h-12 shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm font-medium outline-none transition-colors duration-300 focus-visible:outline-solid focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-[var(--ring)]',
i === active ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
)}
>
<span className="absolute inset-x-0.5 inset-y-2 -z-10 rounded-md bg-accent opacity-0 transition-opacity duration-200 group-hover/tab:opacity-100" aria-hidden />
{tab.label}
{tab.count !== undefined && (
<span className={cn('rounded-full px-1.5 text-[11px] tabular-nums leading-5 transition-colors duration-300', i === active ? 'bg-[color-mix(in_oklab,var(--primary)_16%,transparent)] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]' : 'bg-muted text-muted-foreground')}>
{tab.count.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')}
</span>
)}
{/* Each tab draws its own mark until the shared one is measured */}
{!bar && i === active && <span aria-hidden className="absolute inset-x-3 bottom-0 h-0.5 rounded-full bg-primary" />}
</a>
))}
{action && <span aria-hidden className="w-40 shrink-0 max-sm:hidden" />}
{bar && (
<span
aria-hidden
className={cn('pointer-events-none absolute bottom-0 left-0 h-0.5 w-px origin-left rounded-full bg-primary', bar.glide && 'transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none')}
style={{ transform: `translateX(${round(bar.x + 12)}px) scaleX(${Math.max(0, round(bar.w - 24))})` }}
/>
)}
</div>
{action && (
<div
inert={!stuck}
className={cn('absolute inset-y-0 right-6 flex items-center transition-[opacity,transform] duration-500 ease-[var(--ease-kit)] max-sm:hidden lg:right-8 motion-reduce:transition-none', stuck ? 'opacity-100' : 'pointer-events-none translate-y-1 opacity-0')}
>
<ActionButton a={action} size="sm" />
</div>
)}
</nav>
</div>
</>
);
}
/* ------------------------------------------------------------------ */
/* Stats with a period switch */
/* ------------------------------------------------------------------ */
function PeriodSwitch({ periods, value, onChange, t }: { periods: string[]; value: number; onChange: (i: number) => void; t: Texts }) {
const refs = useRef<(HTMLButtonElement | null)[]>([]);
const move = (to: number) => {
const n = (to + periods.length) % periods.length;
onChange(n);
refs.current[n]?.focus();
};
return (
<div role="radiogroup" aria-label={t.periods} className="relative grid w-full rounded-lg border border-border bg-surface p-0.5 shadow-[var(--highlight)] sm:w-auto" style={{ gridTemplateColumns: `repeat(${periods.length}, minmax(0, 1fr))` }}>
<span
aria-hidden
className="absolute bottom-0.5 left-0.5 top-0.5 rounded-md bg-background shadow-[var(--highlight),var(--shadow-sm)] ring-1 ring-border transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none"
style={{ width: `calc((100% - 4px) / ${periods.length})`, transform: `translateX(${value * 100}%)` }}
/>
{periods.map((p, i) => (
<button
key={p}
ref={(el) => {
refs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={i === value}
tabIndex={i === value ? 0 : -1}
onClick={() => onChange(i)}
onKeyDown={(e) => {
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
move(value + 1);
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
move(value - 1);
}
}}
className={cn('relative z-10 flex min-h-11 items-center justify-center whitespace-nowrap rounded-md px-3.5 text-[13px] font-medium outline-none transition-colors duration-300 focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-[var(--ring)] sm:min-h-9', i === value ? 'text-foreground' : 'text-muted-foreground hover:text-foreground')}
>
{p}
</button>
))}
</div>
);
}
const TREND: Record<'up' | 'down' | 'flat', { icon: IconName; cls: string }> = {
up: { icon: 'trending-up', cls: 'text-success-foreground' },
down: { icon: 'trending-down', cls: 'text-destructive' },
flat: { icon: 'arrow-right', cls: 'text-muted-foreground' },
};
function StatsRow({ stats, period, t }: { stats: PageHeaderStat[]; period: number; t: Texts }) {
const cols = stats.length >= 4 ? 'grid-cols-2 lg:grid-cols-4' : stats.length === 3 ? 'grid-cols-1 sm:grid-cols-3' : 'grid-cols-2';
return (
<dl className={cn('grid gap-px overflow-hidden rounded-xl border border-border bg-border shadow-[var(--highlight),var(--shadow-sm)]', cols)}>
{stats.map((s, i) => {
const v = s.values[Math.min(period, s.values.length - 1)];
const change = s.changes?.[Math.min(period, s.changes.length - 1)];
const trend = s.trends?.[Math.min(period, s.trends.length - 1)] ?? 'flat';
return (
<div key={s.label} className="group/stat relative flex min-w-0 flex-col bg-card px-4 py-4 sm:px-5 sm:py-5">
<dt className="truncate text-[13px] text-muted-foreground">{s.label}</dt>
<dd className="mt-2 text-[clamp(1.375rem,1.15rem+0.8vw,1.75rem)] font-semibold leading-none tracking-[-0.025em] text-foreground tabular-nums">
<NumberTicker key={period} value={v} format={s.format} duration={period === 0 ? 1400 : 700} delay={period === 0 ? i * 90 : 0} />
</dd>
{change && (
<dd className={cn('mt-2.5 flex items-center gap-1 text-[12px] font-medium tabular-nums', TREND[trend].cls)}>
<Icon name={TREND[trend].icon} size={14} />
{change}
<span className="font-normal text-subtle-foreground max-sm:sr-only">{t.vs}</span>
</dd>
)}
</div>
);
})}
</dl>
);
}
/* ------------------------------------------------------------------ */
/* Search for the centered header */
/* ------------------------------------------------------------------ */
function HeaderSearch({ search, onSearch, t }: { search: NonNullable<PageHeaderProps['search']>; onSearch?: (q: string) => void; t: Texts }) {
const [q, setQ] = useState('');
const input = useRef<HTMLInputElement>(null);
return (
<div className="mx-auto mt-9 w-full max-w-[34rem]">
<form
role="search"
onSubmit={(e) => {
e.preventDefault();
onSearch?.(q);
}}
className="group/search relative flex h-14 items-center gap-2 rounded-xl border border-border-strong bg-card pl-4 pr-1.5 shadow-[var(--highlight),var(--shadow-md)] transition-[box-shadow,border-color] duration-300 focus-within:border-[color-mix(in_oklab,var(--primary)_55%,var(--border-strong))] focus-within:shadow-[var(--highlight),0_0_0_4px_color-mix(in_oklab,var(--primary)_14%,transparent),var(--shadow-md)]"
>
<Icon name="search" size={18} className="shrink-0 text-subtle-foreground transition-colors duration-300 group-focus-within/search:text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]" />
<label className="sr-only" htmlFor="kph-search">
{t.search}
</label>
<input
ref={input}
id="kph-search"
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={search.placeholder}
className="h-full w-0 min-w-0 flex-1 bg-transparent text-[15px] text-foreground outline-none placeholder:text-subtle-foreground [&::-webkit-search-cancel-button]:hidden"
/>
<Button type="submit" size="sm" className="max-sm:h-11 max-sm:px-3.5">
{search.action ?? t.search}
</Button>
</form>
{search.suggestions?.length ? (
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
<span className="text-[13px] text-subtle-foreground max-sm:w-full max-sm:text-center">{t.suggestions}</span>
{search.suggestions.map((s) => (
<button
key={s}
type="button"
onClick={() => {
setQ(s);
input.current?.focus();
}}
className="inline-flex h-11 items-center rounded-full border border-border bg-surface px-3.5 text-[13px] text-muted-foreground transition-[color,border-color,transform] duration-200 hover:-translate-y-px hover:border-border-strong hover:text-foreground sm:h-8"
>
{s}
</button>
))}
</div>
) : null}
</div>
);
}
/* ------------------------------------------------------------------ */
/* PageHeader */
/* ------------------------------------------------------------------ */
/**
* The head of an inner page.
* <PageHeader variant="actions" breadcrumbs={[…]} title="Invoices" count={128} actions={[{ label: 'Export' }, { label: 'New invoice', variant: 'primary', icon: 'plus' }]} />
*/
export function PageHeader(props: PageHeaderProps) {
const { variant = 'actions', align = 'center', title, description, eyebrow, breadcrumbs, back, count, badge, meta, actions, menu, copyLink, tabs, onTabChange, stats, periods, defaultPeriod = 0, cover, avatar, search, onSearch, stickyTabs = true, children, labels, className } = props;
const t = useTexts(TEXTS, labels);
const head = useRef<HTMLDivElement>(null);
const [period, setPeriod] = useState(defaultPeriod);
// with-tabs hands over to its sticky tab row instead, which then carries the main action
const condense = props.condense ?? (variant === 'actions' || variant === 'detail');
const main = actions?.length ? [...actions].reverse().find((a) => a.variant === 'primary') ?? actions[actions.length - 1] : undefined;
const plainTitle = typeof title === 'string' ? title : null;
const styles = (
<style href="kit-page-header" precedence="default">
{STYLES}
</style>
);
const crumbs = breadcrumbs?.length ? <Breadcrumbs items={breadcrumbs} /> : null;
if (variant === 'centered') {
const start = align === 'start';
return (
<header className={cn('relative isolate overflow-hidden border-b border-border', className)}>
{styles}
<Parallax speed={-60} className="pointer-events-none absolute inset-0 -z-10">
<div aria-hidden className="absolute inset-x-0 -top-24 h-[130%] bg-[radial-gradient(55%_60%_at_50%_0%,var(--glow),transparent_72%)]" />
<div aria-hidden className="absolute inset-0 bg-[linear-gradient(to_right,color-mix(in_oklab,var(--foreground)_6%,transparent)_1px,transparent_1px),linear-gradient(to_bottom,color-mix(in_oklab,var(--foreground)_6%,transparent)_1px,transparent_1px)] [background-size:48px_48px] [mask-image:radial-gradient(ellipse_60%_70%_at_50%_10%,#000_20%,transparent_75%)]" />
</Parallax>
<Rise className={cn('kit-container flex flex-col py-16 sm:py-20 lg:py-24', start ? 'items-start text-left' : 'items-center text-center')}>
{crumbs && <div style={{ '--kph-i': 0 } as React.CSSProperties} className={cn('mb-8 flex', !start && 'justify-center [&_ol]:justify-center')}>{crumbs}</div>}
{eyebrow && (
<p style={{ '--kph-i': 1 } as React.CSSProperties} className="mb-4 text-sm font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">
{eyebrow}
</p>
)}
<h1 style={{ '--kph-i': 2 } as React.CSSProperties} className="max-w-[44rem] text-balance text-h1 font-semibold text-foreground">
{title}
</h1>
{description && (
<p style={{ '--kph-i': 3 } as React.CSSProperties} className={cn('mt-5 max-w-[36rem] text-lead text-muted-foreground', start ? 'max-w-[40rem] text-pretty' : 'text-balance')}>
{description}
</p>
)}
{search && (
<div style={{ '--kph-i': 4 } as React.CSSProperties} className="w-full">
<HeaderSearch search={search} onSearch={onSearch} t={t} />
</div>
)}
{!search && actions?.length ? (
<div style={{ '--kph-i': 4 } as React.CSSProperties} className={cn('mt-8 flex flex-wrap gap-3', !start && 'justify-center')}>
{actions.map((a) => (
<ActionButton key={a.label} a={a} />
))}
</div>
) : null}
{children && <div className={cn('w-full', start ? 'mt-0' : 'mt-10')}>{children}</div>}
</Rise>
</header>
);
}
if (variant === 'cover') {
return (
<header className={cn('relative', className)}>
{styles}
<div className="kit-container">
<div className="relative mt-4 h-36 overflow-hidden rounded-2xl border border-border bg-muted sm:mt-6 sm:h-48 lg:h-60">
{cover && (
<Parallax speed={-56} className="absolute -inset-y-8 inset-x-0 [&>*]:h-full [&>*]:w-full [&>*]:object-cover">
{cover}
</Parallax>
)}
<div aria-hidden className="absolute inset-0 bg-[linear-gradient(to_top,color-mix(in_oklab,var(--background)_35%,transparent),transparent_55%)]" />
</div>
<Rise className="relative px-1 sm:px-6 lg:px-8">
<div style={{ '--kph-i': 0 } as React.CSSProperties} className="-mt-11 flex items-end justify-between gap-4 sm:-mt-14">
<div className="size-22 shrink-0 overflow-hidden rounded-2xl border-4 border-background bg-card shadow-[var(--shadow-md)] transition-transform duration-500 ease-[var(--ease-kit)] hover:-translate-y-1 hover:rotate-[-2deg] motion-reduce:transition-none sm:size-28 [&>*]:size-full">
{avatar}
</div>
<Actions actions={actions} menu={menu} t={t} fill={false} className="w-auto max-sm:hidden" />
</div>
<div style={{ '--kph-i': 1 } as React.CSSProperties} className="mt-5">
{eyebrow && <p className="mb-1.5 text-[13px] text-muted-foreground">{eyebrow}</p>}
<Title title={title} badge={badge} />
{description && <p className="mt-3 max-w-[40rem] text-pretty text-[15px] leading-relaxed text-muted-foreground">{description}</p>}
</div>
{meta?.length ? <MetaList meta={meta} className="mt-5" /> : null}
<Actions actions={actions} menu={menu} t={t} className="mt-6 sm:hidden" />
{tabs?.length ? (
<div className="mt-7">
<HeaderTabs tabs={tabs} onTabChange={onTabChange} sticky={false} t={t} />
</div>
) : (
<div className="mt-8 border-b border-border" />
)}
</Rise>
{children && <div className="px-1 pt-6 sm:px-6 lg:px-8">{children}</div>}
</div>
</header>
);
}
const barTitle = plainTitle ?? title;
const condensed = condense ? <CondensedBar watch={head} title={barTitle} action={main} /> : null;
if (variant === 'with-stats') {
return (
<header className={cn('kit-container py-8 sm:py-10', className)}>
{styles}
<Rise>
{crumbs && <div style={{ '--kph-i': 0 } as React.CSSProperties} className="mb-5">{crumbs}</div>}
<div style={{ '--kph-i': 1 } as React.CSSProperties} className="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
<Title title={title} badge={badge} count={count} />
{description && <p className="mt-2 max-w-[38rem] text-pretty text-[15px] leading-relaxed text-muted-foreground">{description}</p>}
</div>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:items-center">
{periods?.length ? <PeriodSwitch periods={periods} value={period} onChange={setPeriod} t={t} /> : null}
<Actions actions={actions} menu={menu} t={t} />
</div>
</div>
{stats?.length ? (
<div style={{ '--kph-i': 2 } as React.CSSProperties} className="mt-7">
<StatsRow stats={stats} period={period} t={t} />
</div>
) : null}
</Rise>
{children && <div className="mt-6">{children}</div>}
</header>
);
}
if (variant === 'with-tabs') {
return (
<>
{styles}
{condensed}
<header className={cn('kit-container', className)}>
<Rise className="pt-8 sm:pt-10">
{crumbs && <div style={{ '--kph-i': 0 } as React.CSSProperties} className="mb-5">{crumbs}</div>}
<div ref={head} style={{ '--kph-i': 1 } as React.CSSProperties} className="flex flex-col gap-5 pb-6 sm:flex-row sm:items-end sm:justify-between">
<div className="flex min-w-0 items-start gap-4">
{avatar && <div className="size-12 shrink-0 overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] sm:size-14 [&>*]:size-full">{avatar}</div>}
<div className="min-w-0">
<Title title={title} badge={badge} count={count} />
{description && <p className="mt-1.5 max-w-[38rem] text-pretty text-[15px] leading-relaxed text-muted-foreground">{description}</p>}
</div>
</div>
<Actions actions={actions} menu={menu} copyLink={copyLink} t={t} />
</div>
</Rise>
</header>
{tabs?.length ? <HeaderTabs tabs={tabs} onTabChange={onTabChange} sticky={stickyTabs} bleed action={stickyTabs ? main : undefined} t={t} /> : null}
{children && <div className="kit-container pt-6">{children}</div>}
</>
);
}
if (variant === 'detail') {
return (
<>
{styles}
{condensed}
<header className={cn('kit-container', className)}>
<Rise className="border-b border-border py-8 sm:py-10">
{back ? (
<a
href={back.href}
style={{ '--kph-i': 0 } as React.CSSProperties}
className="group/back -ml-2 mb-5 inline-flex h-11 items-center gap-1.5 rounded-md px-2 text-sm text-muted-foreground transition-colors hover:text-foreground sm:h-8"
>
<Icon name="arrow-left" size={16} className="transition-transform duration-300 ease-[var(--ease-kit)] group-hover/back:-translate-x-0.5 motion-reduce:transition-none" />
{back.label}
</a>
) : (
crumbs && <div className="mb-5">{crumbs}</div>
)}
<div ref={head} style={{ '--kph-i': 1 } as React.CSSProperties} className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<Title title={title} badge={badge} />
{description && <p className="mt-2 max-w-[42rem] text-pretty text-[15px] leading-relaxed text-muted-foreground">{description}</p>}
</div>
<Actions actions={actions} menu={menu} copyLink={copyLink} t={t} className="lg:pt-0.5" />
</div>
{meta?.length ? (
<div style={{ '--kph-i': 2 } as React.CSSProperties}>
<MetaList meta={meta} className="mt-6 border-t border-border pt-5 sm:gap-x-8" />
</div>
) : null}
</Rise>
</header>
{children && <div className="kit-container pt-6">{children}</div>}
</>
);
}
/* actions */
return (
<>
{styles}
{condensed}
<header className={cn('kit-container py-8 sm:py-10', className)}>
<Rise>
{crumbs && <div style={{ '--kph-i': 0 } as React.CSSProperties} className="mb-5">{crumbs}</div>}
<div ref={head} style={{ '--kph-i': 1 } as React.CSSProperties} className="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
<Title title={title} count={count} badge={badge} />
{description && <p className="mt-2 max-w-[38rem] text-pretty text-[15px] leading-relaxed text-muted-foreground">{description}</p>}
</div>
<Actions actions={actions} menu={menu} t={t} />
</div>
{children && (
<div style={{ '--kph-i': 2 } as React.CSSProperties} className="mt-7">
{children}
</div>
)}
</Rise>
</header>
</>
);
}