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.

Tabs and navigation

Tabs builds a WebAwesome wa-tab-group from (label, content) pairs — no hand-pairing of tab headers with panels. Bind its active prop to a state field for routing-aware navigation: the field drives which tab shows, and the user’s selection is written back (serve(packages=["webawesome"]) registers the nav components):

from spaday_webawesome import Tabs

Tabs()
.tab("Overview", overview_panel)
.tab("Settings", settings_panel)
.bind("active", "view", mode="two-way")   # state <-> active tab; seed/read "view" via a store or a wire

Each panel’s name defaults to a slug of its label; pass name= to bind against a stable value. The other navigation primitives are generated typed classes you compose directly — WaDropdown (+ WaDropdownItem), WaBreadcrumb (+ WaBreadcrumbItem), and WaTree (+ WaTreeItem).

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.