-
-
Save cnocon/897d6e1059cb82a8e0535e599800a30f to your computer and use it in GitHub Desktop.
VanillaJS popover with autoposition
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<button id="trigger" data-popover-target="my-popover">Popover</button> | |
<template data-popover="my-popover"> | |
This is the popover content! | |
</template> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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%); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment