Anchored Popover
Similar to the Popover component, but this one is anchored to a virtual reference element. This is used in features such as floating toolbars.
Here, you'll need to create a virtual element because text selection and mouse coordinates don't have a DOM element associated with them. So you'll need to create a virtual element that responds to getBoundingClientRect()
, retrieve the coordinates of the selection or mouse coordinates, and then position the popover relative to that.
HTML:
<h1>Anchored Popover</h1>
<p>An anchored popover relative to any coordinate. In this example, we're usign the mouse position as the reference element.</p>
<cami-anchored-popover></cami-anchored-popover>
<style>
h1, p {
text-align: center;
}
.popover__backdrop {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
justify-content: center;
align-items: center;
}
.popover__backdrop--hidden {
display: none;
}
</style>
<!-- <script src="./build/cami.cdn.js"></script> -->
<!-- CDN version below -->
<script src="https://unpkg.com/cami@latest/build/cami.cdn.js"></script>
<script type="module">
const { html, ReactiveElement } = cami;
class ReactiveModalElement extends ReactiveElement {
isOpen = false;
lastFocusedElement = null;
focusableElements = [];
connectedCallback() {
super.connectedCallback();
this.addEventListener('keydown', (event) => {
const preventDefault = () => { event.preventDefault(); return true; };
const isEscape = event.key === 'Escape';
const isTab = event.key === 'Tab';
const isShiftTab = isTab && event.shiftKey;
const isTabAtStart = isTab && document.activeElement === this.focusableElements[0];
const isTabAtEnd = isTab && document.activeElement === this.focusableElements[this.focusableElements.length - 1];
isEscape && this.closeDialog();
isTabAtStart && preventDefault() && this.focusableElements[this.focusableElements.length - 1]?.focus();
isTabAtEnd && preventDefault() && this.focusFirstElement();
});
}
openDialog() {
this.isOpen = true;
this.querySelector('dialog').setAttribute('open', '');
this.lastFocusedElement = document.activeElement;
this.focusFirstElement();
}
closeDialog() {
this.isOpen = false;
this.querySelector('dialog').removeAttribute('open');
this.lastFocusedElement && this.lastFocusedElement.focus();
}
focusFirstElement() {
const dialog = this.querySelector('dialog');
this.focusableElements = Array.from(dialog.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'));
const hasFocusables = this.focusableElements.length > 0;
hasFocusables && this.focusableElements[0].focus();
}
}
class ReactivePopoverElement extends ReactiveModalElement {
referenceElement = null;
placement = null;
positionPopover() {
this.openDialog();
this.setPopover();
}
setPopover() {
if (!this.referenceElement) return;
const rect = this.referenceElement.getBoundingClientRect();
let popover = this.querySelector('dialog');
['top', 'left', 'right', 'bottom'].forEach((prop) => popover.style[prop] = 'auto');
let popoverRect = popover.getBoundingClientRect();
const placements = {
'top-start': { top: rect.top - popoverRect.height, left: rect.left },
'top': { top: rect.top - popoverRect.height, left: rect.left + rect.width / 2 - popoverRect.width / 2 },
'top-end': { top: rect.top - popoverRect.height, left: rect.right - popoverRect.width },
'right-start': { top: rect.top, left: rect.right },
'right': { top: rect.top + rect.height / 2 - popoverRect.height / 2, left: rect.right },
'right-end': { top: rect.bottom - popoverRect.height, left: rect.right },
'bottom-start': { top: rect.bottom, left: rect.left },
'bottom': { top: rect.bottom, left: rect.left + rect.width / 2 - popoverRect.width / 2 },
'bottom-end': { top: rect.bottom, left: rect.right - popoverRect.width },
'left-start': { top: rect.top, left: rect.left - popoverRect.width },
'left': { top: rect.top + rect.height / 2 - popoverRect.height / 2, left: rect.left - popoverRect.width },
'left-end': { top: rect.bottom - popoverRect.height, left: rect.left - popoverRect.width }
};
const availableSpaceLeft = rect.left;
const availableSpaceRight = window.innerWidth - rect.right;
const spaceAbove = rect.top;
const spaceBelow = window.innerHeight - rect.bottom;
const hasSpaceAbove = spaceAbove >= popoverRect.height;
// If not enough space above, flip to bottom
if (!hasSpaceAbove && spaceBelow >= popoverRect.height) {
this.placement = this.placement.replace('top', 'bottom');
}
let position = placements[this.placement];
// Adjust for viewport overflow
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Flip to the right if it would cover the reference element when placed to the left
if (this.placement === 'left' && availableSpaceLeft < popoverRect.width) {
this.placement = 'right';
position.left = availableSpaceLeft + rect.width;
}
// If flipping to the right causes overflow, adjust the left position
if (this.placement === 'right' && availableSpaceRight < popoverRect.width) {
position.left = viewportWidth - popoverRect.width * 1.3;
}
// Check for right overflow
if (position.left + popoverRect.width > viewportWidth) {
position.left = viewportWidth - popoverRect.width;
}
// Check for bottom overflow
if (position.top + popoverRect.height > viewportHeight) {
position.top = viewportHeight - popoverRect.height;
}
// Check for left overflow
if (position.left < 0) {
position.left = 0;
}
// Check for top overflow
if (position.top < 0) {
position.top = 0;
}
// Apply the adjusted position
popover.style.position = 'absolute';
popover.style.top = `${position.top + window.scrollY}px`;
popover.style.left = `${position.left + window.scrollX}px`;
}
}
class AnchoredPopover extends ReactivePopoverElement {
referenceElement = null
placement = null
createVirtualElement(x, y) {
return {
getBoundingClientRect: () => ({
width: 0,
height: 0,
top: y,
right: x,
bottom: y,
left: x,
x: x,
y: y,
}),
clientWidth: 0,
clientHeight: 0,
};
}
onConnect() {
this.render(); // early render because we need the dialog to be in the DOM
this.referenceElement = null;
this.placement = 'top';
document.addEventListener('mousemove', (event) => {
this.referenceElement = this.createVirtualElement(event.clientX, event.clientY);
this.positionPopover();
});
}
template() {
return html`
<dialog @click=${(event) => event.stopPropagation()} role="dialog" aria-modal=${this.isOpen} aria-labelledby="dialog-title">
<article>
<small id="dialog-title">Hi! I'm pinned to the mouse position</small>
</article>
</dialog>
`;
}
}
customElements.define('cami-anchored-popover', AnchoredPopover);
</script>