-
Notifications
You must be signed in to change notification settings - Fork 0
UI Rapid
How a rAPId application wears this library: the document shell, page layouts and error pages that plug into
Application.initialize, the components that talk to the server through rAPId's swap runtime, and the patterns that
keep back/forward, reloads and no-JS submissions honest.
-
ui: { core: createCoreTemplate(), errorTemplates, prefer: "html", history: true }and every page has the bundle, a skip link, the runtime and a toast region. - Assets come from the versioned CDN with integrity hashes by default;
createCoreTemplate({ assets: "/ui" })self-hosts throughview.asset(). - Components are plain functions returning
Html— compose them in route templates; everything interpolated is escaped. - Server-driven components (combobox, command palette, date picker, data table, pagination, lazy regions, toasts, the
editor's preview) speak rAPId's
data-*contract. Your route returns the fragment the component exports (ComboboxList,CommandList,DatePickerPanel, the table itself). - A pushable region's URL is the page route itself: the handler returns the region on a swap and the whole page on a navigation.
- Forms render straight from
RapidFormError; the same route serves a swap (JS) and a redirect (no JS). - Worked, end-to-end examples of all of this — with the same pages as plain HTML — are in Recipes.
import { Application } from "@tundralibs/rapid";
import { createCoreTemplate } from "@tundralibs/ui/templates/core";
import { createLayoutTemplate } from "@tundralibs/ui/templates/layout";
import { errorTemplates } from "@tundralibs/ui/templates/errors";
const app = await Application.initialize({
name: "acme",
ui: {
core: createCoreTemplate({ history: true }),
layout: createLayoutTemplate({
brand: "Acme",
navLinks: [{ href: "/", label: "Home" }, { href: "/projects", label: "Projects" }],
sidebarItems: [{ label: "Overview", href: "/" }, { label: "Projects", href: "/projects" }],
}),
errorTemplates,
prefer: "html",
history: true,
},
});Core (createCoreTemplate) is the document: <head> with meta, viewport, the stylesheet (and any extra ones you
pass), then the body with a skip link to #main-content, your page, the shared ToastRegion, rAPId's runtime, the
optional history/live modules, ui.js, and your own scripts — all deferred. Options:
| Option | Meaning |
|---|---|
assets |
"cdn" (default) — pinned jsDelivr URLs with integrity; or a prefix like "/ui" — view.asset() URLs under a fingerprinted static mount. |
stylesheets, scripts
|
Extra files. A path starting with / goes through view.asset(); a full URL is used as is. A script may be { src, integrity }. |
head |
Constant extra <head> markup (favicon, preconnects). |
history, live
|
Also load /__rapid/history.js / /__rapid/live.js. Pair with ui.history / ui.live. |
toastRegion |
false to leave the toast region out. |
lang |
<html lang>, default en. |
Layout is the page frame. createLayoutTemplate() builds a navbar (+ sidebar) frame from config;
asRapidLayout(body => SomeLayout({ …, content: body })) turns any of the eight layouts into the
RapidTemplate<{ body, title? }> rAPId expects. Set it app-wide, per module or per route (layout: false opts out —
the example app does that for pages that are complete frames themselves).
Error pages: errorTemplates (default, 4xx, 5xx) render rAPId's error payload (status, code, message,
requestId, details, debug) on the library's tokens with no inline styles — a drop-in for the built-in
DefaultErrorPage, which cannot run under a strict CSP.
import { copyUiAssets } from "@tundralibs/ui/assets";
await copyUiAssets("./static/ui"); // ui.css + ui.js, from JSR or npm
const app = await Application.initialize({
name: "acme",
server: { static: { "/ui": { root: "./static/ui", fingerprint: true } } },
ui: { core: createCoreTemplate({ assets: "/ui" }) },
});rAPId's view.asset() appends a content hash and serves the files immutable; the library never hashes its own
filenames, so the two schemes don't fight.
import { html, template } from "@tundralibs/rapid/ui";
import { Card } from "@tundralibs/ui/card";
import { Grid, GridCol } from "@tundralibs/ui/grid";
import { PageHeader } from "@tundralibs/ui/page-header";
const Projects = template<{ items: { name: string; owner: string }[] }>(
(data) =>
html`${PageHeader({ title: "Projects" })}${
Grid({
items: data.items.map((p) =>
GridCol({ span: 4, content: Card({ title: p.name, subtitle: p.owner, href: `/projects/${p.name}` }) })
),
})
}`,
"Projects",
);
app.get("/projects", { template: Projects }, () => ({ content: { items } }));Every prop is interpolated through rAPId's html, so strings are escaped. raw() is never used on caller data.
Components render correctly as bare fragments — no wrapper is assumed — which is what makes swaps work.
rAPId's runtime does one thing: on a click or submit of a [data-action] element it fetches the URL and swaps the
response into data-target (data-swap: replace / outer / append / prepend), sending the rapid-swap: 1 header.
Your handler sees ctx.isSwap. These components use it:
| Component | What it emits | What your route returns |
|---|---|---|
Combobox({ action }) |
per-keystroke rapid.swap(action?q=…, "#<id>-list")
|
ComboboxList({ id, options, query, selected }) |
Command({ action }) |
same, ?q=
|
CommandList({ id, items, query }) |
DatePicker({ buildMonthHref, buildDayHref, presets }) |
month/day/preset links with data-action + data-target="#<id>" + data-swap="outer" (+ data-push on picks) |
DatePicker(...) again (the whole control) |
DataTable({ buildSortHref }) |
sort links → #<id>, outer, data-push
|
the DataTable again |
Pagination({ target }) |
page links → target, outer, data-push
|
whatever the target is |
Popover({ loadFrom }) |
data-load on open |
the popover's content |
any element with data-load
|
fetched on mount | the region's content (render a Skeleton* as the placeholder) |
a button with data-action + data-target="#toast-region" + data-swap="append"
|
— | a Toast(...)
|
Editor({ previewAction }) |
POST text via rapid.swap
|
the rendered HTML fragment |
a Form with a Dropzone inside (data-action, data-target="#<dropzone id>") |
a multipart post; rapid:progress fills the pending rows dropzone.js renders |
the Dropzone again, with the server's rows |
Form({ attrs: { "data-action", "data-target", "data-swap" } }) |
the form posts as a swap | the form (with error) or the success state |
Without the runtime the same markup degrades: combobox and command filter their rendered options client-side, the date picker runs its own month navigation, links are ordinary links.
History push (data-push) records the fetched URL against the region's id; back/forward re-fetches it. So the URL
you push must render the full page on a plain navigation. The pattern the example app uses on /components/data:
app.get("/components/data", { template: DataPage, layout: shell }, (ctx) => {
const q = new URL(ctx.url).searchParams;
const sort = q.has("sort") ? { key: q.get("sort")!, dir: q.get("dir") === "desc" ? "desc" : "asc" } : undefined;
// On a swap, return only the region the link targets; on a navigation, the whole page.
return { content: { sort, fragment: ctx.isSwap && q.has("sort") ? "invoices" : false } };
});
const DataPage = template<Data>((d) =>
d.fragment === "invoices" ? InvoicesTable(d.sort) : html`…the whole page, with ${InvoicesTable(d.sort)} in it…`
);Build each region's links from a fixed base with distinct query keys, not by merging the current query, so one region's
state never leaks into another's URLs. Every region that pushes needs a stable id (rAPId refuses to push without one).
busy.js marks the target of any swap aria-busy="true" + data-busy the moment the click or submit fires; the
skeleton stylesheet paints a shimmer veil over it and blocks pointer events until rapid:swapped or rapid:error (15 s
safety timeout). data-load regions are left alone — render SkeletonTable() / SkeletonCard() as their initial
content, since only the server knows the shape that is coming. rAPId 0.4+ also sets aria-busy on the target itself and
refuses a second non-GET while one is in flight; the veil works the same either way.
A form that holds a file input posts real multipart/form-data. rAPId 0.4+ streams it over XMLHttpRequest and emits
rapid:progress ({ url, loaded, total }, total is 0 when unknown) on the target while the bytes leave the
browser. Dropzone uses it: on submit, dropzone.js renders one pending row per picked file (same markup as a
server-rendered DropzoneFile, indeterminate bar), fills the bars from the events, and leaves the rows pending until
the reply lands — bytes reach 100% before the server has parsed anything. Your route answers with the Dropzone
re-rendered from your own store (a file input is never echoed back), and rapid:error marks the pending rows failed.
Declare uploads: { allowedExtensions: [".pdf", ".png"] } on the application — rAPId refuses every upload until you do.
See the upload recipe.
formState() hands you RapidFormError ({ state: "error", message, fields, values }); Form({ error }) renders it
as a banner, FormField({ error }) per field, and Input({ value: values.name, invalid }) re-fills. One route serves
both worlds:
app.post("/signup", { template: { render: SignupView, prefer: "html" } }, async (ctx) => {
const form = await formState(SignupSchema, await ctx.payload);
if (!form.ok) return { content: form.error }; // 200: the union's own state
if (!ctx.isSwap) return { content: form.data, redirect: "/welcome" }; // no JS: Post/Redirect/Get
return { content: { state: "added", values: form.data } }; // JS: swap the success state in
});Give the form attrs: { "data-action": "/signup", "data-target": "#signup", "data-swap": "outer" } and an id. A file
input is never echoed back.
Input({ type: "date" }) renders the DatePicker (its hidden input carries the name and ISO value); Otp submits one
autocomplete="one-time-code" field; Editor submits Markdown or HTML through its textarea.
Empty({ tone: "error", code: requestId }) is the in-page failure state. A failed swap fires rapid:error on the
target without touching it — show a toast from that event rather than swapping an error page over a half-filled form.
Full-page errors go through errorTemplates.
examples/app/ is a complete rAPId application exercising all of the above — every layout, every component with its
server-driven cases wired to routes, the form union, the preview route, the error pages — and
examples/tests/test-app.ts drives it with a real browser, asserting that swaps happen in place, history pushes and
restores, reloads of pushed URLs render full pages, and the busy veil comes and goes. Start it with deno task app and
read app.ts as the worked example.
Guides
Reference