Writing a filterable dropdown menu in elm

Elm is a fun language for writing frontend programs. In this post we like to share how we solved implementing a filterable dropdown menu with it!

At PhraseApp we came to the conclusion that we wanted to change something about our frontend stack. After evaluating several different frameworks we decided that we would try out elm.

For our application we needed a dropdown menu with a textfield, such that the user can filter the displayed menu entries by typing in a query string. We usually use the javascript library selectize.js, and of course we could have embedded this component in our elm application (using ports). But we thought, that writing such a dropdown menu is actually an interesting problem, and we wanted to see how we can solve it in elm.

You can find our final result on github along with a small live demo.

In this blog post, we like to share the solution we came up with and especially try to give some reasons, why we did it in this way. Also, we try to explain some implementation details which might be interesting, too.

What are the needed features?

So let’s recap what our dropdown menu should offer:

  • The opened menu should have a maximum height, and it should be scrollable if there are a lot of items to be displayed.
  • The user should be able to select entries by clicking on them, and each entry should get focus if we hover over it with the mouse.
  • Also, the user should be able to focus entries using the up and down keys and actually select them by pressing enter.
  • When the menu is open, typing in something should filter the list of shown entries
  • We also need to be able, to display dividers in the menu, which are not selectable.

What solutions are out there?

The elm community is small, but still there are some (very good) solutions out there! For example, there is the popular package elm-autocomplete, which solves the problem of a filterable menu which is also navigatable with the keyboard in a very general way. And there are several dropdown packages one finds when looking in the elm package database.

So why did we not use elm-autocomplete?

This package does not include the textfield for entering the query into the package. This was done for a very good reason, namely to provide a more flexible solution. The payoff is that one has to add global keydown-subscriptions to handle keyboard navigation, since only html-elements which can have focus, can fire keypress-events. In our use-case we always have an input textfield, so the need for global subscriptions seemed a bit unwieldy. (One could of course argue that having the additional subscription boilerplate is not too much of an afford.)

We guess, that elm-autocomplete was not designed with a scrollable menu in mind. When calling its view or update functions one has to provide a maxium number of menu entries which should be displayed. In our case this would have always been the total number of entries which matched the filtering string. So this part of the api did not seem to be a good fit for our problem.

And last but not least, it is never a bad idea to make a fresh start and try to come up with a another solution to an old problem, later comparing the results with what’s already out there.

And it turns out, writing packages in elm is a really satisfying process and you always learn something!

What should the Api look like?

It is always a good idea to spend a decent amount of time on thinking about what your api should be looking like:

  • What part of the state should live in the application?
  • Which parts have to be handled by the package?
  • How can the user customize the appearance or behaviour of the dropdown menu?

We decided that the (unfiltered) list of menu entries should be stored in the main model. In our application we actually have several dropdown menus which share a common set of entries, and if the user selects an entry in one of them, this entry should disappear in the other menus.

Also the eventually selected entry should not be stored in the state of the dropdown menu. We have to be able to change the selection within our application and having setters and getters is generally a very bad idea as it clashes with the “one source of truth” paradigma.

On the other hand, the open/closed state of the menu and the entered query for filtering the menu entries, should be managed by our dropdown package. Also the current mouse/keyboard focus is certainly not something we want to deal with on the application level.

Another thing we had to think about was: How do we represent the menu entries? We decided that our dropdown menu should be generic over the entry type. So the application has to provide the dropdown menu with a List (Entry a). Here a stands for the (selectable) menu entries and the api provides constructors

one for the actual selectable entries, and one for the dividers. This way, the user of the package has full flexibility on how menu entries can be modeled: Maybe it can be just a list of strings, but perhaps it has to be something more complicated, like a record which stores a title, some description and optionally an image.

Going this more generic route, we need to tell the dropdown menu how it can render an individual entry, i.e. providing a function like a -> Html msg, and how it can filter the entries with a given query, which boiles down to a function String -> List a -> List a.

We did not do something like

for prodiving the possibility to add non selectable dividers, as we did
not want to introduce this extra nesting layer.

We could have also given the dividers a generic type, but for now we just need them to make captions for structuring the list of menu entries. And it is fairly easy to extend the implementation.

So, apart from the opaque state type, type State a, and an opaque message type, type Msg a, the dropdown exposes only a view and an update function, with the following signatures:

Here, model is the model type of our main application and msg is the
main message type. If you compare this to other packages like for example elm-sortable-table, you may wonder why we do not provide the State a in the view and update function. And also, how does view and update know what the menu entries are and what the current selection is?

We decided that instead of given view and update several more arguments, which would probably be more the TEA way, we just provide ViewConfig a model and UpdateConfig a msg model which include functions for retrieving this data from the current model. Namely, these configurations are created in the following way:

So, for example if our main model is given by

We would create the shared configuration via

The update configuration only adds the information, how the dropdown menu can ask the main application to change the selection. This might look like a setter function, but it is conceptually different:

We made the decision that the selection state should live in the main application model and not in the dropdown menu state. But the dropdown menu should have a way to alter the selection. (After all that is the whole purpose of the dropdown menu.) We could have changed the dropdowns update function to return a tuple which includes a Maybe a, indicating that we ask the application to change the selection. But then we (as the user of the package) would have to make sure that this new selection is stored in the main model. But what if we forget to do this? Or what if a change of the selection also requires some other business logic to be performed?

The point is: from the perspective of the dropdown menu, changing (or better asking for a change of) the selection is a side effect. So our dropdown update better returns a Maybe msg, since messages are the way to communicate effects in elm.

It also gives the user the chance to separate the update boilerplate of the dropdown menu from the selection change logic. This is good because the first one really is just the necessary boilerplate one has to write, the second one is part of the actual business logic.

Implementing style-independent scrolling

We want our dropdown menu to be navigatable using up- and down arrow keys. Since the menu also uses overflow-y: scroll, we have to scroll the menu when the next entry lies outside of the current menu viewport. There is already a package for issuing scrolling commands. Awesome!

But wait! If we want to scroll properly we need to know the height of all menu entries, and the elm architecture does not provide an obvious way of fetching data from the DOM. So what are our possibilities?

One way is tagging each entry with an id and setting up some ports

on the javascript side. The listener of fetchHeight looks up the DOM-element with the provided id and sends its height to the height port. This is certainly a good way to do it, but it requires some Javascript logic to be set up and we wanted our package to be elm only.

Luckily there is another way to achieve this! There is a place in the elm architecture where one can actually analize the rendered DOM tree, namely when decoding Json events. So, whenever you are in the situation that

  • you need information about the rendered DOM,
  • you need that information after some event was fired,
  • the element, this event was attached to, is close to the DOM element you need
    the information of,

you can use this DOM decoding trick, to retrieve for example the width and height of some element.

So what we did was attaching a custom decoder onto the focus event of the textfield, which also fetches the heights of all menu entries. To do this properly, we just have to make sure that the menu already exists in the DOM tree before the textfield gets focused. We achive this by always rendering the menu container, but hiding it with position: absolute if it is closed. Note that the parent node then needs position: relative and overflow: hidden.

Then, when we handle the up- and down-keys, we also fetch the current scroll position of the menu and use the previously fetched entry heights to compute where we want to scroll our menu to.

We just have to make sure that these heights are recomputed when the filtering is changed, so that the list of menu heights and the list of filtered entries always is in sync.

Conclusions

We are really happy how this package turned out, and one cannot stress enough that doing this in elm was a main reason for this. Elm just takes care of all the annoying parts of programming and lets you focus on solving the actual problem.

Still there are some things which can be improved:

  • We tried to make the dropdown menu (especially the scrolling) as fast as possible, but there is probably still some space left for optimization. One reason was, that we did not see a simple way of benchmarking the view function.
  • You still have to give each dropdown menu a globally unique id, which is not ideal. Perhaps, it would be a good thing if you had a special type type Node which represents a rendered dom node, alongside with a way of decoding these nodes from JSON values, i.e. something like targetDecoder : Decoder Node. The scrolling api then could look like toY : Node -> Float -> Task Error ().It would be interesting to know if having something like this, is a good idea and actually possible!


Also published on Medium.

Comments