Qwertiy
Qwertiy

Reputation: 21430

How to observe DOM element position changes

I need to observe a DOM element position as I need to show a popup panel relative to it (but not in the same container) and the panel should follow the element. How I should implement such logic?

Here is a snippet where you can see the opening of outer and nested popup panels, but they do not follow the horizontal scroll. I want them both to follow it and keep showing near the corresponding icon (and it should be a generic approach that will work in any place). You may ignore that nested popup is not closed together with outer - it's just to make the snippet simpler. I expect no changes except the showPopup function. Markup is specially simplified for this example; do not try to change it - I need it as it is.

~function handlePopups() {
  function showPopup(src, popup, popupContainer) {
    var bounds = popupContainer.getBoundingClientRect()
    var bb = src.getBoundingClientRect()

    popup.style.left = bb.right - bounds.left - 1 + 'px'
    popup.style.top = bb.bottom - bounds.top - 1 + 'px'

    return () => {
      // fucntion to cleanup handlers when closed
    }
  }

  var opened = new Map()

  document.addEventListener('click', e => {
    if (e.target.tagName === 'I') {
      var wasActive = e.target.classList.contains('active')
      var popup = document.querySelector(`.popup[data-popup="${e.target.dataset.popup}"]`)

      var old = opened.get(popup)

      if (old) {
        old.src.classList.remove('active')
        popup.hidden = true
        old.close()
        opened.delete(old)
      }

      if (!wasActive) {
        e.target.classList.add('active')
        popup.hidden = false

        opened.set(popup, {
          src: e.target,
          close: showPopup(e.target, popup, document.querySelector('.popup-dest')),
        })
      }
    }
  })
}()

~function syncParts() {
  var scrollLeft = 0

  document.querySelector('main').addEventListener('scroll', e => {
    if (e.target.classList.contains('inner') && e.target.scrollLeft !== scrollLeft) {
      scrollLeft = e.target.scrollLeft
      void [...document.querySelectorAll('.middle .inner')]
           .filter(x => x.scrollLeft !== scrollLeft)
           .forEach(x => x.scrollLeft = scrollLeft)
    }
  }, true)
}()
* {
  box-sizing: border-box;
}

[hidden] {
  display: none !important;
}

html, body, main {
  height: 100%;
  margin: 0;
}

main {
  display: grid;
  grid-template: auto 1fr 17px / auto 1fr auto;
}

section {
  overflow: hidden;
  display: flex;
  flex-direction: column;
  outline: 1px dotted red;
  outline-offset: -1px;
  position: relative;
}

.inner {
  overflow: scroll;
  padding: 0 1px 1px 0;
  margin: 0 -18px -18px 0;
  flex: 1 1 0px;
  display: flex;
  flex-direction: column;
}

.top {
  grid-row: 1;
}

.bottom {
  grid-row: 2;
}

.left {
  grid-column: 1;
}

.middle {
  grid-column: 2;
}

.right {
  grid-column: 3;
}

.wide, .scroller {
  width: 2000px;
  flex: 1 0 1px;
}

.wide {
  background: repeating-linear-gradient(to right, rgba(0,255,0,.5), rgba(0,0,255,.5) 16em);
}

.visible-scroll .inner {
  margin-top: -1px;
  margin-bottom: 0;
}

.scroller {
  height: 1px;
}

.popup-dest {
  pointer-events: none;
  grid-row: 1 / 3;
  position: relative;
}

.popup {
  position: absolute;
  border: 1px solid;
  pointer-events: all;
}

.popup-outer {
  width: 8em;
  height: 8em;
  background: silver;
}

.popup-nested {
  width: 5em;
  height: 5em;
  background: antiquewhite;
}

i {
  display: inline-block;
  border-radius: 50% 50% 0 50%;
  border: 1px solid;
  width: 1.5em;
  height: 1.5em;
  line-height: 1.5em;
  text-align: center;
  cursor: pointer;
}

i::after {
  content: "i";
}

i.active {
  background: rgba(255,255,255,.5);
}
<main>
  <section class="top left">
    <div><div class="inner">
      <div>Smth<br>here</div>
    </div></div>
  </section>
  <section class="top middle">
    <div class="inner">
      <div class="wide">
        <i data-popup="outer" style="margin-left:10em"></i>
        <i data-popup="outer" style="margin-left:10em"></i>
        <i data-popup="outer" style="margin-left:10em"></i>
        <i data-popup="outer" style="margin-left:10em"></i>
        <i data-popup="outer" style="margin-left:10em"></i>
        <i data-popup="outer" style="margin-left:10em"></i>
        <i data-popup="outer" style="margin-left:10em"></i>
      </div>
    </div>
  </section>
  <section class="top right">
    <div class="inner">Smth here</div></section>
  <section class="bottom left">
    <div class="inner">Smth here</div>
  </section>
  <section class="bottom middle">
    <div class="inner">
      <div class="wide"><script>document.write("Smth is here too... ".repeat(1000))</script></div>
    </div>
  </section>
  <section class="bottom right">
    <div class="inner">Smth here</div>
  </section>
  <section class="middle visible-scroll">
    <div class="inner">
      <div class="scroller"></div>
    </div>
  </section>
  <section class="middle popup-dest">
    <div class="popup popup-outer" data-popup="outer" hidden>
      <i  data-popup="nested" style="margin-left:5em;margin-top:5em;"></i>
    </div>
    <div class="popup popup-nested" data-popup="nested" hidden>
    </div>
  </section>
</main>

Now I have following ideas:

I'm afraid that such solutions will have issues with performance. Also they seem to me too complex. Maybe there is some known solution which I should use?

Upvotes: 23

Views: 21183

Answers (1)

Qwertiy
Qwertiy

Reputation: 21430

Implementation of the first approach. Just subscribe all scroll events across the document and update the position in the handler. You can't filter events by the parents of an src element as in case of a nested popup scrolling element is not presented in the events chain.

Also it doesn't work if the popup is moved programmatically - you may notice it when the outer popup is moved to the other icon and nested stays in the old place.

function showPopup(src, popup, popupContainer) {
  function position() {
    var bounds = popupContainer.getBoundingClientRect()
    var bb = src.getBoundingClientRect()

    popup.style.left = bb.right - bounds.left - 1 + 'px'
    popup.style.top = bb.bottom - bounds.top - 1 + 'px'
  }

  position()
  document.addEventListener('scroll', position, true)

  return () => { // cleanup
    document.removeEventListener('scroll', position, true)
  }
}

Full code:

~function syncParts() {
  var sl = 0

  document.querySelector('main').addEventListener('scroll', e => {
    if (e.target.classList.contains('inner') && e.target.scrollLeft !== sl) {
      sl = e.target.scrollLeft
      void [...document.querySelectorAll('.middle .inner')]
           .filter(x => x.scrollLeft !== sl)
           .forEach(x => x.scrollLeft = sl)
    }
  }, true)
}()

~function handlePopups() {
  function showPopup(src, popup, popupContainer) {
    function position() {
      var bounds = popupContainer.getBoundingClientRect()
      var bb = src.getBoundingClientRect()

      popup.style.left = bb.right - bounds.left - 1 + 'px'
      popup.style.top = bb.bottom - bounds.top - 1 + 'px'
    }

    position()
    document.addEventListener('scroll', position, true)

    return () => { // cleanup
      document.removeEventListener('scroll', position, true)
    }
  }

  var opened = new Map()

  document.addEventListener('click', e => {
    if (e.target.tagName === 'I') {
      var wasActive = e.target.classList.contains('active')
      var popup = document.querySelector(`.popup[data-popup="${e.target.dataset.popup}"]`)

      var old = opened.get(popup)

      if (old) {
        old.src.classList.remove('active')
        popup.hidden = true
        old.close()
        opened.delete(old)
      }

      if (!wasActive) {
        e.target.classList.add('active')
        popup.hidden = false

        opened.set(popup, {
          src: e.target,
          close: showPopup(e.target, popup, document.querySelector('.popup-dest')),
        })
      }
    }
  })
}()
* {
  box-sizing: border-box;
}

[hidden] {
  display: none !important;
}

html, body, main {
  height: 100%;
  margin: 0;
}

main {
  display: grid;
  grid-template: auto 1fr 17px / auto 1fr auto;
}

section {
  overflow: hidden;
  display: flex;
  flex-direction: column;
  outline: 1px dotted red;
  outline-offset: -1px;
  position: relative;
}

.inner {
  overflow: scroll;
  padding: 0 1px 1px 0;
  margin: 0 -18px -18px 0;
  flex: 1 1 0px;
  display: flex;
  flex-direction: column;
}

.top {
  grid-row: 1;
}

.bottom {
  grid-row: 2;
}

.left {
  grid-column: 1;
}

.middle {
  grid-column: 2;
}

.right {
  grid-column: 3;
}

.wide, .scroller {
  width: 2000px;
  flex: 1 0 1px;
}

.wide {
  background: repeating-linear-gradient(to right, rgba(0,255,0,.5), rgba(0,0,255,.5) 16em);
}

.visible-scroll .inner {
  margin-top: -1px;
  margin-bottom: 0;
}

.scroller {
  height: 1px;
}

.popup-dest {
  pointer-events: none;
  grid-row: 1 / 3;
  position: relative;
}

.popup {
  position: absolute;
  border: 1px solid;
  pointer-events: all;
}

.popup-outer {
  width: 8em;
  height: 8em;
  background: silver;
}

.popup-nested {
  width: 5em;
  height: 5em;
  background: antiquewhite;
}

i {
  display: inline-block;
  border-radius: 50% 50% 0 50%;
  border: 1px solid;
  width: 1.5em;
  height: 1.5em;
  line-height: 1.5em;
  text-align: center;
  cursor: pointer;
}

i::after {
  content: "i";
}

i.active {
  background: rgba(255,255,255,.5);
}
<main>
  <section class="top left">
    <div><div class="inner">
      <div>Smth<br>here</div>
    </div></div>
  </section>
  <section class="top middle">
    <div class="inner">
      <div class="wide">
        <i data-popup="outer" style="margin-left:10em"></i>
        <i data-popup="outer" style="margin-left:10em"></i>
        <i data-popup="outer" style="margin-left:10em"></i>
        <i data-popup="outer" style="margin-left:10em"></i>
        <i data-popup="outer" style="margin-left:10em"></i>
        <i data-popup="outer" style="margin-left:10em"></i>
        <i data-popup="outer" style="margin-left:10em"></i>
      </div>
    </div>
  </section>
  <section class="top right">
    <div class="inner">Smth here</div></section>
  <section class="bottom left">
    <div class="inner">Smth here</div>
  </section>
  <section class="bottom middle">
    <div class="inner">
      <div class="wide"></div>
    </div>
  </section>
  <section class="bottom right">
    <div class="inner">Smth here</div>
  </section>
  <section class="middle visible-scroll">
    <div class="inner">
      <div class="scroller"></div>
    </div>
  </section>
  <section class="middle popup-dest">
    <div class="popup popup-outer" data-popup="outer" hidden>
      <i  data-popup="nested" style="margin-left:5em;margin-top:5em;"></i>
    </div>
    <div class="popup popup-nested" data-popup="nested" hidden>
    </div>
  </section>
</main>

Upvotes: 1

Related Questions