import { queryOne } from '@ecl/dom-utils';
import EventManager from '@ecl/event-manager';
/**
* @param {HTMLElement} element DOM element for component instantiation and scope
* @param {Object} options
* @param {String} options.toggleSelector Selector for toggling element
* @param {Boolean} options.attachClickListener Whether or not to bind click events on toggle
* @param {Boolean} options.attachKeyListener Whether or not to bind keyboard events
*/
export class Popover {
/**
* @static
* Shorthand for instance creation and initialisation.
*
* @param {HTMLElement} root DOM element for component instantiation and scope
*
* @return {Popover} An instance of Popover.
*/
static autoInit(root, { POPOVER: defaultOptions = {} } = {}) {
const popover = new Popover(root, defaultOptions);
popover.init();
root.ECLPopover = popover;
return popover;
}
/**
* @event Popover#onOpen
*/
/**
* @event Popover#onClose
*/
/**
* An array of supported events for this component.
*
* @type {Array<string>}
* @memberof Popover
*/
supportedEvents = ['onOpen', 'onClose'];
constructor(
element,
{
toggleSelector = '[data-ecl-popover-toggle]',
closeSelector = '[data-ecl-popover-close]',
attachClickListener = true,
attachKeyListener = true,
} = {},
) {
// Check element
if (!element || element.nodeType !== Node.ELEMENT_NODE) {
throw new TypeError(
'DOM element should be given to initialize this widget.',
);
}
this.element = element;
this.eventManager = new EventManager();
// Options
this.toggleSelector = toggleSelector;
this.closeSelector = closeSelector;
this.attachClickListener = attachClickListener;
this.attachKeyListener = attachKeyListener;
// Private variables
this.toggle = null;
this.close = null;
this.target = null;
this.container = null;
this.resizeTimer = null;
this.scrollableParent = null;
this.toggleRect = null;
this.scrollable = null;
// Bind `this` for use in callbacks
this.openPopover = this.openPopover.bind(this);
this.closePopover = this.closePopover.bind(this);
this.positionPopover = this.positionPopover.bind(this);
this.handleClickOnToggle = this.handleClickOnToggle.bind(this);
this.handleKeyboardGlobal = this.handleKeyboardGlobal.bind(this);
this.handleClickGlobal = this.handleClickGlobal.bind(this);
this.checkPosition = this.checkPosition.bind(this);
this.resetStyles = this.resetStyles.bind(this);
this.getClosestScrollableParent =
this.getClosestScrollableParent.bind(this);
this.calculateAvailableSpace = this.calculateAvailableSpace.bind(this);
this.POPOVER_CLASSES = {
TOP: 'ecl-popover--top',
BOTTOM: 'ecl-popover--bottom',
LEFT: 'ecl-popover--left',
RIGHT: 'ecl-popover--right',
PUSH_TOP: 'ecl-popover--push-top',
PUSH_BOTTOM: 'ecl-popover--push-bottom',
PUSH_LEFT: 'ecl-popover--push-left',
PUSH_RIGHT: 'ecl-popover--push-right',
};
}
/**
* Initialise component.
*/
init() {
if (!ECL) {
throw new TypeError('Called init but ECL is not present');
}
ECL.components = ECL.components || new Map();
this.toggle = queryOne(this.toggleSelector, this.element);
this.close = queryOne(this.closeSelector, this.element);
this.container = queryOne('.ecl-popover__container', this.element);
this.scrollableParent = this.getClosestScrollableParent(this.toggle);
// Bind global events
if (this.attachKeyListener) {
document.addEventListener('keyup', this.handleKeyboardGlobal);
}
if (this.attachClickListener) {
document.addEventListener('click', this.handleClickGlobal);
if (this.close) {
this.close.addEventListener('click', this.handleClickOnToggle);
}
}
// Get target element
this.target = document.querySelector(
`#${this.toggle.getAttribute('aria-controls')}`,
);
// Exit if no target found
if (!this.target) {
throw new TypeError(
'Target has to be provided for popover (aria-controls)',
);
}
this.scrollable = this.target.firstElementChild;
window.addEventListener('resize', this.checkPosition);
document.addEventListener('scroll', this.checkPosition);
// Bind click event on toggle
if (this.attachClickListener && this.toggle) {
this.toggle.addEventListener('click', this.handleClickOnToggle);
}
// Set ecl initialized attribute
this.element.setAttribute('data-ecl-auto-initialized', 'true');
ECL.components.set(this.element, this);
}
/**
* Register a callback function for a specific event.
*
* @param {string} eventName - The name of the event to listen for.
* @param {Function} callback - The callback function to be invoked when the event occurs.
* @returns {void}
* @memberof Popover
* @instance
*
* @example
* // Registering a callback for the 'onOpen' event
* popover.on('onOpen', (event) => {
* console.log('Open event occurred!', event);
* });
*/
on(eventName, callback) {
this.eventManager.on(eventName, callback);
}
/**
* Trigger a component event.
*
* @param {string} eventName - The name of the event to trigger.
* @param {any} eventData - Data associated with the event.
* @memberof Popover
*/
trigger(eventName, eventData) {
this.eventManager.trigger(eventName, eventData);
}
/**
* Destroy component.
*/
destroy() {
if (this.attachClickListener && this.toggle) {
this.toggle.removeEventListener('click', this.handleClickOnToggle);
}
if (this.attachClickListener && this.close) {
this.close.removeEventListener('click', this.handleClickOnToggle);
}
window.removeEventListener('resize', this.checkPosition);
document.removeEventListener('scroll', this.checkPosition);
if (this.attachKeyListener) {
document.removeEventListener('keyup', this.handleKeyboardGlobal);
}
if (this.attachClickListener) {
document.removeEventListener('click', this.handleClickGlobal);
}
if (this.toggle.getAttribute('aria-expanded') === 'true') {
this.closePopover();
}
if (this.element) {
this.element.removeAttribute('data-ecl-auto-initialized');
ECL.components.delete(this.element);
}
}
/**
* Toggles between collapsed/expanded states.
*
* @param {Event} e
*/
handleClickOnToggle(e) {
e.preventDefault();
// Get current status
const isExpanded = this.toggle.getAttribute('aria-expanded') === 'true';
// Toggle the popover
if (isExpanded) {
this.closePopover(e);
return;
}
this.openPopover(e);
this.positionPopover();
}
/**
* Open the popover.
*
* @param {Event} e
*/
openPopover(e) {
this.toggle.setAttribute('aria-expanded', 'true');
this.target.hidden = false;
this.trigger('onOpen', { event: e, target: this.target });
// Focus close button
if (this.close) {
this.close.focus();
}
}
/**
* Close the popover.
*
* @param {Event} e
*/
closePopover(e) {
this.toggle.setAttribute('aria-expanded', 'false');
this.toggle.focus();
// Reset all the selectors and styles
this.resetStyles();
this.target.hidden = true;
this.trigger('onClose', { event: e, target: this.target });
}
/**
* Find the closest scrollable parent.
*
* @param {Node} element
*/
getClosestScrollableParent(element) {
let parent = element.parentElement;
while (parent) {
const { overflowY, overflowX } = getComputedStyle(parent);
const isScrollableY =
(overflowY === 'auto' || overflowY === 'scroll') &&
parent.scrollHeight > parent.clientHeight;
const isScrollableX =
(overflowX === 'auto' || overflowX === 'scroll') &&
parent.scrollWidth > parent.clientWidth;
if (isScrollableY || isScrollableX) {
return parent; // Found the closest scrollable parent
}
parent = parent.parentElement;
}
return document.body;
}
/**
* Calculate available space for the popover
*
* @param {Node} toggleElement
* @param {Node} scrollableParent
*/
calculateAvailableSpace(toggleElement, scrollableParent = null) {
// Get the bounding rect for the toggle element
this.toggleRect = toggleElement.getBoundingClientRect();
// If no scrollable parent is provided, use the viewport
const containerRect = scrollableParent
? scrollableParent.getBoundingClientRect()
: {
top: 0,
left: 0,
right: window.innerWidth,
bottom: window.innerHeight,
};
const containerWidth = containerRect.right - containerRect.left;
const containerHeight = containerRect.bottom - containerRect.top;
// Calculate the space available in the four directions
const containerBottom = Math.max(
0,
window.innerHeight - containerRect.bottom,
);
const toggleBottom = Math.max(
0,
window.innerHeight - this.toggleRect.bottom,
);
const spaceBottom = Math.max(0, toggleBottom - containerBottom);
// Top Space (from toggle's top to container's top)
const containerTop = Math.max(0, containerRect.top);
const toggleTop = Math.max(0, this.toggleRect.top);
const spaceTop = Math.max(0, toggleTop - containerTop);
// Right Space (from toggle's right to container's right)
const containerRight = Math.max(0, window.innerWidth - containerRect.right);
const toggleRight = Math.max(0, window.innerWidth - this.toggleRect.right);
const spaceRight = Math.max(0, toggleRight - containerRight);
// Left Space (from toggle's left to container's left)
const containerLeft = Math.max(0, containerRect.left);
const toggleLeft = Math.max(0, this.toggleRect.left);
const spaceLeft = Math.max(0, toggleLeft - containerLeft);
return {
containerWidth,
containerHeight,
spaceTop,
spaceBottom,
spaceLeft,
spaceRight,
};
}
/**
* Resets the popover selectors and styles.
*/
resetStyles() {
Object.keys(this.POPOVER_CLASSES).forEach((className) => {
if (
Object.prototype.hasOwnProperty.call(this.POPOVER_CLASSES, className)
) {
this.element.classList.remove(this.POPOVER_CLASSES[className]);
}
});
this.target.style.setProperty('--ecl-popover-position', '');
this.container.style.left = '';
this.container.style.right = '';
this.container.style.top = '';
this.container.style.bottom = '';
this.container.style.transform = '';
this.scrollable.style.width = '';
}
/**
* Manage popover position.
*/
positionPopover() {
this.resetStyles();
const { containerWidth, spaceTop, spaceBottom, spaceLeft, spaceRight } =
this.calculateAvailableSpace(this.toggle, this.scrollableParent);
// Find the direction with the most available space
const positioningClass = 'ecl-popover--';
let direction = '';
if (
spaceTop > spaceBottom &&
spaceTop > spaceLeft &&
spaceTop > spaceRight
) {
direction = 'top';
} else if (spaceBottom > spaceLeft && spaceBottom > spaceRight) {
direction = 'bottom';
} else if (spaceLeft > spaceRight) {
direction = 'left';
} else {
direction = 'right';
}
this.element.classList.add(`${positioningClass}${direction}`);
// Try to use as much of the available width, respecting the max-width set.
const styles = window.getComputedStyle(this.scrollable);
const maxWidth = parseInt(styles.getPropertyValue('max-width'), 10);
const minWidth = parseInt(styles.getPropertyValue('min-width'), 10);
const padding = parseInt(styles.getPropertyValue('padding-left'), 10) * 2;
// We consider 90% of the biggest space available
const horizontalSpace = Math.max(spaceLeft, spaceRight) * 0.9;
let targetWidth;
// If the available space is larger than maxWidth (plus padding), set to maxWidth
if (
maxWidth + padding < horizontalSpace ||
(direction !== 'left' &&
direction !== 'right' &&
containerWidth > maxWidth)
) {
targetWidth = maxWidth;
} else if (horizontalSpace < minWidth + padding) {
// If the available space is smaller than minWidth (plus padding), set to minWidth
targetWidth = minWidth;
} else if (direction === 'left' || direction === 'right') {
// Otherwise, set the width to the available space minus the padding
targetWidth = horizontalSpace - padding;
} else {
targetWidth = (horizontalSpace - padding) * 2;
}
// Ensure the width does not exceed available space
this.scrollable.style.width = `${targetWidth}px`;
this.handlePushClass(direction);
}
/**
* Check whether the popover is going out of its scrollable container and apply the needed repositioning.
*
* @param {string} direction
*/
handlePushClass(direction) {
requestAnimationFrame(() => {
const popoverRect = this.target.getBoundingClientRect();
const scrollableRect = this.scrollableParent.getBoundingClientRect();
const containerBottom =
scrollableRect.bottom > window.innerHeight
? 0
: window.innerHeight - scrollableRect.bottom;
const containerTop =
scrollableRect.top > window.innerHeight ? 0 : scrollableRect.top;
const leftOverflow = scrollableRect.left > popoverRect.left;
const rightOverflow = scrollableRect.right < popoverRect.right;
const topOverflow = popoverRect.top < containerTop;
const bottomOverflow =
containerBottom > window.innerHeight - popoverRect.bottom;
if (direction === 'left' || direction === 'right') {
if (topOverflow) {
this.element.classList.add(this.POPOVER_CLASSES.PUSH_TOP);
// Push the popover to the top edge of the container
this.container.style.top = `-${Math.round(this.toggleRect.top)}px`;
this.container.style.bottom = '';
this.container.style.transform = '';
}
if (bottomOverflow) {
this.element.classList.add(this.POPOVER_CLASSES.PUSH_BOTTOM);
// Push the popover to the bottom edge of the container
this.container.style.bottom = `-${window.innerHeight - this.toggleRect.bottom - containerBottom}px`;
this.container.style.top = '';
this.container.style.transform = '';
}
} else {
if (leftOverflow) {
this.element.classList.add(this.POPOVER_CLASSES.PUSH_LEFT);
// Push the popover 8px to the left edge of the container
this.container.style.left = `-${this.toggleRect.left - scrollableRect.left - 8}px`;
this.container.style.right = 'auto';
}
if (rightOverflow) {
this.element.classList.add(this.POPOVER_CLASSES.PUSH_RIGHT);
// Push the popover 8px to the right edge of the container
this.container.style.right = `-${scrollableRect.right - this.toggleRect.right - 8}px`;
this.container.style.left = 'auto';
}
}
this.handleArrowPosition(direction);
});
}
/**
* Reposition the arrow in case a push class is being used
*
* @param {string} direction
*/
handleArrowPosition(direction) {
const popoverRect = this.target.getBoundingClientRect();
if (direction === 'left' || direction === 'right') {
if (this.element.classList.contains(this.POPOVER_CLASSES.PUSH_BOTTOM)) {
this.target.style.setProperty(
'--ecl-popover-position',
`${Math.round(
this.toggleRect.top - popoverRect.top + this.toggleRect.height / 2,
)}px`,
);
} else if (
this.element.classList.contains(this.POPOVER_CLASSES.PUSH_TOP)
) {
this.target.style.setProperty(
'--ecl-popover-position',
`${Math.round(
popoverRect.top + this.toggleRect.top + this.toggleRect.height / 2,
)}px`,
);
}
} else {
if (this.element.classList.contains(this.POPOVER_CLASSES.PUSH_RIGHT)) {
this.target.style.setProperty(
'--ecl-popover-position',
`${Math.round(
popoverRect.right -
(this.toggleRect.right - this.toggleRect.width / 2),
)}px`,
);
} else if (
this.element.classList.contains(this.POPOVER_CLASSES.PUSH_LEFT)
) {
this.target.style.setProperty(
'--ecl-popover-position',
`${Math.round(
this.toggleRect.left - popoverRect.left + this.toggleRect.width / 2,
)}px`,
);
}
}
}
/**
* Trigger events on resize
* Uses a debounce, for performance
*/
checkPosition() {
clearTimeout(this.resizeTimer);
this.resizeTimer = setTimeout(() => {
if (this.toggle.getAttribute('aria-expanded') === 'true') {
this.positionPopover();
}
}, 200);
}
/**
* Handles global keyboard events, triggered outside of the popover.
*
* @param {Event} e
*/
handleKeyboardGlobal(e) {
if (!this.target) return;
// Detect press on Escape
if (e.key === 'Escape' || e.key === 'Esc') {
if (this.toggle.getAttribute('aria-expanded') === 'true') {
this.closePopover(e);
}
}
}
/**
* Handles global click events, triggered outside of the popover.
*
* @param {Event} e
*/
handleClickGlobal(e) {
if (!this.target) return;
// Check if the popover is open
if (this.toggle.getAttribute('aria-expanded') === 'true') {
// Check if the click occured on the popover
if (!this.target.contains(e.target) && !this.toggle.contains(e.target)) {
this.closePopover(e);
}
}
}
}
export default Popover;