Author a component tree¶
This guide shows you how to build a spaday UI from typed components: setting props, nesting children, laying out with shell components, and serializing the result. To attach behavior, see Add behavior and reactivity.
Use a component package¶
Install spaday-webawesome, then import a generated component and set its attributes as keyword arguments:
from spaday_webawesome import WaButton
WaButton(variant="brand", size="large")
Every attribute is a typed keyword (variant: Optional[Literal["brand", "neutral", ...]],
disabled: Optional[bool], …), so a typo or a wrong type is an error at authoring time. A prop you
don’t pass is omitted, so the element keeps its own default and update patches stay minimal.
To bind a different component library, generate its classes from a manifest.
An independently distributed integration can also register its browser assets through the
ComponentPackage serving contract.
Nest children into slots¶
Compose a tree with .child(...) for the default slot and .child_in("slot", ...) for a named one:
from spaday_webawesome import WaCard, WaButton, WaSwitch
WaCard().child_in("header", WaButton(variant="brand")).child(WaSwitch())
.child returns the parent, so calls chain. Children are other components (or a raw element — below).
Set text¶
.text(...) sets a leaf’s text content — use it for labels, not alongside child nodes:
WaButton(variant="brand").text("Save")
Lay out with shell components¶
spaday does not expose div. Compose layout from the spa-* shell components, which carry their own
encapsulated layout:
from spaday.components.shell import App, Nav, Body, Gutter, Main, Footer, Stack, Row, Toolbar
App().child(Nav().child(...)).child(Body().child(Gutter().child(...)).child(Main().child(...)))
Stack stacks children vertically, Row lays them horizontally, Toolbar is a control strip; App /
Nav / Body / Gutter / Main / Footer are the page shell.
When independent pieces of an app contribute to the same frame (a plugin adds a nav control, a page adds
a sidebar), compose the shell from named regions with AppShell instead of hand-nesting:
from spaday.components.shell import AppShell, Region
shell = AppShell()
shell.add(Region.HEADER_LEFT, "My app")
shell.add(Region.HEADER_RIGHT, theme_toggle, order=10) # lower order sorts earlier within a region
shell.add(Region.GUTTER_LEFT, nav_menu)
shell.add(Region.MAIN, chart)
app = shell.build() # -> App(Nav(...), Body(Gutter(...), Main(...)), ...)
Flow regions are HEADER_LEFT / HEADER_CENTER / HEADER_RIGHT, GUTTER_LEFT / GUTTER_RIGHT,
MAIN, and FOOTER_LEFT / FOOTER_RIGHT. The *_RIGHT regions are right-aligned; a Nav / Gutter /
Footer only appears when its regions have contributions. DRAWER_LEFT, DRAWER_RIGHT, DRAWER_BOTTOM,
and OVERLAY append directly under App, after flow chrome, for top-layer UI.
Use generic controls any design renders¶
A typed catalog ties a page to one design system: WaButton is a wa-button. The 13 generic
controls in spaday.ui cover actions, text, numbers, dates, boolean and single-choice input,
progress, alerts, and dialogs. The page’s design decides what element each becomes:
from spaday import Paragraph, SetField
from spaday.components.shell import Column
from spaday.ui import Alert, Button, Dialog, NumberInput, Select, Switch, TextArea, TextInput
page = Column(
TextInput(label="Name", help="As on your passport").bind("value", "name", mode="two-way"),
TextArea(label="Notes").bind("value", "notes", mode="two-way"),
NumberInput(label="Seats", min=1, max=20).bind("value", "seats", mode="two-way"),
Select(label="Plan", options=["basic", "plus"]).bind("value", "plan", mode="two-way"),
Switch(label="Dark theme").bind("value", "dark", mode="two-way"),
Alert("Check the details before saving.", intent="info"),
Button(label="Save", intent="primary").on("click", SetField("open", True)),
Dialog(Paragraph("Saved."), Button(label="OK").on("click", SetField("open", False)), label="Done")
.bind("open", "open", mode="two-way"),
)
serve(page, packages=["webawesome"]) # rendered with WebAwesome's design
serve(page) # rendered with the themed native baseline
The vocabulary is sized to what every design can express: label / help / error, disabled /
required / readonly, intent (neutral / primary / info / success / warning / danger),
appearance (filled / outline / plain) and size (sm / md / lg). Bind value on every
control. Text controls and dates use strings, NumberInput and Slider use numbers, boolean controls
use booleans, and Select and RadioGroup preserve string, numeric, and boolean option values. Bind
open on a dialog. The binding reaches whatever property and event the design’s element uses
(checked, a Lion control’s model-value-changed, a <dialog>’s showModal()), and a dialog closing
itself writes the field back.
The complete initial set is Button, TextInput, TextArea, NumberInput, DateInput, Checkbox,
Switch, Select, RadioGroup, Slider, Dialog, Alert, and Progress. A DateInput exchanges
ISO YYYY-MM-DD strings; the design may render it with a popup picker. Progress(value=None) is
indeterminate.
A page renders with one design: the one the selected package publishes, or design= to choose
between several ("native" is always available). A design that lacks a control renders it with the
baseline and marks it data-ui-fallback; what a design cannot express is dropped rather than
half-rendered. For the design’s own spelling on one control, for_design("webawesome", pill=True)
sets props only that design sees. At the top level the switch is exported as ToggleSwitch, beside
the shell’s Switch router.
Generate a form from a model¶
form(Model) turns a pydantic model into a Stack of labelled wa-* controls — one per field, typed
from the schema and each two-way bound to a field of the same name. An Enum field becomes a
wa-select of its members; a nested sub-model becomes an expand/collapse wa-details whose controls bind
to dotted parent.child paths:
import enum
from pydantic import BaseModel
from spaday_webawesome import FormField, form
class Size(str, enum.Enum):
small = "small"
large = "large"
class Settings(BaseModel):
name: str = "lamp"
enabled: bool = True
size: Size = Size.small
form(Settings) # a Stack of bound controls — none authored by hand
form and pydantic are provided by spaday-webawesome. The controls bind to fields named name /
enabled / size, so back them with
a seeded store= or a hosted model over transports. Relabel a field with
FormField (as Annotated[int, FormField(label="…")] metadata or via overrides=), and drop fields with
exclude=.
Show data in a table¶
Table renders a lightweight data grid — a spa-table that lays out rows (a list of dicts) under
columns. Both are reactive, so binding or computing rows re-renders the table live:
from spaday import field
from spaday.components.shell import Table
Table(columns=["id", "symbol", "qty", "price"], row_key="id").compute("rows", field("orders"))
Set row_key to a field whose value is a unique string or number. When reactive rows changes, the
browser reuses and reorders existing rows by that identity, updates changed cells, inserts new rows, and
removes missing rows. Omit row_key to retain full-table rendering. The application still replaces its
rows state normally; no separate imperative table state is required.
columns may be plain keys (["symbol"] — the label is the key) or {"key": …, "label": …} dicts; omit
it to infer the columns from the first row. Pass rows=[…] for a static table. Scalar cells render as
text. A static cell can instead contain any spaday component, including a button with an action:
from spaday import NamedJs
from spaday.components.shell import Table
from spaday_webawesome import WaButton
inspect = (
WaButton(appearance="plain")
.text("Inspect")
.prop("data-symbol", "AAPL")
.on("click", NamedJs("inspect-order"))
)
Table(
columns=["symbol", {"key": "action", "label": ""}],
rows=[{"symbol": "AAPL", "action": inspect}],
)
Register the named handler in a JavaScript module loaded through scripts=:
import { registerHandler } from "/js/dist/esm/index.js";
registerHandler("inspect-order", (_event, button) => {
window.inspectOrder(button.getAttribute("data-symbol"));
});
Rich cells are normal component-tree nodes, so their bindings and declarative actions work unchanged.
Reactive rows remain serializable data; define component cells in static rows or update the table’s
component tree. For virtual scrolling or very large datasets, use RegularTable from
spaday-regular-table; it only renders the current viewport. Column cell descriptors create buttons,
badges, formatted values, or custom elements from serializable Python data. Bind its rowPatch prop or
set stream_url for revisioned update, insert, and remove batches without replacing the full dataset.
Rich-cell events accept normal spaday actions, so these workflows need no companion JavaScript.
Render an action for every live record¶
Use Each when every record needs a component subtree rather than table cells. Bind the outer
collection with field=, identify items with key=, and read the current record with item():
from spaday import CallEndpoint, Strong, concat, element, item, obj, scope
from spaday.components import Column, Each, Row, Show
records = Each(
Row(
Strong(item("name")),
Show(item("ready"), element("span").text("Ready")),
element("button").text("X").on(
"click",
CallEndpoint(
"DELETE",
concat("/stage/", scope("staging.channel")),
obj({"id": item("id")}),
),
),
),
items=item("records"),
key="id",
scope="record",
)
staging_panel = Each(
Column(records),
field="stagings",
key="id",
scope="staging",
)
item("path") reads the innermost item. scope("name.path") reads the nearest current or ancestor
scope with that name. field("name") always reads global store state.
Keys must be unique strings or finite numbers. Replacing, inserting, removing, or reordering the bound collection updates at most once per animation frame. Existing keys retain their live root element, focus, cursor position, local properties, bindings, and action scope. Item scopes are read-only; use an action to update global state, send a model patch, or call an endpoint.
Within an animation frame, consecutive granular updates to an item settle its scope once, including updates interleaved with other items. Insert, remove, move, and reorder operations separate these groups and retain their order. A reset supersedes all pending deltas; later deltas apply to that reset. Every intermediate update in the remaining batch is validated before any DOM change; an invalid path or key change rejects the batch even if a later update would overwrite it. Store subscribers still receive each published collection; coalescing applies only to the repeated item scopes.
Set direct=True when a parent custom element discovers children through direct light-DOM parentage or
native slots. Repeated roots then sit directly before the empty spa-each reconciliation anchor. The
default keeps roots inside spa-each, preserving the existing DOM shape for other collections.
Run the complete keyed records example to try nested channels, server-driven add/update/remove/reorder operations, per-record endpoint payloads, and preserved local input state without page-specific JavaScript.
Reach for a raw element¶
For text or a structural tag a typed class doesn’t cover, use element:
from spaday import element
element("strong").text("Settings")
element("a", href="https://example.com").text("docs")
A trailing underscore on a prop name is stripped, so reserved words work: element("label", for_="x").
Set a prop a typed class doesn’t expose¶
.prop(name, value) is the escape hatch for an attribute the generated class doesn’t have (a custom
attribute, style, id, …):
WaButton().prop("id", "save").prop("style", "margin-left:auto")
Key for stable updates¶
Give a node a stable key so the diff engine reconciles it by identity across updates (so a reordered
list moves live elements instead of rebuilding them):
WaSwitch().key("lamp")
Serialize¶
.to_node() returns the JSON-ready node dict; .to_json() returns its string form. This is the wire
form the core’s diff / apply understand and the runtime mounts:
WaCard().child(WaSwitch()).to_node()
In a notebook you rarely call these directly — Widget does it for you; over a server
they are served as the tree the browser mounts.