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
and use onMouseDown
in subscriptions
:
import Browser.Events
import Json.Decode as Decode
subscriptions : Model -> Sub Msg
subscriptions _ =
Browser.Events.onMouseDown (Decode.succeed MouseDown)
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 }
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 =
Decode.map (\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)))
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?
Outside-dropdown-decoder
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.oneOf
[ Decode.field "id" Decode.string
|> Decode.andThen
(\id ->
if dropdownId == id then
-- found match by id
Decode.succeed False
else
-- try next decoder
Decode.fail "continue"
)
, Decode.lazy (\_ -> isOutsideDropdown dropdownId |> Decode.field "parentNode")
-- fallback if all previous decoders failed
, Decode.succeed True
]
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, Decode.fail
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
else
Decode.fail "inside dropdown"
)
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 model.open then
Browser.Events.onMouseDown (outsideTarget "dropdown")
else
Sub.none
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)
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 thedropdown-id
, a fix would be: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
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.
I am glad it was helpful! That was definitely not an easy example of a decoder 😃