Repository navigation
Form
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"))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.
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.
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=Truegives arangefield two thumbs. It reads back as[low, high](inget_values,on_changeandon_submit) and takes a list or tuple wherever it takes a value.Nonefor either end means that bound, so[None, 2000]is "up to 2000". Without avalueit spans the whole range. Values snap tostepand 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_changefires as a thumb moves, like the single slider. -
Ticks work on one- and two-thumb ranges: a mark every
ticksunits and a longer one everymajor_ticks, counted frommin. Major ticks show their value unlesstick_labels=False. - Accessibility: the label names a group of two sliders ("Price minimum", "Price maximum"). Each slider reports the other as its limit.
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
FormButtoninfieldstakes a grid cell, sospanworks; one inbuttonsgoes in the action row, before Cancel and Submit, and the row wraps when it runs out of room. -
on_clickfires with{id}. Withsubmit=Truethe form validates first: if it passes,on_clickgets{id, values}; if not,on_invalidfires with the errors and thisid, and focus goes to the first bad field. It never fireson_submit, and Enter in a field still submits through the main Submit button. -
variant:default(outlined),primary,dangerorlink.full=Truestretches it across its cell.iconandtooltipas elsewhere. -
set_button_loading(id, True)shows a spinner and disables the button;set_button_text(id, text)relabels it.show_field,hide_fieldandset_field_disabledtake button ids too.set_busyalso disablessubmit=Truebuttons. - Ids are unique across fields and buttons. Buttons never appear in
get_values.
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_widthis 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=Truehides a label from sight but keeps it as the field's accessible name, for a search box whose placeholder says what it is.
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"]))-
FormFieldsetis a titled, bordered group with its own grid (columns1–3) that takesspanin the grid around it, and can be nested.show_field,hide_fieldandset_field_disabledwith its id act on everything inside.label_positionandlabel_widthset on it apply to its fields. Its fields stay flat inget_values. -
FormSpaceris an empty cell (spancells wide) or, withheight, a fixed gap. Give it anidto show or hide it. -
type="static"shows a value as text beside or under its label.get_valuesreturns it as it was set,set_valueschanges it, andplaceholdershows while it is empty. It is read out with its label but is not a tab stop, and it cannot berequired. -
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,clearandresetdon't fireon_change, so re-apply any sections you switch on and off after calling them.
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 adata:URL). - The form never uploads anything itself.
get_valuesreports{name, size, type};form.get_files(id)returns the browserFileobjects, andform.adopt_files(id)hands them to filetransfer and returns ids forfiletransfer.upload, with progress and the CSRF token. -
set_valuescan clear a file field, or give an avatar a URL; files can't be put in from code.
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=Truemakes toggle buttons: arrow keys move, Space or Enter presses. The value is a list. - The group is one Tab stop. Disabled options are skipped.
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.
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_changefires on a pick, never on a keystroke. -
multiple=Trueshows picks as chips with a × each. Backspace in the empty box removes the last one. -
allow_custom=Truekeeps 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_valuesandset_field_optionsdrop values that are not options unlessallow_customis on.
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=fnis 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 andvalues, the whole form, so rules that compare fields work. - Return
None(or""orTrue) when the value is fine, or the message to show.Falseshows "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_messageshows 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()andvalidate_field()don't wait;await form.validate_async()andawait 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.
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.
| 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 |
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_focusandon_blurfire 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 aFormButton, 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, orNone.
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.
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.
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.
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 itsmin, a two-thumb range to[min, max]and a colour to#000000. -
reset()puts back each field's configuredvalue; a field without one goes back to how a fresh form shows it. - Neither fires
on_change, likeset_values.
set_field_options works on select, combo, radio and checkbox_group.
Picks that are still offered stay picked.
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.
wapyt is MIT-licensed · Repository · pyTincture · Issues
Getting started
Widgets
- Layout and MainWindow
- Tree
- DataTable
- Form
- ModalWindow
- Window
- TabWidget
- Toolbar
- Sidebar
- Chat
- Terminal
- CardPanel
- ResourceBoard
- File Transfer
- Message
- ContextMenu
- MenuBar
- Listbox
- Kanban
- ProgressBar
- Pagination
- Popup and tooltips
- Chart
- Scheduler
- MediaPlayer
Guides
Planning
Contributing