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: adata-multichip picker over aLocal[set[str]]. Picking toggles membership; the value is the whole set (a JSON array on the wire, read server-side asjson.loads(raw or "[]")). Two shapes:list[str]/set[str]are tags (options are thechoices=strings, value == label);list[Model]is a many-to-many (options are the target's key +label_fieldfromchoices=, so the value is the set of chosen keys). Either can useoptions="server"to draw its options fromcontext()per request (a live<field>__optionslist): the multi analogue of the single server-options picker.widget="Switch"→ the kit'sui-switchtoggle over abool(a nicer checkbox), bound to the sameLocal[bool]. Fails closed on a non-bool field.widget="File"→ an<input type="file">for a first-class file upload (on astrfield, which holds the upload handle). Needsmount_model(on_upload=…)(aFilefield without it is aCompileError): picking a file posts it out of band to the mount'sPOST {path}/uploadroute (never through a/cmdcommand). The hook's returned handle lands in the field'sLocal[str]and rides the normal submit. The hook ison_upload(service, upload) -> handleover a LitestarUploadFile; the route carries the command CSRF guard. Security:upload.filename/.content_typeare attacker-controlled. Sanitize before using one in a path (neveropen(f"…/{upload.filename}")). The only size ceiling is Litestar'srequest_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 theupload_formexample.
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 aCombobox's<div data-value>list from a<field>__optionsserver signal the mount'scontext()returns each request: a live, per-user vocabulary a staticchoices=map can't hold (the option value may differ from its label).depends_on="<parent>"on that field fires@commiton the named parentCombobox: committing the parent runs a server round-trip that re-renders the region, socontext()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.
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 missingbinds=→CompileError; - a
component=that isn't a PascalCase tag, or a screen that places a tag with nocomponents=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-objectobject_editablerule 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.