Forms

Form

Group fields with validation and submit handling.

Rust
1use herogpui::components::form::Form;

Usage

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.

Rust
1use herogpui::prelude::{Button, Form, FormData, FormField, TextField, Variant};
2use gpui::prelude::*;
3use gpui::px;
4
5gpui::div()
6    .flex()
7    .flex_col()
8    .items_start()
9    .gap(px(12.))
10    .children(vec![
11        {
12            // `name` rides on each field's state, so the form finds
13            // it without the call site repeating the name.
14            let form = Form::new()
15                .field(
16                    FormField::text(self.input_name.clone())
17                        .is_required(true)
18                        .default_text(self.input_name.clone(), ""),
19                )
20                .field(
21                    FormField::text(self.input_email.clone())
22                        .default_text(self.input_email.clone(), ""),
23                )
24                .on_submit(cx.listener(|this, data: &FormData, _, cx| {
25                    this.input_submitted = data
26                        .iter()
27                        .map(|(n, v)| format!("{n}={}", v.as_text()))
28                        .collect::<Vec<_>>()
29                        .join(", ");
30                    cx.notify();
31                }))
32                .on_invalid(cx.listener(|this, _: &FormData, _, cx| {
33                    this.input_submitted = "Name is required".to_owned();
34                    cx.notify();
35                }))
36                .on_reset({
37                    let l = cx.listener(
38                        |this: &mut Self, _: &(), _: &mut gpui::Window, cx| {
39                            this.input_submitted = String::new();
40                            cx.notify();
41                        },
42                    );
43                    move |w: &mut gpui::Window, cx: &mut gpui::App| l(&(), w, cx)
44                });
45            let submit = form.submit_handler();
46            let reset = form.reset_handler();
47            form.child(
48                TextField::new(self.input_name.clone())
49                    .name("name")
50                    .label("Name")
51                    .placeholder("Ada Lovelace")
52                    .is_required(true),
53            )
54            .child(
55                TextField::new(self.input_email.clone())
56                    .name("email")
57                    .label("Email")
58                    .placeholder("ada@example.com")
59                    .description("We reply within a day."),
60            )
61            .child(
62                gpui::div()
63                    .flex()
64                    .gap(px(8.))
65                    .child(
66                        Button::new("form-submit")
67                            .label("Submit")
68                            .on_press(move |_, w, cx| submit(w, cx)),
69                    )
70                    .child(
71                        Button::new("form-reset")
72                            .label("Reset")
73                            .variant(Variant::Tertiary)
74                            .on_press(move |_, w, cx| reset(w, cx)),
75                    ),
76            )
77            .into_any_element()
78        },
79        para(
80            &if submitted.is_empty() {
81                "Nothing submitted yet".to_owned()
82            } else {
83                format!("Submitted: {submitted}")
84            },
85            cx,
86        ),
87    ])

Live HeroGPUI compiled to WebAssembly. Select any example without loading another WASM instance.

Anatomy

Rust types

Form

Form is assembled from these builders. The API reference lists each one.

Customization

Appearance builders and theme tokens Form uses.

Styling

Form styling
StyleDescription
built-in flex_col + gap(px(16.)) + w_full stackField appearance and validation states come from the field components the form composes; the form itself ships no stylesheet.
flex_col + gap(px(16.)) + w_fullContainer layout is style work in v3; gpui has no class-name surface, so the documented column rhythm is the built-in stack and width stays with the caller.

API reference

Builders

Form builders
BuilderTypeDefaultDescription
new()AnyElementTakes 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(callback)Fn(event: form submit event) -> ()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(callback)Fn(event: form submit event) -> ()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(callback)Fn(event: form submit event) -> ()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)ValidationBehaviorValidationBehavior::NativeNative 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)ValidationErrorsServer-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.

Parts

Form parts
PartDescription
FormOne root with native form semantics: a flex column on the 16px rhythm whose fields are registered, not discovered.

States

Form states
BuilderStateDescription
Form::run_submission -> Form::first_invalid_focusBlocked submit focuses first invalidA 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.
Form::run_submission + FormField submits_on_enter readersEnter / default submitterA 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.
FormField::is_read_only gate over required_names/own_invalid/first_invalid_focusRead-only barA read-only field stays successful and focusable — its value still submits — but constraint validation bars it: neither required emptiness nor a stored error blocks.
successful_of mirrors (InputState/OtpState/live)Disabled omissionA disabled control is not successful: it contributes no FormData and cannot block with stale validity, until a rerender re-enables it.
Form::render delivery canvas -> FormField::deliver_server_errors -> InputState/OtpState routed_errorsServer errors displayedRouted, 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.