An amendment to elm-portal

A few months ago I ported our modals at work to use <dialog>. This was a huge win for a11y :tada: and it broke our usage of elm-portal :sob:. You see, the browser native modals render on a separate layer from the rest of the DOM and elm-portal was only target the normal DOM layer, causing all of our dropdowns and tooltips to render behind our new modals!

Quick aside, for those wondering what portals I’m talking about I have a blog post about building a web component called elm-portal for building things like tooltips, dropdowns, at the time modals, and more.

Back to the portalling problem, resolving this involved 2 changes. The first is very simple. We already wrap all of our modals in a single module, so I duplicated our various target elements into our modal module. Roughly that means our code went from

module Main exposing (..)

view model =
    { title = ...
    , body =
        [ -- our regular content
        , Html.div [ Html.Attributes.id "elm-portal-tooltip" ] []
        ]

to

module Main exposing (..)

view model =
    { title = ...
    , body =
        [ -- our regular content
        , Html.div [ Html.Attributes.id "elm-portal-tooltip" ] []
        ]

module Modal exposing (..)

view ... =
    Html.node "dialog"
        []
        [ -- modal content
        , Html.div [ Html.Attributes.id "elm-portal-tooltip" ] []
        ]

You might notice that this breaks using document.getElementById() because now there are 2 elements on the page with the same id! We could change to another selector, and maybe there is a better way to do this*, but the route we went with is writing out own “nearest node” function

function nearestNodeWithId(el: HTMLElement, id: string): HTMLElement | null {
  for (const sibling of getAllSiblings(el)) {
    if (sibling.id === id) {
      return sibling
    }
  }

  if (el.parentElement) {
    return nearestNodeWithId(el.parentElement, id)
  }

  return null
}

function getAllSiblings(el: HTMLElement): HTMLElement[] {
  const siblings = []
  let sibling = el?.parentNode?.firstChild
  if (sibling) {
    do {
      // text node
      if (sibling.nodeType !== 3) {
        siblings.push(sibling as HTMLElement)
      }
    } while ((sibling = sibling.nextSibling))
  }

  return siblings
}

This allows our elm-portal component to look up the DOM tree for the nearest node with the specified ID.


* a better way might be to use a data- attribute and a combination of query selectors, though I’m not sure what that’d look like. If anyone has suggestions for how to improve upon nearestNodeWithId I’d love to hear about it!

4 Likes

Here you go!

2 Likes

I did try this, however it only looks at parents and not siblings. Our setup is kind of like

Elm content
<div id="portal-dropdown"></div>
<div id="portal-tooltip"></div>

so closest never finds the targets.

I do wonder though, since we append children to the portal target (to allow for multiple portals to target the same node), I suppose you could have something like

<div id="portal-tooltip">
  <div id="portal-dropdown">
    Elm content
  </div>
</div>

and it should correctly place tooltips, dropdowns and such. If you did that and it does work, then the modal setup would be roughly

<dialog>
  <div id="portal-tooltip">
    <div id="portal-dropdown">
      Elm content
    </div>
  </div>
</dialog>

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