Skip to content

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

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

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


5.1 個別入力の双方向束縛

strand
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), Int/Float (type=number), Time (type=date/datetime)
textareaText
select任意(optionsvalue と同型)
sliderInt / Float
check / switchBool
radiounion 型のいずれか

5.1.2 refinement の扱い

slot draft : Text where len-lt(280) の場合、入力が 280 文字を超えると:

  • デフォルト: 入力を弾く(slot は更新されない)
  • strict=false: slot は更新するが、フォームの valid フラグが false になる
strand
input(bind=draft, strict=false)

5.2 フォーム要素

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

strand
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 の挙動

  • すべての bind された slot がバリデーションを通過していれば ui.submit(WrapperTile) reducer が呼ばれる
  • 1 つでも失敗していれば呼ばれない(個別の error 表示は出る)
  • 厳密モード切替が必要なら strict=false を該当入力に
  • button(type="submit") をクリックするか、input で Enter キーで発火

5.3 入力要素の共通 props

prop意味
bindslot name双方向束縛
valueexpr単方向値(bind の代わりに、reducer で更新)
onChangereducer name値変更時に呼ばれる reducer
onInputreducer nameinput イベントで呼ばれる(onChange より高頻度)
placeholderTextプレースホルダ
disabledBool無効化
readonlyBool読み取り専用
requiredBool必須
auto-focusBoolマウント時にフォーカス
auto-completeTextemail / current-password / new-password / off
strictBoolrefinement 違反時に入力を弾くか(デフォルト true)
idTextHTML id(label の for で参照)

5.3.1 input type 別

strand
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 を受ける:

strand
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

strand
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 値を受け取れる:

strand
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 属性に対応):

strand
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 不要:

strand
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 バリデーション戦略

Strand のバリデーションは 3 層

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

5.6.1 フォーム横断の例

strand
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 要素で表示:

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

error(field=...) は対象 slot の現在の検査エラーをレンダリングする組み込み tile。

5.7.2 標準メッセージ

述語デフォルト
email"Invalid email format"
url"Invalid URL"
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"
regex(P)"Does not match pattern"
one-of(...)"Must be one of: ..."

カスタムメッセージは theme.errors で上書き:

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

5.8 サブミット中の UI

strand
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 フォーム

strand
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 ファイルアップロード

strand
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 相当の組み込み(自動解放)。


5.11 設計上の判断記録

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

5.12 次