LoginSignup
11
6

More than 1 year has passed since last update.

Elmのinput/textarea/selectがうまく動かないときに読む記事

Last updated at Posted at 2023-02-25

Elm-jpのDiscordでselectタグのあつかいが話題にのぼっていました。
実はinput/textarea/selectなどのユーザー入力を受け取るタグに関するあつかいは意外に奥が深く、しかしそれらについて詳しく述べられたことはあまりありません。

そこでこの記事では、input/textarea/selectなどのElmにおける適切なあつかい方を補足解説していきます。

まずはinput

inputタグは比較的あつかいがかんたんです。
Html.Attributes.valueによって、初期値を設定したり、入力されている値を置き換えたりすることができます。

import Html exposing (Html)
import Html.Attributes as Attributes
import Html.Events as Events
import Html.Events.Extra as Events


view : Model -> Html Msg
view model =
    Html.div
        []
        [ Html.input
            [ Attributes.type_ "text"
            , Attributes.value model.freeText
            , Events.onChange ChangeFreeText
            ]
            []
        , Html.button
            [ Attributes.type_ "button"
            , Events.onClick ClickOverwriteFreeText
            ]
            [ Html.text "非情なる上書き"
            ]
        ]


type alias Model =
    { freeText : String
    }


initialModel : Model
initialModel =
    { freeText = "最初に表示される"
    }


type Msg
    = ChangeFreeText String
    | ClickOverwriteFreeText


update : Msg -> Model -> Model
update msg model =
    case msg of
        ChangeFreeText str ->
            { model | freeText = str }

        ClickOverwriteFreeText ->
            { model | freeText = "上書きやぎぃ" }

このコードのコンパイル結果を見てみましょう。
アプリケーションを読み込むと、最初に入力欄には「最初に表示される」と表示されます。

input1.png

次に「非情なる上書き」ボタンをクリックすると、入力欄の内容が上書きされ「上書きやぎぃ」に変わります。

input2.png

この状態で入力欄をクリックして以下のように内容を書き換えてみましょう。

input3.png

再度「非情なる上書き」ボタンをクリックすると再び「上書きやぎぃ」に上書きされます。

input2.png

入力欄に初期値を入れておくのは実用的なアプリにはよくあることです。たとえば個人情報の編集フォームのように、既存のデータを書き換えるような用途で役に立ちます。
また入力値の上書きも、良いUXを提供するために必要なものです。たとえば電話番号の入力欄にユーザーが入力したあと、自動的に全角数字を半角数字に上書きして書き換えてしまうような親切なアプリケーションを実現できます。謎のSIerさんがズブズブな国や地方公共団体から億単位の金をもらって作るような、「住所には全角文字のみを使用してください」なんてエラーを出してくる人権無視アプリとはレベルが違います😤

賢いひとがつまづくtextarea

さて、では先ほどの例のinputをtextareaに置き換えてみましょう。

でも賢いひとは知っています。textareaにはvalue属性をつけてはならないのです!
MDNのtextareaのページから引用します。

は value 属性に対応していません。

そう、<textarea>タグではデフォルト値を以下のように設定するのです。

<textarea>
最初に表示されるよ
</textarea>

ではこれを踏まえて先ほどのコードを書き換えてみましょう!

view : Model -> Html Msg
view model =
    Html.div
        []
        [ Html.textarea
            [ Events.onChange ChangeFreeText
            ]
            [ Html.text model.freeText
            ]
        , Html.button
            [ Attributes.type_ "button"
            , Events.onClick ClickOverwriteFreeText
            ]
            [ Html.text "非情なる上書き"
            ]
        ]

HTMLの仕様に忠実にしたがい、Attributes.valueを使わずにテキストノードとして現在の入力値を指定しました。
でも、実はこれではうまくいきません。

このコードのコンパイル結果を見てみましょう。
アプリケーションを読み込むと、最初に入力欄に「最初に表示される」と表示されます。これは想定通りです。

textarea1.png

この状態で入力欄をクリックして内容を「さくらちゃんやぎぃ」に書き換えてみます。なんと「非情なる上書き」ボタンをクリックしても「さくらちゃんやぎぃ」のままで「上書きやぎぃ」に置き換わりません!

textarea2.png

なぜでしょうか? それは、本来<textarea></textarea>で囲まれた部分に入れるのはデフォルト値1だからです。
実際にMDNのページには「既定のコンテンツ(英語版では "Default content")」と表記されています。
ユーザー入力前のデフォルト値を置き換えたところで、入力内容を上書きすることはできないのです。

textarea 推奨しない方法

ではどうするか。まずは 推奨しない 解決策を示します。
まず推奨しない解決策を示す理由は、「 デフォルト値 を置き換える」という原理を実感してもらうためです。

import Html.Keyed as Keyed

view : Model -> Html Msg
view model =
    Keyed.node "div"
        []
        [ ( model.freeText
          , Html.textarea
            [ Events.onChange ChangeFreeText
            ]
            [ Html.text model.freeText
            ]
          )
        , ( "button"
          , Html.button
            [ Attributes.type_ "button"
            , Events.onClick ClickOverwriteFreeText
            ]
            [ Html.text "非情なる上書き"
            ]
          )
        ]

まずはコンパイル結果を試してください。想定通りに「非情なる上書き」ボタンが使えることが分かるはずです。

ではコードの解説をします。先ほどと変わったのはKeyed.nodeを使っていることです。

Keyed.nodeの本来の使い方についてはElm guideのHtml.keyedのページをご覧ください。
簡単に説明すると、「子要素をIDで同定するノード」を作成するものです。上記のコードにおいて「子要素」とはテキストエリアと「非情なる上書き」ボタンのことです。通常のHtml.divでは、ただそれらをListの要素として渡していました。一方でKeyed.nodeには子要素をタプルで渡しています。そしてそのタプルの2つ目の要素が子要素そのもので、1つ目の要素がその子要素を同定するIDです。
このIDを手がかりに、モデルの変更前後で「この子要素はモデル変更前のあの子要素と同じだな」とElmランタイムが判断します。これによって、DOMの書き換えを最小限に抑えることができます。

今回のコードではこのKeyed.nodeの性質を応用しています。上記のコードではテキストエリアの要素を同定するIDとして、そのテキストエリアに入力された文字列を指定しています。
そうするとどうなるか。テキストエリアの内容が変わるたびにIDが変わるので、「あれ、さっきと別の要素ってこと? じゃあ全体を書き換えなきゃね!」と、<textarea>のDOM全体が書き換えられるのです。全体が書き換えられれば全くあたらしい<textarea>が描画されるわけですから、そのデフォルト値として<textarea></textarea>で囲まれた値が採用されます。

このようにテキストエリアの内容を書き換えるごとにテキストエリア自体を再描画してやれば、 デフォルト値 の上書きによって現在の入力値を上書きすることができるわけです。

でも、当然こんな方法は推奨されません。毎回毎回<textarea>を作成しなおすなんてあまりにもコストが大きい処理を行っているからです。

textarea 適切な方法

上書きしたい値を デフォルト値 として指定してもうまくいかないことがわかりました。ではどうしたらいいのでしょうか?

そこで前提知識として必要になるのが Attributes.attributeAttributes.property のちがいです。
それぞれのドキュメントにもリンクが貼ってありますが、あらためて説明します。

Attributes.attributeは属性を指定するものです。HTMLにおける属性だと思っておけば問題ありません。
たとえばAttributes.attribute "class" "foo"はJavaScriptではdomNode.setAttribute("class", "foo")に対応します。

一方でAttributes.propertyはそのDOMノードのプロパティを直接書き換えるものです。
たとえばAttributes.property "className" (Json.Encode.string "foo")はJavaScriptではdomNode.className = "foo"に対応します。

これを踏まえて、JavaScriptでtextareaの現在の入力値を上書きする方法についてまず考えます。
textareaを操作するためのインターフェースHTMLTextAreaElementには、valueというプロパティが存在します。以下はMDNからの引用です。

value 文字列: このコントロール内の生の値を取得/設定します。

ですから、このプロパティ値を上書きすれば、無事に現在のテキストエリアの入力内容を上書きできることになります。

ここで先ほどのAttributes.propertyAttributes.attributeを思い出しましょう。
今回はDOMノードのプロパティ値を上書きしたいのですから、Attributes.propertyの出番です。
そして、実はAttributes.valueは内部的にAttributes.propertyを使っているのです。
ですから、以下のようにAttributes.valueを使えば、モデルが変更されるたびにHTMLTextAreaElementvalueプロパティが上書きされることになります。

なお、Attributes.valueのドキュメントには以下のような説明があります。

Defines a default value which will be displayed in a button, option, input, li, meter, progress, or param.

おそらくドキュメントが間違っていて2「"default value"(=デフォルト値)をセットするもの」と説明しています。公式すら混乱していてかわいいですね!🐹
安全策をとるなら明示的に Attributes.property "value" を使ったほうが適切かもしれません。

view : Model -> Html Msg
view model =
    Html.div
        []
        [ Html.textarea
            [ Events.onChange ChangeFreeText
            , Attributes.value model.freeText
            ]
            []
        , Html.button
            [ Attributes.type_ "button"
            , Events.onClick ClickOverwriteFreeText
            ]
            [ Html.text "非情なる上書き"
            ]
        ]

コンパイル結果を試してみてください。今度はうまくいっているはずです🌸

振り返ると、inputの例でもAttributes.valueを使っていました。あのときはvalue 属性 の感覚で使っていましたが、実は本当はHTMLInputElementvalue プロパティ を上書きしていたのです🌱
見える世界が変わってきましたね! 分かってる気になってるだけで、実は間違った理解だったのです。世界が変わるぅ〜

大ボス! select

世界の謎が解明されて気持ちよくなったところで、いよいよ大ボスのselectです。
textareaで学んだことを活かして以下のように書き換えてみます。

view : Model -> Html Msg
view model =
    Html.div
        []
        [ Html.select
            [ Events.onChange ChangeChoice
            , Attributes.value model.chosenValue
            ]
            [ Html.option
                [ Attributes.attribute "value" ""
                ]
                [ Html.text "-- 好きな動物は? --"
                ]
            , Html.option
                [ Attributes.attribute "value" "Goat"
                ]
                [ Html.text "ヤギ"
                ]
            , Html.option
                [ Attributes.attribute "value" "Dog"
                ]
                [ Html.text "イヌ"
                ]
            , Html.option
                [ Attributes.attribute "value" "Cat"
                ]
                [ Html.text "ネコ"
                ]
            , Html.option
                [ Attributes.attribute "value" "Tanooookey"
                ]
                [ Html.text "タヌキ"
                ]
            , Html.option
                [ Attributes.attribute "value" "Bird"
                ]
                [ Html.text "トリ"
                ]
            ]
        , Html.button
            [ Attributes.type_ "button"
            , Events.onClick ClickOverwriteChoice
            ]
            [ Html.text "非情なる上書き"
            ]
        ]

Msgupdateも変更する必要がありますが、重要なのはviewなのでここでは省きます。

このコードではHtmlSelectElementvalueプロパティを上書きすることで、選択値を上書きできるようにしています。

なお、<option>value値にはAttributes.attribute "value"をあえて使っています。
HtmlOptionElementvalueプロパティを上書きするようにAttributes.valueを使っても実用上は問題ないのですが、少しだけ挙動が異なります。
Attributes.value ""のように空文字列を指定すると、valueプロパティを上書きすること自体を省略しているようです。3
なので、Attributes.value ""を使った場合はDev consoleを開いて当該<option>.value値を調べるとラベルに設定された文字列"-- 好きな動物は? --"が表示されます。
Attributes.attribute "value" ""の場合は想定通りに""が表示されます。
これによる実害はおそらくないものの、念のためAttributes.attribute "value"を使うようにしています。

また、別の側面ではHTMLOptionElementvalueプロパティがHTMLInputElementHTMLTextAreaElementHTMLSelectElementvalueプロパティとは明らかに用途が異なるという点も、さくらちゃんがoptionにAttributes.valueを使わない理由です。
optionのvalueプロパティは「この選択肢の識別子はこれ」と指定するものです。inputなどの「入力値をこれにする」とは意味あいが全く異なります。その意味でも別の表現を使うようにしています。

さて、コンパイル結果を試すと、どうやらうまく動いているように見えます。でも実は問題があるのです。
初期値である initialModel を変更して chosenValue"" から "Goat" に変更してみてください。
本来ならロード時に「ヤギ」が選択されているはずなのに、なぜか「-- 好きな動物は? --」が表示されます。

select1.png

select 完全な倒し方

selectに初期値がうまく設定されないのは、ElmのVirtual DOMのレンダリング方法が関係しています。
ElmのVirtual DOMライブラリーにさっと目を通しましたが、厳密に確認したわけではないので間違っていたら教えてください。

ElmにおいてDOMノードを作成する関数は、多くの場合以下のような型をしています。

List (Attribute msg) -> List (Html msg) -> Html msg

こうして宣言されたノードは、以下のような順番で実際のDOMとして描画されます。

  1. 当該ノードをdocument.createElementで作成する
  2. そのノードに対して、1番目の引数で渡したList (Attribute msg)を適用する
  3. 2番目の引数で渡したList (Html msg)から対応するDOMノードを作成する
  4. 3.の各ノードをdocument.appendChildで1.のノードに追加する

ゆえにselectAttributes.valueで初期値"Goat"を指定した場合は以下のような挙動になります。

  1. 当該ノードをdocument.createElement("select")で作成する
  2. そのノードに対して、1番目の引数で渡したList (Attribute msg)を適用する
    • このとき、valueプロパティ値として"Goat"がセットされそうになる
    • しかしこの時点ではoptionが1つも存在しておらず、"Goat"が存在しないIDだと判断される
    • そのため、ここではvalueプロパティがセットされない
  3. 2番目の引数で渡したList (Html msg)から対応するDOMノードを作成する
  4. 3.の各ノードをdocument.appendChildで1.のノードに追加する
    • これによって各optionが描画されるが、selectvalueプロパティは既定値の""になっている
    • ゆえにoptionのうち""value属性としてもつ最初の要素が選択された状態になる

これが、inputやtextareaにはvalueプロパティで初期値を設定できるのにselectではうまくいかない理由です。

ですから、もし""以外の初期選択値を有効にしたい場合は別の方法が必要になります。
それがoptionのselected属性です。MDNのoptionのページから引用します。

selected この論理属性を設定すると、その選択肢が初期状態で選択されます。

ということで、以下のように書き換えれば初期選択値も正しく反映されます。

view : Model -> Html Msg
view model =
    Html.div
        []
        [ Html.select
            [ Events.onChange ChangeChoice
            , Attributes.value model.chosenValue
            ]
            [ Html.option
                [ Attributes.attribute "value" ""
                , Attributes.selected <| model.chosenValue == ""
                ]
                [ Html.text "-- 好きな動物は? --"
                ]
            , Html.option
                [ Attributes.attribute "value" "Goat"
                , Attributes.selected <| model.chosenValue == "Goat"
                ]
                [ Html.text "ヤギ"
                ]
            , Html.option
                [ Attributes.attribute "value" "Dog"
                , Attributes.selected <| model.chosenValue == "Dog"
                ]
                [ Html.text "イヌ"
                ]
            , Html.option
                [ Attributes.attribute "value" "Cat"
                , Attributes.selected <| model.chosenValue == "Cat"
                ]
                [ Html.text "ネコ"
                ]
            , Html.option
                [ Attributes.attribute "value" "Tanooookey"
                , Attributes.selected <| model.chosenValue == "Tanooookey"
                ]
                [ Html.text "タヌキ"
                ]
            , Html.option
                [ Attributes.attribute "value" "Bird"
                , Attributes.selected <| model.chosenValue == "Bird"
                ]
                [ Html.text "トリ"
                ]
            ]
        , Html.button
            [ Attributes.type_ "button"
            , Events.onClick ClickOverwriteChoice
            ]
            [ Html.text "非情なる上書き"
            ]
        ]

コンパイル結果を見ると、想定通りに動いていることがわかります🌸

まとめ

このように、elm/htmlがJSのどのような関数に対応しているか理解することで、細かな挙動をうまく制御できるようになりました。
Elmは easy ではないので、このようなJSやDOM要素の仕様に関する知識が必要になる場面があります。一方で simple なので、このような仕様さえ理解すれば、そのまま実際の挙動をうまく制御できます。

「フロントエンドはよくわからんし勉強したくないからElmを使おう」という動機だとだいたい失敗する理由もこれです。必要な知識を学びながらJSやTypeScriptを使って書くことも全く厭わない方にとっては、Elmは最良の選択肢になるはずです。
手持ちのスキルを無理やり流用して問題を解決するのではなく、「無形のスキル」をかつようしましょう。

eyecatch.jpg

さくらちゃんのツイッターをフォローする
さくらちゃんが書いた他の記事を見る
さくらちゃんが翻訳したElmの本を手に入れる
さくらちゃんの写真集を手に入れる

  1. 「初期値」ではなく「デフォルト値」なのはどういうことなのか考えてみると、より一層理解が進みます。開発者コンソールでtextareaのtextContentを書き換えて見てください。テキストエリアをクリックして内容を書き換える前と後では、textContentを書き換えたときの挙動が異なることを確認できるはずです。Elmのプログラムでも同様の挙動を示していることを確認してみてください。

  2. https://github.com/elm/html/issues/249

  3. https://github.com/elm/html/issues/91

11
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
6