フォームとバリデーション
Kumiki のフォームは「個別入力の bind で slot に直接束縛」する形と「専用 tile に ui.submit で受ける」形の 2 通りを提供する。前者はリアクティブな逐次反映、後者はトランザクション的な確定送信向け。
イベントセレクタは 常に tile 名で書く(CSS 属性セレクタは廃止)。組み込み要素 (form, input 等) に直接イベントを受けたい場合は、その要素をラップする小さな tile を作る。
5.1 個別入力の双方向束縛
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は slotdraftを双方向束縛する- ユーザー入力で slot が更新 → tile が再描画
- 型と refinement は 入力ごとに検査される
5.1.1 bind の対応要素
| 要素 | 受け取れる型 |
|---|---|
input | Text (type=text/email/password/url/search/tel、または値が入力されたテキストそのものである他のフィールド:number/date/datetime-local/time/month/week/color), Int/Float (type=number), Time (type=date/datetime-local) |
textarea | Text |
select | 任意(options の value と同型) |
slider | Int / Float |
check / switch | Bool |
radio | union 型のいずれか |
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 の変換中は、変換が経由する途中の値ごとにメッセージを再計算せず、変換が確定したときに一度だけ決める。
input(bind=draft)
error(field=draft)本節の以前の版が第 2 のモードとして規定していた strict prop(拒否された値を受け取り、フォーム単位の valid フラグを false にする)は E0219 である。そのフラグを読むものは存在せず、自身の型が拒否する値を slot が保持することは、そこへ至る他のすべての経路が防ぐように作られている状態である。
5.2 フォーム要素
複数の入力をまとめて確定送信したい場合は、form をラップする tile を作る:
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-complete | Bool | ブラウザのオートコンプリート |
novalidate | Bool | HTML5 標準バリデーションを抑制 |
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 | 型 | 意味 |
|---|---|---|
bind | slot name | 双方向束縛 |
value | expr | 単方向値(bind の代わりに、reducer で更新) |
onChange | reducer name | 値変更時に呼ばれる reducer |
onInput | reducer name | input イベントで呼ばれる(onChange より高頻度) |
placeholder | Text | プレースホルダ |
disabled | Bool | 無効化 |
readonly | Bool | 読み取り専用(select のように読み取り専用状態を持たないコントロールは無視する) |
required | Bool | 必須 |
auto-focus | Bool | マウント時にフォーカス |
auto-complete | Text | email / current-password / new-password / off 等 |
id | Text | HTML id(label の for で参照) |
5.3.1 input type 別
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 を受ける:
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
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.priority | record の field path に bind。_setPath で immutable update |
value=<expr> | value=issues[id].status | read-only 表示。change は ui.change(SelectTile) reducer で自分でハンドル |
value= 形式の場合、change イベントで ui.change(<SelectTile>) を購読する reducer が呼ばれ、$event.value で選択された variant 値を受け取れる:
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 属性に対応):
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 不要:
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 フォーム横断の例
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 要素で表示:
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 も含む):
theme MyTheme = {
...,
errors: {
email: "正しいメールアドレスを入力してください",
nonempty: "入力してください"
}
}5.8 サブミット中の UI
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 フォーム
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 ファイルアップロード
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 prop | HTML name 属性をラップ、Kumiki 内で完結 |
5.12 次
- HTTP の詳細 → HTTP / Storage
- ライフサイクル → ライフサイクル