Forms
A form's rules are written once in Rust. The browser checks them as the visitor types, and the handler checks the same rules before it trusts the values.
Rules
A field of type Form<T> holds a form's values, the values it started from, which fields the visitor has left, and the messages the server sent. T is a signal struct of the values that implements Validate, whose rules adds a check per field: an expression that must be true, and the message shown while it is false.
In the demo below, a message appears once you leave a field, and pressing the button shows every message at once. The button runs its statement only when every rule passes, and Clear appears as soon as a value differs from the start.
Result
use hypersig::html::{div, p};
use hypersig::style::Style;
use hypersig::style::pattern::{hstack, stack};
use hypersig::style::value::rem;
use hypersig::{Form, Page, Rules, Sig, Signals, Validate};
use hypersig_ui::{
Autocomplete, ButtonSize, ButtonVariant, InputKind, TextField, button, checkbox_in_form,
select_field_in_form, submit_button, token,
};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Default)]
pub enum Plan {
#[default]
Unset,
Free,
Pro,
}
#[derive(Signals, Serialize, Deserialize, Clone, Default)]
pub struct Signup {
email: String,
password: String,
confirm: String,
plan: Plan,
terms: bool,
}
impl Validate for Signup {
fn rules<S>(form: Sig<S, Self>, rules: &mut Rules<S, Self>) {
let email = form.at(Signup::email());
let password = form.at(Signup::password());
rules
.check(
Signup::email(),
email.contains("@"),
"Enter an email address.",
)
.check(
Signup::password(),
password.char_count().ge(8),
"Use 8 or more characters.",
)
.check(
Signup::confirm(),
form.at(Signup::confirm()).equals(password),
"The passwords differ.",
)
.check(
Signup::plan(),
!form.at(Signup::plan()).equals(&Plan::Unset),
"Choose a plan.",
)
.check(
Signup::terms(),
form.at(Signup::terms()),
"Accept the terms to continue.",
);
}
}
#[derive(Signals, Serialize, Deserialize)]
pub struct Join {
signup: Form<Signup>,
joined: bool,
}
pub fn page() -> Page<Join> {
let signup = Join::signup();
let joined = Join::joined();
let initial = Join {
signup: Form::new(Signup::default()),
joined: false,
};
Page::new(
"Sign up",
initial,
)
.child(
signup
// The site has no server, so a valid submit sets a signal where
// an application would call an action.
.form(joined.set(true).then(signup.reset()))
.css(stack(rem(1.25)).max_width(rem(24)))
.child(
TextField::in_form(
"Email",
signup,
Signup::email(),
)
.kind(InputKind::Email)
.autocomplete(Autocomplete::Email),
)
.child(
TextField::in_form(
"Password",
signup,
Signup::password(),
)
.kind(InputKind::Password)
.autocomplete(Autocomplete::NewPassword),
)
.child(
TextField::in_form(
"Confirm password",
signup,
Signup::confirm(),
)
.kind(InputKind::Password)
.autocomplete(Autocomplete::NewPassword),
)
.child(select_field_in_form(
"Plan",
signup,
Signup::plan(),
[
(
Plan::Unset,
"Choose…",
),
(Plan::Free, "Free"),
(Plan::Pro, "Pro"),
],
))
.child(checkbox_in_form(
"I accept the terms",
signup,
Signup::terms(),
))
.child(
div()
.css(hstack(rem(0.75)))
.child(
submit_button(ButtonVariant::Default, ButtonSize::Default).text("Create account"),
)
.child(
button(ButtonVariant::Outline, ButtonSize::Default)
.show(signup.is_dirty())
.on_click(signup.reset())
.text("Clear"),
),
)
.child(
p().attr("role", "status")
.css(Style::new().color(token::muted_foreground().var()))
.show(joined.and(!signup.is_dirty()))
.text("Every rule passed."),
),
)
}Messages, changes, and reset
error(field) shows a rule's message once the visitor has left the field, which touch records, or has submitted the form. TextField::in_form binds a text field to the form's value and wires both.
form(then) returns a <form novalidate> that touches every field with a rule on submit and runs then only when every rule passes. The browser's own checks are off because they would stop the submit before the rules run. is_dirty compares the values with the values the form started from, and reset puts those back.
Other controls and your own components
field, touch, error, and Rules::check take a field of any type, so the plan in the demo is an enum and the terms are a bool. hypersig_ui has a form variant of each of its form controls: select_field_in_form, radio_group_in_form, checkbox_in_form, and switch_in_form. Like TextField::in_form, each binds the control to the field, touches it when the visitor leaves, and shows the message under the control, tied to it by aria-describedby and aria-invalid. A checkbox or a switch takes only a bool field, which the compiler checks.
A component you write yourself takes form.field(f) like any signal. Event::Blur runs when focus leaves the element and everything inside it, so on_blur(form.touch(f)) on the element the component returns runs when focus leaves the component, and not while it moves between the controls inside. Put the message on an element of its own with bind_text(form.error(f)), and point the control's aria-describedby at that element's id so that a screen reader reads the message as the control's description.
Checking on the server
rules is generic over the page S, so the same code builds the expressions twice. On the page, the browser evaluates them as the visitor types. In the handler, Form::validate evaluates them with the evaluator that writes the first HTML, so a value the browser accepted passes on the server too. What only the server can check, such as an address that is already taken, goes back as FieldErrors through reject, and its message stays until the next submit.
use hypersig::html::{button, input, span};
use hypersig::{Action, FieldErrors, Form, Page, Patches, Rules, Sig, Signals, Validate};
use serde::{Deserialize, Serialize};
#[derive(Signals, Serialize, Deserialize, Clone, Default)]
struct Signup {
email: String,
password: String,
}
// The rules, written once. The browser and `Form::validate` evaluate the same expressions.
impl Validate for Signup {
fn rules<S>(form: Sig<S, Self>, rules: &mut Rules<S, Self>) {
let email = form.at(Signup::email());
rules
.check(Signup::email(), email.contains("@"), "Enter an email address.")
.check(
Signup::password(),
form.at(Signup::password()).char_count().ge(8),
"Use 8 or more characters.",
);
}
}
#[derive(Signals, Serialize, Deserialize)]
struct Join {
signup: Form<Signup>,
}
const JOIN: Action<Join, Signup> = Action::post("/join");
let signup = Join::signup();
let email = Signup::email();
let page = Page::new("Join", Join { signup: Form::new(Signup::default()) }).child(
signup
.form(JOIN.call_with(signup.value()))
.child(input().bind(signup.field(email)).on_blur(signup.touch(email)))
.child(span().bind_text(signup.error(email)))
.child(button().show(signup.is_dirty()).on_click(signup.reset()).text("Clear"))
.child(button().attr("type", "submit").text("Join")),
);
// The handler checks the same rules, then what only the server knows.
async fn join((): (), value: Signup) -> Patches<Join> {
let signup = Join::signup();
if let Err(errors) = Form::validate(&value) {
return signup.reject(&errors).into();
}
if value.email == "taken@example.com" {
let taken = FieldErrors::new().add(Signup::email(), "That address is taken.");
return signup.reject(&taken).into();
}
signup.patch(Form::new(Signup::default())).into()
}A patch of one field leaves the starting values alone, so a handler that has saved the values patches the whole form with Form::new(saved). This site has no server, so the demo above sets a signal where this code calls JOIN.