What would be the right way to handle a click outside of a single component that is supposed to hide this component?
Example of such component might be a dropdown menu, a datepicker and the like. We typically expect them to hide when we click outside. But to do so, it seems like we have to perform some "impure" hacks that I'm not sure how to avoid in FRP style.
I searched for relevant React examples for ideas and found this but they all seem to rely on attaching callbacks to global objects that then modify internal component's state.
$(document). click(function (event) { $('#myDIV:visible'). hide(); });
To detect click outside div using JavaScript, we can check if e. target doesn't equal the inner element. document. getElementById("outer-container").
A bit late to the party here, but I was struggling with exactly the same problem and the elm community on slack suggested a nice way of detecting click outside an element (let's say, a dropdown).
The idea is that you can attach a global listener to mousedown
via BrowserEvents.onMouseDown
and pass it a custom decoder that would decode target
DOM node from the event object. By "decoding DOM node" I mean decoding only the id
and parentNode
properties of the node. parentNode
will allow recursively travers the DOM tree and for each node check whether its id
is the same as the id of the dropdown.
The code for this (in elm 0.19) looks like this:
-- the result answers the question: is the node outside of the dropdown?
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
]
-- sends message Close if target is outside the dropdown
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"
)
-- subscribes to the global mousedown
subscriptions : Model -> Sub Msg
subscriptions _ =
Browser.Events.onMouseDown (outsideTarget "dropdown")
The code uses Json-Decode package that needs to be installed via elm install elm/json
.
I also wrote an article explaining in details how this works, and have an example of a dropdown on github.
The existing answer doesn't work in elm v0.18 (Signal
was removed in 0.17), so I wanted to update it. The idea is to add a top-level transparent backdrop behind the dropdown menu. This has the bonus effect of being able to darken everything behind the menu if you want.
This example model has a list of words, and any word may have a open dropdown (and some associated info), so I map across them to see if any of them are open, in which case I display the backdrop div in front of everything else:
There's a backdrop in the main view function:
view : Model -> Html Msg
view model =
div [] <|
[ viewWords model
] ++ backdropForDropdowns model
backdropForDropdowns : Model -> List (Html Msg)
backdropForDropdowns model =
let
dropdownIsOpen model_ =
List.any (isJust << .menuMaybe) model.words
isJust m =
case m of
Just _ -> True
Nothing -> False
in
if dropdownIsOpen model then
[div [class "backdrop", onClick CloseDropdowns] []]
else
[]
CloseDropdowns
is handled in the app's top-level update function:
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
CloseDropdowns ->
let
newWords = List.map (\word -> { word | menuMaybe = Nothing } ) model.words
in
({model | words = newWords}, Cmd.none)
And styled things using scss:
.popup {
z-index: 100;
position: absolute;
box-shadow: 0px 2px 3px 2px rgba(0, 0, 0, .2);
}
.backdrop {
z-index: 50;
position: absolute;
background-color: rgba(0, 0, 0, .4);
top: 0;
right: 0;
bottom: 0;
left: 0;
}
The following example that does something similar to what you describe.
modal
is presented with an address (to send a 'dismiss' event to), the current window dimensions, and an elm-html Html
component (which is the thing to be focussed, like a datepicker or a form).
We attach a click handler to the surrounding element; having given it an appropriate id we can work out if received clicks apply to it or the child, and forward them on appropriately. The only really clever bit is the deployment of customDecoder
to filter out clicks on the child element.
Elsewhere, on reception of the 'dismiss' event, our model state changes such that we no longer need to call modal
.
This is quite a large code sample that makes use of a fair few elm packages, so please ask if anything requires further explanation
import Styles exposing (..)
import Html exposing (Attribute, Html, button, div, text)
import Html.Attributes as Attr exposing (style)
import Html.Events exposing (on, onWithOptions, Options)
import Json.Decode as J exposing (Decoder, (:=))
import Result
import Signal exposing (Message)
modal : (Signal.Address ()) -> (Int, Int) -> Html -> Html
modal addr size content =
let modalId = "modal"
cancel = targetWithId (\_ -> Signal.message addr ()) "click" modalId
flexCss = [ ("display", "flex")
, ("align-items", "center")
, ("justify-content", "center")
, ("text-align", "center")
]
in div (
cancel :: (Attr.id modalId) :: [style (flexCss ++ absolute ++ dimensions size)]
) [content]
targetId : Decoder String
targetId = ("target" := ("id" := J.string))
isTargetId : String -> Decoder Bool
isTargetId id = J.customDecoder targetId (\eyed -> if eyed == id then Result.Ok True else Result.Err "nope!")
targetWithId : (Bool -> Message) -> String -> String -> Attribute
targetWithId msg event id = onWithOptions event stopEverything (isTargetId id) msg
stopEverything = (Options True True)
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With