loading...
Cover image for Writing custom-elements for elm

Writing custom-elements for elm

leojpod profile image leojpod ・9 min read

Summary: There are 2 options to integrate JavaScript and Elm, one is the port system which has been around for a while, the other is to use custom-elements.
In this post, we'll see that it is rather simple and show 2 examples of packages that use it.

The introduction is a tad long but you can always just skip to the main part.

What's custom-elements?

Custom elements are part of the Web Components and in short, it allows us to create new HTML tag that has a set of behaviour defined in JavaScript.
Think of it as a "super-tiny-application-wrapped-in-a-tag".

Have you ever wanted to define a small thing that you could call like <drawing-board tool="pencil" thickness="10pt"></drawing-board> and get the whole set of features that goes with it?
Well, custom-elements allows you to do just that.

When you think of it, inputs in general and <textarea> in particular encompass a lot of features and "state" to know what the user input is, where is the cursor, if there is some auto-completion available, ...

Custom-elements are just a neat way of defining your own version of that.

For a more complete look at custom-element, you can refer to this post:

or refer to the GREAT and all-mighty MDN: Using custom elements

How does this help us with Elm?

Quick words of introduction if you don't know Elm: Elm is a functional language designed for the front-end.
Think of it as a "light" and more friendly version of Haskell repurposed for a single task.

Among many advantages, Elm ensures that once compiled your code won't generate any runtime errors.
One of the ways to do this is to force the code your write to handle all the different way things can go wrong using constructs such as Result or Maybe which works just perfectly.

All of this is a long introduction to say that to provide you with this guarantee, Elm restrains the interactions with the unsafe world outside (a.k.a the JavaScript Doomdom...).
Traditionally most interactions are handled in something called ports.
The main interest of exchanging information between the outside world and elm via ports is that you can be sure of preserving the integrity of your elm code.

Custom elements, however, are an interesting way of integrating some isolated JavaScript in your elm codebase.
This covers for instance: charting libraries, chatbots, ...

Yes, yes, good, how does that work then? Well, let's get to it.

Making it work

The elm documentation provides an excellent base to start interoperating custom elements with elm.
However, nothing is better than a shameless plug detailed example.

One thing I often found myself doing in elm in the various project I've worked on is a way to trigger some action based on keyboard events (or rather a combination of keys).
In the past, I had mostly used events from the elm/browser package which worked well but there were some drawbacks (for details on that, you can refer to this link).

Making a custom element to listen to a specific set of shortcut allowed me to keep things simple in my views and treat the shortcut as any other inputs.
Using this small package, I can make a dismissible modal like this:

shortcutModal : List (Html Msg) -> Html Msg
shortcutModal =
    Shortcut.shortcutElement
        [ Shortcut.esc CloseModal ]
        [ class "fixed top-0 bottom-0 left-0 right-0 flex flex-col items-center justify-center bg-gray-500 bg-opacity-75" ]
        << List.singleton
        << div [ class "w-3/4 max-w-4xl p-12 bg-white border-gray-800 rounded-lg shadow-xl" ]
Enter fullscreen mode Exit fullscreen mode

If you look a bit closer to that piece of code you'll see the 2 key lines here:

    Shortcut.shortcutElement -- simply a wrapper for Html.node "shortcut-element"
        [ Shortcut.esc CloseModal ] -- the shortcutElement expect a list of shortcut and Shortcut.esc is just a simple way to say "when the user press ESC send me a CloseModal message"
Enter fullscreen mode Exit fullscreen mode

The main interest of this version compared to doing it with subscriptions and Browser.Events is mainly readability:
Now even small bits of the UI can have shortcut without requiring you to keep track of their visibility/state in your subscriptions and you can also read it directly in the view.

Enough! Show me some code!

The entire code is available here but let's go through the principal components of this solution.

Defining shortcuts

Shortcuts are an association of a message to send and a description of a key combination.
A key combination is a base key and some optional modifier.
Elm provides a nice way to do that with is called union types (if you come from TypeScript or the like, think of them as a super-powerful enum type) and record types (again, TypeScript people, think of it as a simple class without method only some properties).

In the end, the shortcut definition looks like this:

type alias Shortcut msg =
    { msg : msg
    , keyCombination :
        { baseKey : Key
        , alt : Maybe Bool
        , shift : Maybe Bool
        , ctrl : Maybe Bool
        , meta : Maybe Bool
        }
    }
Enter fullscreen mode Exit fullscreen mode

The type Key is a union typed defined as (complete code here):

type Key
    = Escape
    | BackSpace
    -- | ... and many other constructors for the special keys
    | Regular String
Enter fullscreen mode Exit fullscreen mode

Defining a custom element

Before actually writing our custom element(s) one thing we should probably do is install a polyfill.
While custom elements are rather well supported (see Can I use?, even the Android browser joined the party!), it's still safer and nice to people who are stuck on IE11 to use a polyfill and make sure they are not left out.
There is one right here and all you need is just to install it via NPM, ain't that simple?

Once that's done you can start by making a file for your custom element and put the following scaffolding in it.

import '@webcomponents/custom-elements' // that's our polyfill

// custom elements are really just a custom HTMLElement
// so it is really no surprise that you just need to extends the HTMLElement class
export class ShortcutElement extends HTMLElement { 
  connectedCallback () {
    // here goes the code you want to run when your custom element is rendered and initialised
  }

  disconnectedCallback () {
    // here goes the actions you should do when it's time to destroy/remove your custom element
  }
}

// the last important step here: registering our element so people can actually use it in their HTML
customElements.define('shortcut-element', ShortcutElement)
Enter fullscreen mode Exit fullscreen mode

If we look at the code above, the key really is in creating a new class to backup our element that extends HTMLElement and registering it to a tag name via customElements.define(tagName: string, constructor: HTMLElement).

Now let's fill that up.
As mentioned in the comments on the snippet above, the first entry and exit points are the 2 callbacks: connectedCallback and disconnectedCallback.
The first one is called when your element is added to the page, the second one when it's taken away.

In our shortcut example, we'll use the connectedCallback to register an event listener on the body (since that will capture events regardless of what's on the page) and disconnectedCallback to unsubscribe our event listener from the body.
So we'll start with something like:

export class ShortcutElement extends HTMLElement {
  connectedCallback () {
    this.listener = (evt) => {
      const event = evt
      // TODO check with the associated shortcuts if we have a match
      // TODO if we have one then send a custom event
    }
    // let's register
    // NOTE: we will register at the capture phase so as to take precedence over the rest (e.g. textarea, input, ...)
    document.body.addEventListener('keydown', this.listener, { capture: true })
  }

  disconnectedCallback () {
    // let's unregister
    document.body.removeEventListener('keydown', this.listener, {
      capture: true
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

And we're almost done for the JavaScript part! Yes there are 2 big TODO in there but we'll get back to them after we take a look at the elm side of things

How to use this in Elm?

On the elm side, things are rather simple. We need but 2 things: define a custom Html.Html msg that uses our element and find a way to communicate with that element.

The first part is super easy: Html.node "shortcut-element".
To make it nice we can wrap that in a function:

shortcutElement: List (Html.Attribute msg) -> List (Html msg) -> Html msg
shortcutElement =
  Html.node "shortcut-element"
Enter fullscreen mode Exit fullscreen mode

Now, the communication part. Well, this one has 2 subparts actually: information going to the custom element and information coming from the custom element.
For sending information from the JavaScript to Elm we'll use CustomEvent on the JavaScript part which means we can just use our normal Html.Events.on function and the familiar Json.Decode (and Json.Decode.Extra)
For sending information down to the JavaScript from the Elm world we'll play with attributes and properties.

So it's gonna look like this:

encodeShortcut : Shortcut msg -> Json.Encode.Value
encodeShortcut ({ keyCombination } as shortcut) =
    Json.Encode.object
        [ ( "name", Json.Encode.string <| hashShortcut shortcut )
        , ( "baseKey", Json.Encode.string <| keyToString keyCombination.baseKey )
        , ( "alt", Json.Encode.Extra.maybe Json.Encode.bool keyCombination.alt )
        , ( "shift", Json.Encode.Extra.maybe Json.Encode.bool keyCombination.shift )
        , ( "ctrl", Json.Encode.Extra.maybe Json.Encode.bool keyCombination.ctrl )
        , ( "meta", Json.Encode.Extra.maybe Json.Encode.bool keyCombination.meta )
        ]


onShortcut : List (Shortcut msg) -> Html.Attribute msg
onShortcut shortcuts =
    Html.Events.on "shortcut"
        (Json.Decode.at [ "detail", "name" ] Json.Decode.string
            |> Json.Decode.andThen
                (\hash ->
                    List.Extra.find (hashShortcut >> (==) hash) shortcuts
                        -- NOTE: if a event decoding failed then no message is emitted
                        |> Maybe.Extra.unwrap (Json.Decode.fail "did not match a known shortcut") (.msg >> Json.Decode.succeed)
                )
        )


shortcutElement : List (Shortcut msg) -> List (Html.Attribute msg) -> List (Html msg) -> Html msg
shortcutElement shortcuts attrs =
    node "shortcut-element"
        -- Add 2 attributes here: one to send the props we're listening to
        (Html.Attributes.property "shortcuts" (Json.Encode.list encodeShortcut shortcuts)
            -- one to listen to the stuff
            :: onShortcut shortcuts
            :: attrs
        )
Enter fullscreen mode Exit fullscreen mode

(For those curious about the note on the onShortcut function, have a look at this article)

The main thing here is that we're setting a property called shortcuts on our custom elements that contains all the shortcuts passed to the shortcutElement function and that we will listen to the shortcut event from which we are going to extract the name of our shortcut and find out which message should be sent.

In the end, the elm-side looks rather simple doesn't it?

Huston, JavaScript speaking do you copy?

Getting back to our 2 TODO in JavaScript:

  • find out if we have a match among the shortcut the element should listen for
  • send an event if there is one.

Since the elm part will set the shortcuts property we can simply access this array via this.shortcuts from within our ShortcutElement class. Then one small caveat with shortcuts is the need to detect which key was really pressed since if we ask the user to press ShiftAlto for instance, the value of event.key might vary a lot based on the user's input method and OS (e.g. o, Ø, ...).
As explained on MDN, using event.code would work if we assume our user are all using QWERTY keyboards but that is kind of a rubbish solution.
Instead, I'd recommend using deburr from lodash, which will remove all the "diacritical marks" (a.k.a. give you back the original letter that was pressed).

Sending out the event is as simple as using the constructor for a CustomEvent and setting a property in the detail part of its second parameter.
Putting it all together we get:

    this.listener = (evt) => {
      const event = evt
      this.shortcuts
        .filter(
          ({ baseKey, alt, shift, ctrl, meta }) =>
            deburr(event.key).toLowerCase() === baseKey.toLowerCase() &&
            (alt == null || alt === event.altKey) &&
            (shift == null || shift === event.shiftKey) &&
            (ctrl == null || ctrl === event.ctrlKey) &&
            (meta == null || meta === event.metaKey)
        ) // now we have all the shortcuts that match the current event
        .map(({ name }) => {
          event.preventDefault()
          event.stopPropagation()
          this.dispatchEvent(
            new CustomEvent('shortcut', {
              bubbles: false,
              detail: {
                name,
                event
              }
            })
          )
        })
    }
Enter fullscreen mode Exit fullscreen mode

To see it in action you can have a look at the Github page here

Apex Charts in Elm

Apex charts is a fancy charting library for JavaScript that provides a lot of interactive chart types and interesting ways to combine them.
As I was looking for such library in Elm but could not quite find the one I was looking for, I thought I would make a custom element to integrate Apex charts and Elm.

In the end, it allows the dev to write things like:

Apex.chart
    |> Apex.addLineSeries "Connections by week" (connectionsByWeek logins)
    |> Apex.addColumnSeries "Connections within office hour for that week" (dayTimeConnectionByWeek logins)
    |> Apex.addColumnSeries "Connections outside office hour for that week" (outsideOfficeHourConnectionByWeek logins)
    |> Apex.withXAxisType Apex.DateTime
Enter fullscreen mode Exit fullscreen mode

and get a nice chart with one line and 2 columns.

Since this post is already quite lengthy, I'll keep the second custom element for another time but you can already have a primeur of it here (with the code here).
To make it work, we will need to take a closer look at getter and setter in JavaScript so as to handle properties that can change over time (i.e. during the life-time of our custom element).

Discussion

pic
Editor guide
Collapse
jamesrweb profile image
James Robb

Nice write up, really cool to see an article on 2 topics I’m into, namely custom elements and elm! Currently re-reading “Elm in action” and just as I put the book down this evening I opened the dev app and found this article, small world, huh?

Anyway, thanks also for the shoutout to my article on custom elements, much appreciated and keep writing dude, this one was great!

Collapse
leojpod profile image
leojpod Author

Small world indeed :) I saw your article when I was writing the first draft of that article and thought perfect now there is a great one out there and I can shorten my introduction 😆.
I had no idea you were into elm as well 😁, how random!

Thanks a lot for your kind words!
I would like to have the time to make the follow up about the apex-chart library, it's another good candidate for custom-elements

Collapse
jamesrweb profile image
James Robb

Yeah, it’s always fun to find fellow developers with similar niche interests!

Looking forward to reading you’re upcoming articles. My next one will probably be a continuation of my rust series but I haven’t written in some months due to life basically but yeah, that’ll be changing soon!

Yep, apex is a nice one. What about charting in elm for example? Or even a series building a basic backend to get and set data for a Frontend in elm which uses ports or something? I feel a lot of these topics are rarely covered and could be a cool set of articles 🤷‍♂️. Just an idea.

Anyhow, keep up the good work dude!