Skip to content

Instantly share code, notes, and snippets.

@elclanrs
Last active February 18, 2024 06:55
Show Gist options
  • Star 13 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save elclanrs/bb719c17504e5d9b3ec985def050a041 to your computer and use it in GitHub Desktop.
Save elclanrs/bb719c17504e5d9b3ec985def050a041 to your computer and use it in GitHub Desktop.
VanillaJS popover with autoposition
<button id="trigger" data-popover-target="my-popover">Popover</button>
<template data-popover="my-popover">
This is the popover content!
</template>
function isInViewport(element) {
const rect = element.getBoundingClientRect();
const html = document.documentElement;
return rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || html.clientHeight) &&
rect.right <= (window.innerWidth || html.clientWidth);
}
class Popover {
constructor(trigger, { position = 'top', className = 'popover' }) {
this.trigger = trigger;
this.position = position;
this.className = className;
this.orderedPositions = ['top', 'right', 'bottom', 'left'];
const popoverTemplate = document.querySelector(`[data-popover=${trigger.dataset.popoverTarget}]`);
this.popover = document.createElement('div');
this.popover.innerHTML = popoverTemplate.innerHTML;
Object.assign(this.popover.style, {
position: 'fixed'
});
this.popover.classList.add(className);
this.handleWindowEvent = () => {
if (this.isVisible) {
this.show();
}
};
this.handleDocumentEvent = (evt) => {
if (this.isVisible && evt.target !== this.trigger && evt.target !== this.popover) {
this.popover.remove();
}
};
}
get isVisible() {
return document.body.contains(this.popover);
}
show() {
document.addEventListener('click', this.handleDocumentEvent);
window.addEventListener('scroll', this.handleWindowEvent);
window.addEventListener('resize', this.handleWindowEvent);
document.body.appendChild(this.popover);
const { top: triggerTop, left: triggerLeft } = this.trigger.getBoundingClientRect();
const { offsetHeight: triggerHeight, offsetWidth: triggerWidth } = this.trigger;
const { offsetHeight: popoverHeight, offsetWidth: popoverWidth } = this.popover;
const positionIndex = this.orderedPositions.indexOf(this.position);
const positions = {
top: {
name: 'top',
top: triggerTop - popoverHeight,
left: triggerLeft - ((popoverWidth - triggerWidth) / 2)
},
right: {
name: 'right',
top: triggerTop - ((popoverHeight - triggerHeight) / 2),
left: triggerLeft + triggerWidth
},
bottom: {
name: 'bottom',
top: triggerTop + triggerHeight,
left: triggerLeft - ((popoverWidth - triggerWidth) / 2)
},
left: {
name: 'left',
top: triggerTop - ((popoverHeight - triggerHeight) / 2),
left: triggerLeft - popoverWidth
}
};
const position = this.orderedPositions
.slice(positionIndex)
.concat(this.orderedPositions.slice(0, positionIndex))
.map(pos => positions[pos])
.find(pos => {
this.popover.style.top = `${pos.top}px`;
this.popover.style.left = `${pos.left}px`;
return isInViewport(this.popover);
});
this.orderedPositions.forEach(pos => {
this.popover.classList.remove(`${this.className}--${pos}`);
});
if (position) {
this.popover.classList.add(`${this.className}--${position.name}`);
} else {
this.popover.style.top = positions.bottom.top;
this.popover.style.left = positions.bottom.left;
this.popover.classList.add(`${this.className}--bottom`);
}
}
hide() {
this.popover.remove();
document.removeEventListener('click', this.handleDocumentEvent);
window.removeEventListener('scroll', this.handleWindowEvent);
window.removeEventListener('resize', this.handleWindowEvent);
}
toggle() {
if (this.isVisible) {
this.hide();
} else {
this.show();
}
}
}
const trigger = document.getElementById('trigger');
const popover = new Popover(trigger, { position: 'top' });
trigger.addEventListener('click', () => popover.toggle());
body {
margin: 400px;
font: 16px/1.4 normal Arial, sans-serif;
}
@keyframes slide-top {
0% {
opacity: 0;
transform: translateY(-15%);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slide-right {
0% {
opacity: 0;
transform: translateX(15%);
}
100% {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slide-bottom {
0% {
opacity: 0;
transform: translateY(15%);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slide-left {
0% {
opacity: 0;
transform: translateX(-15%);
}
100% {
opacity: 1;
transform: translateX(0);
}
}
.popover {
$pad: 1.5em;
$bg-color: white;
$border-color: #aaa;
$arrow-pad: 8px;
$arrow-size: 8px;
$radius: 4px;
padding: $pad;
border: 1px solid $border-color;
border-radius: $radius;
background: $bg-color;
box-shadow: 0 1px 4px rgba(0,0,0,.2);
&--top {
margin-top: -$arrow-size - $arrow-pad;
animation: .4s slide-top;
&::before, &::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
margin-left: -$arrow-size;
border: $arrow-size solid transparent;
border-top-color: $bg-color;
}
&::before {
margin-top: 1px;
border-top-color: darken($border-color, 25%);
}
}
&--right {
margin-left: $arrow-size + $arrow-pad;
animation: .4s slide-right;
&::before, &::after {
content: "";
position: absolute;
top: 50%;
right: 100%;
margin-top: -$arrow-size;
border: $arrow-size solid transparent;
border-right-color: $bg-color;
}
&::before {
margin-right: 1px;
border-right-color: darken($border-color, 25%);
}
}
&--bottom {
margin-top: $arrow-size + $arrow-pad;
animation: .4s slide-bottom;
&::before, &::after {
content: "";
position: absolute;
bottom: 100%;
left: 50%;
margin-left: -$arrow-size;
border: $arrow-size solid transparent;
border-bottom-color: $bg-color;
}
&::before {
margin-bottom: 1px;
border-bottom-color: darken($border-color, 25%);
}
}
&--left {
margin-left: -$arrow-size - $arrow-pad;
animation: .4s slide-left;
&::before, &::after {
content: "";
position: absolute;
top: 50%;
left: 100%;
margin-top: -$arrow-size;
border: $arrow-size solid transparent;
border-left-color: $bg-color;
}
&::before {
margin-left: 1px;
border-left-color: darken($border-color, 25%);
}
}
}
@giosifelis
Copy link

giosifelis commented Feb 21, 2020

@elclanrs first of all, congrats. This is really good. I found a small bug here is my proposal to fix it.

in line 35 inside "this.handleDocumentEvent" you are calling:
this.popover.remove()
This results in NOT removing the listener. If you have multiple popover instances, the document compiles the listeners from all previous popovers

line 35 should run
this.hide()

@mnyikka
Copy link

mnyikka commented Jan 19, 2022

Very nice pop-up, however, one correction a bit, on the popup style div please add display:inline
On line 22

Object.assign(this.popover.style, {
position: 'fixed',
display:'inline'
});

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment