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.
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(element("span").text("Ready"), when=item("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.
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.