新しいフレームワークを学ぶとき、よく題材として取り上げられるのがTODOアプリです。
「TODOアプリ程度の規模でフレームワークの特性なんかわからんやろ」というのがさくらちゃんの意見ですが、ともかくよく作られます。
そんなTODOアプリの最低限の使いやすさを考えたとき、パソコンで操作するなら「テキストボックス内でEnterを押してもTODOアイテムとして作成されてほしい」という需要があります。
サンプルを用意したので、試しに何か文字を入力してそのままEnterを押してみてください。
逆に、こういう最低限の配慮がされていないTODOアプリでは、わざわざ「追加」ボタンを押さないといけません:
- どうにかテキストボックスにフォーカスして
- テキストボックスにキーボードで入力して
- ヒヅメをキーボードから離して
- 「マウスどこだっけ?」って手探りの最中に誤ってマウスを机から落として
- 「ぶめぇぇえええええ!」って叫んで
- 落としたマウスを拾おうと思ってもヒヅメだからツルツル滑っちゃってうまく持ち上げられなくて
- なんとか机に戻したらなんかマウスのボタンが変なところで押されて別の画面になっちゃって
- さっきの画面に戻って再度同じテキストをキーボードで入力しなおして
- 今度こそマウスの位置を目視しながら「追加」ボタンを押そうとするもヒヅメだから滑っちゃってまたマウスが落ちちゃって
- 「ぶっめぇええええええええい!」「追加できないぃいいいいいいいいい!」🐐💢💢
ヤギさんの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文字入力されるたびにイベントが発火するのは無駄にCPU負荷がかかるから
DOMのレンダリングコストと比較すれば誤差みたいなもんですが、避けられるCPU負荷であれば避けるに越したことはありません。
「お前のサイトはユーザーに無断で不必要なonInputをつかってCPUリソースを食ったから逮捕な!」と、不正指令電磁的記録に関する罪を拡大解釈した神奈川県警が早朝にあなたのお宅のピンポンを鳴らしてきて、安眠を妨害される恐れがあります。 -
IMEとの相性が悪く、まれに文字入力がうまくいかなくなるから
残念ながら世界はラテン文字に支配されています。日本語なんていうマイナーな言語のことを配慮するのは二の次です。
ですから、たまにウェブブラウザーがIME(日本語入力のためのソフト)を無視した厄介なバグを入れてくることがあります。
onInputをつかっていると、こういった種類のバグに起因する不可思議な挙動に遭遇することがたまにあります。
初めて知ったそこのあなた!
さくらちゃんによる豊富な注釈によってもはや原文で読むよりも学びが多いことで有名なプログラミングElmには、こういったことも注釈でしっかり補足してあるらしいですよ!
onChangeでどうにかしたい
Html.Events.onInput
を避ける有力な手段が、 elm-community/html-extra の Html.Events.Extra.onChange
を使う方法です。
Html.Events.Extra.onChange
は changeイベント を捕捉してその時点での入力値とともにメッセージをupdate
関数に渡します。
テキストボックス(type
属性がtext
のinput
要素)において、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
イベントを捕捉していることです。
form
のsubmit
イベントは、当該フォームに含まれるテキストボックス内で 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を使う上でもできることの幅を広げることに繋がりますね🌱