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
The options menu extends its scrollable container instead of “poping-out” of it.
Known solutions
I am aware of two techniques to fix this:
- Set the “popout”
div
withposition: absolute
and relative to an ancestor of the scrollable (oroverflow: hidden
)div
, as explained in css-tricks. It then needs to be positioned dynamically because its container may have been scrolled. - 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. BecausegetElement
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
andtouchmove
events, because the “popout” would not move inside the scrolled viewport
Here is an Ellie:
https://ellie-app.com/4M7bs2YVPPca1
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