English

フォーム

フォームのルールは Rust で 1 回だけ書きます。ブラウザは入力に合わせてそのルールを検査し、ハンドラは値を使う前に同じルールで検査します。

ルール

型が Form<T> のフィールドは、フォームの値、開始時の値、訪問者が離れたフィールド、サーバーが送ったメッセージを持ちます。T は値を持つシグナルの構造体で、Validate を実装します。Validate の rules は、フィールドごとに検査を足します。検査は、真であるべき式と、偽のあいだ表示するメッセージの組です。

下のデモでは、フィールドを離れるとメッセージが出て、ボタンを押すとすべてのメッセージが一度に出ます。ボタンの文は、すべてのルールが通るときだけ実行されます。値が開始時と変わると、クリアのボタンが出ます。

実行結果

signup.rs
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 を呼ぶところでシグナルを書き換えています。