BillTracker/client/components/ui/app-primitives.tsx

160 lines
5.1 KiB
TypeScript

import type { ComponentType, ReactNode } from 'react';
import { Inbox } from 'lucide-react';
import { cn } from '@/lib/utils';
import { BrandGlyph } from '@/components/brand/Brand';
import type { BrandGlyphName } from '@/lib/brand';
type Tone = 'neutral' | 'good' | 'warn' | 'danger' | 'info';
const toneClasses: Record<Tone, string> = {
neutral: 'border-border/75 bg-card/95 text-muted-foreground',
good: 'border-emerald-500/25 bg-emerald-500/[0.07] text-emerald-700 dark:text-emerald-300',
warn: 'border-amber-500/30 bg-amber-500/[0.08] text-amber-700 dark:text-amber-300',
danger: 'border-rose-500/30 bg-rose-500/[0.08] text-rose-700 dark:text-rose-300',
info: 'border-sky-500/25 bg-sky-500/[0.07] text-sky-700 dark:text-sky-300',
};
const dotClasses: Record<Tone, string> = {
neutral: 'bg-muted-foreground/55',
good: 'bg-emerald-500',
warn: 'bg-amber-500',
danger: 'bg-rose-500',
info: 'bg-sky-500',
};
interface PageHeaderProps {
eyebrow?: ReactNode;
title: ReactNode;
description?: ReactNode;
glyph?: BrandGlyphName;
icon?: ComponentType<{ className?: string }>;
actions?: ReactNode;
meta?: ReactNode;
className?: string;
}
export function PageHeader({
eyebrow,
title,
description,
glyph,
icon: Icon,
actions,
meta,
className,
}: PageHeaderProps) {
return (
<section className={cn('flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between', className)}>
<div className="flex min-w-0 items-start gap-3">
{glyph && <BrandGlyph name={glyph} className="mt-0.5 hidden sm:inline-grid" />}
{Icon && !glyph && (
<span className="mt-0.5 hidden h-10 w-10 shrink-0 place-items-center rounded-xl border border-primary/20 bg-primary/[0.08] text-primary shadow-sm shadow-primary/10 sm:grid">
<Icon className="h-5 w-5" />
</span>
)}
<div className="min-w-0">
{eyebrow && (
<p className="mb-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{eyebrow}
</p>
)}
<h1 className="text-balance text-2xl font-semibold tracking-tight text-foreground sm:text-3xl">
{title}
</h1>
{description && (
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted-foreground">
{description}
</p>
)}
{meta && <div className="mt-2 text-xs text-muted-foreground">{meta}</div>}
</div>
</div>
{actions && <div className="flex flex-wrap items-center gap-2 sm:justify-end">{actions}</div>}
</section>
);
}
export function SectionSurface({
children,
className,
tone = 'neutral',
}: {
children: ReactNode;
className?: string;
tone?: Tone;
}) {
return (
<section className={cn('surface-premium', tone !== 'neutral' && toneClasses[tone], className)}>
{children}
</section>
);
}
export function StatusDot({ tone = 'neutral', pulse = false, className }: { tone?: Tone; pulse?: boolean; className?: string }) {
return (
<span className={cn('relative inline-flex h-2.5 w-2.5 shrink-0', className)} aria-hidden="true">
{pulse && <span className={cn('absolute inline-flex h-full w-full animate-ping rounded-full opacity-40', dotClasses[tone])} />}
<span className={cn('relative inline-flex h-2.5 w-2.5 rounded-full', dotClasses[tone])} />
</span>
);
}
export function MetricCard({
label,
value,
hint,
icon: Icon,
tone = 'neutral',
action,
className,
}: {
label: ReactNode;
value: ReactNode;
hint?: ReactNode;
icon?: ComponentType<{ className?: string }>;
tone?: Tone;
action?: ReactNode;
className?: string;
}) {
return (
<div className={cn('metric-card', tone !== 'neutral' && toneClasses[tone], className)}>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
{Icon && <Icon className="h-4 w-4 shrink-0 text-current opacity-80" />}
<p className="truncate text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
{label}
</p>
</div>
{action}
</div>
<p className="metric-value mt-3 text-2xl leading-none sm:text-[1.75rem]">{value}</p>
{hint && <p className="mt-2 text-xs leading-5 text-muted-foreground">{hint}</p>}
</div>
);
}
export function EmptyState({
title,
description,
icon: Icon = Inbox,
action,
className,
}: {
title: ReactNode;
description?: ReactNode;
icon?: ComponentType<{ className?: string }>;
action?: ReactNode;
className?: string;
}) {
return (
<div className={cn('surface-premium flex flex-col items-center justify-center px-6 py-12 text-center', className)}>
<span className="grid h-11 w-11 place-items-center rounded-xl border border-border/70 bg-muted/35 text-muted-foreground">
<Icon className="h-5 w-5" />
</span>
<h2 className="mt-4 text-base font-semibold text-foreground">{title}</h2>
{description && <p className="mt-1 max-w-md text-sm leading-6 text-muted-foreground">{description}</p>}
{action && <div className="mt-4">{action}</div>}
</div>
);
}