SPB Git

spb/rareindex

Public
8.0 KB · 172 lines tsx
1import Link from 'next/link';2import type { ReactNode } from 'react';3import { cn, deltaClass, fmtPct } from '@/lib/format';45/** Minimal, dense UI primitives. Keep them boring; the data is the design. */67export function Card({ children, className, as: Tag = 'section', id }: { children: ReactNode; className?: string; as?: 'section' | 'div' | 'article'; id?: string }) {8  return (9    <Tag className={cn('card', className)} id={id}>10      {children}11    </Tag>12  );13}1415export function CardHeader({ title, subtitle, action, className, icon }: { title: ReactNode; subtitle?: ReactNode; action?: ReactNode; className?: string; icon?: ReactNode }) {16  return (17    <header className={cn('flex items-start justify-between gap-3 border-b border-border px-4 py-3', className)}>18      <div className="flex min-w-0 items-start gap-2">19        {icon ? <span className="mt-0.5 text-subtle">{icon}</span> : null}20        <div className="min-w-0">21          <h2 className="t-subtitle text-fg">{title}</h2>22          {subtitle ? <p className="mt-0.5 text-xs text-muted">{subtitle}</p> : null}23        </div>24      </div>25      {action ? <div className="shrink-0 text-xs">{action}</div> : null}26    </header>27  );28}2930export function Delta({ value, digits = 2, className, arrow = false }: { value: number | null | undefined; digits?: number; className?: string; arrow?: boolean }) {31  const has = value !== null && value !== undefined && Number.isFinite(value);32  const glyph = !has || Math.abs(value) < 0.00005 ? '' : value > 0 ? '▲ ' : '▼ ';33  return (34    <span className={cn('num font-medium', deltaClass(value), className)}>35      {arrow && glyph ? <span className="text-[0.7em]">{glyph}</span> : null}36      {fmtPct(value, digits)}37    </span>38  );39}4041export function Badge({ children, tone = 'neutral', className, dot = false }: { children: ReactNode; tone?: 'neutral' | 'gain' | 'loss' | 'index' | 'rarity' | 'alert' | 'gold'; className?: string; dot?: boolean }) {42  const tones: Record<string, string> = {43    neutral: 'bg-inset text-muted',44    gain: 'bg-gain-bg text-gain',45    loss: 'bg-loss-bg text-loss',46    index: 'bg-index-bg text-index',47    rarity: 'bg-rarity-bg text-rarity',48    alert: 'bg-alert-bg text-alert',49    gold: 'bg-alert-bg text-gold',50  };51  return (52    <span className={cn('inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-[11px] font-medium leading-4 tracking-wide', tones[tone], className)}>53      {dot ? <span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden /> : null}54      {children}55    </span>56  );57}5859export function Stat({ label, value, sub, className, size = 'md' }: { label: ReactNode; value: ReactNode; sub?: ReactNode; className?: string; size?: 'sm' | 'md' | 'lg' }) {60  return (61    <div className={cn('flex min-w-0 flex-col gap-0.5', className)}>62      <span className="t-label">{label}</span>63      <span className={cn('num truncate font-semibold leading-tight text-fg', size === 'lg' ? 'text-2xl' : size === 'sm' ? 'text-sm' : 'text-lg')}>{value}</span>64      {sub ? <span className="truncate text-xs text-muted">{sub}</span> : null}65    </div>66  );67}6869/** Dense KPI strip used under page headers and in the hero. */70export function StatStrip({ items, className, cols }: { items: Array<{ label: ReactNode; value: ReactNode; sub?: ReactNode; href?: string }>; className?: string; cols?: string }) {71  const grid = cols ?? (items.length >= 8 ? 'grid-cols-2 sm:grid-cols-4 lg:grid-cols-8' : items.length >= 6 ? 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6' : items.length === 5 ? 'grid-cols-2 sm:grid-cols-5' : items.length === 4 ? 'grid-cols-2 sm:grid-cols-4' : 'grid-cols-2 sm:grid-cols-3');72  return (73    <dl className={cn('grid gap-px overflow-hidden rounded-md border border-border bg-border', grid, className)}>74      {items.map((it, i) => {75        const inner = (76          <>77            <dt className="t-label">{it.label}</dt>78            <dd className="num mt-1 truncate text-[17px] font-semibold leading-none text-fg sm:text-lg">{it.value}</dd>79            {it.sub ? <dd className="mt-1 truncate text-[11px] text-muted">{it.sub}</dd> : null}80          </>81        );82        return it.href ? (83          <Link key={i} href={it.href} className="block min-w-0 bg-elevated px-3 py-2.5 hover:bg-sunken">84            {inner}85          </Link>86        ) : (87          <div key={i} className="min-w-0 bg-elevated px-3 py-2.5">88            {inner}89          </div>90        );91      })}92    </dl>93  );94}9596export function EmptyState({ title, description, action, className, compact }: { title: ReactNode; description?: ReactNode; action?: ReactNode; className?: string; compact?: boolean }) {97  return (98    <div className={cn('flex flex-col items-center justify-center gap-1.5 px-6 text-center', compact ? 'py-6' : 'py-12', className)}>99      <span className="mb-1 h-1 w-8 rounded-full bg-inset" aria-hidden />100      <p className="text-sm font-medium text-fg">{title}</p>101      {description ? <p className="max-w-md text-xs leading-relaxed text-muted">{description}</p> : null}102      {action ? <div className="mt-2">{action}</div> : null}103    </div>104  );105}106107/** Honest placeholder for metrics without enough evidence (§191–§192). */108export function Unavailable({ reason = 'Data unavailable', className }: { reason?: string; className?: string }) {109  return (110    <span className={cn('text-subtle', className)} title={reason}>111      —112    </span>113  );114}115116export function SectionTitle({ children, href, hrefLabel = 'View all', className, subtitle }: { children: ReactNode; href?: string; hrefLabel?: string; className?: string; subtitle?: ReactNode }) {117  return (118    <div className={cn('mb-3 flex items-end justify-between gap-3', className)}>119      <div className="min-w-0">120        <h2 className="t-title text-fg">{children}</h2>121        {subtitle ? <p className="mt-0.5 text-xs text-muted">{subtitle}</p> : null}122      </div>123      {href ? (124        <Link href={href} className="shrink-0 text-xs font-medium text-muted hover:text-fg">125          {hrefLabel} →126        </Link>127      ) : null}128    </div>129  );130}131132export function Skeleton({ className }: { className?: string }) {133  return <div className={cn('animate-pulse rounded-sm bg-inset', className)} aria-hidden />;134}135136/** Skeleton matching a dense table (header + n rows) to avoid layout shift. */137export function TableSkeleton({ rows = 8, className }: { rows?: number; className?: string }) {138  return (139    <div className={cn('card overflow-hidden', className)} aria-hidden>140      <div className="h-9 border-b border-border bg-elevated" />141      {Array.from({ length: rows }).map((_, i) => (142        <div key={i} className="flex items-center gap-3 border-b border-border px-3 py-2 last:border-b-0">143          <Skeleton className="h-8 w-8" />144          <Skeleton className="h-3 w-1/3" />145          <Skeleton className="ml-auto h-3 w-16" />146          <Skeleton className="h-3 w-12" />147        </div>148      ))}149    </div>150  );151}152153export function Table({ children, className, dense = true, sticky = true }: { children: ReactNode; className?: string; dense?: boolean; sticky?: boolean }) {154  return (155    <div className={cn('w-full overflow-x-auto overscroll-x-contain scrollbar-none', className)}>156      <table className={cn('w-full border-collapse text-left', dense ? 'text-[13px]' : 'text-sm', sticky && 'table-sticky md:[&_td:first-child]:static md:[&_th:first-child]:static md:[&_td:first-child]:shadow-none md:[&_th:first-child]:shadow-none')}>{children}</table>157    </div>158  );159}160export const th = 'sticky top-0 z-[1] whitespace-nowrap border-b border-border bg-elevated px-3 py-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-subtle';161export const td = 'whitespace-nowrap border-b border-border px-3 py-2 align-middle';162export const tdNum = `${td} num text-right`;163164/** Sequential page "kicker" — small uppercase label above a title. */165export function Kicker({ children, className }: { children: ReactNode; className?: string }) {166  return <p className={cn('t-label mb-2', className)}>{children}</p>;167}168169export function Divider({ className }: { className?: string }) {170  return <hr className={cn('border-0 border-t border-border', className)} />;171}172
Files · f5091c0a3f32b0f99bc705c36f16b636342fbdb4
Menu

Theme