Binding Form Controls
Bind form controls to native fields, handle user edits, and validate incoming changes.
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:
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:
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:
<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:
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:
- The user moves the slider, and the control shows the new position right away.
- The next
Sync()runs the handler on the home thread (before any actions). - The handler stores the value to accept the edit, or leaves the field as it was to decline it.
- 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-valuein a list row (see Binding Lists) or on a nested object shows the value but sends no changes. A field withoutdd::Editabledoesn't bind the control at all.
Controls for Each Field Type
Each field type pairs with a matching form control on the page:
<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:
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:
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:
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">():
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.