1667 lines
79 KiB
TypeScript
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"><div id="portal"></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"><ToastProvider></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 "&"
|
|
match each other, so "First Bank and Trust" also finds "First Bank & 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"><canvas></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>
|
|
);
|
|
}
|