Skip to content

Forms

Form state helpers, validators, aggregated validation, and accessibility patterns. Everything here is a thin layer over signals and delegated events, so you can drop down to plain create_signal and on_input whenever the helpers don't fit. Import the helpers from wybthon.forms; they aren't exported from the top-level wybthon package.

from wybthon import button, component, form, input_, label, option, select, span
from wybthon.forms import (
    a11y_control_attrs,
    bind_checkbox,
    bind_select,
    bind_text,
    email,
    error_message_attrs,
    form_state,
    min_length,
    on_submit_validated,
    required,
)

fields = form_state({"name": "", "email": "", "agree": False, "choice": ""})

rules = {
    "name": [required(), min_length(2)],
    "email": [email()],
}


@component
def SignupForm():
    name = fields["name"]
    email_field = fields["email"]

    def save(f):
        print({k: field.value.peek() for k, field in f.items()})

    return form(
        label("Name", for_="name"),
        input_(
            id="name",
            **bind_text(name, validators=rules["name"]),
            **a11y_control_attrs(name, described_by_id="name-err"),
        ),
        span(name.error, **error_message_attrs(id="name-err")),
        label("Email", for_="email"),
        input_(
            id="email",
            type="email",
            **bind_text(email_field, validators=rules["email"]),
            **a11y_control_attrs(email_field, described_by_id="email-err"),
        ),
        span(email_field.error, **error_message_attrs(id="email-err")),
        label(input_(type="checkbox", **bind_checkbox(fields["agree"])), " Agree"),
        label("Choice", for_="choice"),
        select(
            option("--", value=""),
            option("A", value="a"),
            option("B", value="b"),
            id="choice",
            **bind_select(fields["choice"]),
        ),
        button("Submit", type="submit"),
        on_submit=on_submit_validated(rules, save, fields),
    )

Fields

form_state turns a dict of initial values into a FormState mapping of Field objects. Each field exposes reactive values:

Attribute Type Meaning
value / set_value Accessor[T] / method The current input value.
error / set_error Accessor[str \| None] The latest validation message, or None.
touched / set_touched Accessor[bool] True once the user has interacted with the field.

Because these are ordinary accessors, you can render them directly as children or bindings. span(name.error) shows the message reactively and renders nothing while it's None; field.set_value("...") from code updates the bound input too.

Use touched to hold error display until the user has typed:

span(lambda: name.error() if name.touched() else None, **error_message_attrs(id="name-err"))

Field.validate(validators) runs the rules against the current value, marks the field touched, and stores the error. It's what the aggregate helpers call under the hood.

Bindings

The bind_* helpers return prop dicts to spread onto a control:

Checkbox and select bindings clear the error on change; text bindings revalidate on every input event. Add more props alongside the spread (placeholder, class_, autocomplete) as usual.

Validators

A Validator is a function from a value to an error string or None. The built-ins are factories so messages are customizable:

from wybthon.forms import email, max_length, min_length, required

rules = {
    "name": [required("Please enter a name"), min_length(2), max_length(40)],
    "email": [required(), email()],
}
  • required rejects None and blank strings.
  • min_length and max_length compare len(str(value)).
  • email checks a lightweight pattern and treats empty values as valid, so pair it with required when the field is mandatory.

Write your own by returning a message or None:

def matches(other):
    def _v(value):
        return None if value == other.value.peek() else "Passwords don't match"

    return _v

validate(value, validators) returns the first failing message. rules_from_schema builds a rules map from a small declarative dict when you'd rather configure than compose.

Submitting

Both return an event handler for the form's on_submit prop. Signal writes inside the handler flush when it returns, so error messages and aria-invalid states update in one commit.

To validate a single field on blur or on demand, call validate_field(field, validators).

Accessibility

  • Set html_for (or for_) on label to match the control's id. The prop applier writes it as the for attribute.
  • a11y_control_attrs(field, described_by_id=...) returns reactive aria_invalid and aria_describedby props: aria-invalid is "true" while the field has an error, and aria-describedby points at the message container only while a message exists, so screen readers don't announce an empty region.
  • error_message_attrs(id=...) returns id, role="alert", and aria-live="polite" for the message container.

Because the ARIA props are accessors, they update through fine-grained bindings without re-rendering the form.

Controlled inputs without the helpers

The helpers are optional. A controlled input is a signal, a value binding, and an on_input handler:

from wybthon import component, create_signal, input_


@component
def Search():
    query, set_query = create_signal("")
    return input_(value=query, on_input=lambda e: set_query(e.target.value))

e.target.value is read from the dispatch payload, not from the DOM, so it's cheap even in large lists. See Events.

Next steps

  • See the Forms example for an end-to-end form.
  • Browse the forms API reference for every helper.
  • Read Events for delegated handler details.

Dirty state, reset, and async workflows

Each field has dirty and validating accessors. field.reset() restores its initial value and clears touched and error state; field.reset(value) establishes a new baseline. FormState's dirty and validating accessors aggregate its fields, .data() returns current values, and .reset(values=None) resets the form.

await field.validate_async(validators) accepts synchronous or async validators (AsyncValidator). Editing the value cancels stale validation, and a response for an old revision can't overwrite the current error. Owning scope disposal cancels validation too.

async def save(values):
    return await post_profile(values)


async def submit(event):
    event.prevent_default()
    await fields.submit(save, rules=rules)


form(input_(**bind_text(fields["name"])), button("Save", type="submit"), on_submit=submit)

FormState.submit validates the fields, snapshots their values, and awaits the handler. .submitting and .submit_error expose the result. If values change during validation, that submission doesn't send stale values. The simpler on_submit helpers also propagate async handler results to the event task.

bind_text(field, parse=..., format=...) separates display text from stored values and reports conversion errors. bind_number(field) handles numeric values and stores an empty input as None. bind_multiselect(field) binds a list of selected values through selected_values, with no per-option Python-to-JS reads. Controlled selections are applied after options are inserted or replaced.