92 lines
3.5 KiB
TypeScript
92 lines
3.5 KiB
TypeScript
import clsx from "clsx";
|
|
|
|
/*
|
|
* The surface every screen is built from.
|
|
*
|
|
* Before this there were four panel treatments in the app: `rounded border
|
|
* border-rule bg-panel p-5` with an `<h2 className="text-xl">`, the same thing
|
|
* with `text-[0.95rem] font-medium`, a bare `<section className="space-y-2">`
|
|
* with no border at all, and a table wrapper that was a panel in everything but
|
|
* name. They were all trying to be the same object.
|
|
*
|
|
* The header is title-left, meta-right. Meta is the keyed idiom — mono, small,
|
|
* tracked, dimmed — because it is always a count, a scope or an identifier,
|
|
* never prose.
|
|
*/
|
|
export function Panel({
|
|
title,
|
|
meta,
|
|
actions,
|
|
tone,
|
|
children,
|
|
bodyless,
|
|
className,
|
|
}: {
|
|
title?: string;
|
|
meta?: React.ReactNode;
|
|
actions?: React.ReactNode;
|
|
/** Draws the panel's own border in a state colour. For a panel that IS the warning. */
|
|
tone?: "warn" | "expired";
|
|
children: React.ReactNode;
|
|
/** Skip the padded body — for a panel whose content is a full-bleed table. */
|
|
bodyless?: boolean;
|
|
className?: string;
|
|
}) {
|
|
const head = title || meta || actions;
|
|
|
|
return (
|
|
<section
|
|
className={clsx(
|
|
"grid overflow-hidden rounded border bg-panel",
|
|
tone === "warn" ? "border-warn" : tone === "expired" ? "border-expired" : "border-rule",
|
|
className,
|
|
)}
|
|
>
|
|
{head && (
|
|
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft px-4 py-3">
|
|
{title && <h2 className="text-[0.95rem] font-bold tracking-[-0.01em]">{title}</h2>}
|
|
<div className="flex items-center gap-3">
|
|
{meta && <span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">{meta}</span>}
|
|
{actions}
|
|
</div>
|
|
</header>
|
|
)}
|
|
{bodyless ? children : <div className="grid gap-3.5 p-4">{children}</div>}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/*
|
|
* An aside that is part of the argument rather than beside it: the consequence
|
|
* of the action on screen, or the constraint the reader is about to hit. The
|
|
* left rule carries the tone, so the note reads as annotation and never as a
|
|
* second panel competing with the one it sits in.
|
|
*/
|
|
export function Note({ tone = "accent", children }: { tone?: "accent" | "warn" | "expired"; children: React.ReactNode }) {
|
|
return (
|
|
<p
|
|
className={clsx(
|
|
"rounded border border-rule border-l-[3px] bg-panel-2 px-3.5 py-2.5 text-[0.84rem] text-ink-2",
|
|
tone === "warn" ? "border-l-warn" : tone === "expired" ? "border-l-expired" : "border-l-accent",
|
|
)}
|
|
>
|
|
{children}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
/*
|
|
* An empty screen is an invitation to act. Every one of these says what the
|
|
* thing is before offering to make one — "No licences match those filters" on
|
|
* its own tells someone the filter worked, not what to do about it.
|
|
*/
|
|
export function EmptyState({ title, body, action }: { title: string; body?: React.ReactNode; action?: React.ReactNode }) {
|
|
return (
|
|
<div className="grid justify-items-center gap-2 px-5 py-12 text-center">
|
|
<p className="text-[1rem] font-bold">{title}</p>
|
|
{body && <p className="max-w-[46ch] text-[0.86rem] text-ink-2">{body}</p>}
|
|
{action && <div className="mt-2">{action}</div>}
|
|
</div>
|
|
);
|
|
}
|