Skip to content
PorabuildHeroGPUI component library
v0.14.0 · Apache-2.0

Forms

Form

Group fields with validation and submit handling.

Rust
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.

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. Runs in a view's render: self is the view holding this example's state and cx is its Context<Self>.

Rust
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).

Server Errors

`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>.

Rust
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.

para
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()
}
FORM_SERVER_RECORD
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

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

Form builders
BuilderValuesDefaultDescription
new()PartialAnyElement—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(callback)PartialFn(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)ValidationBehavior::Native | ValidationBehavior::AriaValidationBehavior::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)ValidationErrors = Record<string, string | string[]>—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
Form builders not ported
HeroUI v3Why it is not ported
Form.actionThe 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.classNameGpui has no class-name surface; the documented container layout is built in.
Form.encTypeA wire-format choice for the HTTP submission this port does not perform.
Form.methodThe HTTP verb for a navigation this port does not perform; onSubmit receives the collected FormData instead.
Form.targetWhere a browser displays the response; there is no navigation to target.
Form.aria-labelNames the form landmark; gpui exposes no accessibility tree.
Form.aria-labelledbyLabels the form landmark by element id; gpui exposes no accessibility tree.
Form.renderSubstitutes the form element; a gpui element tree has no root to replace.

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
StateBuilderDescription
Blocked submit focuses first invalidForm::run_submission -> Form::first_invalid_focusA 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 submitterPartialForm::run_submissionA 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 barFormField::is_read_only gate over required_names/own_invalid/first_invalid_focusA 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 omissionsuccessful_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 displayedForm::render delivery canvas -> FormField::deliver_server_errors -> InputState/OtpState routed_errorsRouted, 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.