Skip to content

Fields & models

Declaring intent: Field(...)

Field is a transparent Annotated carrier: the same discipline as situ's site markers, so title: Annotated[str, Field(label="Title")] is still str to every type checker.

from situ.declui import Field

@dataclass
class Post:
    title:   Annotated[str,  Field(label="Title", label_field=True)]
    user:    Annotated[str,  Field(editable="editing and value != 'admin'")]
    comment: Annotated[str,  Field(widget="RichText", visible="editing or not is_private")]
    token:   Annotated[str,  Field(hidden=True)]
Option Effect
label= the caption (default: the field name, capitalized)
label_field=True this field is the object's display label: used by references, lists, one-to-many rows
hidden=True never rendered (primary keys, internal flags)
secret=True a password input
widget= override the type's default widget: "RichText", "Popup", "Combobox", "Switch", "File"
search=True on a server tracker, generate a client search box filtering rows on this str field (zero network)
options="server" a Combobox whose options come from the mount's context() per request (a live list[(key, label)], value ≠ label), in place of a baked choices=
depends_on= a options="server" field whose options depend on another Combobox: committing that parent refreshes them (a dependent picker)
required=True on a write form, disable the submit until this field is filled
link= on a server tracker, make this cell a link: names a row field carrying the URL
cell_format= on a table, a display formatter: "money" / "date" / "datetime" / "num" / "percent" / "bool" (a Literal; a bad key is a CompileError)
filter=True on a table, a per-column filter input
align= on a table, cell alignment: "left" / "right" / "center" (numeric columns default right)
sort=False / sort_type= on a table, turn a column's sort off / override the type-derived comparator ("text" / "numeric" / "date")
visible= / editable= / valid= predicate strings

labels= / badge= / format= / source= shape a server-tracker cell: see tracker chrome. (format= is the tracker's server-side strftime; a client table uses cell_format= above.)

Reserved options

in_= is accepted so models type-check and currently has no effect: reserved for a planned slice. (filter= is now consumed on a table screen.)

Type → widget

The DRY floor: a bare typed field gets a working control. Anything unmapped is a CompileError naming the type. Silent degradation to a text box would hide the problem.

Model type Generated signal Control
str Local[str] <input type="text"> (type="password" if secret)
int Local[int] <input type="number">: truly numeric in predicates
float Local[float] <input type="number" step="any">
Decimal Local[str] <input inputmode="decimal"> (stays a string: a JS float would corrupt money)
bool Local[bool] a checkbox (or the kit's toggle with widget="Switch")
date (or date \| None) Local[str] <input type="date"> (an ISO string; it compares correctly, including against today())
Enum Local[str] <select> over the members, value = each member's .value
another model class Local[str] a reference: <select> over choices, option value = the target's key, label = its label_field
list[SubModel] Local[list] a one-to-many: a read-only nested list of the children's labels (or a many-to-many chip picker with widget="Combobox")
list[str] / set[str] Local[set[str]] a multi-select with widget="Combobox" (the kit's chip picker); unmapped without it

Nullable references get a — none — option; required ones default to the first choice.

Widget overrides

  • widget="RichText" → a <textarea>.
  • widget="Popup" → the native <select> (an alias, for Enum or reference fields; MetaUI's vocabulary).
  • widget="Combobox" → the kit's searchable single-select over the same signal. On a collection field it becomes a multi-select instead: a data-multi chip picker over a Local[set[str]]. Picking toggles membership; the value is the whole set (a JSON array on the wire, read server-side as json.loads(raw or "[]")). Two shapes: list[str] / set[str] are tags (options are the choices= strings, value == label); list[Model] is a many-to-many (options are the target's key + label_field from choices=, so the value is the set of chosen keys). Either can use options="server" to draw its options from context() per request (a live <field>__options list): the multi analogue of the single server-options picker.
  • widget="Switch" → the kit's ui-switch toggle over a bool (a nicer checkbox), bound to the same Local[bool]. Fails closed on a non-bool field.
  • widget="File" → an <input type="file"> for a first-class file upload (on a str field, which holds the upload handle). Needs mount_model(on_upload=…) (a File field without it is a CompileError): picking a file posts it out of band to the mount's POST {path}/upload route (never through a /cmd command). The hook's returned handle lands in the field's Local[str] and rides the normal submit. The hook is on_upload(service, upload) -> handle over a Litestar UploadFile; the route carries the command CSRF guard. Security: upload.filename / .content_type are attacker-controlled. Sanitize before using one in a path (never open(f"…/{upload.filename}")). The only size ceiling is Litestar's request_max_body_size (10 MB by default). An upload that fails clears the input, so the empty handle is what the next submit carries. See the upload_form example.

declui auto-injects the kit assets into the page head only when a screen uses a kit widget: the ui.css stylesheet for any of them, plus the widgets.js runtime for the binder-driven ones (Combobox, DataGrid). The Switch is pure dialect binders, so it pulls the stylesheet but no runtime.

Dependent pickers

In a dependent picker, the second picker's options depend on the first: pick a country and its cities populate the city picker. Two Field options compose it, with no author JavaScript:

  • options="server" renders a Combobox's <div data-value> list from a <field>__options server signal the mount's context() returns each request: a live, per-user vocabulary a static choices= map can't hold (the option value may differ from its label).
  • depends_on="<parent>" on that field fires @commit on the named parent Combobox: committing the parent runs a server round-trip that re-renders the region, so context() recomputes this field's options for the just-picked value. The parent keeps its type-ahead search.
country: Annotated[str, Field(widget="Combobox")]  # searchable, static choices=
city: Annotated[
    str, Field(widget="Combobox", options="server", depends_on="country")
]

The parent→child mapping lives in your mount context(): read the committed parent off the view (a command's re-render overlays its just-committed signals there) and return the child's options.

async def context(service, view):
    return {"city__options": service.cities(view.get("country", ""))}

depends_on needs options="server" and a rendered Combobox parent, or it is a CompileError. A runnable end-to-end version is examples/declui/dependent_picker/.

widget="custom": the composite escape hatch

The type→widget map is one field → one control. When a single logical control spans several model attributes with author-supplied markup (a country + city pair; a latitude + longitude pair typed into one row), declare a custom widget on a write form:

place: Annotated[str, Field(
    label="Location",
    widget="custom",
    control='<div class="country-city"><select :bind="country"></select>'
            '<input :bind="city"></div>',   # situ-dialect markup, its own binders
    binds=("country", "city"),              # the model attributes it reads/writes
)]

declui owns the slot and the value channel, nothing more: it splices control into the field's row and declares one Local[str] per name in binds. Each bind rides the form's POST with channel into facade.create / update by name, so a composite writes N attributes from one control. The field's own name never becomes a signal; binds do.

control= is declaratively-bound markup only: :bind, :show, :each, :text work, but there is no handler channel. Declui generates the component .py, so an event binder like @input="pick" would reference a function that doesn't exist. Use it for composites whose attributes are entered independently. For a linked composite (one control that drives another), use a component.

component=: a linked composite (a real situ component)

When the composite needs behaviour (choosing a country prefills or filters the city; a control that fetches), point the slot at a real situ component and resolve it with mount_model(components=...):

# the model
location: Annotated[str, Field(
    widget="custom",
    component="CountryCity",      # a PascalCase tag, resolved via components=
    binds=("country", "city"),   # the attributes it reads/writes
)]

# the mount
mount_model(path="/new", screen=Screen(model=Place), screens=("create",),
            service_type=PlaceService, facade=Places,
            components=Context.from_dir(HERE / "components"))  # resolves <CountryCity/>

declui declares each bind Provide[str] and places a bare <CountryCity/>; at mount it splices the real component (components/country_city.{html,py}) into the one island. The child Injects the binds by name, so it reads and writes the form's cells. It is an ordinary component, so it has its own state and handlers:

# country_city.py — the child control
from situ.compiler.markers import Inject
country: Inject[str]              # the form provides these; the child aliases them
city: Inject[str]
def pick() -> None:              # a handler — the thing the inline control can't have
    global city                  # a bare-signal write, declared global
    if country == "FR": city = "Paris"
    # ... (no docstring: a handler body is statements, not a bare string)
<!-- country_city.html — @input, not @change (situ's events: click/dblclick/submit/input) -->
<select :bind="country" @input="pick"> … </select>
<input :bind="city">

The child can also carry a server command, with one constraint: declui's mount injects the same facade into every server-sited signal, so a child's fetch must call a verb on the form's own facade (not a separate service). A per-country server fetch is reachable but shares the form facade; a client-seeded linkage (above) has no such caveat.

It fails closed, by design: the control is opaque to declui, so declui refuses anything it can't honour:

  • neither, or both, of control= / component=; or missing binds= → CompileError;
  • a component= that isn't a PascalCase tag, or a screen that places a tag with no components= Context to resolve it → CompileError;
  • a bind that isn't a Python identifier, or collides with a reserved name (value, editing) → CompileError;
  • editable= / valid= / a whole-object object_editable rule targeting it → CompileError (there is no single native control to disable or gate; gate inside the control, or on the facade).

visible= is supported: it compiles to a :show on the row, same as any field. Use a custom widget only when a single control must span several attributes; Combobox already covers searchable single-selects, and a plain typed field already covers everything atomic.

Model sources

read_model(cls) reads five model families through one interface; the generated component is byte-identical whichever you use:

@dataclass
class Product:
    name: Annotated[str, Field(label_field=True)]
    price: Annotated[Decimal, Field()] = Decimal("0")

It uses only the standard library, with no extra dependency.

class Product(msgspec.Struct):          # or @attrs.define, or pydantic.BaseModel
    name: Annotated[str, Field(label_field=True)]
    price: Annotated[Decimal, Field()] = Decimal("0")

Same Annotated[T, Field(...)] convention; install situ[model-adapters]. Each library is imported only if your model actually uses it.

class Product(Base):
    __tablename__ = "product"
    id: Mapped[int] = mapped_column(primary_key=True, info={"declui": Field(hidden=True)})
    name: Mapped[str] = mapped_column(info={"declui": Field(label_field=True)})
    price: Mapped[Decimal]

The adapter is structural: it reads mapped columns from the mapper (col.type.python_type → the Python type). Declui intent rides mapped_column(info={"declui": Field(...)}). Generating a form runs no query. relationship() attributes are not columns and are out of scope for this adapter; a MappedAsDataclass model is detected as SQLAlchemy first. Known edge: a native-string sa.Enum("a", "b") (no Python Enum class) renders as a text input.

Defaults and seeding

Field defaults become the generated signals' initial values, coerced to the wire: an Enum default → its .value, Decimal → its string form, date | None = None → "", a default_factory is resolved where it yields a static literal (an attrs Factory(takes_self=True) degrades gracefully to no default). The same coercions apply to seeded rows.