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:
Listening to the scroll
event on the capturing
phase on body and getting the actual position of the element via getBoundingClientRect
and the reposition panel according to the current location. I am currently using a similar solution, but there is an issue. When the element is moving by another script, it doesn't force panel repositioning. One of the cases - when the element itself is another panel - simple filtering of unrelated scroll events filters such scrolls out. Also I have some cases with debounce and they are difficult to handle too.
Create IntersectionObserver
to track moves. The problem seems to be in the fact that it only works on intersection size changes, not on any moves. I have an idea to crop viewport by rootMargin
to the same rectangle that the element covers, but as options are readonly. It means I would need to create new observer on each move. I'm not sure about the performance impact of such a solution. Also as it provides only an approximate position, so I think that I can't eliminate calls to getBoundingClientRect
.
A hybrid solution as scrolls are usually taking some continuous time. Use the previous idea with IntersectionObserver
, but when the first move is detected, just subscribe to requestAnimationFrame
and check the element position there. While position differs, handle it and recursively use requestAnimationFrame
. If the position is the same (I am not sure if one frame is enough, maybe in 5 frames?), stop subscribing requestAnimationFrame
and create a new IntersectionObserver
.
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?
“MutationObserver” is a Web API provided by modern browsers for detecting changes in the DOM. By using this API you can listen to changes in DOM, like added or removed nodes, attribute changes or changes in the text content of text nodes and make changes.
Use the Element. getBoundingClientRect() Function to Get the Position of an Element in JavaScript. The getBoundingClientRect() function produces a DOMRect object containing information about an element's size and position in the viewport.
When you update the DOM, the reflow and repaint happen. Every time the DOM changes, the browser needs to recalculate the CSS, do a layout and repaint the web page. React doesn't really do anything new. It's just a strategic move.
The MutationObserver interface provides the ability to watch for changes being made to the DOM tree. It is designed as a replacement for the older Mutation Events feature, which was part of the DOM3 Events specification.
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>
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With