7.7

Interacting with Web Components

So far in this chapter, we have communicated with JavaScript using ports and flags. There is one other way: Web Components.

Web Components
Web Components are a set of APIs provided by the Web Platform for building reusable custom elements which contain all the necessary HTML, CSS, and JavaScript code. These custom elements can be used inside any framework — including Elm — that works with HTML and JavaScript. You can browse various custom elements on the webcomponents.org site.

Let’s build an app that shows the highest mountains in the world on a map to learn how to interact with custom elements from Elm. Here’s how the final app will look:

Installing Custom Elements

The recommended way for installing custom elements is to use a package manager called Bower. It works similarly to NPM. The main difference is that NPM is used for installing Node.js packages whereas Bower is used for managing components that contain HTML, CSS, JavaScript, fonts, and image files. Install Bower by running the following command from the beginning-elm directory in terminal.

$ npm install bower -g

We’ll be using the google-map custom element in our app, so let’s install it by running the following command from the beginning-elm directory in terminal.

$ bower install GoogleWebComponents/google-map

Once the installation is complete, you should see a new directory called bower_components inside beginning-elm. That directory contains all the code necessary for the google-map custom element to work.

Importing Custom Elements

We can load custom elements in our app by using the <link> tag inside the beginning-elm/index.html file. Replace the contents of that file with the following.

<!DOCTYPE html>
<html>
    <head>
        <script src="bower_components/webcomponentsjs/webcomponents-lite.js"></script>
        <link rel="import" href="bower_components/google-map/google-map.html">
        <link rel="import" href="bower_components/google-map/google-map-marker.html">

        <style>
            google-map {
                height: 400px;
            }
        </style>
    </head>
    <body>
        <div id="elm-code-is-loaded-here"></div>
        <script src="elm.js"></script>
        <script>
            var element = document.getElementById("elm-code-is-loaded-here");
            var app = Elm.CustomElements.embed(element);
        </script>
    </body>
</html>

The first line inside <head> loads the JavaScript code necessary for running Web Components.

<script src="bower_components/webcomponentsjs/webcomponents-lite.js"></script>

The next line imports the google-map custom element.

<link rel="import" href="bower_components/google-map/google-map.html">

The third line imports the google-map-marker custom element. We’ll be using markers to show where the mountains are located on a map.

<link rel="import" href="bower_components/google-map/google-map-marker.html">

The code for google-map-marker is located inside bower_components/google-map.

Finally, we need to declare the height of the map inside the <style> tag.

<style>
    google-map {
        height: 400px;
    }
</style>

By default the map’s height is set to zero pixels, so if you don’t specify a height you won’t see a map on the page.

Defining Model

Create a new file called CustomElements.elm in the beginning-elm/elm-examples directory and add the code below to it.

module CustomElements exposing (..)

import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
import Json.Decode exposing (Decoder, float)
import Json.Decode.Pipeline exposing (decode, requiredAt)


type alias Model =
    { center : Coordinates
    , markers : List Marker
    }


type alias Coordinates =
    { latitude : Float
    , longitude : Float
    }


type alias Marker =
    { coordinates : Coordinates
    , title : String
    , imageUrl : String
    }

We imported a bunch of modules needed by our app. We then defined a model which contains the coordinates to center the maps on and a list of markers.

Defining Custom Element Nodes

The beautiful thing about custom elements is that someone else writes the necessary code and all we have to do to take advantage of their hard work is include the tag, such as google-map, in our app. Just like that our app acquires all the super powers possessed by those custom elements.

In Elm a tag is nothing but a simple wrapper to the Html.node function. If you peruse the Html module’s source code, you’ll notice that all of the functions we have used so far from that module do nothing more than apply the node function. For example, here is what the button and input function’s implementation looks like:

button : List (Attribute msg) -> List (Html msg) -> Html msg
button attributes children =
    node "button" attributes children


input : List (Attribute msg) -> List (Html msg) -> Html msg
input attributes children =
    node "input" attributes children

Actually, the real code for those functions looks something like this:

button : List (Attribute msg) -> List (Html msg) -> Html msg
button =
    node "button"


input : List (Attribute msg) -> List (Html msg) -> Html msg
input =
    node "input"

Elm doesn’t require us to specify parameters when defining a function. That’s because Elm functions can be partially applied. Although the node function actually takes three arguments, we don’t have to provide all of them when we apply it.

node
    :  String
    -> List (Attribute msg)
    -> List (Html msg)
    -> Html msg

The expression node "button" returns a partially applied function whose type is:

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

That’s why the button and input functions and all other tag functions in Html module have that type.

To use the custom elements inside an Elm app we need to convert them into DOM nodes using the Html.node function. Add the following code to the bottom of CustomElements.elm.

googleMap : List (Attribute a) -> List (Html a) -> Html a
googleMap =
    Html.node "google-map"


googleMapMarker : List (Attribute a) -> List (Html a) -> Html a
googleMapMarker =
    Html.node "google-map-marker"

As you can see the implementation for googleMap and googleMapMarker look very similar to that of button and input.

Defining View

Let’s use those custom tags to create our app’s view. Add the following code to the bottom of CustomElements.elm.

view : Model -> Html Msg
view model =
    div []
        [ h2 [] [ text "Highest Mountains on Earth" ]
        , googleMap
            [ attribute "latitude" (toString model.center.latitude)
            , attribute "longitude" (toString model.center.longitude)
            , attribute "drag-events" "true"
            , attribute "zoom" "5"
            ]
            (List.map viewMarker model.markers)
        ]


viewMarker : Marker -> Html Msg
viewMarker marker =
    googleMapMarker
        [ attribute "latitude" (toString marker.coordinates.latitude)
        , attribute "longitude" (toString marker.coordinates.longitude)
        , attribute "title" marker.title
        ]
        [ img [ src marker.imageUrl, width 140, height 105 ] []
        , br [] []
        , text marker.title
        ]

The view function displays a header and a map. The googleMap node accepts a list of attributes and a list of children nodes. To specify a custom attribute we need to use the attribute function defined in the Html.Attributes module. Here’s how that function’s type looks:

attribute : String -> String -> Attribute msg

The first argument represents the name of an attribute and the second argument represents the value for that attribute. latitude and longitude attributes represent the coordinates to center the map on. The drag-events attribute tells the map to notify us whenever the map is moved around. Finally zoom sets the zoom level for the map. Many more attributes are available for customizing the map. You can find them here.

Markers are displayed as the map’s children nodes. The googleMapMarker node used inside viewMarker also takes a list of attributes and a list of children nodes. When a marker is clicked, its children nodes are displayed on a popup view like this:

Update

There are no messages flowing through the app yet, so our update function will look very simple. Add the following code to the bottom of CustomElements.elm.

type Msg
    = NoOp


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        NoOp ->
            ( model, Cmd.none )

NoOp means “no operation.” It’s just a placeholder message for now. We’ll replace it with a real one later.

Initial Model

Next we need to create an initial model. Add the following code to the bottom of CustomElements.elm.

initialModel : Model
initialModel =
    let
        center =
            Coordinates 32.545349 82.689855

        markers =
            [ Marker (Coordinates 27.9878 86.925) "Everest" "https://goo.gl/Di3zLj"
            , Marker (Coordinates 35.88 76.5151) "K2" "https://goo.gl/fYjfa4"
            , Marker (Coordinates 27.7025 88.1475) "Kanchenjunga" "https://goo.gl/jAokum"
            ]
    in
        { center = center
        , markers = markers
        }


init : ( Model, Cmd Msg )
init =
    ( initialModel, Cmd.none )

initialModel uses various constructor functions generated by the type alias definitions we added earlier.

Putting Everything Together

Let’s wire everything up by adding main to the bottom of CustomElements.elm.

main : Program Never Model Msg
main =
    Html.program
        { init = init
        , view = view
        , update = update
        , subscriptions = \_ -> Sub.none
        }

With that, we’re ready to test. Run elm-live from the beginning-elm directory in terminal using the following command.

$ elm-live elm-examples/CustomElements.elm --output=elm.js

Go to http://localhost:8000/ in a browser and you should see three markers plotted on a map.

When you click a marker, a popup view will display the image and title associated with that marker.

When a marker is clicked, the google-map custom element automatically opens the popup view with children nodes. We don’t need to handle any events for that to happen.

Handling Events Generated by Custom Elements

We can ask a custom element to notify us when something interesting happens by handling one of the published events. For example the google-map element generates more than a dozen events listed here. Let’s handle the event called google-map-drag to understand how an Elm app receives event notifications from a custom element.

As the name suggests, the google-map-drag event is generated whenever we drag the map around. This is a custom event, so the Html.Events module doesn’t know anything about it. In the previous chapters, we used functions such as onClick and onInput — defined in Html.Events — to handle events generated by the button and input elements respectively.

Although Html.Events doesn’t know about google-map-drag, it lets us handle that event through the use of on function which has the following type.

Add the following code right above the view function in CustomElements.elm.

onGoogleMapDrag : Attribute Msg
onGoogleMapDrag =
    coordinatesDecoder
        |> Json.Decode.map UpdateCenter
        |> on "google-map-drag"


coordinatesDecoder : Decoder Coordinates
coordinatesDecoder =
    decode Coordinates
        |> requiredAt [ "target", "latitude" ] float
        |> requiredAt [ "target", "longitude" ] float

onGoogleMapDrag uses the on function to create a custom event handler. The first argument is the name of the event: google-map-drag. The second argument is a JSON decoder. Whenever the map is dragged around, google-map sends an event object to the Elm app. The structure of the event object looks something like this:

{
    .
    .
    "target" : {
        "latitude" : 32.545349,
        "longitude" : 82.689855
        .
        .
    }
}

Unfortunately, as of this writing the official documentation for google-map or any other custom element for that matter doesn’t specify the structure of the data included in an event object.

To figure out what data was contained inside the google-map-drag event object, I created a separate JavaScript app that also used google-map. I then used the code inside the last <script> tag shown below to print the event object in the browser console. Hopefully, the documentation will include this detail in the future.

<!DOCTYPE html>
<html>
    <head>
        <script src="bower_components/webcomponentsjs/webcomponents-lite.js"></script>
        <link rel="import" href="bower_components/google-map/google-map.html">
        <link rel="import" href="bower_components/google-map/google-map-marker.html">

        <style>
            google-map {
                height: 400px;
            }
        </style>
    </head>
    <body>
        <google-map latitude="37.78" longitude="-122.4">
          <google-map-marker latitude="37.78" longitude="-122.4">
              <img src="https://goo.gl/PCvXLn"  width="140" height="105" />
          </google-map-marker>
        </google-map>

        <script>
            var googleMap = document.querySelector('google-map');
            googleMap.addEventListener('google-map-drag', function(eventObject) {
                console.log("eventObject: ", eventObject);
            });
        </script>
    </body>
</html>

The code for decoding coordinates out of the event object JSON is quite simple.

coordinatesDecoder : Decoder Coordinates
coordinatesDecoder =
    decode Coordinates
        |> requiredAt [ "target", "latitude" ] float
        |> requiredAt [ "target", "longitude" ] float

We used the requiredAt function from the Json.Decode.Pipeline module to pull the coordinates out of a nested JSON object. The code in onGoogleMapDrag, however, looks a bit strange.

onGoogleMapDrag : Attribute Msg
onGoogleMapDrag =
    coordinatesDecoder
        |> Json.Decode.map UpdateCenter
        |> on "google-map-drag"

Why are we using the Json.Decode.map function instead of passing coordinatesDecoder directly to the on function like this:

on "google-map-debug" coordinatesDecoder

The answer lies in the on function’s type signature.

on : String -> Decoder msg -> Attribute msg

Our decoder returns the Coordinates type, but the on function expects a decoder that returns a message. We need a function that can convert Coordinates into a message type. Json.Decode.map is that function. Here’s how its type signature looks:

map : (a -> value) -> Decoder a -> Decoder value

Json.Decode.map translates the Coordinates type into UpdateCenter which is a message we need to define. Replace NoOp with UpdateCenter in the Msg type.

type Msg
    = UpdateCenter Coordinates

Now do the same in the update function.

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        UpdateCenter coordinates ->
            ( { model | center = coordinates }, Cmd.none )

Next we need to add the onGoogleMapDrag custom event handler to the googleMap node in view.

view : Model -> Html Msg
view model =
    div []
        .
        .
        , googleMap
            .
            .
            , attribute "zoom" "5"
            , onGoogleMapDrag
            ]
            (List.map viewMarker model.markers)
        ]

The only thing remaining is to display the center coordinates when the map is being dragged. Add the following code right below the viewMarker function.

viewCoordinates : Coordinates -> Html Msg
viewCoordinates mapCenter =
    div []
        [ h3 []
            [ text "The above map is centered on the following coordinates:" ]
        , text ("Latitude: " ++ (toString mapCenter.latitude))
        , br [] []
        , text ("Longitude: " ++ (toString mapCenter.longitude))
        ]

Render viewCoordinates below the googleMap node in view.

view : Model -> Html Msg
view model =
    div []
        [ h2 [] [ text "Highest Mountains on Earth" ]
        , googleMap
            .
            .
            (List.map viewMarker model.markers)
        , viewCoordinates model.center
        ]

Check the elm-live window in terminal to make sure there are no errors and go back to the page at http://localhost:8000/. The coordinates displayed at the bottom of the page should change when you drag the map around.

Summary

In this section, we discovered yet another way of interacting with JavaScript from an Elm app. Custom elements are reusable widgets built using the Web Components specification. They contain the necessary HTML, CSS, and JavaScript code. All we have to do is include the tag in our app. We can also listen to the events generated by a custom element using the Html.Events.on function.

Unfortunately, Elm can’t guarantee that the custom elements won’t cause runtime errors because they are written in JavaScript which doesn’t have Elm’s robust type system. So use them only if you don’t have a safer alternative.

Here is the entire code from CustomElements.elm for your reference:

module CustomElements exposing (..)

import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
import Json.Decode exposing (Decoder, float)
import Json.Decode.Pipeline exposing (decode, requiredAt)


type alias Model =
    { center : Coordinates
    , markers : List Marker
    }


type alias Coordinates =
    { latitude : Float
    , longitude : Float
    }


type alias Marker =
    { coordinates : Coordinates
    , title : String
    , imageUrl : String
    }


googleMap : List (Attribute a) -> List (Html a) -> Html a
googleMap =
    Html.node "google-map"


googleMapMarker : List (Attribute a) -> List (Html a) -> Html a
googleMapMarker =
    Html.node "google-map-marker"


onGoogleMapDrag : Attribute Msg
onGoogleMapDrag =
    coordinatesDecoder
        |> Json.Decode.map UpdateCenter
        |> on "google-map-drag"


coordinatesDecoder : Decoder Coordinates
coordinatesDecoder =
    decode Coordinates
        |> requiredAt [ "target", "latitude" ] float
        |> requiredAt [ "target", "longitude" ] float


view : Model -> Html Msg
view model =
    div []
        [ h2 [] [ text "Highest Mountains on Earth" ]
        , googleMap
            [ attribute "latitude" (toString model.center.latitude)
            , attribute "longitude" (toString model.center.longitude)
            , attribute "drag-events" "true"
            , attribute "zoom" "5"
            , onGoogleMapDrag
            ]
            (List.map viewMarker model.markers)
        , viewCoordinates model.center
        ]


viewMarker : Marker -> Html Msg
viewMarker marker =
    googleMapMarker
        [ attribute "latitude" (toString marker.coordinates.latitude)
        , attribute "longitude" (toString marker.coordinates.longitude)
        , attribute "title" marker.title
        ]
        [ img [ src marker.imageUrl, width 140, height 105 ] []
        , br [] []
        , text marker.title
        ]


viewCoordinates : Coordinates -> Html Msg
viewCoordinates mapCenter =
    div []
        [ h3 []
            [ text "The above map is centered on the following coordinates:" ]
        , text ("Latitude: " ++ (toString mapCenter.latitude))
        , br [] []
        , text ("Longitude: " ++ (toString mapCenter.longitude))
        ]


type Msg
    = UpdateCenter Coordinates


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        UpdateCenter coordinates ->
            ( { model | center = coordinates }, Cmd.none )


initialModel : Model
initialModel =
    let
        center =
            Coordinates 32.545349 82.689855

        markers =
            [ Marker (Coordinates 27.9878 86.925) "Everest" "https://goo.gl/Di3zLj"
            , Marker (Coordinates 35.88 76.5151) "K2" "https://goo.gl/fYjfa4"
            , Marker (Coordinates 27.7025 88.1475) "Kanchenjunga" "https://goo.gl/jAokum"
            ]
    in
        { center = center
        , markers = markers
        }


init : ( Model, Cmd Msg )
init =
    ( initialModel, Cmd.none )


main : Program Never Model Msg
main =
    Html.program
        { init = init
        , view = view
        , update = update
        , subscriptions = \_ -> Sub.none
        }
Back to top

New chapters are coming soon!

Sign up for the Elm Programming newsletter to get notified!

* indicates required
Close