Files
kjol/go/cmd/kjol-website/frontend/src/pages/Components.tsx

1667 lines
79 KiB
TypeScript

// /js/components — the whole Solid kit, on one page.
//
// It used to be three pages ("Components", "Forms", "AutoTable"), which is a split along
// the lines of the SOURCE FILES rather than along anything a reader wants: a person
// looking for a date picker does not know, and should not have to guess, whether it was
// filed under forms or under overlays. So: one page, one scroll, and a sidebar of groups
// that jumps you to the one you are after.
//
// The section ids come from componentGroups.ts, which is also what the sidebar renders —
// so the sidebar cannot offer a jump to a section that does not exist.
import { createSignal, For, JSXElement } from "solid-js";
import {
ButtonUI,
ButtonLink,
ButtonLinkRed,
BackLink,
SegmentedButtons,
BUTTON_COLOR_PRIMARY,
BUTTON_COLOR_NEUTRAL,
BUTTON_COLOR_WHITE,
BUTTON_COLOR_LIGHT_NEUTRAL,
BUTTON_COLOR_BLUE,
BUTTON_COLOR_GREEN,
BUTTON_COLOR_RED,
} from "@ui/Buttons";
import { Badge, BADGE_GREEN, BADGE_RED, BADGE_BLUE, BADGE_AMBER, BADGE_NEUTRAL, BADGE_MUTED } from "@ui/Badges";
import { AlertWhite, AlertGray, AlertBlue, AlertGreen, AlertRed, AlertYellow } from "@ui/Alerts";
import { EnvBadge } from "@ui/EnvBadge";
import { Card, BorderCard, BorderCutCornerCard, CardHeader, CardSubheader } from "@ui/Cards";
import { Divider, CodeBox, PageHeader, PageLink, Loader, Breadcrumbs, ManagerPageHeader } from "@ui/General";
import { Icon, IconInline, IconSuccess, IconError, IconContainer } from "@ui/Icons";
import {
FormInput,
FormLabel,
FormSelect,
FormTextarea,
FormFieldset,
FormFileInput,
FormEmailInput,
FormNumberInput,
FormCurrencyInput,
FormPercentInput,
FormPhoneInput,
FormZipCodeInput,
FormCombobox,
FormMultiSelect,
FormMultiSelectTrigger,
FormAsyncCombobox,
FormStateSelector,
FormTimezoneSelector,
FormSignaturePad,
US_STATES,
} from "@ui/Forms";
import { ToggleSwitch } from "@ui/ToggleSwitch";
import { isEmailValid } from "@ui/Validation";
import { Calendar } from "@ui/Calendar";
import { DatePicker, DateOfBirthPicker } from "@ui/DatePicker";
import { PrettyTable } from "@ui/PrettyTable";
import { CellGrid } from "@ui/CellGrid";
import AutoTable, {
AutoTableColumn,
AutoTableSearch,
AutoTableFilterFields,
TdLeft,
TdRight,
TdCenter,
COL_POS_LEFT,
COL_POS_RIGHT,
COL_POS_CENTER,
AUTOTABLE_SIZE_COMPACT,
} from "@ui/AutoTable";
import { Modal, ConfirmModal, WizardModal } from "@ui/Modal";
import { Tooltip } from "@ui/Tooltips";
import { Popover, PopoverTrigger, PopoverContent, HoverPopover, HoverPopoverTrigger, HoverPopoverContent } from "@ui/Popovers";
import { Menu, MenuTrigger, MenuContent, MenuItem, MenuDivider, Submenu } from "@ui/Menu";
import { ToastProvider, useToast } from "@ui/Toast";
import { TutorialProvider, StartTutorialButton, TutorialStep } from "@ui/Tutorial";
import { createRemoteFlash, RemoteUpdateFlash } from "@ui/RemoteUpdateFlash";
import { TabGroup } from "@ui/Tabs";
import { CrmTabGroup, CrmSubTabGroup } from "@ui/CrmTabs";
import { Accordion, SingleAccordion } from "@ui/Accordion";
import { SidebarNav } from "@ui/Sidebar";
import { FuzzyMatch } from "@ui/FuzzyMatch";
import { Chart, ChartSeries } from "@ui/Chart";
import { USHeatmap } from "@ui/USHeatmap";
import { ThemeToggle, useTheme } from "@ui/Theme";
import { Demo } from "../layout/Demo.tsx";
// ---- page scaffolding ----------------------------------------------------
// Section is one group. The id is what the sidebar jumps to and what the tour targets;
// scroll-mt-24 keeps the heading clear of the sticky nav once it gets there.
function Section(props: { id: string; title: string; children?: JSXElement }) {
return (
<section id={props.id} class="mt-12 scroll-mt-24">
<h2 class="text-xl font-semibold tracking-tight text-ink">{props.title}</h2>
{props.children}
</section>
);
}
function Prose(props: { children?: JSXElement }) {
return <p class="mt-3 leading-relaxed text-ink-soft">{props.children}</p>;
}
// Panel is a live demo with no source alongside it — for the components whose usage is
// obvious from looking at them. Demo (with code) is for the ones where it is not.
function Panel(props: { title: string; children?: JSXElement }) {
return (
<div class="mt-4 rounded-default border border-line bg-surface shadow-xs">
<div class="border-b border-line px-4 py-2">
<span class="text-ss text-ink-muted">{props.title}</span>
</div>
<div class="p-4">{props.children}</div>
</div>
);
}
function Field(props: { label: string; children?: JSXElement }) {
return (
<div class="flex flex-col gap-1">
<FormLabel>{props.label}</FormLabel>
{props.children}
</div>
);
}
const TOUR_STEPS: TutorialStep[] = [
{ title: "Buttons", target: "#buttons", content: "The presentational end: props in, an element out." },
{ title: "Overlays", target: "#overlays", content: "Measured against the real viewport, portaled out of the tree." },
{ title: "Tables", target: "#tables", content: "Filter, sort, calculate, export — from a column list." },
{ title: "That's the tour", content: "Escape ends it. Arrow keys and Enter move between steps." },
];
export function Components() {
// ToastProvider and TutorialProvider are the only two components in this kit that are
// PROVIDERS: useToast() and useTutorial() throw outside them. Everything else —
// modals, popovers, menus, the date picker — self-portals to document.body and needs
// no host mounted anywhere.
return (
<ToastProvider position="bottom-right">
<TutorialProvider steps={TOUR_STEPS}>
<Body />
</TutorialProvider>
</ToastProvider>
);
}
function Body() {
return (
<div>
<div class="border-b border-line pb-6">
<p class="text-ss font-semibold uppercase tracking-widest text-primary">Kjøl JS Web</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight text-ink">Components</h1>
<p class="mt-3 leading-relaxed text-ink-muted">
Every component in the Solid kit, running. Not a screenshot of one anywhere: each block
below is the real component, imported from <code class="font-mono">@ui/*</code> and rendered
on this page. Use the sidebar to jump to a group.
</p>
</div>
<Section id="using" title="Using a component">
<Prose>
They are ordinary Solid components: named exports, props in, an element out. Two things
catch people out. Handlers keep their DOM names —{" "}
<code class="font-mono">onclick</code>, <code class="font-mono">oninput</code>,{" "}
<code class="font-mono">onchange</code>, never <code class="font-mono">onClick</code>. And
many props accept a signal <em>getter</em> as well as a value, so you can pass{" "}
<code class="font-mono">value={"{"}name{"}"}</code> rather than{" "}
<code class="font-mono">value={"{"}name(){"}"}</code> and let the component track it.
</Prose>
<div class="mt-4 flex gap-2">
<StartTutorialButton>Take the tour</StartTutorialButton>
</div>
</Section>
<Buttons />
<Badges />
<CardsAndLayout />
<Icons />
<Forms />
<Selects />
<Toggles />
<Dates />
<Tables />
<Overlays />
<Feedback />
<Navigation />
<Search />
<Charts />
<Theming />
</div>
);
}
// ---- buttons -------------------------------------------------------------
function Buttons() {
const [clicks, setClicks] = createSignal(0);
const [span, setSpan] = createSignal("week");
return (
<Section id="buttons" title="Buttons">
<Prose>
Colour, outline, size and an optional icon. A button with an icon and no text gets square
padding rather than the wide pill a text button gets — so an icon button is a square, not a
lozenge with a picture rattling around in it.
</Prose>
<Panel title="Colours">
<div class="flex flex-wrap items-center gap-2">
<ButtonUI color={BUTTON_COLOR_PRIMARY}>Primary</ButtonUI>
<ButtonUI color={BUTTON_COLOR_GREEN}>Green</ButtonUI>
<ButtonUI color={BUTTON_COLOR_RED}>Red</ButtonUI>
<ButtonUI color={BUTTON_COLOR_BLUE}>Blue</ButtonUI>
<ButtonUI color={BUTTON_COLOR_NEUTRAL}>Neutral</ButtonUI>
<ButtonUI color={BUTTON_COLOR_WHITE}>White</ButtonUI>
<ButtonUI color={BUTTON_COLOR_LIGHT_NEUTRAL}>Light</ButtonUI>
</div>
</Panel>
<Panel title="Outline, size, icon, disabled">
<div class="flex flex-wrap items-center gap-2">
<ButtonUI color={BUTTON_COLOR_PRIMARY} outline>Outline</ButtonUI>
<ButtonUI color={BUTTON_COLOR_RED} outline>Danger</ButtonUI>
<ButtonUI color={BUTTON_COLOR_GREEN} small icon="check">
<Icon icon="check" size={12} /> Small + icon
</ButtonUI>
<ButtonUI color={BUTTON_COLOR_NEUTRAL} icon="plus">
<Icon icon="plus" size={14} />
</ButtonUI>
<ButtonUI color={BUTTON_COLOR_PRIMARY} disabled>Disabled</ButtonUI>
</div>
</Panel>
<Demo
title="Click it"
code={`<ButtonUI color={BUTTON_COLOR_PRIMARY} onclick={() => setClicks(clicks() + 1)}>
Clicked {clicks()} times
</ButtonUI>
// onclick, not onClick. These are DOM handler names.`}
>
<div class="flex flex-wrap items-center gap-3">
<ButtonUI color={BUTTON_COLOR_PRIMARY} onclick={() => setClicks(clicks() + 1)}>
Clicked {clicks()} times
</ButtonUI>
<ButtonLink onclick={() => setClicks(0)}>Reset (a link that is a button)</ButtonLink>
<ButtonLinkRed onclick={() => setClicks(0)}>And a destructive one</ButtonLinkRed>
<BackLink href="#using" text="Back link" />
</div>
</Demo>
<Panel title={"Segmented control — selected: " + span()}>
<SegmentedButtons
class="max-w-xs"
options={[
{ value: "day", label: "Day" },
{ value: "week", label: "Week" },
{ value: "month", label: "Month" },
]}
value={span}
onchange={setSpan}
/>
</Panel>
</Section>
);
}
// ---- badges & alerts -----------------------------------------------------
function Badges() {
return (
<Section id="badges" title="Badges & alerts">
<Prose>
A badge is a solid fill with white text, so it stays legible in both themes without a variant
on it. An alert is the opposite: a wash of colour behind dark text, which is exactly the case
a token cannot carry into dark mode — a red-50 tint is invisible on a near-black surface — so
alerts are one of only two places in the kit with a <code class="font-mono">dark:</code> rule.
</Prose>
<Panel title="Badges">
<div class="flex flex-wrap items-center gap-2">
<Badge color={BADGE_GREEN} pill>active</Badge>
<Badge color={BADGE_RED} pill>failed</Badge>
<Badge color={BADGE_BLUE}>info</Badge>
<Badge color={BADGE_AMBER} pill>pending</Badge>
<Badge color={BADGE_NEUTRAL}>default</Badge>
<Badge color={BADGE_MUTED}>muted</Badge>
</div>
</Panel>
<Panel title="Alerts">
<div class="space-y-3">
<AlertBlue header="Heads up">An informational message with a header.</AlertBlue>
<AlertGreen>A success alert, with no header.</AlertGreen>
<AlertYellow header="Warning">Something needs your attention.</AlertYellow>
<AlertRed header="Error">Something went wrong.</AlertRed>
<AlertGray>And a neutral one.</AlertGray>
<AlertWhite header="White">On the plain surface.</AlertWhite>
</div>
</Panel>
<Panel title="Environment badge">
{/* It is absolutely positioned (top-0 -right-2), so it needs a relative
ancestor to hang off — normally the logo. And it renders NOTHING in
production, which is the whole point of it: a badge that is always
there tells you nothing. */}
<div class="relative inline-block rounded-default border border-line px-4 py-2">
<span class="text-sm font-semibold text-ink">kjol</span>
<EnvBadge />
</div>
<p class="mt-3 text-ss text-ink-muted">
Nothing beside the wordmark? Then this build is production which is what it is telling
you.
</p>
</Panel>
</Section>
);
}
// ---- cards & layout ------------------------------------------------------
function CardsAndLayout() {
return (
<Section id="cards" title="Cards & layout">
<Prose>
The structural furniture: cards, headings, dividers, crumb trails, a spinner, a code box.
Nothing here holds state they are containers, and they take their children.
</Prose>
<Panel title="Cards">
<div class="grid gap-4 sm:grid-cols-3">
<Card>
<CardHeader>Card</CardHeader>
<p class="text-sm text-ink-soft">Padded, and grows to fill its row.</p>
</Card>
<BorderCard>
<CardSubheader>BorderCard</CardSubheader>
<p class="text-sm text-ink-soft">A border instead of a shadow.</p>
</BorderCard>
<BorderCutCornerCard>
<CardSubheader>Cut corner</CardSubheader>
<p class="text-sm text-ink-soft">The same, with a clipped corner.</p>
</BorderCutCornerCard>
</div>
</Panel>
<Panel title="Headers, dividers, crumbs">
<Breadcrumbs
items={[
{ url: "/", displayText: "kjol" },
{ url: "/js", displayText: "Kjøl JS Web" },
{ url: "/js/components", displayText: "Components" },
]}
/>
<PageHeader text="A page header" />
<Divider />
<ManagerPageHeader
title="With a description and an action"
description="The three-part page heading."
action={<ButtonUI color={BUTTON_COLOR_PRIMARY} small>New</ButtonUI>}
/>
</Panel>
<Panel title="Loader, code box, links">
<div class="flex flex-wrap items-center gap-6">
<Loader />
<PageLink href="https://solidjs.com" newTab class="text-primary underline underline-offset-4">
An external link (new tab)
</PageLink>
</div>
<CodeBox class="mt-3" code={"go run ./server -build\ngo run ./server"} />
</Panel>
</Section>
);
}
// ---- icons ---------------------------------------------------------------
const ICON_NAMES = [
"check", "xmark", "plus", "pen-to-square", "trash-can", "download", "print", "calendar",
"envelope", "globe", "user", "house", "table", "code", "star", "bell", "sun", "moon",
"magnifying-glass", "circle-info", "circle-check", "circle-exclamation", "triangle-exclamation",
"chevron-down", "bars", "copy", "sliders", "palette", "cog", "server", "bolt", "cube",
"chart-column", "table-columns", "list-ol", "grip-vertical", "phone", "arrow-right",
];
function Icons() {
return (
<Section id="icons" title="Icons">
<Prose>
FontAwesome, tree-shaken. The bundler scans the source for the names actually referenced,
pulls just those paths out of the SVG kit, and emits a registry containing only them
which is why this site ships a few dozen glyphs rather than FontAwesome's 41.5 MB kit.
</Prose>
<Prose>
A name nothing in the source references is not bundled; a name the kit has never heard of
renders an empty box and is reported by the bundler rather than shipping silently.
</Prose>
<Panel title="The registry">
<div class="grid grid-cols-4 gap-2 sm:grid-cols-8">
<For each={ICON_NAMES}>
{(name) => (
<div class="flex flex-col items-center gap-1.5 rounded-default border border-line px-2 py-3">
<Icon icon={name} size={18} class="text-ink-soft" />
<span class="font-mono text-[10px] text-ink-faint">{name}</span>
</div>
)}
</For>
</div>
</Panel>
<Panel title="Variants">
<div class="flex flex-wrap items-center gap-5">
<IconContainer>
<Icon icon="check" size={16} />
<span class="text-sm text-ink-soft">IconContainer</span>
</IconContainer>
<IconSuccess icon="circle-check" size={18} />
<IconError icon="circle-exclamation" size={18} />
<span class="inline-flex items-center gap-1.5 text-sm text-ink-soft">
<IconInline icon="bolt" size={14} /> IconInline sits on the text baseline
</span>
<Icon icon="star" size={18} solid />
<Icon icon="star" size={18} solid={false} />
</div>
<p class="mt-3 text-ss text-ink-muted">
The last two are the same name in the solid and regular styles. Which one you get by
default is a CSS variable the app sets, read at runtime.
</p>
</Panel>
</Section>
);
}
// ---- forms ---------------------------------------------------------------
function Forms() {
const [name, setName] = createSignal("");
const [email, setEmail] = createSignal("");
const [notes, setNotes] = createSignal("");
const [plan, setPlan] = createSignal("pro");
// Derived, not stored — so it cannot fall out of step with the field it describes.
// Blank is not "invalid", it is unfilled.
const emailError = () => (email() && !isEmailValid(email()) ? "That is not an email address." : "");
return (
<Section id="forms" title="Forms & inputs">
<Prose>
Inputs are controlled: the value goes in as a prop, the change comes out as a callback, and
the caller owns the state. The masked fields carry their behaviour inside the component — a
currency field will not let you type a letter into it — which is the only reason it is the
same in every app.
</Prose>
<Demo
title="Text, email, textarea, select"
code={`const emailError = () =>
email() && !isEmailValid(email()) ? "That is not an email address." : "";
<FormEmailInput
value={email()}
oninput={(e) => setEmail(e.currentTarget.value)}
error={emailError()}
showIcon
/>`}
>
<div class="grid gap-4 sm:grid-cols-2">
<Field label="Name">
<FormInput
placeholder="Ada Lovelace"
value={name()}
oninput={(e) => setName(e.currentTarget.value)}
/>
</Field>
<Field label="Email">
<FormEmailInput
placeholder="ada@example.com"
value={email()}
oninput={(e) => setEmail(e.currentTarget.value)}
error={emailError()}
showIcon
/>
</Field>
<Field label="Plan">
<FormSelect value={plan()} onchange={(e) => setPlan(e.currentTarget.value)}>
<option value="free">Free</option>
<option value="pro">Pro</option>
<option value="enterprise">Enterprise</option>
</FormSelect>
</Field>
<Field label="Notes">
<FormTextarea
rows={3}
placeholder="Anything worth remembering…"
value={notes()}
oninput={(e) => setNotes(e.currentTarget.value)}
/>
</Field>
</div>
</Demo>
<Panel title="Masked inputs — try typing letters into them">
<div class="grid gap-4 sm:grid-cols-3">
<Field label="Currency">
<FormCurrencyInput />
</Field>
<Field label="Percent">
<FormPercentInput />
</Field>
<Field label="Phone">
<FormPhoneInput />
</Field>
<Field label="Zip">
<FormZipCodeInput />
</Field>
<Field label="Whole number">
<FormNumberInput int unsigned placeholder="0" />
</Field>
<Field label="Attachment">
<FormFileInput />
</Field>
</div>
</Panel>
<Panel title="Fieldset">
<FormFieldset legend="Account">
<div class="grid gap-4 sm:grid-cols-2">
<Field label="Name">
<FormInput value={name()} oninput={(e) => setName(e.currentTarget.value)} />
</Field>
<Field label="Email (validated)">
<FormEmailInput
value={email()}
oninput={(e) => setEmail(e.currentTarget.value)}
error={emailError()}
showIcon
/>
</Field>
</div>
</FormFieldset>
</Panel>
</Section>
);
}
// ---- selects & comboboxes ------------------------------------------------
const LANGUAGES = [
{ value: "go", label: "Go" },
{ value: "rust", label: "Rust" },
{ value: "ts", label: "TypeScript" },
{ value: "python", label: "Python" },
{ value: "kotlin", label: "Kotlin" },
{ value: "swift", label: "Swift" },
];
const PEOPLE = [
{ value: "ada@example.com", label: "Ada Lovelace" },
{ value: "alan@example.com", label: "Alan Turing" },
{ value: "grace@example.com", label: "Grace Hopper" },
{ value: "katherine@example.com", label: "Katherine Johnson" },
{ value: "margaret@example.com", label: "Margaret Hamilton" },
{ value: "radia@example.com", label: "Radia Perlman" },
];
function Selects() {
const [one, setOne] = createSignal("");
const [many, setMany] = createSignal<string[]>(["go"]);
const [tags, setTags] = createSignal<string[]>(["go"]);
const [state, setState] = createSignal("");
const [tz, setTz] = createSignal("");
const [picked, setPicked] = createSignal("");
return (
<Section id="selects" title="Selects & comboboxes">
<Prose>
A combobox picks one; a multi-select picks several, with checkboxes on the rows and removable
pills in the field. Past three selections — or once the pills stop fitting — the field
collapses to "N items selected" rather than growing until it wraps.
</Prose>
<Panel title="Combobox, multi-select, and the built-in selectors">
<div class="grid gap-4 sm:grid-cols-2">
<Field label="Language (searchable, one)">
<FormCombobox
options={LANGUAGES}
value={one()}
onchange={setOne}
searchable
placeholder="Pick one"
/>
</Field>
<Field label="Languages (several)">
<FormMultiSelect
options={LANGUAGES}
value={many}
onchange={setMany}
searchable
showSelectAll
placeholder="Pick a few"
/>
</Field>
<Field label="State">
<FormCombobox options={US_STATES} value={state()} onchange={setState} searchable />
</Field>
<Field label="Timezone">
<FormTimezoneSelector value={tz()} onchange={(e: any) => setTz(e?.currentTarget?.value ?? e)} />
</Field>
</div>
<p class="mt-3 text-ss text-ink-muted">
one: <span class="font-mono text-ink">{one() || "—"}</span> · several:{" "}
<span class="font-mono text-ink">{many().join(", ") || "—"}</span>
</p>
</Panel>
<Panel title={"Async combobox — picked: " + (picked() || "nothing yet")}>
<div class="max-w-sm">
{/* The search is the CALLER's. The component knows how to debounce, order
and render; it knows nothing about where options come from. Here it is a
local array; in an app it would be a fetch. */}
<FormAsyncCombobox
options={[]}
placeholder="Search people…"
minChars={2}
onSearch={(q) =>
PEOPLE.filter((p) => p.label.toLowerCase().includes(q.toLowerCase()))
}
onSelect={(value, option) => setPicked(option.label + " <" + value + ">")}
/>
</div>
<p class="mt-3 text-ss text-ink-muted">
Two characters before it asks; 200 ms after you stop typing. A response for a query you
have already typed past is DISCARDED rather than shown which is the whole bug with
hand-rolled autocompletes.
</p>
</Panel>
<Panel title="Multi-select behind your own trigger">
<FormMultiSelectTrigger
trigger={
<ButtonUI color={BUTTON_COLOR_LIGHT_NEUTRAL} small>
<Icon icon="sliders" size={12} /> Tags ({tags().length})
</ButtonUI>
}
options={LANGUAGES}
value={tags}
onchange={setTags}
searchable
showSelectAll
/>
<p class="mt-3 text-ss text-ink-muted">
Same selection model as the field above; only the thing you click on differs.
</p>
</Panel>
</Section>
);
}
// ---- toggles & signature -------------------------------------------------
function Toggles() {
const [notify, setNotify] = createSignal(true);
const [locked, setLocked] = createSignal(false);
const [signed, setSigned] = createSignal("");
return (
<Section id="toggles" title="Toggles & signature">
<Prose>
A toggle is a checkbox that admits what it is there is no{" "}
<code class="font-mono">FormCheckbox</code> in this kit, and that is deliberate. The
signature pad hands you back the drawing as an SVG string.
</Prose>
<Panel title="Toggles">
<div class="flex flex-col gap-4">
<ToggleSwitch
checked={notify}
onchange={setNotify}
label="Email notifications"
description="At most one message a day."
/>
<ToggleSwitch
checked={locked}
onchange={setLocked}
label="Locked"
description="This one is disabled."
disabled
/>
</div>
</Panel>
<Panel title={"Signature pad — " + signed().length + " bytes of SVG"}>
<FormSignaturePad onchange={setSigned} />
<p class="mt-3 text-ss text-ink-muted">
Draw in it. Clearing it emits an empty string, so "did they sign?" is just a length check.
</p>
</Panel>
</Section>
);
}
// ---- dates ---------------------------------------------------------------
function Dates() {
const [date, setDate] = createSignal("");
const [dob, setDob] = createSignal("");
const [day, setDay] = createSignal("");
return (
<Section id="dates" title="Dates">
<Prose>
The field is typeable, not merely clickable, and it parses loosely. A picker you can only
click is a picker that is slower than the keyboard for everyone who already knows the date.
</Prose>
<Panel title={"Pickers — picked: " + (date() || "nothing")}>
<div class="grid gap-4 sm:grid-cols-2">
<Field label="Date (portaled; flips near the bottom)">
<DatePicker value={date()} onchange={setDate} clearable placeholder="Pick a date" />
</Field>
<Field label="Date of birth (inline, month/year selects)">
<DateOfBirthPicker value={dob()} onchange={setDob} />
</Field>
</div>
</Panel>
<Panel title={"The calendar on its own — selected: " + (day() || "none")}>
<div class="max-w-xs">
<Calendar selected={day()} onSelect={setDay} />
</div>
<p class="mt-3 text-ss text-ink-muted">
The same grid the picker drops down, usable directly when you want it inline. Pass{" "}
<code class="font-mono">variant="month"</code> for the big version.
</p>
</Panel>
</Section>
);
}
// ---- tables --------------------------------------------------------------
interface Institution {
name: string;
state: string;
term: string;
rate: number;
minimum: number;
status: "open" | "closed" | "waitlist";
}
const ROWS: Institution[] = [
{ name: "First Meridian Bank", state: "CA", term: "90 day", rate: 4.85, minimum: 1000, status: "open" },
{ name: "Harborline Credit Union", state: "WA", term: "180 day", rate: 5.1, minimum: 2500, status: "open" },
{ name: "Cascade Federal", state: "OR", term: "1 year", rate: 5.35, minimum: 500, status: "waitlist" },
{ name: "Ironwood Savings", state: "IL", term: "90 day", rate: 4.6, minimum: 10000, status: "closed" },
{ name: "Great Lakes Trust", state: "MI", term: "2 year", rate: 5.55, minimum: 1000, status: "open" },
{ name: "Sunbelt National", state: "TX", term: "180 day", rate: 4.95, minimum: 5000, status: "open" },
{ name: "Granite State Bank", state: "NH", term: "1 year", rate: 5.2, minimum: 2000, status: "waitlist" },
{ name: "Pacific Crest", state: "CA", term: "5 year", rate: 5.75, minimum: 25000, status: "open" },
{ name: "Copper Ridge Bank", state: "AZ", term: "90 day", rate: 4.4, minimum: 1000, status: "closed" },
{ name: "Bayou Community", state: "LA", term: "1 year", rate: 5.05, minimum: 1500, status: "open" },
{ name: "Northern Pine FCU", state: "MN", term: "2 year", rate: 5.45, minimum: 500, status: "open" },
{ name: "Chesapeake First", state: "MD", term: "180 day", rate: 4.75, minimum: 3000, status: "waitlist" },
];
// The whole AutoTable is this list. Sorting, column ordering, hiding, resizing and CSV
// export are all driven from it — there is no per-column wiring anywhere else.
const COLUMNS: AutoTableColumn[] = [
{ displayName: "Institution", sortable: true, sortIdentifier: "name", displayPosition: COL_POS_LEFT },
{ displayName: "State", sortable: true, sortIdentifier: "state", displayPosition: COL_POS_CENTER, toggleable: true },
{ displayName: "Term", sortable: true, sortIdentifier: "term", displayPosition: COL_POS_LEFT },
{
displayName: "Rate", sortable: true, sortIdentifier: "rate", sortType: "numeric",
displayPosition: COL_POS_RIGHT, csvValue: (i: Institution) => i.rate,
},
{
displayName: "Minimum", sortable: true, sortIdentifier: "minimum", sortType: "money",
displayPosition: COL_POS_RIGHT, toggleable: true, csvValue: (i: Institution) => i.minimum,
},
{ displayName: "Status", sortable: true, sortIdentifier: "status", displayPosition: COL_POS_CENTER },
];
const money = (n: number) => "$" + n.toLocaleString("en-US");
function StatusBadge(props: { status: Institution["status"] }) {
if (props.status === "open") return <Badge color={BADGE_GREEN} pill>open</Badge>;
if (props.status === "closed") return <Badge color={BADGE_RED} pill>closed</Badge>;
return <Badge color={BADGE_NEUTRAL} pill>waitlist</Badge>;
}
const GRID_SEED = [
{ id: "1", sku: "KJ-100", qty: 12, price: 4.5 },
{ id: "2", sku: "KJ-220", qty: 3, price: 18 },
{ id: "3", sku: "KJ-330", qty: 47, price: 1.25 },
];
function Tables() {
const [gridRows, setGridRows] = createSignal(GRID_SEED.map((r) => ({ ...r })));
const [sortKey, setSortKey] = createSignal<string | null>("sku");
const [sortDesc, setSortDesc] = createSignal(false);
return (
<Section id="tables" title="Tables">
<Prose>
Three of them, and the difference is what the user is allowed to do. PrettyTable prints rows.
CellGrid lets them be edited in place. AutoTable filters, sorts, pages, reorders, resizes and
exports configured with a column list and an array of rows.
</Prose>
<Panel title="PrettyTable — it prints rows, and that is all">
<PrettyTable
columns={[
{ displayName: "Name" },
{ displayName: "Plan" },
{ displayName: "Status", displayPosition: COL_POS_RIGHT },
]}
options={{ hover: true, alternate: true, surroundingBorder: true, headerBorderY: true }}
>
<tr>
<TdLeft>Ada Lovelace</TdLeft>
<TdLeft>Pro</TdLeft>
<TdRight><Badge color={BADGE_GREEN} pill>active</Badge></TdRight>
</tr>
<tr>
<TdLeft>Alan Turing</TdLeft>
<TdLeft>Free</TdLeft>
<TdRight><Badge color={BADGE_NEUTRAL} pill>trial</Badge></TdRight>
</tr>
<tr>
<TdLeft>Grace Hopper</TdLeft>
<TdLeft>Enterprise</TdLeft>
<TdRight><Badge color={BADGE_BLUE} pill>invited</Badge></TdRight>
</tr>
</PrettyTable>
</Panel>
<Panel title="CellGrid — editable cells">
<CellGrid
columns={[
{ key: "sku", label: "SKU", sortKey: "sku" },
{ key: "qty", label: "Qty", sortKey: "qty", sortType: "numeric", editable: true, inputMode: "numeric" },
{ key: "price", label: "Price", sortKey: "price", sortType: "numeric", editable: true, inputMode: "decimal" },
]}
rows={gridRows()}
initialRows={GRID_SEED}
idField="id"
onCellChange={(id, field, value) =>
setGridRows((rows) => rows.map((r) => (r.id === id ? { ...r, [field]: value } : r)))
}
sortKey={sortKey()}
setSortKey={setSortKey}
sortDesc={sortDesc()}
setSortDesc={setSortDesc}
/>
<p class="mt-3 text-ss text-ink-muted">
Click a Qty or Price cell and type; Tab and the arrow keys move between editable cells.
The grid does not own the rows it tells you which cell changed and hands the value back.
</p>
</Panel>
<Prose>
AutoTable is the big one. Sort by clicking a header, drag a header to reorder it, use the
toolbar to hide a column or export what you are looking at. The column layout is keyed to
localStorage, so it survives a reload.
</Prose>
<div class="mt-4">
<AutoTable
data={ROWS}
columns={COLUMNS}
emptyMessage="No institutions match those filters."
options={{
size: AUTOTABLE_SIZE_COMPACT,
hover: true,
alternate: true,
surroundingBorder: true,
headerBorderY: true,
draggableColumns: true,
toggleColumns: true,
resizableColumns: true,
resetButton: true,
exportCSV: true,
exportFilename: "kjol-rates",
inlineToolbar: true,
columnOrderStorageKey: "kjolweb.js.table.order",
columnVisibilityStorageKey: "kjolweb.js.table.visible",
columnWidthStorageKey: "kjolweb.js.table.widths",
}}
searchFields={(ctx) => (
<AutoTableFilterFields>
<AutoTableSearch
label="Institution"
placeholder="Search by name…"
value={ctx.getSearchValue("name")}
onchange={(v) => ctx.setSearchValue("name", v)}
/>
<AutoTableSearch
label="State"
placeholder="CA"
value={ctx.getSearchValue("state")}
onchange={(v) => ctx.setSearchValue("state", v)}
/>
</AutoTableFilterFields>
)}
rowRenderer={(item: Institution) => (
<>
<TdLeft class="font-medium text-ink">{item.name}</TdLeft>
<TdCenter>{item.state}</TdCenter>
<TdLeft>{item.term}</TdLeft>
<TdRight class="font-mono">{item.rate.toFixed(2)}%</TdRight>
<TdRight class="font-mono">{money(item.minimum)}</TdRight>
<TdCenter><StatusBadge status={item.status} /></TdCenter>
</>
)}
/>
</div>
<AlertBlue header="Local rows, or a server" class="mt-6">
This table is passed <code class="font-mono">data</code>. Give it{" "}
<code class="font-mono">url</code> instead and the same column list drives a server-side
query the sort identifier becomes the sort key, the search fields become query parameters,
and pagination is handled for you. Nothing else on the page changes.
</AlertBlue>
</Section>
);
}
// ---- overlays ------------------------------------------------------------
function Overlays() {
const toast = useToast();
const [modalOpen, setModalOpen] = createSignal(false);
const [nestedOpen, setNestedOpen] = createSignal(false);
const [confirmOpen, setConfirmOpen] = createSignal(false);
const [wizardOpen, setWizardOpen] = createSignal(false);
const [deleted, setDeleted] = createSignal(false);
const [wizardName, setWizardName] = createSignal("");
return (
<Section id="overlays" title="Overlays">
<Prose>
Tooltips, popovers, menus and modals every one measured against the real viewport. A
floating panel portals itself to <code class="font-mono">document.body</code>, positions from
its trigger's bounding box, and flips or shifts when it would otherwise run off the screen.
</Prose>
<Prose>
None of them needs a host or a portal root mounted anywhere. That is the difference from a
modal library that makes you remember a <code class="font-mono">&lt;div id="portal"&gt;</code>{" "}
at the bottom of your index.html.
</Prose>
<Panel title="Tooltips — hover and focus">
<div class="flex flex-wrap items-center gap-3">
<Tooltip content="Above — the default">
<ButtonUI color={BUTTON_COLOR_LIGHT_NEUTRAL}>Top</ButtonUI>
</Tooltip>
<Tooltip content="To the right, unless it would run off the edge" placement="right">
<ButtonUI color={BUTTON_COLOR_LIGHT_NEUTRAL}>Right</ButtonUI>
</Tooltip>
<Tooltip content="No open delay" delay={0}>
<ButtonUI color={BUTTON_COLOR_LIGHT_NEUTRAL}>Instant</ButtonUI>
</Tooltip>
<Tooltip content="Shown on FOCUS, not hover — tab to the field" trigger="focus">
<FormInput placeholder="Focus me" />
</Tooltip>
</div>
<p class="mt-3 text-ss text-ink-muted">
A tooltip that only answers to a mouse is a tooltip a keyboard user cannot read.
</p>
</Panel>
<Panel title="Popovers — click, alignment, and a hover bridge">
<div class="flex flex-wrap items-center gap-3">
<Popover>
<PopoverTrigger>
<ButtonUI color={BUTTON_COLOR_PRIMARY}>Click me</ButtonUI>
</PopoverTrigger>
<PopoverContent class="w-64">
<p class="text-sm text-ink-soft">
Click outside, or press Escape. Only the TOPMOST floating panel closes per press.
</p>
</PopoverContent>
</Popover>
<Popover placement="bottom-end">
<PopoverTrigger>
<ButtonUI color={BUTTON_COLOR_LIGHT_NEUTRAL}>Aligned to my right edge</ButtonUI>
</PopoverTrigger>
<PopoverContent class="w-56">
<p class="text-sm text-ink-soft">Placement bottom-end.</p>
</PopoverContent>
</Popover>
<HoverPopover placement="top" hoverCloseDelay={300}>
<HoverPopoverTrigger>
<ButtonUI color={BUTTON_COLOR_BLUE} outline>Hover, then reach the panel</ButtonUI>
</HoverPopoverTrigger>
<HoverPopoverContent class="w-64">
<p class="text-sm text-ink-soft">
Move the cursor across the gap and onto this panel — it stays open. Select this
text to prove it. Without that grace period the panel would close in the dead
space between the two, which is what happens once a panel is portaled and CSS
:hover no longer reaches it.
</p>
</HoverPopoverContent>
</HoverPopover>
</div>
</Panel>
<Panel title="Menus & submenus">
<div class="flex flex-wrap items-center gap-3">
<Menu>
<MenuTrigger>
<ButtonUI color={BUTTON_COLOR_WHITE}>
Actions <Icon icon="chevron-down" size={11} />
</ButtonUI>
</MenuTrigger>
<MenuContent>
<MenuItem icon="check" onclick={() => toast.success("Profile opened")}>Profile</MenuItem>
<MenuItem onclick={() => toast.info("Settings opened")}>Settings</MenuItem>
<Submenu trigger="More" icon="copy">
<MenuItem onclick={() => toast.info("Archived")}>Archive</MenuItem>
<MenuItem onclick={() => toast.warning("Duplicated")}>Duplicate</MenuItem>
</Submenu>
<MenuDivider />
<MenuItem closeOnClick={false} onclick={() => toast.generic("Menu stayed open")}>
Stay open (closeOnClick={"{false}"})
</MenuItem>
<MenuItem onclick={() => toast.error("Signed out")}>Sign out</MenuItem>
</MenuContent>
</Menu>
<Menu openOnHover>
<MenuTrigger>
<ButtonUI color={BUTTON_COLOR_LIGHT_NEUTRAL}>Opens on hover</ButtonUI>
</MenuTrigger>
<MenuContent>
<MenuItem>One</MenuItem>
<MenuItem>Two</MenuItem>
</MenuContent>
</Menu>
</div>
<p class="mt-3 text-ss text-ink-muted">
The items raise toasts, which is how you can see that an item really does close its own
menu — and that the one marked closeOnClick={"{false}"} does not.
</p>
</Panel>
<Panel title={"Modals — deleted: " + deleted()}>
<div class="flex flex-wrap items-center gap-3">
<ButtonUI color={BUTTON_COLOR_PRIMARY} onclick={() => setModalOpen(true)}>Open modal</ButtonUI>
<ButtonUI color={BUTTON_COLOR_RED} outline onclick={() => setConfirmOpen(true)}>
Delete something…
</ButtonUI>
<ButtonUI color={BUTTON_COLOR_BLUE} outline onclick={() => setWizardOpen(true)}>
Open wizard
</ButtonUI>
</div>
<p class="mt-3 text-ss text-ink-muted">
Open the modal, then the nested one inside it, and press Escape twice: modals unwind ONE
LAYER per press rather than all at once.
</p>
<Modal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
header={<h3 class="text-lg font-semibold">A modal</h3>}
footer={
<ButtonUI color={BUTTON_COLOR_NEUTRAL} onclick={() => setModalOpen(false)}>Close</ButtonUI>
}
>
<p class="text-sm leading-relaxed text-ink-soft">
Portaled to <code class="font-mono">document.body</code>, so no ancestor's{" "}
<code class="font-mono">overflow: hidden</code> or transform can clip it the two
things that silently clip a floating panel.
</p>
<div class="mt-4">
<ButtonUI color={BUTTON_COLOR_BLUE} outline onclick={() => setNestedOpen(true)}>
Open a nested modal
</ButtonUI>
</div>
</Modal>
<Modal
isOpen={nestedOpen}
onClose={() => setNestedOpen(false)}
size="small"
header={<h3 class="text-lg font-semibold">Nested</h3>}
>
<p class="text-sm text-ink-soft">Escape closes THIS one first, not the one behind it.</p>
</Modal>
<ConfirmModal
isOpen={confirmOpen}
onClose={() => setConfirmOpen(false)}
onConfirm={() => {
setDeleted(true);
toast.error("Row deleted");
}}
title="Delete row"
message="This cannot be undone. (Nothing is actually deleted — this is a docs page.)"
confirmText="Delete"
/>
<WizardModal
isOpen={wizardOpen}
onClose={() => setWizardOpen(false)}
onComplete={() => toast.success("Wizard complete: " + (wizardName() || "nobody"))}
title="Set up your account"
finishText="Finish"
steps={[
{
title: "Your name",
// Each step gets its own context: setCanContinue gates THIS step's
// Next button, which one shared boolean could not express.
content: (ctx) => {
ctx.setCanContinue(wizardName() !== "");
return (
<Field label="Name (required to continue)">
<FormInput
value={wizardName()}
placeholder="Ada Lovelace"
oninput={(e) => setWizardName(e.currentTarget.value)}
/>
</Field>
);
},
},
{
title: "Confirm",
content: (ctx) => {
ctx.setCanContinue(true);
return (
<p class="text-sm text-ink-soft">
All set for {wizardName() || "nobody"}. Finish to close.
</p>
);
},
},
]}
/>
</Panel>
</Section>
);
}
// ---- feedback ------------------------------------------------------------
function Feedback() {
const toast = useToast();
const flash = createRemoteFlash(2000);
return (
<Section id="feedback" title="Toasts & tours">
<Prose>
Toasts dismiss themselves after five seconds, with a bar counting down. A sticky one (
<code class="font-mono">duration: null</code>) waits for the user instead.
</Prose>
<Panel title="Push, dismiss, and a sticky one">
<div class="flex flex-wrap items-center gap-2">
<ButtonUI color={BUTTON_COLOR_GREEN} small onclick={() => toast.success("Saved.")}>
Success
</ButtonUI>
<ButtonUI color={BUTTON_COLOR_RED} small onclick={() => toast.error("Something went wrong.")}>
Error
</ButtonUI>
<ButtonUI color={BUTTON_COLOR_BLUE} small onclick={() => toast.info("Just so you know.")}>
Info
</ButtonUI>
<ButtonUI
color={BUTTON_COLOR_LIGHT_NEUTRAL}
small
onclick={() =>
toast.addToast({
message: "This one waits for you to dismiss it.",
type: "warning",
duration: null,
})
}
>
Sticky (no timer)
</ButtonUI>
</div>
<p class="mt-3 text-ss text-ink-muted">
<code class="font-mono">useToast()</code> throws outside a{" "}
<code class="font-mono">&lt;ToastProvider&gt;</code> which is one of only two providers
this kit has.
</p>
</Panel>
<Panel title="The guided tour">
<div class="flex flex-wrap items-center gap-3">
<StartTutorialButton>Take the tour</StartTutorialButton>
<span class="text-ss text-ink-muted">
It dims the page, cuts a hole around each target, and animates the spotlight from one
to the next. Targets are CSS SELECTORS the same section ids the sidebar jumps to.
</span>
</div>
</Panel>
<Panel title="Remote update flash">
<div class="flex flex-wrap items-center gap-3">
<ButtonUI color={BUTTON_COLOR_LIGHT_NEUTRAL} small onclick={flash.fire}>
Something changed elsewhere
</ButtonUI>
<RemoteUpdateFlash when={flash.visible()} />
</div>
<p class="mt-3 text-ss text-ink-muted">
A brief acknowledgement that data you are looking at was changed by somebody else. It is
not a toast: it belongs next to the thing that moved, not in the corner.
</p>
</Panel>
</Section>
);
}
// ---- navigation ----------------------------------------------------------
function Navigation() {
const [side, setSide] = createSignal("buttons");
const panel = (s: string) => <p class="pt-3 text-sm text-ink-soft">{s}</p>;
return (
<Section id="navigation" title="Tabs & navigation">
<Prose>
Tabs, an accordion, and a sidebar of jump links. Tabs can persist their active index to
localStorage with <code class="font-mono">storageKey</code>, and sync it across tabs of the
browser via storage events which is either delightful or alarming, so it is opt-in.
</Prose>
<Panel title="Tabs">
<TabGroup
items={[
{ title: "Overview", content: panel("The overview panel.") },
{ title: "Details", content: panel("The details panel.") },
{ title: "Activity", badge: 3, content: panel("The activity panel (3 new).") },
]}
/>
</Panel>
<Panel title="CRM tabs — the same thing, wearing a different suit">
<div class="flex flex-col gap-4">
<CrmTabGroup
items={[
{ title: "Contacts", badge: 12, content: panel("A contacts list would go here.") },
{ title: "Deals", badge: 3, content: panel("And the deals.") },
{ title: "Notes", content: panel("And the notes.") },
]}
/>
<CrmSubTabGroup
items={[
{ title: "All", content: panel("The sub-tab strip: smaller, for nesting inside a tab.") },
{ title: "Mine", content: panel("Mine.") },
]}
/>
</div>
</Panel>
<Panel title="Accordion — one open at a time, or several">
<div class="grid gap-6 lg:grid-cols-2">
<div class="flex flex-col gap-2">
<p class="text-ss font-semibold uppercase tracking-widest text-ink-faint">
SingleAccordion
</p>
<SingleAccordion
items={[
{ title: "What is Kjøl JS Web?", content: panel("The Solid kit, built by a Go toolchain.") },
{ title: "Is there a Node build?", content: panel("No. TSX → Solid → esbuild, all in Go.") },
{ title: "How is it styled?", content: panel("Tailwind v4, compiled by kjol/tw.") },
]}
/>
</div>
<div class="flex flex-col gap-2">
<p class="text-ss font-semibold uppercase tracking-widest text-ink-faint">
Accordion (several at once)
</p>
<Accordion
items={[
{ title: "First", content: panel("Open me.") },
{ title: "Second", content: panel("And me, at the same time.") },
{ title: "Third", content: panel("And me.") },
]}
/>
</div>
</div>
</Panel>
<Panel title={"Sidebar nav — clicked: " + side()}>
<div class="max-w-xs">
<SidebarNav
items={[
{ id: "buttons", label: "Buttons", icon: <Icon icon="check" size={14} /> },
{ id: "forms", label: "Forms", icon: <Icon icon="pen-to-square" size={14} /> },
{
id: "tables",
label: "Tables",
icon: <Icon icon="table" size={14} />,
children: [
{ id: "tables", label: "AutoTable" },
{ id: "overlays", label: "Overlays" },
],
},
]}
onItemClick={setSide}
/>
</div>
<p class="mt-3 text-ss text-ink-muted">
It scrolls the element whose <code class="font-mono">id</code> matches the item into view
so these really do jump, because those sections really do exist on this page.
</p>
</Panel>
</Section>
);
}
// ---- fuzzy search --------------------------------------------------------
const KIT_NAMES = [
"AutoTable", "Accordion", "Alert", "Badge", "ButtonUI", "Calendar", "CellGrid", "FormCombobox",
"DatePicker", "FormInput", "Menu", "Modal", "FormMultiSelect", "Popover", "PrettyTable",
"SegmentedButtons", "FormSignaturePad", "TabGroup", "ThemeToggle", "ToastProvider", "ToggleSwitch",
"Tooltip", "TutorialProvider", "SidebarNav", "Chart", "FuzzyMatch",
];
// Both "and" and "&" spellings appear here on purpose: with andAmpersand on, typing
// either finds both, and the exact spelling ranks above the substituted one.
// "Standard" / "Brand" hold an "and" that is NOT the whole word — left untouched.
const AND_AMP_NAMES = [
"First Bank & Trust",
"First Bank and Trust Company",
"Smith & Wesson Financial",
"Johnson and Johnson Federal CU",
"Highland Savings & Loan",
"Standard Chartered",
"Brand Mortgage Group",
"AT&T Employees CU",
];
function Search() {
const [hit, setHit] = createSignal("");
return (
<Section id="search" title="Fuzzy search">
<Prose>
Subsequence matching with a typo tolerance, scored so the best hit sorts first, and the
matched characters highlighted in the result. "atbl" finds AutoTable; so does "autotbale",
which is what you actually typed.
</Prose>
<Panel title={"Type into it — picked: " + (hit() || "nothing")}>
<div class="max-w-md">
<FuzzyMatch
options={KIT_NAMES}
placeholder="Search the kit…"
maxResults={6}
showScores
onSelect={(value) => setHit(value)}
/>
</div>
<p class="mt-3 text-ss text-ink-muted">
The scorer is headless too: <code class="font-mono">rankFuzzyMatches</code> and{" "}
<code class="font-mono">fuzzySegments</code> give you the ranking and the highlight runs,
and you render them however you like.
</p>
</Panel>
<Prose>
Pass <code class="font-mono">andAmpersand</code> and the word "and" and the symbol "&amp;"
match each other, so "First Bank and Trust" also finds "First Bank &amp; Trust". The exact
spelling still wins the substituted form is a penalized extra pass, not a free swap and a
stray "and" inside "Standard" or "Brand" is left alone.
</Prose>
<Panel title="andAmpersand — type “first bank and trust”, or “smith & wesson”">
<div class="max-w-md">
<FuzzyMatch
options={AND_AMP_NAMES}
andAmpersand
placeholder="Search bank names…"
maxResults={6}
showScores
/>
</div>
</Panel>
</Section>
);
}
// ---- charts --------------------------------------------------------------
const CHART_DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
// A made-up per-state metric for the choropleth, and a few cities (lat/lng) to drop on
// top of it — Anchorage and Honolulu included, to land on albersUsa's AK/HI insets.
const US_SIGNUPS: Record<string, number> = {
CA: 4820, TX: 3910, NY: 3120, FL: 2870, IL: 1740, PA: 1610, OH: 1490, GA: 1450,
NC: 1360, MI: 1280, WA: 1230, AZ: 1180, MA: 1120, VA: 1090, CO: 980, TN: 940,
NJ: 910, OR: 720, MN: 690, WI: 610, MO: 560, MD: 540, IN: 520, NV: 480,
UT: 430, AL: 390, SC: 360, KY: 310, LA: 300, OK: 280, CT: 260, IA: 210,
KS: 180, AK: 140, HI: 160, ME: 120, MT: 90, WY: 60, ND: 70, SD: 80,
};
const US_CITIES = [
{ label: "Seattle", lat: 47.6062, lng: -122.3321, value: 1230 },
{ label: "San Francisco", lat: 37.7749, lng: -122.4194, value: 2110 },
{ label: "Denver", lat: 39.7392, lng: -104.9903, value: 980 },
{ label: "Chicago", lat: 41.8781, lng: -87.6298, value: 1740 },
{ label: "New York", lat: 40.7128, lng: -74.006, value: 3120 },
{ label: "Miami", lat: 25.7617, lng: -80.1918, value: 1460 },
{ label: "Anchorage", lat: 61.2181, lng: -149.9003, value: 140 },
{ label: "Honolulu", lat: 21.3069, lng: -157.8583, value: 160 },
];
function Charts() {
const [seed, setSeed] = createSignal(0);
const [threeD, setThreeD] = createSignal(false);
const [depth, setDepth] = createSignal(16);
const [tilt, setTilt] = createSignal(0.6);
// Two series over the same week. seed() reshuffles them so you can watch the SVG
// move — no teardown, no new instance, just the marks that changed.
const shuffle = (base: number[]) =>
base.map((v) => (seed() === 0 ? v : Math.max(4, (v * 7 + seed() * 13) % 80)));
const requests = (): ChartSeries => ({ name: "Requests", data: shuffle([42, 17, 63, 28, 55, 9, 71]) });
const errors = (): ChartSeries => ({ name: "Errors", data: shuffle([8, 3, 12, 6, 9, 2, 14]) });
const pieData = (): ChartSeries => ({ name: "Traffic", data: shuffle([40, 25, 20, 15, 8]) });
const pieLabels = ["Direct", "Search", "Social", "Email", "Referral"];
const regions = ["East", "Central", "Mountain", "Pacific"];
return (
<Section id="charts" title="Charts">
<Prose>
<code class="font-mono">@ui/Chart</code> draws SVG no charting library, nothing vendored, no{" "}
<code class="font-mono">&lt;canvas&gt;</code>. Because the picture is JSX it is already reactive:
change <code class="font-mono">series</code> and Solid re-renders the marks that moved. There is
no <code class="font-mono">update()</code> to call.
</Prose>
<Prose>
Being SVG buys two things a canvas cannot. The marks name the theme tokens
(<code class="font-mono">var(--color-chart-1)</code> ), so the palette inverts for dark mode with
the rest of the site a canvas paints pixels and cannot read a CSS variable. And every mark is a
real element, so hovering shows a crosshair and one tooltip listing every series at that point.
</Prose>
<Panel title="Grouped bars, a smooth area, a two-line series, and a donut — from one data set">
<div class="grid gap-6 lg:grid-cols-12">
<div class="lg:col-span-7">
<Chart kind="bar" title="Requests & errors this week" labels={CHART_DAYS} series={[requests(), errors()]} height={260} threeD={threeD()} />
</div>
<div class="lg:col-span-5">
<Chart kind="donut" title="Traffic by source" labels={pieLabels} series={[pieData()]} height={260} threeD={threeD()} />
</div>
<div class="lg:col-span-7">
<Chart kind="area" curve="smooth" title="Requests, smoothed" labels={CHART_DAYS} series={[requests()]} height={220} threeD={threeD()} />
</div>
<div class="lg:col-span-5">
<Chart kind="line" title="Requests & errors" labels={CHART_DAYS} series={[requests(), errors()]} height={220} threeD={threeD()} />
</div>
</div>
<div class="mt-4 flex items-center gap-3">
<ButtonUI color={BUTTON_COLOR_PRIMARY} small onclick={() => setSeed(seed() + 1)}>
New data
</ButtonUI>
<ButtonUI color={BUTTON_COLOR_NEUTRAL} small onclick={() => setThreeD(!threeD())}>
{threeD() ? "Flat" : "3D"}
</ButtonUI>
<span class="text-ss text-ink-muted">
Each chart carries a <code class="font-mono">title</code>; a multi-series chart also draws a
legend. Click a legend key say <em>Errors</em> or a donut slice to hide it, and the scale
and marks recompute from what's left. The <code class="font-mono">threeD</code> prop gives
every plot a 3D grid — bars extrude, a line or area draws flat over it, the donut tilts.
</span>
</div>
</Panel>
<Panel title="3D grid — bars extrude into it, lines draw flat on the front">
<div class="grid items-center gap-6 lg:grid-cols-12">
<div class="lg:col-span-4">
<Chart kind="bar" title="Bars" labels={CHART_DAYS}
series={[requests()]} height={240} threeD depth={depth()} tilt={tilt()} />
</div>
<div class="lg:col-span-4">
<Chart kind="line" title="Lines" labels={CHART_DAYS}
series={[requests(), errors()]} height={240} threeD depth={depth()} tilt={tilt()} />
</div>
<div class="flex flex-col justify-center gap-5 lg:col-span-4">
<label class="flex flex-col gap-1.5">
<span class="flex items-center justify-between text-ss text-ink-soft">
<code class="font-mono">depth</code>
<span class="font-mono tabular-nums text-ink-muted">{depth()}px</span>
</span>
<input type="range" min="0" max="40" step="1" value={depth()}
class="w-full accent-accent"
oninput={(e) => setDepth(+e.currentTarget.value)} />
</label>
<label class="flex flex-col gap-1.5">
<span class="flex items-center justify-between text-ss text-ink-soft">
<code class="font-mono">tilt</code>
<span class="font-mono tabular-nums text-ink-muted">{tilt().toFixed(2)}</span>
</span>
<input type="range" min="0" max="1" step="0.05" value={tilt()}
class="w-full accent-accent"
oninput={(e) => setTilt(+e.currentTarget.value)} />
</label>
</div>
</div>
<p class="mt-3 text-ss text-ink-muted">
The same <code class="font-mono">depth</code> and <code class="font-mono">tilt</code> give the grid
itself perspective: a floor recedes from the value-0 baseline into a back wall. Bars extrude into
that space; a line or area stays flat on the front plane, drawn over the grid and read against the
front axis — the stroke is <em>not</em> itself extruded, since depth on a hairline reads as noise.{" "}
<code class="font-mono">tilt</code> runs 1 (head-on, near-flat) to 0 (bird's-eye);{" "}
<code class="font-mono">depth</code> is the sweep length in px.
</p>
</Panel>
<Prose>
One <code class="font-mono">kind</code> prop picks the form {" "}
<code class="font-mono">"line" | "area" | "bar" | "pie" | "donut"</code> and{" "}
<code class="font-mono">stacked</code>, <code class="font-mono">horizontal</code>,{" "}
<code class="font-mono">curve</code>, <code class="font-mono">threeD</code>{" "}
(with <code class="font-mono">depth</code> / <code class="font-mono">tilt</code>),{" "}
<code class="font-mono">title</code>, <code class="font-mono">palette</code> and{" "}
<code class="font-mono">valueFormat</code> refine it. The legend it draws for a multi-series or
pie/donut chart is interactive clicking a key toggles that series or slice. The width is measured
from the container, so a chart fills whatever column you give it.
</Prose>
<Panel title="Horizontal bars, and stacked — the same kind, transposed and layered">
<div class="grid gap-6 lg:grid-cols-2">
<Chart kind="bar" horizontal labels={CHART_DAYS} series={[requests(), errors()]} height={260} />
<Chart kind="bar" stacked labels={regions}
series={[
{ name: "Requests", data: shuffle([42, 55, 28, 63]) },
{ name: "Errors", data: shuffle([8, 9, 6, 12]) },
{ name: "Retries", data: shuffle([5, 7, 3, 9]) },
]} height={260} />
</div>
<p class="mt-3 text-ss text-ink-muted">
<code class="font-mono">horizontal</code> runs the categories down the y-axis;{" "}
<code class="font-mono">stacked</code> layers the series with a 2px surface gap between segments.
</p>
</Panel>
<Panel title="US heatmap — a value per state, with proportional lat/lng points on top">
<USHeatmap data={US_SIGNUPS} points={US_CITIES} proportional valueFormat={(v) => v.toLocaleString("en-US")} />
<p class="mt-3 text-ss text-ink-muted">
<code class="font-mono">@ui/USHeatmap</code> shades each state on a themed sequential ramp and
projects <code class="font-mono">points</code> (latitude/longitude) with a dependency-free
albersUsa port so Anchorage and Honolulu land on the Alaska and Hawaii insets. With{" "}
<code class="font-mono">proportional</code>, each dot's area scales with its value. Hover a
state or a point.
</p>
</Panel>
</Section>
);
}
// ---- theming -------------------------------------------------------------
const TOKENS: { swatch: string; name: string; role: string }[] = [
{ swatch: "bg-surface", name: "surface", role: "the page" },
{ swatch: "bg-surface-muted", name: "surface-muted", role: "a recessed strip" },
{ swatch: "bg-surface-raised", name: "surface-raised", role: "a panel, a hover" },
{ swatch: "bg-surface-strong", name: "surface-strong", role: "a track, a divider fill" },
{ swatch: "bg-line", name: "line", role: "an ordinary border" },
{ swatch: "bg-line-strong", name: "line-strong", role: "a border that has to be seen" },
{ swatch: "bg-ink", name: "ink", role: "body text, headings" },
{ swatch: "bg-ink-soft", name: "ink-soft", role: "secondary text" },
{ swatch: "bg-ink-muted", name: "ink-muted", role: "captions, labels" },
{ swatch: "bg-ink-faint", name: "ink-faint", role: "placeholders, disabled" },
];
function Theming() {
const { isDark, mode } = useTheme();
return (
<Section id="theming" title="Theming">
<Prose>
No component in this kit names a colour. They say <code class="font-mono">bg-surface</code>,{" "}
<code class="font-mono">text-ink</code>, <code class="font-mono">border-line</code> — and what
those mean is decided in one place. Dark mode re-points a dozen CSS variables and not one
component knows it happened.
</Prose>
<Demo
title="The switch"
code={`/* jsruntime/styles/theme.css */
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-surface: #ffffff;
--color-ink: #171717;
}
.dark {
--color-surface: #101013; /* not black: black makes every border vanish */
--color-ink: #f2f2f3;
}`}
>
<div class="flex flex-wrap items-center gap-4">
<ThemeToggle />
<div class="text-sm text-ink-soft">
currently <span class="font-mono text-ink">{isDark() ? "dark" : "light"}</span>, because
you asked for <span class="font-mono text-ink">{mode()}</span>
</div>
</div>
<p class="mt-4 text-sm leading-relaxed text-ink-muted">
Press it. Every component on this page moves — none of them were told. Your choice is
remembered, and it is the same choice Kjøl Wasm Web reads: both layers of this site share
one <code class="font-mono">kjol-theme</code> key, so the theme survives crossing between
two front-ends that share no code at all.
</p>
</Demo>
<Panel title="The contract — each swatch is drawn WITH the token it names">
<div class="overflow-hidden rounded-default border border-line">
<For each={TOKENS}>
{(t, i) => (
<div class={"flex items-center gap-4 px-4 py-2.5 " + (i() > 0 ? "border-t border-line" : "")}>
<span class={"h-7 w-7 shrink-0 rounded border border-line-strong " + t.swatch} />
<code class="w-40 shrink-0 font-mono text-[13px] text-ink">{t.name}</code>
<span class="text-sm text-ink-muted">{t.role}</span>
</div>
)}
</For>
</div>
<p class="mt-3 text-ss text-ink-muted">
The swatch class is written out in full in the source, not built as{" "}
<code class="font-mono">"bg-" + name</code>. Tailwind finds the classes it must compile by
scanning the source for literal strings — a concatenation is invisible to it, and every
swatch here would come out colourless.
</p>
</Panel>
<div class="mt-6 grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader>Coloured tints</CardHeader>
<p class="text-sm leading-relaxed text-ink-soft">
A <code class="font-mono">red-50</code> wash is invisible on a near-black surface. An
alert's tint has to become a deep, transparent one a different colour, not a
different value of the same one. One of only two places a{" "}
<code class="font-mono">dark:</code> variant survives.
</p>
</Card>
<Card>
<CardHeader>Fills that invert</CardHeader>
<p class="text-sm leading-relaxed text-ink-soft">
The neutral button is dark on a light page and light on a dark one, so its label must
invert with it <code class="font-mono">text-white</code> would disappear the moment
the fill went pale. Hence three tokens, not one. The other place.
</p>
</Card>
</div>
</Section>
);
}