{}

Custom form inputs

Most apps never need to write one — DefaultFactory ships text, number, date, dropdown, chips, file, toggle, range, color, rating, and timezone inputs. You write one when you want a UI BESPA doesn’t have: a canvas-based editor, a map picker, an integration with a third-party JS widget like Quill or Monaco. The framework is set up for this — the contract is small.

The contract

An input widget is a Widget that also satisfies the InputWidget interface. Five methods, three of which the base type implements for you:

// widget/inputwidget.go
type InputWidget interface {
    Name() string                  // name of the state variable
    Value(r *http.Request) string  // current value (initial OR posted)
    Valid(r *http.Request) bool    // validation result
    Changed(r *http.Request) bool  // posted value differs from initial
    SetFormName(formName string)   // wired up by the parent Form
}

Name() and SetFormName() come from *InputWidgetBase[T]. You implement Value, Valid, and Changed. The parent Form walks its children at submit time, picks out everything satisfying InputWidget, calls Valid on each, and only fires the post-submit hook if every input returns true.

Skeleton

A custom ColorWheel widget, factory constructor, and the three interface methods:

type ColorWheelWidget struct {
    *widget.InputWidgetBase[*ColorWheelWidget]
    value string
}

var _ = widget.Widget(&ColorWheelWidget{})      // interface assertion
var _ = widget.InputWidget(&ColorWheelWidget{}) // interface assertion

func (f MyFactory) ColorWheel(name string) *ColorWheelWidget {
    x := &ColorWheelWidget{}
    x.InputWidgetBase = widget.NewInputWidgetBase(x)
    x.WithName(name)
    return x
}

func (x *ColorWheelWidget) WithValue(v string) *ColorWheelWidget {
    x.value = v
    return x
}

func (x *ColorWheelWidget) Value(r *http.Request) string {
    if x.Disabled() {
        return x.value
    }
    state := factory.StateOf(r)
    if state.Has(x.Name()) {
        return state.Get(x.Name())
    }
    return x.value
}

func (x *ColorWheelWidget) Valid(r *http.Request) bool {
    if x.Disabled() || !x.Submitted(r) {
        return true
    }
    v := x.Value(r)
    if v == "" && x.Required() {
        return false
    }
    return colorRegex.MatchString(v)
}

func (x *ColorWheelWidget) Changed(r *http.Request) bool {
    if x.Disabled() || !x.Submitted(r) {
        return false
    }
    return x.value != x.Value(r)
}

Three conventions to follow:

Draw: the hidden-input rule

BESPA’s client runtime gathers form values by walking <input>, <textarea>, and <select> elements inside the <form>. That’s the only mechanism. If your widget renders its UI as a canvas, a <div> tree, or a custom element, you need a hidden <input> (or <textarea>) carrying the value and using Name() as its name attribute. Otherwise the form submit sees nothing.

func (x *ColorWheelWidget) Draw(w io.Writer, r *http.Request) error {
    // The hidden input is what the form submission picks up. Its "name"
    // attribute MUST equal x.Name(). The visible UI — the color wheel —
    // is whatever you want; sync its value into the hidden input via JS.
    _, err := fmt.Fprintf(w,
        "<span data-id=%q class=\"ColorWheel\">"+
        "<input type=\"hidden\" name=%q value=%q>"+
        "<canvas data-target=%q></canvas>"+
        "<script>colorwheel_init(%q)</script>"+
        "</span>",
        x.ID(), x.Name(), x.Value(r), x.ID(), x.ID())
    return err
}

JS bridge

For widgets backed by a JS library, the bridge is: on every value change in the JS widget, write the new value into the hidden input. The form-submit handler reads from the DOM, so as long as the hidden input is current at submit time, BESPA sees the right value.

// Companion JS, registered via widget.AssetRegistry.RegisterScript.
// Whenever the visible widget changes, write the new value into the hidden
// input so the form submission picks it up.

function colorwheel_init(id) {
    const root = document.getElementById(id);
    const hidden = root.querySelector('input[type=hidden]');
    const canvas = root.querySelector('canvas');
    canvas.addEventListener('colorpicked', (e) => {
        hidden.value = e.detail.color;
    });
}

Register the companion JS via widget.AssetRegistry.RegisterScript — see Extend → Assets & CSS.

Validation

Valid(r) runs at submit time. The convention used across the built-in inputs is to short-circuit when disabled or not yet submitted, then check Required / length / type rules, then delegate to a registered predicate chain so callers can attach their own validators via WithValidator(...).

Store the error message on the widget so Draw can render it alongside the field. The built-in Field wrapper widget already reads Valid and surfaces an error label — pair your input with Field and you get the standard error layout for free.

A worked example: richedit

The richedit package (github.com/microbus-io/bespa/richedit) is a separate package — not part of DefaultFactory — that wraps the Quill editor as a BESPA input. It’s ~300 lines of Go plus the embedded JS/CSS, and a good reference for the full shape:

Read it end-to-end before writing your own — most of the questions you’ll have are already answered there.

See also

Widget anatomy — the underlying Widget interface and WidgetBase. InputWidgetBase is the input-specific subclass.

Assets & CSS — how to register the companion JS and CSS.

Packaging as a library — how to ship your input as a separate Go package that consumers compose into their factory.

Build → Forms & validation — the consumer-side view: how a form built with your custom input behaves at submit time.