Skip to content

フォームとバリデーション ​

Kumiki のフォームは「個別入力の bind で slot に直接束縛」する形と「専用 tile に ui.submit で受ける」形の 2 通りを提供する。前者はリアクティブな逐次反映、後者はトランザクション的な確定送信向け。

イベントセレクタは 常に tile 名で書く(CSS 属性セレクタは廃止)。組み込み要素 (form, input 等) に直接イベントを受けたい場合は、その要素をラップする小さな tile を作る。


5.1 個別入力の双方向束縛 ​

kumiki
slot draft : Text where len-lt(280) = ""

tile Compose = column(
                 textarea(bind=draft, placeholder="What's on your mind?") {rows: 3},
                 text(draft.length.show + "/280") {color: "muted"},
                 button(text="Post", onClick=post) {disabled: draft.is-empty})
  • bind=draft は slot draft を双方向束縛する
  • ユーザー入力で slot が更新 → tile が再描画
  • 型と refinement は 入力ごとに検査される

5.1.1 bind の対応要素 ​

要素受け取れる型
inputText (type=text/email/password/url/search/tel、または値が入力されたテキストそのものである他のフィールド:number/date/datetime-local/time/month/week/color), Int/Float (type=number), Time (type=date/datetime-local)
textareaText
select任意(options の value と同型)
sliderInt / Float
check / switchBool
radiounion 型のいずれか

Int / Float / Time に bind した input は、テキストを Int.parse / Float.parse / Time.parse と同じように読み(標準ライブラリ §2.4.3)、読んだ値を書き込む。型は bind した位置の基底型である:slot の型をレコードのフィールド、または .get による Option / Result のペイロードを辿り、エイリアスを解いた型なので、type Qty = Int where positive や nominal Int は Int として読まれる。読み方は T.parse と完全に同じである — number フィールドが保持しうる ".5" は Float ではなく、"1e3" は Float だが Int ではない。基底型の値を表さないテキスト(""、Int に対する "1.5" など)は refinement 違反と同じく拒否される(§5.1.2):slot は最後に受け入れた値を保ち、フィールドは入力されたテキストを保ち、error(field=…) は失敗した読み方を示す(§5.7.2)。Time は type="date" のフィールドには yyyy-MM-dd、type="datetime-local" のフィールドには yyyy-MM-ddTHH:mm として、Time.parse がゾーンなし文字列を読むのと同じローカル時刻で表示されるので、フィールドの表示は同じ日(または同じ分)として読み戻される。Text は入力されたとおりに書き込まれるので、値が入力されたテキストそのものであるどのフィールドとも組み合わせられる。Int / Float / Time は表にあるフィールド種別、つまりテキストが往復できるものとだけ組み合わせられる。それ以外の組み合わせ — type="time" のフィールドの Time、text フィールドの Int、Bool、.get を通さずに丸ごと bind した Option など — は kumiki check が報告する(E0226)。(type="datetime" は HTML では廃止されており、text フィールドとして描画される。)

check / switch は bind した Bool を表示し、チェックの切り替えで新しい状態を書き戻す。radio(group=…, bind=b, value=V) は b == V のときちょうど選択状態になり、選ばれると V を書き込む。3 つとも input と同じ書き戻し経路を通り、§5.1.2 の refinement による拒否もそのまま適用される。書き戻しはコントロール自身の onClick / onChange より先に行われるので、ハンドラは書き込み済みの slot を読む:check(value=b, onClick=toggle) を check(bind=b, onClick=toggle) に移すと b は 2 回反転するので、onClick は外す必要がある。bind= があるとき、bind していないコントロールの選択状態を決める引数 — check / switch の value=、radio の selected= — は読まれない(W0216)。radio 自身の value= は引き続き書き込む値である。check / switch に別の型を bind した場合や、bind した型の値でない radio の value は kumiki check が報告する(E0201。別の union のバリアントなら E0216)。書き込む value= のない bind した radio も同様である(E0225)。

5.1.2 refinement の扱い ​

slot draft : Text where len-lt(280) の場合、入力が 280 文字を超えるとその値は拒否される:slot は最後に受け取った値を保つ。モードは 1 つで、意図的に静かである — 入力途中の値は欠陥ではなく想定内なので、何も報告しない。代入経路(reducer 内の draft := …)での refinement 違反は逆のケースで、reducer のバッチを丸ごと破棄したうえで報告される。batching を参照。

コントロールは入力されたものを表示し続けるので、フィールドが slot の受け取る値に編集されるまで、フィールドと slot は食い違う。error(field=draft) はフィールドが表示しているものについて語る(§5.7.1):refinement が拒否した値をフィールドが表示している間は、その値のメッセージを出す。slot を書き換える reducer はフィールドも一緒に動かし、メッセージは再び slot に従う。bind した Int / Float / Time がそもそも読めないテキスト(§5.1.1)も、refinement の有無にかかわらず同様である:そのメッセージは読み方のものであり、どの refinement のメッセージよりも先に出る。したがって、フィールドが slot の保持していない値を表示するのは、その理由を述べるメッセージと一緒のときだけである。この規則には 2 つの補足がある。対象のフィールドは同じビューのものである — 1 つの app を複数のホストにマウントした場合(runtime.md §10.9)、各ビューの error(field=…) は自分のビューのフィールドについて語り、他のビューのフィールドは slot の値を表示したままである。また IME の変換中は、変換が経由する途中の値ごとにメッセージを再計算せず、変換が確定したときに一度だけ決める。

kumiki
input(bind=draft)
error(field=draft)

本節の以前の版が第 2 のモードとして規定していた strict prop(拒否された値を受け取り、フォーム単位の valid フラグを false にする)は E0219 である。そのフラグを読むものは存在せず、自身の型が拒否する値を slot が保持することは、そこへ至る他のすべての経路が防ぐように作られている状態である。


5.2 フォーム要素 ​

複数の入力をまとめて確定送信したい場合は、form をラップする tile を作る:

kumiki
slot loginEmail    : Text                = ""
slot loginPassword : Text     volatile   = ""
slot loginError    : Option(HttpError)   = None
slot loginPending  : Bool                = false

tile LoginForm
    = form(
        column(
          label(text="Email") {for: "loginEmail"},
          input(bind=loginEmail, type="email", id="loginEmail", required=true),
          label(text="Password") {for: "loginPw"},
          input(bind=loginPassword, type="password", id="loginPw", required=true,
                auto-complete="current-password"),
          when(loginError.is-some,
            text(loginError.get.message) {color: "danger"}),
          button(text="Log in", type="submit", loading=loginPending) {bg: "primary"}
        ) {gap: "sm"}
      )

reducer doLogin
    on=ui.submit(LoginForm)
    do= loginError := None
        loginPending := true
        emit login({email: loginEmail, password: loginPassword})

effect login    cap=http.post
                in={email: Text, password: Text}
                out=Result(SessionId, HttpError)
                policy=latest
                map-request={url: "/api/auth/login", body: Json($1), decode: Decoder.Json(SessionId)}

5.2.1 form props ​

prop型意味
auto-completeBoolブラウザのオートコンプリート
novalidateBoolHTML5 標準バリデーションを抑制

form 自体には onSubmit を書かない。submit ハンドラは その form をラップする tile 名で ui.submit(WrapperTile) を reducer の on= に書く。

5.2.2 submit の挙動 ​

  • form の中のコントロールが bind しているすべての slot がバリデーションを通過していれば ui.submit(WrapperTile) reducer が呼ばれる
  • 1 つでも失敗していれば呼ばれない(個別の error 表示は出る)
  • 判定するのは各コントロールが表示している値であり、error(field=…) と同じ判定を同じ順序で行う(§5.1.2):Int / Float / Time のフィールドがその型の値としてまったく読めないテキストを表示していればそれ、そうでなければ refinement に拒否された値を form の中のコントロールがまだ表示していればその値、そうでなければ slot 自身の値。したがって、宣言時の初期値が refinement を満たさない未入力のフィールドも form の送信を止める(§5.6)— 理由を示す error(field=…) タイルの有無によらない。そのため、そのようなフィールドを持つ form にはそのタイルを置くべきである。拒否された編集や読めないテキストを表示しているフィールドも送信を止める — そうでなければ reducer はフィールドが表示していない、slot が最後に受け入れた値を読むことになる
  • 数えるのは form の中で bind されている slot だけであり、それについて判定に加わるのも form 自身のコントロールだけである。form のコントロールが bind していない slot は問わず、form の外のコントロールが表示している拒否された値も送信を止めない:form 自身のコントロールは slot の値を表示しており、reducer が読むのもその値である。error(field=…) は描画中のビュー全体について判定するので、form の中の error タイルがその外側の編集についてメッセージを表示したまま form が送信されることがある
  • button(type="submit") をクリックするか、input で Enter キーで発火
  • そのボタンのクリック reducer(ボタン自身への ui.click、ボタンを包む tile から持ち上げられたもの、onClick= 引数)は submit とは独立している:ボタンをクリックすると、クリック reducer が走り、かつ form が送信される。クリック reducer を束縛してもクリックはキャンセルされないので、ここでクリック reducer が代わりを務められないのは type だけである:クリック reducer は form の中のボタンの送信を止めず、止めるのは type="button" である。(disabled / loading のボタンも送信しない:無効化されているので、そもそも活性化されない。)
  • type は submit / button / reset のいずれかで、そのまま DOM に書かれ、意味を持つのは form の中だけである。type を書かなかったボタンは HTML の既定に従う — すなわち submit になるので、form の中にあって送信させたくないボタンには type="button" が必要である。3 つ以外のリテラルは E0201 になる:不正な type 属性は submit に解決されるので、綴り間違いは送信してしまう
  • クリック reducer を持つ type="reset" のボタンは、reducer を実行し、かつ form をリセットする。リセットはフィールドの DOM 上の値を既定値に戻すが、bind された slot はそのまま残る — reset を listen しているものはない。form をクリアするには、type="button" のボタンの reducer で slot に書き込む

5.3 入力要素の共通 props ​

prop型意味
bindslot name双方向束縛
valueexpr単方向値(bind の代わりに、reducer で更新)
onChangereducer name値変更時に呼ばれる reducer
onInputreducer nameinput イベントで呼ばれる(onChange より高頻度)
placeholderTextプレースホルダ
disabledBool無効化
readonlyBool読み取り専用(select のように読み取り専用状態を持たないコントロールは無視する)
requiredBool必須
auto-focusBoolマウント時にフォーカス
auto-completeTextemail / current-password / new-password / off 等
idTextHTML id(label の for で参照)

5.3.1 input type 別 ​

kumiki
input(bind=email, type="email", auto-complete="email")
input(bind=password, type="password", auto-complete="current-password")
input(bind=age, type="number", min=0, max=120)
input(bind=birthday, type="date", min="1900-01-01")
input(bind=search, type="search")
input(bind=phone, type="tel", pattern="[0-9-]+")

5.4 個別入力イベントを reducer に届ける ​

bind で十分足りない(例:入力の都度カスタム処理を走らせたい)場合は、その入力を専用の小 tileでラップして ui.input / ui.change を受ける:

kumiki
slot pw  : Text                   = ""
slot pw2 : Text                   = ""
slot pwError : Option(Text)       = None

tile Pw1Input = input(bind=pw,  type="password")
tile Pw2Input = input(bind=pw2, type="password")

reducer validatePw
    on=ui.input(Pw2Input)
    do= pwError := if pw == pw2 then None else Some("Passwords don't match")

ui.input(TileName) は TileName tile が描画するルート要素のイベントを受け取る。複合 tile の場合、ルート要素以外を狙うには更に細かい tile に分割する。


5.5 select / radio ​

5.5.1 select ​

kumiki
type Filter = All | Active | Done
slot filter : Filter = All

tile FilterSelect = select(
                      bind=filter,
                      options=[
                        {label: "All",    value: All},
                        {label: "Active", value: Active},
                        {label: "Done",   value: Done}
                      ],
                      placeholder="Filter")

3 つの value/state バインディング形式 ​

形式例用途
bind=<slot>bind=filter単一 slot に直結。change で slot を自動更新
bind=<slot.field>bind=draft.priorityrecord の field path に bind。_setPath で immutable update
value=<expr>value=issues[id].statusread-only 表示。change は ui.change(SelectTile) reducer で自分でハンドル

value= 形式の場合、change イベントで ui.change(<SelectTile>) を購読する reducer が呼ばれ、$event.value で選択された variant 値を受け取れる:

kumiki
tile StatusSelect = select(value=issues[iid].status,
                           options=statusOptions(),
                           placeholder="Status")

reducer updateStatus
    on=ui.change(StatusSelect)
    do= match routeIssueId(route) with
          | Some(iid) -> { issues[iid].status := $event.value;
                           issues[iid].updatedAt := now }
          | None      -> ()

input / textarea の変更検出 ​

input/textarea も bind= で slot を更新するほか、ui.change(InputTile) / ui.input(InputTile) reducer で fire できる。$event.value に現在の text が入る。

5.5.2 radio ​

radio はグループ化のため group prop を持つ(CSS の name 属性に対応):

kumiki
tile FilterRadioAll    = radio(group="filter", value=All,    selected=(filter == All))    {label: "All"}
tile FilterRadioActive = radio(group="filter", value=Active, selected=(filter == Active)) {label: "Active"}
tile FilterRadioDone   = radio(group="filter", value=Done,   selected=(filter == Done))   {label: "Done"}

tile FilterRadioGroup = column(FilterRadioAll, FilterRadioActive, FilterRadioDone)

reducer setFilterAll    on=ui.change(FilterRadioAll)    do= filter := All
reducer setFilterActive on=ui.change(FilterRadioActive) do= filter := Active
reducer setFilterDone   on=ui.change(FilterRadioDone)   do= filter := Done

または、bind で union 型を直接受ければ単一 reducer 不要:

kumiki
tile FilterRadioGroup = column(
                          radio(group="filter", bind=filter, value=All)    {label: "All"},
                          radio(group="filter", bind=filter, value=Active) {label: "Active"},
                          radio(group="filter", bind=filter, value=Done)   {label: "Done"})

こちらが推奨。


5.6 バリデーション戦略 ​

Kumiki のバリデーションは 3 層:

層担当例
型コンパイラslot age : Int には文字列を入れられない
refinementランタイムage : Int where between(0, 120)
フォーム横断reducer / fn「password と password-confirm が一致」

refinement 層は §1.3.3 が登録するすべての述語を対象とする。標準ライブラリのドメイン型が宣言に使っている述語も含まれる — Email、Url、Uuid、HttpStatus は refinement 付きの nominal(標準ライブラリ §2.1.3)なので、これらで宣言された slot は Text where email と書かれた slot とまったく同じように検査される。ツールチェーンがチェックへ lowering できない述語は、黙って通るチェックではなくビルドエラー(E0803)になる。

2 つの書き込み経路は報告の大きさが異なり、さらに bind は書き込むパスでだけ判定される(後述)。代入は、1 つのフィールドだけを書くもの(signup.name := …)でも値全体で判定される:

  • 代入(reducer 内の age := …)はバッチ全体を破棄し、報告する — slot は書かれず、effect も発行されない(ランタイム §10.3.3)。
  • bind はそのフィールドの値だけを受け取らず、何も報告しない。入力途中の値は欠陥ではなく想定内だからである。フィールドはその値を表示し続け、error(field=…) がそのメッセージを出す(§5.1.2)。

どちらも宣言時の初期値は通さない: slot email : Email = "" は自分自身の refinement が拒否する値から始まり、それが未入力のフォームにメッセージを出す仕組みである(§5.7.1)。

slot の一部への bind(input(bind=form.age)、input(bind=draft.nick.get))は、書き込むパスで判定する:そのパスに沿った述語(slot 自身の型のものを含む)と、パスの終点より下のすべての述語である。兄弟フィールドの述語はそのパス上にないので、兄弟が失敗していても書き込みは拒否されない。これにより、デフォルトが複数のフィールドで失敗するレコードでも、どの順番でも入力できる。error(field=…) が判定するときは、拒否された値を表示しているフィールドをその時点の slot に重ねて判定する。したがって、後から兄弟フィールドを書いても、そのフィールドにもう当てはまらないメッセージが戻ってくることはない。slot 全体に bind したコントロールは、その中へ bind したフィールドが収まる値を表示しているので、両方が拒否された値を表示しているときは slot 全体の値を先に重ね、その上にフィールドの値を重ねる。

5.6.1 フォーム横断の例 ​

kumiki
slot pw  : Text  = ""
slot pw2 : Text  = ""
slot pwError : Option(Text) = None

fn validatePassword(p1: Text, p2: Text) -> Option(Text)
   = if p1 == p2 then None else Some("Passwords don't match")

tile Pw2Input = input(bind=pw2, type="password")

reducer onPw2Change
    on=ui.input(Pw2Input)
    do= pwError := validatePassword(pw, pw2)

tile SignupForm
    = form(
        column(
          input(bind=pw, type="password"),
          Pw2Input,
          when(pwError.is-some,
            text(pwError.get) {color: "danger"}),
          button(text="Sign up", type="submit", disabled=pwError.is-some)))

reducer doSignup on=ui.submit(SignupForm) do= ...

5.7 エラー表示 ​

5.7.1 個別フィールドの refinement 違反 ​

error 要素で表示:

kumiki
input(bind=email, type="email")
error(field=email)

error(field=...) は対象フィールドの現在の検査エラーをレンダリングする組み込み tile。slot から述語を読み取り、フィールドが表示している値がそれを満たさないときにそのメッセージを表示し、満たすときは何も表示しない。それは slot の現在の値だが、bind されたコントロールが refinement に拒否された値を表示している間は、その値が判定される(§5.1.2)。したがって型に refinement を持たない slot にはどの値についても表示すべきメッセージがない — これは slot についての言明であり、その中の値についての言明ではない。それでも表示しうるのは、値についてではない唯一のメッセージである:bind された input が slot の Int / Float / Time としてそもそも読めないテキストを表示している間(§5.1.1)、メッセージはその読み方のもの(下の int / float / time)であり、どの slot でも、どの refinement よりも先に出る — Int where between(0, 120) への "1.5" は整数ではないのであって、範囲外の数ではない。

述語を複数持つ型(§1.3.1)では、§1.3.1 が与える順で、現在の値が最初に失敗した述語のメッセージが出る。slot draft : Text where nonempty where len-lt(7) = "" の手つかずのフィールドは「Required」であり、空の値が十分満たしている側の境界ではない。

5.7.2 標準メッセージ ​

述語デフォルト
email"Invalid email format"
url"Invalid URL"
uuid"Invalid identifier"
nonempty"Required"
len-eq(N)"Must be exactly N characters"
len-lt(N) / len-gt(N)"Must be less than / more than N characters"
between(A, B)"Must be between A and B"
positive / negative"Must be positive" / "Must be negative"
regex(P)"Does not match pattern"
one-of(...)"Must be one of: ..."
int(Int でないテキスト、§5.1.1)"Must be a whole number"
float(Float でないテキスト)"Must be a number"
time(Time でないテキスト)"Must be a date"

カスタムメッセージは theme.errors で上書き(キーは表の 1 列目で、int / float / time も含む):

kumiki
theme MyTheme = {
    ...,
    errors: {
        email: "正しいメールアドレスを入力してください",
        nonempty: "入力してください"
    }
}

5.8 サブミット中の UI ​

kumiki
slot loginPending : Bool = false

reducer doLogin
    on=ui.submit(LoginForm)
    do= loginPending := true
        emit login({email: loginEmail, password: loginPassword})

reducer loginOk
    on=login.ok($s, _)
    do= loginPending := false
        session := Some($s)
        emit navigate({path: "/app", params: {}, query: {}})

reducer loginErr
    on=login.err($e, _)
    do= loginPending := false
        loginError := Some($e)

button.loading で自動的にスピナー表示・無効化。


5.9 multi-step フォーム ​

kumiki
type Step = Account | Profile | Confirm

slot step : Step = Account
slot acct : {email: Text, pw: Text}     = {email: "", pw: ""}
slot prof : {name: Text, bio: Text}     = {name: "", bio: ""}

fn nextStep(s: Step) -> Step = match s with | Account -> Profile | Profile -> Confirm | Confirm -> Confirm
fn prevStep(s: Step) -> Step = match s with | Profile -> Account | Confirm -> Profile | Account -> Account

tile NextBtn = button(text="Next") {bg: "primary"}
tile PrevBtn = button(text="Back") {variant: "ghost"}

reducer next on=ui.click(NextBtn) do= step := nextStep(step)
reducer prev on=ui.click(PrevBtn) do= step := prevStep(step)

tile Wizard = column(
                ProgressIndicator(step),
                match step with
                  | Account -> AcctStep
                  | Profile -> ProfStep
                  | Confirm -> ConfirmStep,
                row(PrevBtn, NextBtn) {gap: "sm"})

各ステップは独立した tile に分割すれば AI も追跡しやすい。


5.10 ファイルアップロード ​

kumiki
slot avatar : Option(File) = None

tile AvatarPicker = input(type="file", accept="image/*")

reducer pickFile
    on=ui.change(AvatarPicker)
    do= avatar := $event.files.head

tile UploadBtn = button(text="Upload")

reducer upload
    on=ui.click(UploadBtn)
    do= match avatar with
            | Some(f) -> emit uploadFile({file: f})
            | None    -> ()

tile AvatarUpload = column(
                      AvatarPicker,
                      when(avatar.is-some,
                        image(src=file-url(avatar.get)) {w: 100, h: 100, aspect: "1/1"}),
                      UploadBtn)

effect uploadFile  cap=http.post
                   in={file: File}
                   out=Result({url: Url}, HttpError)
                   policy=latest
                   map-request={url: "/api/upload", body: Multipart({file: FileV($1.file)}), decode: Decoder.Json({url: Url})}

file-url(file) は URL.createObjectURL 相当の組み込み(自動解放)。

accept / multiple は type="file" のときのみ有効。他の type で使う場合 — あるいは type を省略した場合(デフォルトは "text")— typecheck が E0206 で拒否する。


5.11 設計上の判断記録 ​

判断理由
bind で slot 直結controlled/uncontrolled の二重モデルを排除
イベントセレクタは tile 名のみCSS 知識依存を排除、Kumiki のレイヤ分離と整合
form の submit ハンドラは form 自体ではなくラッパ tile に bind「どの reducer で受けるか」が tile ツリー上で 1 箇所に見える
refinement で型レベルバリデーション「型が通れば値が妥当」
エラーメッセージを theme で集中管理i18n と一貫性
multi-step は slot で表現専用 wizard DSL を増やさない
ファイルは Bytes ではなく File 型サイズ・MIME・名前を構造化
radio の group propHTML name 属性をラップ、Kumiki 内で完結

5.12 次 ​