A "popout" implementation in Elm

tl;dr

https://ellie-app.com/4M989xvtwJKa1

What?

What I mean by “popout” is a div that can overflow its scrollable (or overflow: hidden) container .

One typical example is a chat box with an options menu for each message. For example in the web version of slack, if you have the “Thread” panel open, you can do:

You can notice that the message options menu overflows the scrollable “Channel” messages panel and goes over the “Thread” one.

The Problem

As far as I know, this is not solvable only with CSS except for some very simple cases (no scrolling and each “popout” positioned manually relative to an ancestor outside its container, see next section).

I made a simple Ellie to illustrate the problem with a naive approach using position: absolute relative to an ancestor inside the scrollable container:
https://ellie-app.com/4M6xfxfvqkBa1

failed_popout

The options menu extends its scrollable container instead of “poping-out” of it.

Known solutions

I am aware of two techniques to fix this:

  1. Set the “popout” div with position: absolute and relative to an ancestor of the scrollable (or overflow: hidden) div, as explained in css-tricks. It then needs to be positioned dynamically because its container may have been scrolled.
  2. Change the parent of the popout div but keep its position. I believe that this is what React portals are for (I think that portals also keep bubbling events to its original parent).

In addition, to avoid updating the position on scrolling, scrolling is often disabled when the “popout” is displayed, like in slack.

An imperfect implementation in Elm

Solution 2. would require some javascript and would most likely not play well with the Virtual Dom, so I implemented solution 1. using the following techniques:

  • When the "popout’ is enabled, Browser.Dom.getElement is used to get the position of an element that will be used as an anchor for positioning.
  • The “popout” has position: absolute and is displayed only once its anchor element position is known in order to compute the final position. Because getElement will wait for the next frame, this means that we often miss one frame before displaying the “popout” but I don’t think that there is a better solution.
  • When the “popout” is displayed, scrolling is disabled by intercepting the scrollable container wheel and touchmove events, because the “popout” would not move inside the scrolled viewport

Here is an Ellie:
https://ellie-app.com/4M7bs2YVPPca1

popout

Unfortunately scrolling is still possible with the keyboard, which leads to an incorrect position of the popout. I guess we could also ignore some keyboard events, but TAB can cause scrolling, and it does not seem reasonable to block it.

I’m also not convinced that blocking the wheel and touchmove events works everywhere and does not have unwanted side effects.

A better solution

As scrolling seems to be the main pain point and an events handling rabbit hole, a reverse thinking solution is to close the “popout” on any scroll event. It seems reasonable, a lot more robust, and does not break any keyboard/mouse/touchscreen navigation:

https://ellie-app.com/4Md7MmfZYQsa1

module Main exposing (main)

import Browser
import Browser.Dom as Dom
import Html exposing (Html, button, div, h1, text)
import Html.Attributes exposing (id, style)
import Html.Events exposing (keyCode, on, onClick)
import Json.Decode as Decode
import Json.Encode as Encode
import Lorem
import Task


type alias Model =
    { options : Popout
    }


type Popout
    = Disabled
    | Enabled Int
    | Displayed Int ( Float, Float )


init : () -> ( Model, Cmd msg )
init _ =
    ( { options = Disabled }, Cmd.none )


type Msg
    = NoOp
    | ToggleOptions (Maybe Int)
    | PositionUpdated (Result Dom.Error Dom.Element)


view : Model -> Html Msg
view model =
    div [] <|
        [ h1 [] [ text "Chat" ]
        , div
            [ style "width" "400px"
            , style "height" "300px"
            , style "border" "1px solid grey"
            , style "overflow" "auto"
            , on "scroll" (Decode.succeed <| ToggleOptions Nothing)
            ]
            (List.indexedMap (viewPost model) <| Lorem.paragraphs 15)
        ]


viewPost : Model -> Int -> String -> Html Msg
viewPost model postIdx str =
    div
        [ style "margin-bottom" "24px"
        ]
        [ div
            [ id (postId postIdx)
            , style "float" "right"
            ]
            [ button
                [ onClick (ToggleOptions <| Just postIdx) ]
                [ text "..." ]
            , options postIdx model.options
            ]
        , text str
        ]


postId : Int -> String
postId postIdx =
    "post" ++ String.fromInt postIdx


options : Int -> Popout -> Html msg
options postIdx popout =
    case popout of
        Displayed optionsPostIdx ( x, y ) ->
            if optionsPostIdx == postIdx then
                viewOptions x y

            else
                text ""

        _ ->
            text ""


viewOptions : Float -> Float -> Html msg
viewOptions x y =
    div
        [ style "position" "absolute"
        , style "top" (String.fromFloat (32 + y) ++ "px")
        , style "left" (String.fromFloat x ++ "px")
        , style "box-shadow" "0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0, 0, 0, 0.12), 0 5px 5px -3px rgba(0, 0, 0, 0.3)"
        , style "width" "150px"
        , style "height" "100px"
        , style "background-color" "white"
        ]
        [ div [ style "padding" "8px" ] [ text "Edit" ]
        , div [ style "padding" "8px" ] [ text "Remove" ]
        , div [ style "padding" "8px" ] [ text "..." ]
        ]


setPopoutPosition : Int -> Cmd Msg
setPopoutPosition postIdx =
    Task.attempt PositionUpdated <|
        Dom.getElement (postId postIdx)


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        NoOp ->
            ( model, Cmd.none )

        ToggleOptions maybePostIdx ->
            case ( model.options, maybePostIdx ) of
                ( Disabled, Just postIdx ) ->
                    ( { model | options = Enabled postIdx }
                    , setPopoutPosition postIdx
                    )

                _ ->
                    ( { model | options = Disabled }, Cmd.none )

        PositionUpdated (Ok { element }) ->
            case model.options of
                Enabled postIdx ->
                    ( { model | options = Displayed postIdx ( element.x, element.y ) }
                    , Cmd.none
                    )

                _ ->
                    ( { model | options = Disabled }, Cmd.none )

        PositionUpdated (Err _) ->
            ( { model | options = Disabled }, Cmd.none )


main : Program () Model Msg
main =
    Browser.element
        { init = init
        , view = view
        , update = update
        , subscriptions = always Sub.none
        }

This could be improved even more by using the scene to compute a position that prevents the “popout” to extend the scene. We could also detect clicks outside the “popout” to close it.

Feedback

  • If you are interested by the subject, tell me what you think of the last solution.
  • Also please tell me if it does not work for you, with your browser version. I would like to have it compatible down to IE11, including mobiles, but I didn’t test it enough yet.
  • At last any feedback is of course welcome to improve the solution.

Hopefully it can be useful to others.
Thank you

8 Likes

We struggle with this so much, this is the kind of thing I wish the browser had better primitives for. I will try your approach, thanks for posting this.

1 Like

Yeah. It would be a killer feature if a library like elm-ui could manage this.

Or maybe a package could be done but it’s not easy to find an API generic enough for most use cases that still provide some added value versus implementing it on a case by case basis.

This seems like something elm-ui could do with some effort. For instance with the slack layout example you have the “channel” pane and the “thread” pane. These would be wrapped in some sort of “content” element. In elm-ui you can have the message options menu inFront of the “content”. This popout would be present upon some msg being passed to update, so passing the position info along with the msg would be reasonable.

To fix the scrolling behavior you could store something like scrollable: bool in the model and when you pass the msg for the “popout” to launch or hide you toggle the scrollable field. Then make the scrollable elements attributes dependent on the scrollable state.

I tried something like this by setting overflow: hidden instead of overflow: auto on the scrollable container when the popout is displayed:

https://ellie-app.com/4M8z3LKGDkma1

But:

  • as this removes the scroll bars, the width is changed and consequently the layout also changes (overflow-y: overlay exists only for webkit browsers)
  • TAB still causes scrolling if there are some “tabbable” elements in the container (like the buttons here), even with overflow: hidden.

So I gave up trying to prevent scrolling.

Ah, that is tricky. I suppose you could use some javascript to have a custom scrollbar with a fixed width and you can replace it with a transparent element the same width when switching to overflow: hidden. Probably more effort than it’s worth and would be tough to maintain.

Yes, also this would not prevent scrolling with TAB, and I’m not convinced anyway that this is a better solution overall than closing the “popout” when scroll occurs. It might be a more common behaviour though (so less surprising for users), I’m not sure.

I think closing the popout on scroll is pretty good. It think it might even be better UX than blocking it, because if the user wants to scroll, they should be allowed to, without first having to make sure any popout is closed. This is how the context menu and tooltips in Chrome behave too. Although in Firefox, the context menu blocks scrolling.

3 Likes

It behaves really nice! And “close-on-scroll” approach is indeed concise in implementation AND intuitive!
Thanks for writing this up :heart:
I had the very similar situation in my dropdown impl and was looking for a good solution. Now I will definitely adopt your approach.

I think this is a requirement found commonly enough. If we can find a good way to define generic APIs, it deserves to be a package!

1 Like

Yeah it looks like an easy and common problem but for some reason, I did not find a good solution until I wrote this post, which was a request for help at first :sweat_smile:

So it is useful to formalize its own issues, it often leads to the solution.

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.