Skip to content
oldmangan edited this page Oct 8, 2026 · 10 revisions

Labelled fields with client-side validation, error slots and submit/cancel buttons.

from wapyt import FieldConfig, Form, FormConfig, SelectOption

form = Form(FormConfig(
    fields=[
        FieldConfig(id="host", label="Host", required=True),
        FieldConfig(id="port", label="Port", type="number", value=22, min=1, max=65535),
        FieldConfig(id="auth", label="Auth", type="select",
                    options=[SelectOption("key", "SSH key"), SelectOption("password", "Password")]),
        FieldConfig(id="password", label="Password", type="password"),
        FieldConfig(id="confirm", label="Confirm", type="password", matches="password"),
    ],
    submit_text="Save", cancel_text="Cancel", columns=2,
), container=modal.body)
form.on_submit(lambda values: spawn(save(values), "save"))

Field types

An unknown type raises ValueError when the config is serialized.

type Control Reads back as
text, password, email, url, search, tel text input string
number number input number, None when empty
textarea multi-line text string
select native dropdown string
combo searchable dropdown; see Combo string or None; a list with multiple=True
checkbox one checkbox bool
toggle on/off switch (role="switch") bool
radio one-of-several options the picked value, or None
checkbox_group several checkboxes list of values
date, time, datetime-local native pickers ISO string (2026-10-06, 14:30, 2026-10-06T14:30), or None
color native colour picker #rrggbb
range slider with its value shown beside it; without a value it starts at the midpoint of min–max. With range=True it has two thumbs; see Two-thumb ranges and ticks number; [low, high] with range=True
toggle_group segmented buttons over options: one choice (a radio group) or several with multiple=True; see Toggle groups the choice or None; a list with multiple=True
file drop zone, Choose button and a list of picked files; see File and avatar fields list of {name, size, type}
avatar round picture button with Remove the picture's URL as set, {name, size, type} once a new one is chosen, or None
static a read-only value shown as text, with its label; see Fieldsets, spacers and static fields whatever was set (string, number…), or None
hidden none string
import datetime as dt

FieldConfig(id="day", label="Day", type="date", value=dt.date.today(),
            min=dt.date(2026, 1, 1), max="2026-12-31")
FieldConfig(id="volume", label="Volume", type="range", min=0, max=10, value=4)
FieldConfig(id="plan", label="Plan", type="radio", inline=True, required=True,
            options=["free", SelectOption("pro", "Pro"), "team"])
FieldConfig(id="notify", label="Notifications", type="toggle", value=True)
FieldConfig(id="tags", label="Tags", type="checkbox_group", value=["beta"],
            options=["alpha", "beta", "gamma"])

Date and time fields take datetime.date, time or datetime objects wherever they take a value: value, min, max and set_values. Time zones are refused, because the pickers have none. They still read back as strings.

Field options

FieldConfig
value, placeholder, help Initial value, hint, help line
required, min_length, max_length, pattern, matches Validation (pattern is a JS regex; matches names another field). max_length also stops typing at the limit. A required checkbox_group, multiple combo, multiple toggle group or file field needs at least one value
min, max, step Bounds for number, range, date, time and datetime-local; out-of-range values fail validation
*_message Override the validation copy (required_message, min_length_message, max_length_message, pattern_message, matches_message, range_message)
options Choices for select, combo, radio, checkbox_group and toggle_group: strings or SelectOption(value, label, disabled=False, icon=None). A disabled option shows but can't be picked; icon is shown on toggle-group buttons
icon An MDI icon inside text-like inputs, date/time pickers, select and combo, on the left
accept, max_size, max_files file / avatar rules: accepted types (".csv,.txt", "image/*"), largest file in bytes, most files (file with multiple)
inline Put radio / checkbox_group options in a row
show_value False hides a range field's value
range Two thumbs on a range field
ticks, major_ticks, tick_labels Tick marks under a range field
multiple Several values: a combo (chips), a toggle_group, or a file field
allow_custom combo only; see below
rows textarea height
span Columns to span when FormConfig(columns > 1)
disabled, readonly, autocomplete Control state

FormConfig(submit_text=None) or cancel_text=None hides that button.

A date outside its bounds gets "Must be 2026-01-01 or later", quoting the ISO date even though the picker shows the browser's locale format. Pass range_message for friendlier copy.

Two-thumb ranges and ticks

Added in #49.

FieldConfig(id="price", label="Price", type="range", range=True,
            min=0, max=500, step=10, value=(50, 200),
            ticks=50, major_ticks=100)
  • range=True gives a range field two thumbs. It reads back as [low, high] (in get_values, on_change and on_submit) and takes a list or tuple wherever it takes a value. None for either end means that bound, so [None, 2000] is "up to 2000". Without a value it spans the whole range. Values snap to step and a reversed pair is put in order.
  • The thumbs cannot cross. Drag a thumb, or press the track to move the nearer one. Keyboard: arrows step, Page Up / Page Down move a tenth of the range, Home / End go as far as the thumb can.
  • on_change fires as a thumb moves, like the single slider.
  • Ticks work on one- and two-thumb ranges: a mark every ticks units and a longer one every major_ticks, counted from min. Major ticks show their value unless tick_labels=False.
  • Accessibility: the label names a group of two sliders ("Price minimum", "Price maximum"). Each slider reports the other as its limit.

Buttons

Added in #53.

from wapyt import FormButton

form = Form(FormConfig(
    fields=[
        FieldConfig(id="host", label="Host", required=True),
        FormButton("test", "Test connection", "mdi-lan-connect"),   # among the fields
    ],
    buttons=[                                                        # in the action row
        FormButton("delete", "Delete", "mdi-delete", variant="danger"),
        FormButton("save_close", "Save and close", submit=True),
    ],
    cancel_text="Cancel",
), container=modal.body)

def clicked(p):
    if p["id"] == "test":
        form.set_button_loading("test")       # spinner until the check returns
    elif p["id"] == "save_close":
        save(p["values"]); modal.hide()
form.on_click(clicked)
  • A FormButton in fields takes a grid cell, so span works; one in buttons goes in the action row, before Cancel and Submit, and the row wraps when it runs out of room.
  • on_click fires with {id}. With submit=True the form validates first: if it passes, on_click gets {id, values}; if not, on_invalid fires with the errors and this id, and focus goes to the first bad field. It never fires on_submit, and Enter in a field still submits through the main Submit button.
  • variant: default (outlined), primary, danger or link. full=True stretches it across its cell. icon and tooltip as elsewhere.
  • set_button_loading(id, True) shows a spinner and disables the button; set_button_text(id, text) relabels it. show_field, hide_field and set_field_disabled take button ids too. set_busy also disables submit=True buttons.
  • Ids are unique across fields and buttons. Buttons never appear in get_values.

Labels on the left

Added in #53.

FormConfig(label_position="left", label_width=150, fields=[...])
FieldConfig(id="notes", label="Notes", type="textarea", label_position="top")  # override
FieldConfig(id="q", label="Search", type="search", placeholder="Search", hidden_label=True)
  • label_position="left" puts labels in a column beside the controls, each on its control's first line of text. Help text and errors stay under the control. Checkbox and toggle labels move to the label column too, and buttons among the fields line up with the controls.
  • label_width is pixels or any CSS length ("30%", "12rem"); default 160px. Both can be set per field.
  • When a row would leave its control less than 160px (a narrow modal, a phone), that row puts its label back on top, and returns once there is room.
  • hidden_label=True hides a label from sight but keeps it as the field's accessible name, for a search box whose placeholder says what it is.

Fieldsets, spacers and static fields

Added in #54.

from wapyt import FormFieldset, FormSpacer

FormConfig(columns=2, label_position="left", fields=[
    FieldConfig(id="created", label="Created", type="static", value="2026-10-08"),
    FormSpacer(),                                    # leaves the next cell empty
    FormFieldset("conn", "Connection", span=2, columns=2, fields=[
        FieldConfig(id="host", label="Host", required=True),
        FieldConfig(id="port", label="Port", type="number", value=22),
    ]),
    FieldConfig(id="use_proxy", label="Use a proxy", type="toggle", span=2),
    FormFieldset("proxy", "Proxy", span=2, disabled=True, fields=[
        FieldConfig(id="proxy_host", label="Proxy host", required=True),
    ]),
])
form.on_change(lambda p: p["id"] == "use_proxy" and form.set_field_disabled("proxy", not p["value"]))
  • FormFieldset is a titled, bordered group with its own grid (columns 1–3) that takes span in the grid around it, and can be nested. show_field, hide_field and set_field_disabled with its id act on everything inside. label_position and label_width set on it apply to its fields. Its fields stay flat in get_values.
  • FormSpacer is an empty cell (span cells wide) or, with height, a fixed gap. Give it an id to show or hide it.
  • type="static" shows a value as text beside or under its label. get_values returns it as it was set, set_values changes it, and placeholder shows while it is empty. It is read out with its label but is not a tab stop, and it cannot be required.
  • Hidden and disabled fields are not validated: a field that is hidden or disabled, directly or through a fieldset, never blocks a submit. That is what makes optional sections like the proxy above work. Those fields still appear in get_values.
  • set_values, clear and reset don't fire on_change, so re-apply any sections you switch on and off after calling them.

File and avatar fields

Added in #61.

from wapyt import filetransfer

FieldConfig(id="docs", label="Attachments", type="file", multiple=True,
            accept=".csv,.txt", max_size=5_000_000, max_files=3, required=True)
FieldConfig(id="pic", label="Avatar", type="avatar", value=user["avatar_url"])

async def save(values):
    for file_id in form.adopt_files("docs"):
        await filetransfer.upload("/api/upload", file_id)
  • type="file" shows a drop zone with a Choose button, and the picked files with their sizes and a × to remove each. Files that break a rule (accept, max_size, max_files) aren't added; the message says which and why. Picking the same file twice adds it once.
  • type="avatar" is a round picture: click it (or press Enter, or drop an image on it) to choose a new one, or Remove. It can start from the URL of an existing picture (same-origin, https or a data: URL).
  • The form never uploads anything itself. get_values reports {name, size, type}; form.get_files(id) returns the browser File objects, and form.adopt_files(id) hands them to filetransfer and returns ids for filetransfer.upload, with progress and the CSRF token.
  • set_values can clear a file field, or give an avatar a URL; files can't be put in from code.

Toggle groups

Added in #62.

FieldConfig(id="view", label="View", type="toggle_group", value="list", options=[
    SelectOption("list", "List", icon="mdi-format-list-bulleted"),
    SelectOption("grid", "Grid", icon="mdi-view-grid"),
])
FieldConfig(id="days", label="Days", type="toggle_group", multiple=True, options=["Mon", "Tue", "Wed"])
  • One choice works like radio buttons: arrow keys move and select.
  • multiple=True makes toggle buttons: arrow keys move, Space or Enter presses. The value is a list.
  • The group is one Tab stop. Disabled options are skipped.

Max length, icons and disabled options

Added in #59: max_length stops typing at the limit (and a longer value from set_values fails validation); icon="mdi-magnify" puts an icon inside the field; SelectOption(..., disabled=True) shows an option that can't be picked in a select, radio group, checkbox group, combo or toggle group.

Combo

A select you can type into, for long lists and for tag-style picks.

FieldConfig(id="db", label="Database", type="combo", options=databases,
            placeholder="Search databases")
FieldConfig(id="labels", label="Labels", type="combo", multiple=True,
            allow_custom=True, options=["bug", "feature", "docs"])
  • Typing filters the options by label. Arrow keys move, Enter picks, and Escape closes the list. on_change fires on a pick, never on a keystroke.
  • multiple=True shows picks as chips with a × each. Backspace in the empty box removes the last one.
  • allow_custom=True keeps typed text that is not an option, on Enter or when the field loses focus.
  • When the field loses focus: an exact label match is picked, other text goes back to the previous choice, and an emptied single combo is cleared.
  • The list can extend past a dialog's or cell's edge. Enter in it does not submit the form, and Escape closes it without closing the enclosing ModalWindow.
  • set_values and set_field_options drop values that are not options unless allow_custom is on.

Validators

Added in #55.

def end_after_start(value, values):
    start = values.get("start")
    return f"Must be after {start}" if start and value <= start else None

FieldConfig(id="end", label="End", type="date", validate=end_after_start,
            success_message="Looks good")
form.set_validator("port", lambda v, vals: None if v < 1024 else "Use a port below 1024")
  • validate=fn is an ordinary Python function. Your app's UI code runs in the browser under Pyodide, so the form calls it directly, like an event handler. It gets the field's value and values, the whole form, so rules that compare fields work.
  • Return None (or "" or True) when the value is fine, or the message to show. False shows "Invalid value". If it raises, the field shows "Invalid value" and the traceback goes to the browser console (as warnings: Pyodide prints Python errors there).
  • It runs after the built-in checks (required, bounds, min_length, pattern, matches) pass, and only when the field has a value and is shown and enabled. An empty optional field is never passed to it.
  • set_validator(id, fn) adds or replaces one later; set_validator(id, None) removes it.
  • success_message shows under the field, in green, once it passes; the next edit clears it.
  • A validator can be an async def, to ask the BFF ("is this name taken?"). While it runs the field shows a small spinner after its label; the answer replaces it, unless the value has changed since. Submitting waits for outstanding checks, with Submit disabled, and an edit during the wait cancels that submit. validate() and validate_field() don't wait; await form.validate_async() and await form.validate_field_async(id) do. Added in #60.
async def username_free(value, values):
    taken = await bff.call("user_exists", value)
    return "That name is taken" if taken else None

FieldConfig(id="user", label="Username", validate=username_free, success_message="Available")

Messages that blur validation shows (on_blur → validate_field) wait while the mouse button is held down, so a message line appearing under a field can't move the button being clicked out from under the pointer.

Validation is a convenience

on_submit fires only after client-side validation passes, but the BFF must revalidate everything. Fields that are hidden or disabled (directly or through a fieldset) are skipped, but still sent. Show its errors with set_error(field_id, message) (None for form-wide) or set_errors({...}), and disable the buttons with set_busy(True) while the call is in flight.

Events

Method Payload
on_submit values dict
on_cancel {}
on_change {id, value}: per keystroke or drag for text, number, date, colour and range fields; per pick for select, combo, checkbox, toggle and groups
on_invalid {errors}, plus id when a submit=True button was pressed
on_click {id} from a FormButton; {id, values} from a submit=True one, once validation passes
on_focus {id} when focus moves into a field
on_blur {id, value} when focus leaves a field

Focus

Added in #56.

form.on_blur(lambda p: form.validate_field(p["id"]))   # check each field as it is left
form.set_focus("host")                                  # False if hidden, disabled or static
form.get_focused()                                      # "host", or None
  • on_focus and on_blur fire once per field: moving between radio options, the two thumbs of a range, or a combo's chips stays inside the field and fires nothing. Form buttons don't fire them.
  • set_focus(id) focuses a field (a radio group's checked option, a range's low thumb) or a FormButton, and returns whether it could.
  • validate_field(id) runs one field's checks and shows only its result, leaving the other fields' messages alone. It returns the message, or None.

Methods

get_values, set_values, clear, reset, set_field_options, show_field, hide_field, set_field_disabled, set_busy, focus_first, set_focus, get_focused, submit, validate, validate_field, set_error, set_errors, clear_errors, set_validator, validate_async, validate_field_async, set_properties, get_properties, disable, enable, is_disabled, hide, show, is_visible, get_files, adopt_files, set_button_text, set_button_loading.

Changing fields after the form is built

Added in #57.

form.set_properties("email", label="Work email", required=True, help="We never share it")
form.set_properties("hours", min=6, max=22)        # a range re-snaps and redraws its ticks
form.set_properties("save", text="Saving…", variant="primary")
form.get_properties("email")                        # {"label": "Work email", "required": True, ...}

Fields take label, placeholder, help, icon, required, readonly, min / max / step, min_length / max_length, pattern, matches, options, success_message and the *_message overrides; None clears an optional one. Buttons take text, icon, tooltip and variant. Anything else raises ValueError.

The whole form

Added in #58. form.disable() disables every field and button, Submit and Cancel included; enable() brings them back, leaving fields you disabled yourself disabled. submit() does nothing while the form is disabled. hide() / show() hide it, keeping the values. FormConfig(disabled=True, hidden=True) sets the starting state.

Clearing and resetting

Added in #43.

form.on_submit(lambda v: (save(v), form.clear()))   # ready for the next entry
form.reset()                                         # back to the configured values
  • clear(values=True, errors=True) empties every field and/or removes the error messages. Checkboxes and toggles turn off, groups and combos empty, and a select ends with nothing chosen. A range or colour field cannot be blank, so a range goes to its min, a two-thumb range to [min, max] and a colour to #000000.
  • reset() puts back each field's configured value; a field without one goes back to how a fresh form shows it.
  • Neither fires on_change, like set_values.

set_field_options works on select, combo, radio and checkbox_group. Picks that are still offered stay picked.

Not yet

No on_keydown, form title or nested rows inside a row. Range tick labels are plain numbers (no units or custom text). Combo options can't be loaded from the server as you type. See the Roadmap.

Clone this wiki locally