Popover
This inherits from the Modal component, so it has all the same features, but it's a bit more complex as it has to position itself relative to the element that triggered it.
This also demonstrates how you'd want to build & reuse components. The DemoPopoverElement defines just the properties you'd want: the reference element, the placement, and the template.
It inherits from ReactivePopoverElement, which has all the logic for positioning the popover relative to the reference element. And this inherits from ReactiveModalElement, which has all the logic for the modal behavior & accessibility.
Note that if you want a background click to close the popover, you'll need to define a backdrop div and add a click handler to it that calls this.closeDialog(). See the Modal component for an example.
HTML:
<h1>Popover</h1>
<p>When you click on either the top / bottom / left / right buttons, a popover will appear in the corresponding position against the reference element.</p>
<cami-popover></cami-popover>
<style>
h1, p {
text-align: center;
}
.popover__backdrop { // unused in this example
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
justify-content: center;
align-items: center;
}
.popover__backdrop--hidden { // unused in this example
display: none;
}
</style>
<!-- --><script src="./build/cami.cdn.js"></script>
<!-- CDN version below -->
<script src="https://unpkg.com/cami@0.3.5/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;
openDialog() {
super.openDialog();
this.positionPopover();
}
positionPopover() {
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 DemoPopoverElement extends ReactivePopoverElement {
referenceElement = null
placement = null
onConnect() {
this.render(); // early render because we need the referenceElement to be set
this.referenceElement = document.querySelector(".referenceElement");
this.placement = 'top';
}
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 a Popover</small>
<button @click=${() => this.closeDialog()} aria-label="Close Popover">Close</button>
</article>
</dialog>
<div style="display: flex; flex-direction: column; align-items: center; gap: 200px;">
<button @click=${() => { this.placement = 'top'; this.openDialog(); }}>Top</button>
<div style="display: flex; gap: 200px;">
<button @click=${() => { this.placement = 'left'; this.openDialog(); }}>Left</button>
<button class="referenceElement" style="margin-left: 10px;" disabled>Reference</button>
<button @click=${() => { this.placement = 'right'; this.openDialog(); }}>Right</button>
</div>
<button @click=${() => { this.placement = 'bottom'; this.openDialog(); }}>Bottom</button>
</div>
`;
}
}
customElements.define('cami-popover', DemoPopoverElement);
</script>