Hide a component when clicked outside

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)

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;
}

Tags:

Elm