[Elm] SPAルーティングで特殊クリックをあつかう



以下の内容は Elm 0.18 を対象にしています。
Elm 0.19 では`Browser.application` を使う限りは、Elm側でいい感じにやってくれてこのような low level な実装は必要ありません。

## 問題提起

Elmを使って1つのHTMLファイルで複数のページをあつかいたい場合、
以下のように `Model` に「現在はどのページにいるか」をあらわす情報を持たせておいて、
この `route` の値に応じて適切な View を出し分けることで実現できます。

```haskell
-- Model


type alias Model =
    Model
        { route : Route
        }


type Route
    = RouteHome
    | RoutePosts
    | RouteAuthors


-- View


view : Model -> Html Msg
view model =
    case model.route of
        RoutePosts ->
            viewRoutePosts model

        RouteAuthors ->
            viewRouteAuthors model
```

ページごとにHTMLとそのページのためのElmコードを用意するのに比べ、ページ遷移の際にサーバーと通信する必要がないため遷移時間が早くなったり、通信が不安定な状況でもページ遷移が可能になります。
また、Elmコードを複数のページにわたって流用しやすいという副次効果もあります。

ただ、これだけだとページをまたいでも URL が同じままになってしまい、ブックマークをしたり誰かに該当ページを知らせるときに不便です。
また、ユーザーはそんな内部事情を知らないで使いますから、「前のページに戻ろう」と思ってブラウザのバックボタンを押しても、Elmの内部状態を知らないブラウザはうまくそれを処理できません。

## Hisotry API

こういった需要に対応するため、HTML5の仕様にはページ遷移時にURLもそのページにあったものに変える手段として[History API](https://developer.mozilla.org/ja/docs/Web/API/History)が用意されています。
これを使うと、ページを実際に遷移させることなくURLだけを置き換えることができます。
ブラウザに「このページ遷移を記憶しておいて、ユーザーがバックボタンを押したら1つ前のURLに戻してね！」と指示することもできます。
もちろん、これだけではページ遷移したときにURLをそのページにあわせて置き換えることが可能になるだけなので、
逆にどのURLを直接アドレスバーに入れてリクエストをしても、ちゃんとElm側でそのURLに応じて内部状態を同期させ、適切なViewを描画する必要があります。

Elmでは、History API を使ったり、URLに応じて内部状態を同期させるために、[`elm-lang/navigation`](http://package.elm-lang.org/packages/elm-lang/navigation/latest)(以降 `Navigation` と呼びます)というパッケージが用意されています。

## 特殊クリックが無効になってしまう

さて前置きが長くなりましたが、この History API を使ったページ遷移の管理を行う際に、Elmに限らず一般的に注意しないといけないことがあります。

あるページから別のページに遷移するメニューなどのリンクを用意する際、JSでクリックイベントを取得して History API を呼ぶ必要があるのですが、あくまでJSのクリックイベントなのでブラウザ固有の

* Ctrl を押しながらクリックすると新しいタブでリンク先を開く
* Shift を押しながらクリックすると新しいウィンドウでリンク先を開く
* Alt を押しながらクリックするとリンク先をHTMLファイルとして保存する
* マウスのホイールボタンでクリックすると新しいタブでリンク先を開く
* マウスの右ボタンでクリックすると、メニューでそのリンクに対する処理を選べる

などの処理が無効になってしまいます。

もちろん、`a` タグで `href` に飛び先のURLを指定すればこれらのブラウザ固有の機能を使えますが、
毎度ページをサーバー側にリクエストすることになってしまい、前述のメリットが享受できなくなります。

## 解決策

この問題に対する解決方法が [`Navigation` の説明](http://package.elm-lang.org/packages/elm-lang/navigation/latest)にちゃんと書いてあります。
ただ、github の issue を追わないといけなくてわかりにくいのと、対策が完全ではないこと、そして日本語情報がほとんどないことから、こちらに対処方法をまとめ直します。

対処法は `onNormalClick` という関数をどこかで定義しておいて、`a` タグでリンクを作成するだけです。
この `onNormalClick` という関数は、Ctrlと一緒に押した時やミドルクリックなどの特殊なクリックの時には発火せず、また `preventDefault` を実行しないため、ふつうに `a` タグに対して特殊クリックをした時の動作が適用されます。
一方、単体の左クリックを行った際には第一引数の `Msg` の動作が発火し、`preventDefault` も実行されるため、`update` 関数で別途定義している History API をつかった処理のみが適用されます。

```haskell
link : Route -> String -> Html Msg
link route label =
    Html.a
        [ Attributes.href <| Route.toPath route
        , Util.Html.onNormalClick (Update.ClickLink route)
        ]
        [ Html.text label
        ]
```

この `onNormalClick` の定義は以下のとおりです。

```haskell
module Util.Html
    exposing
        ( onNormalClick
        )

import Json.Decode as Json exposing (Decoder)
import Html exposing (Attribute, Html)
import Html.Events as Html


{-| マウスの左クリックイベントが単独で起きたときにのみ発火するイベント
    Ctrlを押しながらのクリックや、ミドルボタンによるクリックなどの際には
    `preventDefault` が実行されず、HTMLのデフォルトの動作が起きる。
-}
onNormalClick : msg -> Attribute msg
onNormalClick message =
    Html.onWithOptions "click"
        { stopPropagation = True
        , preventDefault = True
        }
        (preventDefault2
            |> Json.andThen (maybePreventDefault message)
        )


preventDefault2 : Decoder Bool
preventDefault2 =
    Json.map4
        (\x y z w -> not (x || y || z || w))
        (Json.field "ctrlKey" Json.bool)
        (Json.field "metaKey" Json.bool)
        (Json.field "shiftKey" Json.bool)
        (Json.field "altKey" Json.bool)


maybePreventDefault : msg -> Bool -> Decoder msg
maybePreventDefault msg preventDefault =
    case preventDefault of
        True ->
            Json.succeed msg

        False ->
            Json.fail "Normal link"
```

前置きばっかり長かったけど、それだけの話。
![P_20171115_141401_vHDR_On.jpg](https://qiita-image-store.s3.amazonaws.com/0/16345/d07fc630-0098-9ccf-526b-db1bc09913da.jpeg)