フォーム
フォームのルールは Rust で 1 回だけ書きます。ブラウザは入力に合わせてそのルールを検査し、ハンドラは値を使う前に同じルールで検査します。
ルール
型が Form<T> のフィールドは、フォームの値、開始時の値、訪問者が離れたフィールド、サーバーが送ったメッセージを持ちます。T は値を持つシグナルの構造体で、Validate を実装します。Validate の rules は、フィールドごとに検査を足します。検査は、真であるべき式と、偽のあいだ表示するメッセージの組です。
下のデモでは、フィールドを離れるとメッセージが出て、ボタンを押すとすべてのメッセージが一度に出ます。ボタンの文は、すべてのルールが通るときだけ実行されます。値が開始時と変わると、クリアのボタンが出ます。
実行結果
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("@"),
"メールアドレスを入力してください。",
)
.check(
Signup::password(),
password.char_count().ge(8),
"8 文字以上にしてください。",
)
.check(
Signup::confirm(),
form.at(Signup::confirm()).equals(password),
"パスワードが一致しません。",
)
.check(
Signup::plan(),
!form.at(Signup::plan()).equals(&Plan::Unset),
"プランを選んでください。",
)
.check(
Signup::terms(),
form.at(Signup::terms()),
"続けるには規約に同意してください。",
);
}
}
#[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(
"登録",
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(
"メールアドレス",
signup,
Signup::email(),
)
.kind(InputKind::Email)
.autocomplete(Autocomplete::Email),
)
.child(
TextField::in_form(
"パスワード",
signup,
Signup::password(),
)
.kind(InputKind::Password)
.autocomplete(Autocomplete::NewPassword),
)
.child(
TextField::in_form(
"パスワードの確認",
signup,
Signup::confirm(),
)
.kind(InputKind::Password)
.autocomplete(Autocomplete::NewPassword),
)
.child(select_field_in_form(
"プラン",
signup,
Signup::plan(),
[
(
Plan::Unset,
"選んでください",
),
(Plan::Free, "Free"),
(Plan::Pro, "Pro"),
],
))
.child(checkbox_in_form(
"規約に同意します",
signup,
Signup::terms(),
))
.child(
div()
.css(hstack(rem(0.75)))
.child(
submit_button(ButtonVariant::Default, ButtonSize::Default).text("アカウントを作成"),
)
.child(
button(ButtonVariant::Outline, ButtonSize::Default)
.show(signup.is_dirty())
.on_click(signup.reset())
.text("クリア"),
),
)
.child(
p().attr("role", "status")
.css(Style::new().color(token::muted_foreground().var()))
.show(joined.and(!signup.is_dirty()))
.text("すべてのルールを通りました。"),
),
)
}メッセージ、変更の検出、リセット
error(field) は、訪問者がそのフィールドを離れたか(touch が記録します)、フォームを送信したあとに、ルールのメッセージを返します。TextField::in_form はテキストフィールドをフォームの値に束縛し、この 2 つを付けます。
form(then) は <form novalidate> を返します。送信すると、ルールのあるフィールドをすべて触れたことにし、すべてのルールが通るときだけ then を実行します。ブラウザ自身の検査を止めているのは、それがルールより先に送信を止めてしまうからです。is_dirty は値を開始時の値と比べ、reset は開始時の値に戻します。
テキスト以外のコントロールと自作のコンポーネント
field、touch、error、Rules::check はどの型のフィールドも受け取るので、デモのプランは列挙型、規約への同意は bool です。hypersig_ui のフォーム用のコントロールには、それぞれフォーム版があります。select_field_in_form、radio_group_in_form、checkbox_in_form、switch_in_form です。どれも TextField::in_form と同じく、コントロールをフィールドに束縛し、離れたときに touch し、メッセージをコントロールの下に出して aria-describedby と aria-invalid で結び付けます。チェックボックスとスイッチは bool のフィールドしか受け取らず、コンパイラがそれを検査します。
自作のコンポーネントには、ほかのシグナルと同じように form.field(f) を渡します。Event::Blur は、フォーカスが要素とその中のすべてから外れたときに実行されます。そのため、コンポーネントが返す要素に on_blur(form.touch(f)) を付けると、フォーカスがコンポーネントの外へ出たときに実行されます。中のコントロールどうしでフォーカスが移っても実行されません。メッセージは bind_text(form.error(f)) を付けた別の要素に出し、コントロールの aria-describedby でその要素の id を指してください。そうするとスクリーンリーダーが、メッセージをコントロールの説明として読みます。
サーバーでの検査
rules はページの型 S について総称的なので、同じコードから式を 2 回作ります。ページでは、訪問者の入力に合わせてブラウザが式を評価します。ハンドラでは、Form::validate が最初の HTML を書く評価器で同じ式を評価するので、ブラウザが受け付けた値はサーバーでも通ります。登録済みのメールアドレスのようにサーバーにしか検査できないことは、FieldErrors にして reject で返します。そのメッセージは次の送信まで残ります。
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()
}1 つのフィールドへの差分は開始時の値を変えないので、値を保存したハンドラは Form::new(saved) でフォーム全体に差分を送ります。このサイトにはサーバーがないので、上のデモはこのコードが JOIN を呼ぶところでシグナルを書き換えています。