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>