10

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.

ave
  • 18,083
  • 9
  • 30
  • 39
  • 1
    Can't you try something similar to the React stuff you found? A global click handler sends an `Action` that's handled in the `update` function of the parent of the component (or the component itself, if you consider it part of it's task to know when to be hidden), and evaluates if something needs to be hidden, then reflect that in your `Model`. – Apanatshka Oct 17 '15 at 11:50
  • Thanks for the suggestion, I'll try to do something like that and if it will work I'll most my solution (I just need to learn more about Native js modules first). My main concern with going with this path (assuming you mean attaching the handlers on mount/unmount events like on the React solutions) was that this might defeat the purpose of FRP if we're going to break the imposed restrictions anyways. But I realized that it might be okay if it's used only on rare occasions such as that. – ave Oct 17 '15 at 12:30
  • I don't know of a proper in-Elm solution, so that's why I'm suggesting that you replicate the React solution to fix your problem now. But open a discussion on the mailing list about this issue. There should be a proper solution that doesn't require these kinds of hacks ;) – Apanatshka Oct 17 '15 at 13:03
  • Okay, will make a post to the mailing list a little bit later (seems to be more active) – ave Oct 18 '15 at 11:07

3 Answers3

4

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;
}
Charlie
  • 705
  • 1
  • 5
  • 14
3

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)
grumpyjames
  • 366
  • 1
  • 5
  • 1
    Wow, thanks a lot, this is very helpful! Also a bit similar to what I tried so far. The decoding part of `target` is interesting. It's probably out of scope of the question but just wanted to point out that it seems to me that it is inevitable to make use of JS native calls to be able to figure out the clicked element position relative to the document and the generated modal dimensions (at least that's what I did in my case, bypassing type safety checks and calling js native functions that make use of `target.getBoundingClientRect()` internally so that modal could be placed near the target). – ave Oct 21 '15 at 03:18
  • 1
    You might be able to replace the native calls you have with bits of http://package.elm-lang.org/packages/TheSeamau5/elm-html-decoder/1.0.1/Html-Decoder (note: not mine) – grumpyjames Oct 21 '15 at 17:37
2

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.

margaretkru
  • 2,751
  • 18
  • 20