8
7

More than 1 year has passed since last update.

onInputに頼らずEnterを捕捉する

Last updated at Posted at 2023-05-03

新しいフレームワークを学ぶとき、よく題材として取り上げられるのがTODOアプリです。
「TODOアプリ程度の規模でフレームワークの特性なんかわからんやろ」というのがさくらちゃんの意見ですが、ともかくよく作られます。

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

Screenshot at 2023-05-03 08-44-33.png
Screenshot at 2023-05-03 08-44-56.png

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

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

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

すぐ思いつく実現方法

Enterキーを押してもTODOに追加できるアプリをElmで作ってみましょう。
まず思いつくのは、elm/html の Html.Events.onInput を使う方法です。

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リソースを食ったから逮捕な!」と、不正指令電磁的記録に関する罪を拡大解釈した神奈川県警が早朝にあなたのお宅のピンポンを鳴らしてきて、安眠を妨害される恐れがあります。

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

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

初めて知ったそこのあなた!
さくらちゃんによる豊富な注釈によってもはや原文で読むよりも学びが多いことで有名なプログラミングElmには、こういったことも注釈でしっかり補足してあるらしいですよ!

onChangeでどうにかしたい

Html.Events.onInputを避ける有力な手段が、 elm-community/html-extra の Html.Events.Extra.onChange を使う方法です。

Html.Events.Extra.onChangechangeイベント を捕捉してその時点での入力値とともにメッセージをupdate関数に渡します。

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

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

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

onInputに頼らない解決方法

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

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 にアップしてあります。

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

ポイントは、テキストボックスをform要素で囲って、 form要素の側で submitイベントを捕捉していることです。
formsubmitイベントは、当該フォームに含まれるテキストボックス内で Enterキーを押した際にも発火します。

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

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コードにするだけです:

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

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

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

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

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

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

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

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

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

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を使う上でもできることの幅を広げることに繋がりますね🌱

さくらちゃんの追悼写真集を手に入れる
さくらちゃんをもっと見る
他の記事を見る

eyecatch.jpg

8
7
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
8
7