Writing custom-elements for elm
Originally posted on dev.to/leojpod as part of my on-going series about vim.
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:
{% link https://dev.to/jamesrweb/an-introduction-to-custom-elements-5327 %}
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" ]
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"
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
}
}
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
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)
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
})
}
}
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"
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
)
(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 <kbd>Shift</kbd><kbd>Alt</kbd><kbd>o</kbd> 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
}
})
)
})
}
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
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).
During the time when I was working as a developer, I had an interest in essay writing. I really hope that you will find it to be intriguing as well. Whether you’re writing for a class or an application, it’s important to know how to write a unique essay . The subject you choose should be interesting to you, and it should also be relevant to your audience. This will allow you to express yourself more authentically and provide the reader with a unique point of view. Adding your own personal perspective will also give your paper a unique quality.