import type { HTMLAttributes, KeyboardEvent, ReactNode } from "react";
import { useEffect, useRef } from "react";
import { Link } from "../router";
import { Count, Dot, type Tone } from "./Badge";
import { cn } from "./cn";
import { Icon, type IconName } from "./Icon";
/** Section title with its count and an optional "See all". */
export function SectionHeader({ title, count, action, to, className }: { title: ReactNode; count?: number; action?: ReactNode; to?: string; className?: string }) {
return (
<div className={cn("flex items-center justify-between gap-3", className)}>
<h2 className="m-0 flex items-center gap-2 text-[15px] leading-5 font-semibold tracking-[-0.01em] text-ink">
{title}
{count !== undefined && <Count>{count}</Count>}
</h2>
{action ??
(to && (
<Link to={to} className="text-[13px] font-medium text-muted no-underline hover:text-ink">
See all
</Link>
))}
</div>
);
}
/** Label over value, no box. A tail ("of 84", ".5%") stays quiet. */
export function Stat({ label, value, tail, icon, tone, className }: { label: ReactNode; value: ReactNode; tail?: ReactNode; icon?: IconName; tone?: string; className?: string }) {
return (
<div className={cn("flex min-w-0 flex-col gap-1.5", className)}>
<span className="flex items-center gap-1.5 text-[12.5px] text-muted">
{icon && <Icon name={icon} size={14} className={tone ?? "text-subtle"} />}
{label}
</span>
<span className="truncate text-[15px] leading-5 font-medium text-ink tabular-nums">
{value}
{tail && <span className="font-normal text-subtle"> {tail}</span>}
</span>
</div>
);
}
export type TabItem = { id: string; label: string; to: string; count?: number; countLabel?: string; dot?: { tone: Tone; label: string } };
/**
* Underline tabs, as links so each tab has its own address. A count can carry words for a screen reader
* ("3 unread notes") and a dot can mark something new. On a phone they scroll sideways and keep the open one in view.
*/
export function UnderlineTabs({ items, active, label, className }: { items: TabItem[]; active: string; label: string; className?: string }) {
const ref = useRef<HTMLElement>(null);
useEffect(() => {
const nav = ref.current;
const open = nav?.querySelector<HTMLElement>('[aria-current="page"]');
if (!nav || !open) return;
const [n, o] = [nav.getBoundingClientRect(), open.getBoundingClientRect()];
if (o.right > n.right || o.left < n.left) nav.scrollLeft += o.left - n.left - 16;
}, [active]);
return (
<nav
ref={ref}
aria-label={label}
className={cn("flex gap-[22px] overflow-x-auto shadow-[inset_0_-1px_0_var(--color-hairline)] [scrollbar-width:none] max-sm:gap-[18px]", className)}
>
{items.map((t) => {
const on = t.id === active;
return (
<Link
key={t.id}
to={t.to}
aria-current={on ? "page" : undefined}
className={cn(
"relative flex h-10 shrink-0 items-center gap-1.5 rounded-sm px-0.5 text-[14px] font-medium whitespace-nowrap no-underline transition-colors duration-150 outline-none focus-visible:shadow-[inset_0_0_0_2px_var(--color-brand)]",
on ? "text-ink after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:rounded-full after:bg-ink" : "text-muted hover:text-ink",
)}
>
{t.label}
{t.count !== undefined && t.count > 0 && (
<>
<Count aria-hidden={t.countLabel ? true : undefined} className={on ? "bg-ink text-white" : undefined}>
{t.count}
</Count>
{t.countLabel && <span className="sr-only">, {t.countLabel}</span>}
</>
)}
{t.dot && <Dot tone={t.dot.tone} role="img" aria-label={t.dot.label} />}
</Link>
);
})}
</nav>
);
}
/** Pill segmented control for small view switches (Week / 30 days). Arrow keys move the choice, as in any radio group. */
export function Segmented<T extends string>({ options, value, onChange, label }: { options: { value: T; label: string }[]; value: T; onChange: (v: T) => void; label: string }) {
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
if (!step) return;
e.preventDefault();
const next = options[(options.findIndex((o) => o.value === value) + step + options.length) % options.length];
onChange(next.value);
e.currentTarget.querySelector<HTMLElement>(`[data-value="${CSS.escape(next.value)}"]`)?.focus();
};
return (
<div role="radiogroup" aria-label={label} onKeyDown={onKeyDown} className="inline-flex h-8 items-center gap-0.5 rounded-full bg-quiet p-[3px]">
{options.map((o) => (
<button
key={o.value}
type="button"
role="radio"
data-value={o.value}
aria-checked={o.value === value}
tabIndex={o.value === value ? 0 : -1}
onClick={() => onChange(o.value)}
className={cn(
"h-[26px] cursor-pointer rounded-full border-0 px-3 font-sans text-[12.5px] font-medium transition-[background-color,color,box-shadow] outline-none focus-visible:shadow-[0_0_0_2px_var(--color-brand)]",
o.value === value ? "bg-page text-ink shadow-chip" : "bg-transparent text-muted hover:text-ink",
)}
>
{o.label}
</button>
))}
</div>
);
}
/** Empty state: a faint sketch of what will appear, one line, one link. */
export function EmptyState({ title, body, action, className }: { title: string; body?: ReactNode; action?: ReactNode; className?: string }) {
return (
<div className={cn("flex flex-col items-center gap-3 rounded-2xl bg-tile px-6 py-10 text-center", className)}>
<div aria-hidden="true" className="flex w-44 flex-col gap-1.5">
{[0.9, 0.65, 0.8].map((w, i) => (
<span key={i} className="flex items-center gap-2 rounded-lg bg-page px-2.5 py-2 shadow-card">
<span className="size-3 rounded-full bg-hairline" />
<span className="h-1.5 rounded-full bg-hairline" style={{ width: `${w * 100}%` }} />
</span>
))}
</div>
<p className="m-0 text-[14px] font-medium text-ink">{title}</p>
{body && <p className="m-0 max-w-sm text-[13px] text-muted">{body}</p>}
{action}
</div>
);
}
/** A white card on the page: hairline ring, no border. */
export function Card({
children,
className,
as: As = "section",
...rest
}: { children: ReactNode; className?: string; as?: "section" | "div" | "article" } & Pick<HTMLAttributes<HTMLElement>, "id" | "aria-label" | "aria-labelledby" | "role">) {
return (
<As className={cn("rounded-2xl bg-page p-5 shadow-card", className)} {...rest}>
{children}
</As>
);
}
/** A card with a title row: an optional coloured icon, a quiet note and actions on the right. Flush drops the body padding for rows. */
export function Panel({
title,
icon,
iconClass = "text-muted",
note,
actions,
flush,
children,
className,
id,
label,
}: {
title: ReactNode;
icon?: IconName | ReactNode;
iconClass?: string;
note?: ReactNode;
actions?: ReactNode;
flush?: boolean;
children: ReactNode;
className?: string;
id?: string;
label?: string;
}) {
return (
<section id={id} aria-label={label ?? (typeof title === "string" ? title : undefined)} className={cn("flex min-w-0 flex-col rounded-2xl bg-page shadow-card", className)}>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 px-5 pt-4 pb-3">
{typeof icon === "string" ? <Icon name={icon as IconName} size={18} className={iconClass} /> : icon}
<h2 className="m-0 text-[15px] leading-5 font-semibold tracking-[-0.01em] text-ink">{title}</h2>
{note && <span className="text-[12.5px] text-muted">{note}</span>}
{actions && <div className="ml-auto flex items-center gap-2">{actions}</div>}
</div>
<div className={cn("flex min-w-0 flex-col", flush ? "pb-2" : "gap-4 px-5 pb-5")}>{children}</div>
</section>
);
}