Custom input elements and Elm

Custom input elements are a lot of hassle and even more with Elm, probably not impossible.

TL;DR

Elm recently [released version 0.19](Elm relase) which brings [many nice things and improvements](release notes) but it is also removes support for native modules. While Elm 0.18 is still alive it can be safely expected that new packages or new versions of existing packages will target only the new version forcing an update.

What are native modules

Elm 0.18 has four modes of interoperability with JS. Flags, ports, native modules, and custom HTML elements. Flags are a way to initialize an Elm app with data from JS (I wrote about them some time ago). Ports are border points at which Elm can send out data to JS or vice versa their nature is asynchronous which is often correct, sometimes irrelevant but completely useless in some situations (I talked about them at Elm Prague meetup). Native modules are a way to call a JS function directly from Elm (sort of an FFI scheme), they have never been officially documented and developers were discouraged from using them. The last way are custom HTML elements whose attributes can be manipulated and their events listened to in Elm code.

Why use native modules

Flags have very narrow use case of providing initial data for an app. After the app is loaded there’s no more use for them. Custom elements are an option if you want to wrap a widget or some UI element and are willing to pay the cost (more about that later). Ports are the recommended way to interop with JS and usually it’s pretty fine unless their async nature makes it unwieldy to work with. That’s where native modules come in.

We have two use cases for them: local cache and i18n (internationalization). Local cache is one those HTML5 APIs Elm doesn’t handle in the standard library but there are several ELm (0.18) packages relying on native modules that bridge the gap. We use one of them (written by Evan, author of Elm itself) even though it clearly discourages potential users. It is simple to replace this package by ports and move the code to JS.

The second use case is the core of the matter. Enectiva is a Ruby on Rails application. Rails have a built-in support for i18n on the Ruby side and there’s a JS library i18n-js which adds supports for the frontend. It’s stated goal is to provide functional parity with the Ruby code so you don’t have to define or configure your translations twice. When we started using Elm, it was pretty natural to write a small native module (5 functions) to interface with this library. Flags are useless for translating text (and localizing dates, times and numbers) and while ports are probably capable of handling it, it would be a very twisted setup.

Custom elements to the rescue

Forced by the release we were forced to re-evaluate our strategy. The only reasonable interop option left are custom elements. They come from the [Web Components spec](wc spec) and allow to define an HTML element in JS giving application full control over the behaviour. There are a plenty of resources on the web with basic examples. While the prototypical examples showcase self-contained widgets there’s nothing limiting you from tying them closer to your application. With this in mind, I spiked an implementation of <translated-text> element.

The definition is trivial:

class TranslatedText extends HTMLElement {
  connectedCallback() {
    this.innerText = I18n.t(this.innerText)
  }
}

window.customElements.define('translated-text', TranslatedText);

New class for the element is defined, it inherits from HTMLElement. When the element is loaded, the connectedCallback() is called. It grabs the text content of the element, calls I18n.t to translate it and replaces the text with it. The use then looks like:

<translated-text>path.to.piece.of.text</translated-text>

That was pretty simple to get set up and running. Life isn’t that simple though. In order to get this working in browsers other than Chrome and Safari, you need to add a polyfill (at the time of writing, FF supports the spec when you switch on a configuration flag, default support is coming out very soon). Also, to make the code work when compiled to ES5 you need to add a shim. A lot of code but it works, time to step it up.

Translation placeholders

The natural next step is to get a way to pass placeholder values to the new element. Data attributes are a natural way to do so assuming the placeholder names are compatible with the syntax. The only thing that needs to change in the new element is:

    // ...
    this.innerText = I18n.t(this.innerText, this.dataset)
    // ...

and the calling code to:

<translated-text data-username="Jane">path.to.piece.of.text</translated-text>

Admittedly this code is pretty loose but good enough as a proof of concept.

For completeness, it’s simple to do the same in Elm:

translatedText : String -> Html msg
translatedText key =
    translatedTextWithPlaceholders key []


translatedTextWithPlaceholders :
    String
    -> List ( String, String )
    -> Html msg
translatedTextWithPlaceholders key args =
    let
        dataAttrs =
            List.map dataAttr args
    in
    Html.node "translated-text" dataAttrs [ text key ]


dataAttr : ( String, String ) -> Html msg
dataAttr ( name, value ) =
    Html.Attributes.attribute ("data-" ++ name) value


view : Model -> Html msg
view model =
    div []
        [ h1 [] [ text "Non translated heading" ]
        , h1 [] [ translatedText "translated.heading" ]
        , p []
            [ translatedTextWithPlaceholders "translated.text"
                [ ( "username", model.username ) ]
            ]
        ]

Other i18n problems

Some texts need to be pluralized to reflect quantity of something (e.g. “there’s 1 item available”, “there are 2 items available”, “there are no items available”). Luckily, the current solution already works because I18n.t expects a count key in the second argument so passing data-count="3" works just fine.

Not only texts are translated. There are dates, times and numbers as well. But there’s nothing stopping us from defining <translated-date date-format> and similar elements.

Another issue are translation strings with placeholders whose values are also translated strings. We can’t make an HTML element be an attribute value of another HTML element. It is, however, possible to define some special data-* keys which would get translated. Not a very robust solution but possible (and how often does it really come up?).

Increase the difficulty

We scanned our code base for uses of translations in Elm. Apart from few places all of them are in view functions and the rest could easily be moved there allowing to leverage custom elements. Most of the cases are simple rendered text as a part of the UI. There is, however, second use case which can’t be ignored: localized attributes of elements, most commonly <input placeholder="...">.

As already discussed, we can’t embed an HTML element into an attribute of another and there’s nothing like custom attributes. This points to a very unpleasant image of having to manually define translated versions of all HTML elements or at least those that need attributes translated. This set is pretty small with out current usage (input, textarea) and is unlikely to grow much (<img alt="..."> comes to mind, any attribute using title attribute) so it’s worth testing feasibility of this approach.

Extending native elements

The spec has two parts: general custom elements and custom elements extending existing HTML elements. So far, I’ve only talked about the former (extend HTMLElement) but for inputs it makes sense to just decorate the native element, probably add some specific attribute like data-translated-placeholder="path.to.placeholder" and create placeholder attribute when shown/mounted/connected. Unfortunately, this part of the spec is only implemented in Chrome, Safari is not planning to implement it at all and other browsers are somewhere in the middle; it’s basically dead on arrival. This leaves plain custom elements.

The element would be a pretty plain wrapper around an <input>, it would pass most of its attributes to it, translate the others and pass them as a attributes. I won’t include the code but it is pretty verbose and probably buggy but the basic scenario works. Rendering such an element in Elm is, however, not that easy. The root of the problem is that Elm distinguishes between attributed and properties and a custom element can listen to changes to attributes only. So calling:

Html.node 
  "translated-input" 
  [ Html.Attributes.value model.username
  , Html.Attributes.attribute 
      "data-translated_placeholder" 
      "path.to.placeholder.text"
  ] 
  []

gives the custom element access to data-translated_placeholder but not the value because Elm handles it as a property. To make it work, the calling code would have to create an attribute instead. Additional hassle but still workable:

Html.node 
  "translated-input" 
  [ Html.Attributes.attribute "value" model.username
  , Html.Attributes.attribute 
      "data-translated_placeholder" 
      "path.to.placeholder.text"
  ] 
  []

But setting the attributes is only part of the story. An input emits events which need to be handled and passed back to Elm. This would require similar proxying as the attributes do but the other way. I haven’t tried it but I expect more complications in this direction.

The next steps

I spent half a day on this exploration and while the basic use case can be easily handled by custom elements translated attributes are a higher degree of difficulty if at all possible. I’m afraid that in our case there is no other way that to implement i18n library directly in Elm, the translation data would be passed in as a value via flags and a translation function with the data partially applied would have to be threaded through all view functions (making use of most external libraries impossible).

I posted the question on Elm Slack some time ago and got a few responses which mostly converged on custom elements. There are existing i18n libraries in Elm already but the options are pretty limited. The current docs show only ChristophP/elm-i18next which deals with async loading of translations (which we don’t need) but doesn’t handle dates, times and numbers.

While I understand the motivation behind the move away from native modules I can’t avoid feeling disheartened because it makes our life much harder. If you have an alternative solution to the problem, please, contact me!

We're looking for developers to help us save energy

If you're interested in what we do and you would like to help us save energy, drop us a line at jobs@enectiva.cz.

comments powered by Disqus