Form
Group fields with validation and submit handling.
use herogpui::components::form::Form;Form on docs.rsSource at v0.14.0
Usage
Showing Usage, live: HeroGPUI compiled to WebAssembly. The canvas has no accessibility tree; each example's description and code below carry the same information.
The wired Submit button and Enter in a focused field run the same submission: with the required Name empty, either door reports the invalid path instead. Runs in a view's render: self is the view holding this example's state and cx is its Context<Self>.
use herogpui::prelude::{ActiveTheme, Button, Form, FormData, FormField, TextField, Variant};
use herogpui::gpui::prelude::*;
use herogpui::gpui::{App, px};
use herogpui::gpui;
let submitted = self.input_submitted.clone();
gpui::div()
.flex()
.flex_col()
.items_start()
.justify_center()
.gap(px(12.))
.children(vec![
{
// `name` rides on each field's state, so the form finds
// it without the call site repeating the name.
let form = Form::new()
.field(
FormField::text(self.input_name.clone())
.is_required(true)
.default_text(self.input_name.clone(), ""),
)
.field(
FormField::text(self.input_email.clone())
.default_text(self.input_email.clone(), ""),
)
.on_submit(cx.listener(|this, data: &FormData, _, cx| {
this.input_submitted = data
.iter()
.map(|(n, v)| format!("{n}={}", v.as_text()))
.collect::<Vec<_>>()
.join(", ");
cx.notify();
}))
.on_invalid(cx.listener(|this, _: &FormData, _, cx| {
this.input_submitted = "Name is required".to_owned();
cx.notify();
}))
.on_reset({
let l = cx.listener(
|this: &mut Self, _: &(), _: &mut gpui::Window, cx| {
this.input_submitted = String::new();
cx.notify();
},
);
move |w: &mut gpui::Window, cx: &mut gpui::App| l(&(), w, cx)
});
let submit = form.submit_handler();
let reset = form.reset_handler();
form.child(
TextField::new(self.input_name.clone())
.name("name")
.label("Name")
.placeholder("Ada Lovelace")
.is_required(true),
)
.child(
TextField::new(self.input_email.clone())
.name("email")
.label("Email")
.placeholder("ada@example.com")
.description("We reply within a day."),
)
.child(
gpui::div()
.flex()
.gap(px(8.))
.child(
Button::new("form-submit")
.label("Submit")
.on_press(move |_, w, cx| submit(w, cx)),
)
.child(
Button::new("form-reset")
.label("Reset")
.variant(Variant::Tertiary)
.on_press(move |_, w, cx| reset(w, cx)),
),
)
.into_any_element()
},
para(
&if submitted.is_empty() {
"Nothing submitted yet".to_owned()
} else {
format!("Submitted: {submitted}")
},
cx,
),
])Also calls para (included when you copy).
`ValidationErrors` is a record of server errors keyed by field name. The Form routes each name into that field's own error slot: editing a field clears only its message while its sibling keeps theirs, Reset hides them all, and a re-render that passes the same record re-arms nothing. New response supplies a genuinely new record — identical content, fresh identity — so both messages re-arm. Runs in a view's render: self is the view holding this example's state and cx is its Context<Self>.
use herogpui::prelude::{ActiveTheme, Button, Form, FormData, FormField, TextField, ValidationErrors, Variant};
use herogpui::gpui::prelude::*;
use herogpui::gpui::{App, px};
use herogpui::gpui;
use std::cell::RefCell;
{
let email = self.demo_text("form-srv-email", "ada@example.com", cx);
let name = self.demo_text("form-srv-name", "Ada", cx);
let report = self.demo_text_value("form-srv-report");
FORM_SERVER_RECORD.with_borrow_mut(|slot| {
slot.get_or_insert_with(|| {
ValidationErrors::new()
.set("email", "Already registered")
.set("name", "That name is taken")
});
});
// A clone per frame keeps the record's identity, so the
// page's own re-renders never re-arm a field the user has
// edited; only the New response button mints a record.
let record = FORM_SERVER_RECORD
.with_borrow(|slot| slot.as_ref().expect("seeded below").clone());
let form = Form::new()
.validation_errors(record)
.field(FormField::text(email.clone()))
.field(FormField::text(name.clone()))
.on_submit(cx.listener(|this, data: &FormData, _, cx| {
let body = data
.iter()
.map(|(n, v)| format!("{n}={}", v.as_text()))
.collect::<Vec<_>>()
.join(", ");
this.set_demo_text_value("form-srv-report", body);
cx.notify();
}))
.on_invalid(cx.listener(|this, _: &FormData, _, cx| {
this.set_demo_text_value(
"form-srv-report",
"onInvalid: a routed server error is still blocking".to_owned(),
);
cx.notify();
}));
let submit = form.submit_handler();
let reset = form.reset_handler();
gpui::div()
.flex()
.flex_col()
.items_start()
.justify_center()
.gap(px(12.))
.children(vec![
form.child(
TextField::new(email)
.name("email")
.label("Email")
.description("Edit this and only its server message clears."),
)
.child(TextField::new(name).name("name").label("Name"))
.child(
gpui::div()
.flex()
.gap(px(8.))
.child(
Button::new("form-srv-submit")
.label("Submit")
.on_press(move |_, w, cx| submit(w, cx)),
)
.child(
Button::new("form-srv-reset")
.label("Reset")
.variant(Variant::Tertiary)
.on_press(move |_, w, cx| reset(w, cx)),
)
.child(
Button::new("form-srv-rearm")
.label("New response")
.on_press(|_, window, _| {
FORM_SERVER_RECORD.with_borrow_mut(|slot| {
// Same content, fresh record: a
// new response re-arms every
// named field.
*slot = Some(
ValidationErrors::new()
.set("email", "Already registered")
.set("name", "That name is taken"),
);
});
window.refresh();
}),
),
)
.into_any_element(),
para(
&if report.is_empty() {
"Submit while a message is showing and onInvalid runs.".to_owned()
} else {
report
},
cx,
),
])
.into_any_element()
}Also calls FORM_SERVER_RECORD, para (included when you copy).
Helpers used by these examples
Plain functions over the public API, defined once here and copied with each example that calls them.
fn para(text: &str, cx: &App) -> gpui::AnyElement {
let colors = cx.colors();
gpui::div()
.text_size(px(14.5))
.line_height(px(24.))
.text_color(colors.foreground)
.max_w(px(720.))
.child(text.to_owned())
.into_any_element()
}thread_local! {
/// The Form "Server Errors" demo's current `validationErrors` record.
///
/// Held outside the page function on purpose: the page rebuilds its
/// elements every frame, and the record's *identity* is the contract the
/// demo exists to show. A record minted per frame would re-arm both
/// fields on every keystroke; this one is cloned per frame — clones keep
/// the record's revision — and replaced only by the demo's New response
/// button, which is a genuinely new response.
static FORM_SERVER_RECORD: RefCell<Option<ValidationErrors>> = const { RefCell::new(None) };
}Anatomy
Rust types
Form is assembled from these builders. The API reference lists each one.
API reference
Against HeroUI v3.2.6: 9 entries ported, 3 partial, 8 not ported. Partial rows are marked in the tables; entries not ported are listed under each table with the reason.
Builders
| Builder | Values | Default | Description |
|---|---|---|---|
new(Partial | AnyElement | — | Takes child elements like any container; the fields among them are additionally registered with field(.), because gpui gives a child no way to discover its ancestor form. |
on_invalid(Partial | Fn( | — | Runs instead of onSubmit when validation blocks, and focuses the first invalid field by default. V3's cancelable event — preventDefault() to customize that focus — has no port: the callback receives the FormData and the focus move is not cancelable. |
on_reset( | Fn( | — | Fires after the registered fields that declared a default are restored; wired through reset_handler() the way the submit button is wired through submit_handler(). |
on_submit( | Fn( | — | Receives the collected record in registration order, read back as name=value pairs through FormData::text/get_all — the same shape however the submission arrived: the wired submit button or Enter in a participating field. |
validation_behavior( | ValidationBehavior:: | ValidationBehavior:: | Native blocks submission on a failed field and routes to onInvalid; aria shows messages without blocking. Settable per field as well as per form. |
validation_errors( | ValidationErrors = Record< | — | Server-side errors shown immediately, routed by field name: each named field's own state receives its messages and displays them in its error slot, joined in upstream order. Editing a field suppresses only its messages, reset hides them all, and a genuinely new record re-arms every named field even when the content is identical — a clone keeps the record's identity and re-arms nothing. Unmatched names neither display nor block, and fields without an error-display path (the live-registered selects, switches, checkboxes and pickers) receive no routed messages, so a name can never block invisibly. |
Not ported8 HeroUI entries not ported
| HeroUI v3 | Why it is not ported |
|---|---|
Form.action | The URL the submission navigates to; there is no browser here and no submission endpoint, so the HTTP half of a native form is out of reach. |
Form.className | Gpui has no class-name surface; the documented container layout is built in. |
Form.encType | A wire-format choice for the HTTP submission this port does not perform. |
Form.method | The HTTP verb for a navigation this port does not perform; onSubmit receives the collected FormData instead. |
Form.target | Where a browser displays the response; there is no navigation to target. |
Form.aria-label | Names the form landmark; gpui exposes no accessibility tree. |
Form.aria-labelledby | Labels the form landmark by element id; gpui exposes no accessibility tree. |
Form.render | Substitutes the form element; a gpui element tree has no root to replace. |
Parts
| Part | Description |
|---|---|
Form | One root with native form semantics: a flex column on the 16px rhythm whose fields are registered, not discovered. |
States
| State | Builder | Description |
|---|---|---|
Blocked submit focuses first invalid | Form::run_submission -> Form::first_invalid_focus | A blocked submission moves the focus to the first registered field whose error blocks — required emptiness or stored invalidity — on both the button and the Enter path; from Enter the move is deferred past the keystroke so the release cannot click the control it lands on. |
Enter / default submitterPartial | Form::run_submission | A browser picks the default submitter (the first submit button in tree order) and skips implicit submission with no submit button and more than one field blocking validation; gpui children are opaque elements, so neither can be inferred. This port always runs the one shared submission when Enter lands in a participating registered field — a GPUI substitute for the browser rule, which fields with their own Enter (a set onSubmit, an open ComboBox list) keep by stopping propagation. |
Read-only bar | FormField::is_read_only gate over required_names/own_invalid/first_invalid_focus | A read-only field stays successful and focusable — its value still submits — but constraint validation bars it: neither required emptiness nor a stored error blocks. |
Disabled omission | successful_of mirrors (InputState/OtpState/live) | A disabled control is not successful: it contributes no FormData and cannot block with stale validity, until a rerender re-enables it. |
Server errors displayed | Form::render delivery canvas -> FormField::deliver_server_errors -> InputState/OtpState routed_errors | Routed, not form-level: each named field displays its own messages in its error slot and blocks a native submit while present — unless it is disabled or read-only, which display without blocking. Delivery is keyed to the record's identity, so an edit stays suppressed across re-renders and reset keeps a clone from resurrecting a message. |