DEV Community

Margarita Krutikova
Margarita Krutikova

Posted on • Updated on

Elm DOM node decoder to detect click outside

The other day I was implementing a reusable dropdown on a side project in elm, and wanted the dropdown to close when I click outside of it.

I came up with an ad-hoc solution, that felt like 😕, so I asked the elm community on slack to point me in the right direction. The folks there came up with a couple of creative solutions, and went on to implement them for different edge cases. As en elm noob, I was thrilled to learn from more experienced elm devs, and decided to share what I have learned from that discussion in a couple of posts.

This one is going to be about implementing a generic way to detect click outside using some advanced decoding mechanisms to get DOM nodes from the event object.

How cool is that, decode a DOM node in elm? 🤯

Solution outline

I will use a simple dropdown as an example, but it can be any other element that benefits from detecting click outside (autocomplete, menu, popover etc).

The solution is to determine whether the DOM node that was clicked is located within the dropdown:

  • subscribe to the global mousedown event on the document,
  • get the element that dispatched the event (event target),
  • check whether the target element is a descendant of the dropdown by recursively traversing up the DOM tree,
  • if there is a match (either by id, className or any other attribute), the click happened within the dropdown - keep it open.

DOM API has Node.contains that checks whether a node is a descendant of another node. This function doesn't seem to exist in Elm, but no worry, we are going to roll our own implementation.

Subscribe to global mousedown

Browser.Events package allows to attach listeners to events on the whole document. It exposes onMouseDown listener, that accepts a JSON decoder to decode the event object and sends a message with the result if decoding succeeds.

To use JSON decoder install Json-Decode package:

elm install elm/json
Enter fullscreen mode Exit fullscreen mode

and use onMouseDown in subscriptions:

import Browser.Events
import Json.Decode as Decode

subscriptions : Model -> Sub Msg
subscriptions _ =
    Browser.Events.onMouseDown (Decode.succeed MouseDown)
Enter fullscreen mode Exit fullscreen mode

NOTE: To use subscriptions, the elm program should be at least of type Browser.element, Browser.sandbox can't talk to the outside world and doesn't support subscriptions, see effects on elm guide.

Our subscription doesn't do much right now: Decode.succeed ignores the JSON event object and only sends MouseDown message to the update function.

Recursive decoding of event target

The event target of the mousedown event is a DOM node that has a property parentNode, which we can use to recursively traverse up the DOM tree until we find the dropdown node or reach the top of the tree. To determine whether the nodes match we will use their ids.

I will first explain a more detailed implementation of the decoder before jumping to the eventual compact and elegant solution, which might seem quite cryptic at first glance.

Naive decoding

Here is a data structure that represents a DOM node:

type DomNode
    = RootNode { id : String }
    | ChildNode { id : String, parentNode : DomNode }
Enter fullscreen mode Exit fullscreen mode

A decoder of this union type will use Decode.lazy for decoding recursive structures, and Decode.oneOf for decoding the individual constructors of the union.

In general, Decode.oneOf is useful for decoding data that can be in different formats, and works by accepting different decoders and trying them in sequence until one of them succeeds.

Here is the implementation:

domNode : Decode.Decoder DomNode
domNode =
    Decode.oneOf [ childNode, rootNode ]

rootNode : Decode.Decoder DomNode
rootNode = (\x -> RootNode { id = x })
        (Decode.field "id" Decode.string)

childNode : Decode.Decoder DomNode
childNode =
    Decode.map2 (\id parentNode -> ChildNode { id = id, parentNode = parentNode })
        (Decode.field "id" Decode.string)
        (Decode.field "parentNode" (Decode.lazy (\_ -> domNode)))
Enter fullscreen mode Exit fullscreen mode

The trick here is the decoders that recursively reference each other: decoding ChildNode references the domNode decoder and requires using Decode.lazy, and decoding DomNode references the child node decoder.

Now we can decode target from the event in the subscriptions and send a message with the decoded DomNode. The update function will then recursively traverse the node up the tree and check for nodes that match the dropdown.

But here comes the mind-blowing idea: what if we let the decoder determine whether the clicked node is inside the dropdown while recursively traversing the tree? 🤔

Instead of handing the whole recursive DOM structure to the update function, the decoder just answers the essential question of what to do with the dropdown: to close or not to close?


A better decoder will consist of a sequence of decoders traversing the DOM tree and making the decision on the fly:

isOutsideDropdown : String -> Decode.Decoder Bool
isOutsideDropdown dropdownId =
        [ Decode.field "id" Decode.string
            |> Decode.andThen
                (\id ->
                    if dropdownId == id then
                        -- found match by id
                        Decode.succeed False

                        -- try next decoder
        , Decode.lazy (\_ -> isOutsideDropdown dropdownId |> Decode.field "parentNode")

        -- fallback if all previous decoders failed
        , Decode.succeed True
Enter fullscreen mode Exit fullscreen mode

The responsibilities of these decoders are:

  • the first decoder will check the node's id and succeed with False (inside dropdown) if it finds a match, or fail causing the other decoders to step in,
  • the second decoder will recursively call the parent decoder, and might fail if the parentNode is null (the top of the tree is reached), causing the last decoder to run,
  • the last decoder simply succeeds with True (outside dropdown).

As a side note, takes in an arbitrary string which becomes a custom error message.

We need one more decoder that gets target, feeds it to the isOutsideDropdown decoder and sends message Close based on the result:

outsideTarget : String -> Decode.Decoder Msg
outsideTarget dropdownId =
    Decode.field "target" (isOutsideDropdown "dropdown")
        |> Decode.andThen
            (\isOutside ->
                if isOutside then
                    Decode.succeed Close

           "inside dropdown"
Enter fullscreen mode Exit fullscreen mode

Decode.andThen is used to create a new decoder that depends on the result of isOutsideDropdown decoder.

Final step, we subscribe to mousedown if the dropdown is open (to avoid listening to the event unnecessarily) and pass the id of the dropdown to the decoder.

subscriptions : Model -> Sub Msg
subscriptions model =
    if then
        Browser.Events.onMouseDown (outsideTarget "dropdown")

Enter fullscreen mode Exit fullscreen mode

Other applications of the decoder

This technique of DOM node decoding is extremely powerful and can be extended to more than determining click outside.

To improve keyboard accessibility of the dropdown, we need to close it when it loses focus (when "tabbing out" of it). One trick is to use the focusout event and apply the same decoder to the relatedTarget property, which gives the element that is about to receive focus.

In my next post I will show how to make the dropdown more accessible and handle focus and keyboard events.

Here is the source code on github, and here is a slightly trimmed version in ellie for you to play with.

Disclaimer: none of this magic would have happened without the amazing elm community, and none of this would have mattered without you, reader! 😍 To be continued...

P.S. If by any chance, you need to implement detecting click outside in ReasonML, you can drop by my other post, where I explain how to create a custom useClickOutside hook in a ReasonReact application.

I seem to have a click-outside obsession... Anyway, thanks for reading! 😅

Top comments (4)

kutyel profile image
Flavio Corpa • Edited

Love this post, it has saved me a couple of times, thank you very much for putting it together!! 🙌

Just wanted to point that there is a slight mistake in the implementation of oustideTarget, you are not using the provided String param anywhere, and thus would have to duplicate the dropdown-id, a fix would be:

outsideTarget : String -> Decode.Decoder Msg
outsideTarget dropdownId =
    Decode.field "target" (isOutsideDropdown dropdownId)
        |> Decode.andThen
            (\isOutside ->
                if isOutside then
                    Decode.succeed Close

           "inside dropdown"
Enter fullscreen mode Exit fullscreen mode
victor_bezak profile image
Victor Bezak • Edited

Wow, this has been a really great article for me and my team. Our initial solution was to send, onClick, the ids for both our open object and the toggle which opened it (eg. your dropdown, and the button which is responsible for opening/closing it) to Browser.Dom.getElement with Task.attempt, and then store the Maybe (Browser.Element, Browser.Element) response in our model (thus satifsfying our click subscription condition and activating our click listener). From there, when a click event fires we’d compare the event’s pageX and pageY attributes against the x and y attributes from our stored Browser.Element’s, returning a boolean of whether that click was located within the bounds of our stored objects of interest.

I've learned a lot from your solution and we're testing to see whether it's right for our use-case. Thanks again!

p.s. you also put ReasonML on my radar.. very cool

perty profile image
Per Lundholm

Great post. One of the things with Elm that I have found hard to understand is decoding. This was a pedagogic explanation of one usage for it.

margaretkrutikova profile image
Margarita Krutikova

I am glad it was helpful! That was definitely not an easy example of a decoder 😃