import { queryOne } from '@ecl/dom-utils';
/**
* @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;
}
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;
// 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;
// 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.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);
// 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)',
);
}
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);
}
/**
* 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();
return;
}
this.openPopover();
this.positionPopover();
}
/**
* Open the popover.
*/
openPopover() {
this.toggle.setAttribute('aria-expanded', 'true');
this.target.hidden = false;
}
/**
* Close the popover.
*/
closePopover() {
this.toggle.setAttribute('aria-expanded', 'false');
// Reset all the selectors and styles
this.resetStyles();
this.target.hidden = true;
}
/**
* 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.target.firstElementChild.width = '';
}
/**
* Manage popover position.
*/
positionPopover() {
this.resetStyles();
const toggleRect = this.toggle.getBoundingClientRect();
const screenHeight = window.innerHeight;
const screenWidth = window.innerWidth;
// Calculate available space in each direction
const spaceTop = toggleRect.top;
const spaceBottom = screenHeight - toggleRect.bottom;
const spaceLeft = toggleRect.left;
const spaceRight = screenWidth - toggleRect.right;
// 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}`);
this.handlePushClass(screenWidth, screenHeight, direction);
// Try to use as much of the available width, respecting the max-width set.
const scrollable = this.target.firstElementChild;
const styles = window.getComputedStyle(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;
let availableSpace = '';
if (direction === 'left' || direction === 'right') {
availableSpace = (direction === 'left' ? spaceLeft : spaceRight) * 0.9;
} else {
const centerPosition =
(this.toggle.getBoundingClientRect().right -
this.toggle.getBoundingClientRect().left) /
2;
availableSpace =
(screenWidth - centerPosition + this.target.offsetWidth / 2) * 0.9;
}
if (maxWidth + padding < availableSpace) {
scrollable.style.width = `${maxWidth}px`;
} else if (availableSpace < minWidth + padding) {
scrollable.style.width = `${minWidth}px`;
} else {
scrollable.style.width = `${availableSpace - padding}px`;
}
}
handlePushClass(screenWidth, screenHeight, direction) {
const toggleRect = this.toggle.getBoundingClientRect();
const popoverRect = this.target.getBoundingClientRect();
if (direction === 'left' || direction === 'right') {
if (popoverRect.top < 0) {
this.element.classList.add(this.POPOVER_CLASSES.PUSH_TOP);
this.container.style.top = `-${Math.round(toggleRect.top)}px`;
this.container.style.bottom = '';
this.container.style.transform = '';
} else if (popoverRect.bottom > screenHeight) {
this.element.classList.add(this.POPOVER_CLASSES.PUSH_BOTTOM);
// We add 0.5rem to the calculus to avoid vertical scrollbars.
this.container.style.bottom = `-${Math.round(
screenHeight - (toggleRect.bottom + 8),
)}px`;
this.container.style.top = '';
this.container.style.transform = '';
}
} else {
if (popoverRect.left < 0) {
this.element.classList.add(this.POPOVER_CLASSES.PUSH_LEFT);
this.container.style.left = `-${toggleRect.left}px`;
this.container.style.right = 'auto';
this.container.style.transform = 'none';
}
if (popoverRect.right > screenWidth) {
this.element.classList.add(this.POPOVER_CLASSES.PUSH_RIGHT);
this.container.style.right = `-${screenWidth - toggleRect.right}px`;
this.container.style.left = 'auto';
this.container.style.transform = 'none';
}
}
this.handleArrowPosition(direction);
}
handleArrowPosition(direction) {
const toggleRect = this.toggle.getBoundingClientRect();
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(
toggleRect.top - popoverRect.top + 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 + toggleRect.top + toggleRect.height / 2,
)}px`,
);
}
} else {
// eslint-disable-next-line no-lonely-if
if (this.element.classList.contains(this.POPOVER_CLASSES.PUSH_RIGHT)) {
this.target.style.setProperty(
'--ecl-popover-position',
`${Math.round(
popoverRect.right - (toggleRect.right - toggleRect.width / 2),
)}px`,
);
} else if (
this.element.classList.contains(this.POPOVER_CLASSES.PUSH_LEFT)
) {
this.target.style.setProperty(
'--ecl-popover-position',
`${Math.round(
popoverRect.left + toggleRect.left + 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') {
this.closePopover();
}
}
/**
* 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();
}
}
}
}
export default Popover;