docs

Binding Form Controls

Bind form controls to native fields, handle user edits, and validate incoming changes.

On this page

You can bind form controls on the page to native fields using ul-value. The control shows the field's value, and user changes come back to native code as requests that native code accepts or declines.

Example Data

The examples on this page use a settings panel where dd::Editable marks the fields the page may change— see Describing Types with Schemas:

C++
enum class Difficulty { Easy, Normal, Hard };

struct Settings {
  double volume = 0.8;
  bool subtitles = true;
  std::string playerName = "Ava";
  Difficulty difficulty = Difficulty::Normal;

  static constexpr auto schema = dd::Schema(
      dd::Field("volume", &Settings::volume, dd::Editable),
      dd::Field("subtitles", &Settings::subtitles, dd::Editable),
      dd::Field("playerName", &Settings::playerName, dd::Editable),
      dd::Field("difficulty", &Settings::difficulty, dd::Editable));
};

Native code binds the settings instance:

C++
Settings settings;

// This instance will be exposed to markup as "settings.<field>..."
dd::Binding binding = ctx.Bind("settings", settings);

Binding a Control

Set ul-value on a form control with the path to the field:

HTML
<input type="range" min="0" max="1" step="0.01" ul-value="settings.volume">

The library never writes directly to the field— it delivers the change request to an OnChange handler:

C++
binding.OnChange<"volume">([&](double volume) {
  settings.volume = volume;  // storing the value accepts the edit
});

How an Edit Travels

When a user edits a control, the change passes through four stages:

  1. The user moves the slider, and the control shows the new position right away.
  2. The next Sync() runs the handler on the home thread (before any actions).
  3. The handler stores the value to accept the edit, or leaves the field as it was to decline it.
  4. The same Sync() sends the field's value back to the page, so a declined edit snaps back.

📘 Only Fields of the Bound Type

Edits come back only for fields of the bound type itself. Using ul-value in a list row (see Binding Lists) or on a nested object shows the value but sends no changes. A field without dd::Editable doesn't bind the control at all.

Controls for Each Field Type

Each field type pairs with a matching form control on the page:

HTML
<input type="checkbox" ul-value="settings.subtitles">
<input type="text" ul-value="settings.playerName">
<select ul-value="settings.difficulty">
  <option>Easy</option>
  <option>Normal</option>
  <option>Hard</option>
</select>

Each OnChange handler takes the field's type:

C++
binding.OnChange<"subtitles">([&](bool on) { settings.subtitles = on; });
binding.OnChange<"playerName">(
    [&](const std::string& name) { settings.playerName = name; });
binding.OnChange<"difficulty">([&](Difficulty d) { settings.difficulty = d; });
Field Type Control Notes
bool Checkbox Sends its checked state.
Number Range, number, or text input
String Text input or <textarea>
Enum <select> Each option's value (or text) is an enumerator name.
Color Color input (type="color") The handler takes a Color.

Text and Number Fields

A text field sends a change on every keystroke— the handler runs once per Sync() with the latest text.

A number field ignores text that isn't a number yet (such as an empty field or a lone minus sign).

An integer field rounds the number.

Validating Changes

A validator in the schema checks or corrects a value before the change request is queued— it returns the value to accept:

C++
struct Audio {
  double volume = 0.8;

  static constexpr auto schema = dd::Schema(
      dd::Field("volume", &Audio::volume, dd::Editable,
                dd::Validate([](double v) {
                  return std::clamp(v, 0.0, 1.0);
                })));
};

Rejecting a Value

Return a std::optional and return std::nullopt to reject a value— the control snaps back to the last accepted value right away:

C++
struct Profile {
  std::string playerName = "Ava";

  static constexpr auto schema = dd::Schema(
      dd::Field("playerName", &Profile::playerName, dd::Editable,
                dd::Validate(
                    [](std::string name) -> std::optional<std::string> {
                      if (name.empty())
                        return std::nullopt;  // the field snaps back
                      return name;
                    })));
};

Checking Other Fields

A second parameter receives the values the page shows— declare it auto inside the schema and read fields with Get<"name">():

C++
struct Shop {
  int64_t gold = 500;
  int64_t bid = 0;

  static constexpr auto schema = dd::Schema(
      dd::Field("gold", &Shop::gold),
      dd::Field("bid", &Shop::bid, dd::Editable,
                dd::Validate([](int64_t bid, auto s) -> std::optional<int64_t> {
                  if (bid > s.template Get<"gold">())
                    return std::nullopt;  // can't bid more than you have
                  return bid;
                })));
};

🚧 Validators Run on the Renderer's Thread

Validators must not read or change native objects (read other fields through the second parameter). A validator must not capture anything and must not throw.