onInputに頼らずEnterを捕捉する

新しいフレームワークを学ぶとき、よく題材として取り上げられるのがTODOアプリです。
[「TODOアプリ程度の規模でフレームワークの特性なんかわからんやろ」](https://qiita.com/arowM/items/dfb38d1c5f3dfde8b8bf#%E3%83%95%E3%83%AD%E3%83%B3%E3%83%88%E3%82%A8%E3%83%B3%E3%83%89%E9%96%8B%E7%99%BA%E3%81%A3%E3%81%A6%E7%B5%90%E5%B1%80%E3%81%AA%E3%81%AB%E4%BD%BF%E3%81%88%E3%81%B0%E3%81%84%E3%81%84%E3%82%93%E3%81%A0%E3%82%88%E5%95%8F%E9%A1%8C)というのがさくらちゃんの意見ですが、ともかくよく作られます。

そんなTODOアプリの最低限の使いやすさを考えたとき、パソコンで操作するなら「テキストボックス内でEnterを押してもTODOアイテムとして作成されてほしい」という需要があります。
[サンプル](https://ellie-app.com/mHGLBNC5qJha1)を用意したので、試しに何か文字を入力してそのままEnterを押してみてください。

![Screenshot at 2023-05-03 08-44-33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/16345/87e8de47-6a24-4abc-d63c-19736c0bfb0f.png)
![Screenshot at 2023-05-03 08-44-56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/16345/fc4b47f0-1e6e-9c98-075f-b37eb1b0a37d.png)

逆に、こういう最低限の配慮がされていないTODOアプリでは、わざわざ「追加」ボタンを押さないといけません:

1. どうにかテキストボックスにフォーカスして
1. テキストボックスにキーボードで入力して
1. ヒヅメをキーボードから離して
1. 「マウスどこだっけ？」って手探りの最中に誤ってマウスを机から落として
1. 「ぶめぇぇえええええ！」って叫んで
1. 落としたマウスを拾おうと思ってもヒヅメだからツルツル滑っちゃってうまく持ち上げられなくて
1. なんとか机に戻したらなんかマウスのボタンが変なところで押されて別の画面になっちゃって
1. さっきの画面に戻って再度同じテキストをキーボードで入力しなおして
1. 今度こそマウスの位置を目視しながら「追加」ボタンを押そうとするもヒヅメだから滑っちゃってまたマウスが落ちちゃって
1. 「ぶっめぇええええええええい！」「追加できないぃいいいいいいいいい！」🐐💢💢

ヤギさんのUXを軽視した最悪なUIですね！

# すぐ思いつく実現方法

Enterキーを押してもTODOに追加できるアプリをElmで作ってみましょう。
まず思いつくのは、elm/html の [`Html.Events.onInput`](https://package.elm-lang.org/packages/elm/html/latest/Html-Events#onInput) を使う方法です。

```elm
import Html
import Html.Attributes as Attributes
import Html.Events as Events
import Json.Decode as JD

type alias Model =
    -- 新しいTODOの内容を入力するテキストボックスの入力内容
    { newTodoFormInput : String
    , todo : List String
    }


view : Model -> Html Msg
view model =
    ...
    ...
    -- 新しいTODOの内容を入力するテキストボックス
    Html.input
        [ Attributes.type_ "text"
        -- 1文字入力されるごとに `InputNewTodoFormInput` を呼び出す
        , Events.onInput InputNewTodoFormInput
        , Events.on "keydown"
            ( Events.keyCode
                |> JD.andThen
                    (\n ->
                        -- Enter のキーコードは `13`
                        if n == 13 then
                            JD.succeed InputEnterOnNewTodoFormInput

                        else
                            JD.fail "ignore"
                    )
            )
        , Attributes.value model.newTodoFormInput
        ]
        []
    ...
    ...

update : Msg -> Model -> Model
update msg model =
    case msg of
        InputNewTodoFormInput str ->
            { model | newTodoFormInput = str }

        InputEnterOnNewTodoFormInput ->
            { model
              | todo = model.newTodoFormInput :: model.todo
            }
        ...
        ...
```

The Elm Architectureでは、「Viewのあらゆる状態をModelで管理する」という潔癖な設計を受け入れることでさまざまな恩恵を受けられます。
そのため、もちろん「テキストボックスの現在の値を取得する」という処理は禁止されています。

上記の例では、テキストボックスの現在の値をModelに反映させるため、テキストボックスにおいて文字が1つ入力されるたびに`InputNewTodoFormInput`イベントを通してModelを更新しています。
これによって、Enterキーが押された際に、その時点での入力内容をModelから参照して TODO 一覧に追加しています。

# onInputはつかいたくない

「すぐ思いつく実現方法」では、テキストボックスの入力値をModelに反映させるため、`Html.Events.onInput`を使っていました。
しかし`Html.Events.onInput`は極力つかわないに越したことはありません。

1. 1文字入力されるたびにイベントが発火するのは無駄にCPU負荷がかかるから

    DOMのレンダリングコストと比較すれば誤差みたいなもんですが、避けられるCPU負荷であれば避けるに越したことはありません。
    「お前のサイトはユーザーに無断で不必要なonInputをつかってCPUリソースを食ったから逮捕な！」と、不正指令電磁的記録に関する罪を拡大解釈した神奈川県警が早朝にあなたのお宅のピンポンを鳴らしてきて、安眠を妨害される恐れがあります。

1. IMEとの相性が悪く、まれに文字入力がうまくいかなくなるから

    残念ながら世界はラテン文字に支配されています。日本語なんていうマイナーな言語のことを配慮するのは二の次です。
    ですから、たまにウェブブラウザーがIME（日本語入力のためのソフト）を無視した厄介なバグを入れてくることがあります。
    onInputをつかっていると、こういった種類のバグに起因する不可思議な挙動に遭遇することがたまにあります。

初めて知ったそこのあなた！
さくらちゃんによる豊富な注釈によってもはや原文で読むよりも学びが多いことで有名な[プログラミングElm](https://amzn.to/3HzSozM)には、こういったことも注釈でしっかり補足してあるらしいですよ！

# onChangeでどうにかしたい

`Html.Events.onInput`を避ける有力な手段が、 elm-community/html-extra の [`Html.Events.Extra.onChange`](https://package.elm-lang.org/packages/elm-community/html-extra/latest/Html-Events-Extra#onChange) を使う方法です。

`Html.Events.Extra.onChange`は [changeイベント](https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/change_event) を捕捉してその時点での入力値とともにメッセージを`update`関数に渡します。

テキストボックス（`type`属性が`text`の`input`要素）において、changeイベントはテキストボックスからフォーカスが外れた瞬間に発火します。ですから、テキストボックスに文字を入力している最中はModelの値も古いままですが、「追加」ボタンをクリックした瞬間にフォーカスが外れ、それによってまずModelに最新の入力値が上書きされ、続いて「追加」ボタンをクリックした際の処理が`update`関数で走ります。

このように、多くの場合は`onChange`で代用可能なのです💯

しかし、今回のケースではうまくいきません。テキストボックスにフォーカスされたままの状態でEnterキーが押されるからです。
Enterキーが押されたことをきっかけに`update`関数を呼び出しても、その時点でModelに入っているのは以前の古い値しか入っていません。

# onInputに頼らない解決方法

安心してください。うまい解決方法があります。さくらちゃんはかしこいのです🐐

```elm
type alias Model =
    -- 新しいTODOの内容を入力するテキストボックスの入力内容
    { newTodoFormInput : String
    , todo : List String
    }


view : Model -> Html Msg
view model =
    ...
    ...
    -- 新しいTODOの内容を入力する「フォーム」
    Html.form
        [ Attributes.novalidate True
        , Events.preventDefaultOn "submit"
            ( JD.oneOf
                [ JD.at [ "target", "newTodoFormInput", "value" ] JD.string
                    |> JD.map (\str -> (SubmitNewTodoForm str, True))
                , JD.succeed (NoOp, True)
                ]
            )
        ]
        -- 新しいTODOの内容を入力するテキストボックス
        [ Html.input
            [ Attributes.type_ "text"
            -- name属性を設定する
            , Attributes.name "newTodoFormInput"
            -- onChange を使う
            , Events.onChange ChangeNewTodoFormInput
            , Attributes.value model.newTodoFormInput
            ]
            []
        -- 「追加」ボタン
        , Html.button
            [ Attributes.type_ "button"
            , Events.onClick ClickNewTodoFormAddButton
            ]
            [ Html.text "Add"
            ]
        ]
        ...
        ...


update : Msg -> Model -> Model
update msg model =
    case msg of
        SubmitNewTodoForm str ->
            { model
                | newTodoFormInput = str
            }
            |> addNewTodo

        ClickNewTodoFormAddButton ->
            addNewTodo model

        ChangeNewTodoFormInput str ->
            { model
                | newTodoFormInput = str
            }

        NoOp ->
            model


addNewTodo : Model -> Model
addNewTodo model =
    { model
        | newTodoFormInput = ""
        , todo = model.newTodoFormInput :: model.todo
    }
```

コードの全体像は [Ellie](https://ellie-app.com/mHGLBNC5qJha1) にアップしてあります。

大枠の方針は`onChange`による方法と同じです。「追加」ボタンを押したときの挙動には問題ないのですから、基本的には`onChange`で対処しておいて、別途 Enterキーが押されたときの対応をします。

ポイントは、テキストボックスを`form`要素で囲って、 **`form`要素の側で** `submit`イベントを捕捉していることです。
[`form`の`submit`イベント](https://developer.mozilla.org/ja/docs/Web/API/HTMLFormElement/submit_event)は、当該フォームに含まれるテキストボックス内で Enterキーを押した際にも発火します。

このイベントには`target`プロパティが存在し、その中にはテキストボックスの要素も含まれています。つまり、JavaScriptのコードであれば、以下のようなコードによって`submit`イベント発火時のテキストボックス内の入力内容を取得できます。

```js
targetForm.addEventListener('submit', (e) => {
  e.preventDefault();
  console.log(e.target["newTodoFormInput"].value);
});
```

注目すべき点は2つあります。
まず、`e.preventDefault`です。`form`要素において`submit`イベントが起きた場合、既定の動作として画面遷移が引き起こされます。昔ながらのフォーム送信をするウェブページならこれで問題ないのですが、今回作っているアプリケーションのように1画面内で操作を完結したい場合は邪魔な挙動です。これを抑制するために`preventDefault`を使っています。

次に、`console.log`内を見てみましょう。`submit`イベントの`target`、つまり`form`要素に対して、その中に含まれる`"newTodoFormInput"`という`name`属性を持つ要素を指定しています。この`name`属性の値は先ほどのElmコードにおいてテキストボックスに事前に付与してあります。ですから、`e.target["newTodoFormInput"]`がテキストボックスの要素を示します。その要素の`value`を指定していますから、全体として「テキストボックス内でEnterを押したときの、そのテキストボックスの入力値」が得られます。

あとはこれをElmコードにするだけです:

```elm
Events.preventDefaultOn "submit"
    (JD.at [ "target", "newTodoFormInput", "value" ] JD.string
        |> JD.map (\str -> (SubmitNewTodoForm str, True))
    )
```

このコードでは[`Html.Events.preventDefaultOn`](https://package.elm-lang.org/packages/elm/html/latest/Html-Events#preventDefaultOn) を使っています。
`preventDefaultOn`には、最初の引数として捕捉するイベントの名前を指定します。ここでは`submit`イベントなのでそのまま`"submit"`です。
2つ目の引数は、イベントオブジェクトから所望の値を取り出すためのJSONデコーダーです。[elm/json](https://package.elm-lang.org/packages/elm/json/latest/Json-Decode)の関数を使って構築します。

最初の行を見てみましょう:

```elm
JD.at [ "target", "newTodoFormInput", "value" ] JD.string
```

ほぼJavaScriptのコードそのままに、テキストボックスの入力値を`String`型の値として取得します。

次の行はどうでしょうか？

```elm
    |> JD.map (\str -> (SubmitNewTodoForm str, True))
```

取得した入力値を`SubmitNewTodoForm`メッセージで包んでいます。
タプルの2番目の要素`True`は、「デコードに成功したら常に`preventDefault`を実行せよ」と指示しています。

これで基本的には良いのですが、実際のコードではもうひと工夫しています:

```elm
Events.preventDefaultOn "submit"
    ( JD.oneOf
        [ JD.at [ "target", "newTodoFormInput", "value" ] JD.string
            |> JD.map (\str -> (SubmitNewTodoForm str, True))
        , JD.succeed (NoOp, True)
        ]
    )
```

`Html.Events.preventDefaultOn`の返り値は、タプルの2つ目の要素として、「**デコードに成功した際に** `preventDefault`を実行するか」を指定します。
ですから、もし万が一何らかの理由でデコードに失敗すると画面遷移が発生してしまいます。

それを防ぐための工夫を加えたのが上記のコードです。全体を`JD.oneOf`でくくってやり、本体である1つ目のデコーダーが失敗した場合に2つ目のデコーダーが評価されます。2つ目のデコーダーは常に成功するものであり、その返り値は`(NoOp, True)`です。 1つ目の要素`NoOp`は実際には発火しても何もしないメッセージであり、2つ目の要素`True`によって`preventDefault`が必ず実行されます。


DOMの仕様について知ることは、Elmを使う上でもできることの幅を広げることに繋がりますね🌱


[さくらちゃんの追悼写真集を手に入れる](https://amzn.to/38DyjWr)
[さくらちゃんをもっと見る](https://twitter.com/hashtag/%E3%81%95%E3%81%8F%E3%82%89%E3%81%A1%E3%82%83%E3%82%93%E6%97%A5%E8%A8%98?src=hash)
[他の記事を見る](https://qiita.com/arowM)

![eyecatch.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/16345/a64ff5d5-93a4-e323-225b-0fbce4d689f3.jpeg)