Skip to content

Source: lib/ui/Popup.js

import { publish, subscribe, unsubscribe } from '../util/publisher.js';

/**
 * The popup module. Helper view to display any content in an "popup" style overlay, with a pointer to a certain element on the screen.
 * The popup has "auto" Layout, ie it can adapt to the screen itself, seeking for the optimal placement.
 * @exports  module:lib/ui/Popup
 * @requires lib/util/publisher
 * @author   Frank Kudermann - alphanull
 * @version  1.5.0
 * @license  MIT
 * @example
 * popup.show(myContentNode);
 * popup.show("<h1>Content as string</h1>"); // <- this updates the opened overlay
 * popup.hide();
 */
export default class Popup {

    /**
     * Creates a new Popup instance.
     * @param {module:lib/ui/Popup~options} [options]  Configuration settings for this popup instance.
     */
    constructor(options = {}) { // eslint-disable-line max-lines-per-function

        if (options.ignore) { return; }

        /**
         * Holds the *active* configuration that applies to the current action.
         * @private
         * @type {Object<module:lib/ui/Popup~options>}
         */
        this.aConf = {};

        /**
         * Holds the *instance* configuration for each instance.
         * @private
         * @type {Object<module:lib/ui/Popup~options>}
         */
        this.iConf = this.extend(Popup.defaults, options, true);

        // prepare View

        /**
         * All references to Dom nodes are stored here.
         * @private
         * @type     {Object<HTMLElement>}
         * @property {HTMLElement}         target      The "target" of the Popup, ie the Element the Popup points to. Based on this Element, also the layout is calculated.
         * @property {HTMLElement}         root        The root element of this widget (i.e. The outermost layer).
         * @property {HTMLElement}         bg          The "background" layer, which is displayed underneath the popup. Can be used to create a "dim" effect when styled appropriately, and has also an event that closes the popup when the user clicks in this area.
         * @property {HTMLElement}         box         The popup element.
         * @property {HTMLElement}         pointer     The pointer element.
         * @property {HTMLElement}         cntWrapper  The outer content element. Just needed to have some extra margin around the scrollbars, if needed.
         * @property {HTMLElement}         cnt         The inner content element, which holds popup content injected later on.
         */
        this.els = {
            target: null,
            root: document.createElement('div'),
            bg: document.createElement('div'),
            cntWrapper: document.createElement('div'),
            cnt: document.createElement('div'),
            box: document.createElement('div'),
            pointer: document.createElement('div'),
            pointers: {
                top: { ele: document.createElement('div') },
                right: { ele: document.createElement('div') },
                bottom: { ele: document.createElement('div') },
                left: { ele: document.createElement('div') }
            }
        };

        // add classes
        this.els.cntWrapper.className = 'pu-cnt';
        this.els.cnt.className = 'pu-cnt-inner';
        this.els.bg.className = 'pu-bg';
        this.els.pointer.className = this.iConf.pointerViewClass;
        this.els.pointers.top.ele.className = 'pu-pointer top';
        this.els.pointers.right.ele.className = 'pu-pointer right';
        this.els.pointers.bottom.ele.className = 'pu-pointer bottom';
        this.els.pointers.left.ele.className = 'pu-pointer left';
        this.els.box.className = 'pu-box';

        this.els.cnt.setAttribute('role', 'dialog');
        this.els.cnt.setAttribute('aria-modal', 'true');
        this.els.cnt.setAttribute('aria-labelledby', 'pu-aria-label');

        this.els.root.className = `${this.iConf.baseViewClass} ${this.iConf.viewClass}`;
        this.els.root.setAttribute('data-lenis-prevent', '');
        this.els.bg.setAttribute('data-lenis-prevent', '');
        // build View
        this.els.cntWrapper.appendChild(this.els.cnt);
        this.els.box.appendChild(this.els.cntWrapper);
        this.els.box.appendChild(this.els.pointer);
        this.els.root.appendChild(this.els.bg);
        this.els.root.appendChild(this.els.box);

        /**
         * References to the various handlers, all bound (to "this").
         * @private
         * @type     {Object<Function>}
         * @property {Function}         hide     Handler for the "hide" event.
         * @property {Function}         resize   Handler for resize events.
         * @property {Function}         key      Handler for keyboard events.
         * @property {Function}         visible  Handler for the show transition event.
         * @property {Function}         hidden   Handler for the hide transition event.
         */
        this.handlers = {
            hide: this.hide.bind(this),
            resize: this.resize.bind(this),
            key: this.onKey.bind(this),
            visible: this.onVisible.bind(this),
            hidden: this.onHidden.bind(this),
            onFocus: this.onFocus.bind(this)
        };

        /**
         * Holds all relevant positioning values for calculating the layout.
         * @private
         * @type     {Object}
         * @property {number} scrollTop       Scrolling posiiton from the top.
         * @property {number} scrollLeft      Scrolling posiiton from the left.
         * @property {number} viewportWidth   Width of viewport in pixels.
         * @property {number} viewportHeight  Height of viewport in pixels.
         * @property {number} targetWidth     Width of target in pixels.
         * @property {number} targetHeight    Height of target in pixels.
         * @property {number} targetTop       Target top position in pixels.
         * @property {number} targetLeft      Target left position in pixels.
         * @property {number} popupWidth      Width of popup in pixels.
         * @property {number} popupHeight     Height of popup in pixels.
         * @property {number} pointerWidth    Width of pointer in pixels.
         * @property {number} pointerHeight   Height of pointer in pixels.
         */
        this.measurements = {
            viewportWidth: null,
            viewportHeight: null,
            popupWidth: null,
            popupHeight: null,
            cntDeltaWidth: null,
            cntDeltaHeight: null,
            target: {
                top: null,
                bottom: null,
                left: null,
                right: null
            },
            deltas: {
                top: null,
                bottom: null,
                left: null,
                right: null
            }
        };

        /**
         * Holds all calculated Layouts.
         * @private
         * @type {module:lib/ui/Popup~layoutObject}
         */
        this.layouts = [];

        /**
         * Determines if the client has CSS transitions.
         * @private
         * @type {boolean}
         */
        this.hasTransitions = 'transition' in document.documentElement.style || 'WebkitTransition' in document.documentElement.style;

        /**
         * Determines which transition event name the client needs. Only useful for older Safaris.
         * @private
         * @type {string}
         */
        this.transitionend = 'WebkitTransition' in document.documentElement.style ? 'webkitTransitionEnd' : 'transitionend';

        const ua = navigator.userAgent;

        const iPadOS = navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 4 && typeof window.DeviceMotionEvent !== 'undefined' && typeof window.DeviceOrientationEvent !== 'undefined';
        this.iOS = (/iPhone/i.test(ua) || /iPad/i.test(ua) || /iPod/i.test(ua)) && !/Windows Phone/i.test(ua) || iPadOS;

        /**
         * The current state of the Popup.
         * @private
         * @enum {string}
         */
        this.state = 'initialised';

        subscribe('location/changed', this.handlers.hide, { topicArg: true });

    }

    /**
     * Sets various options for the popup. Called by the "show" Function.
     * @param {module:lib/ui/Popup~options} [options]  Various options for the popup, see also {@link module:ui/Popup.show}.
     */
    configure(options) {

        // create configuration object based on defaults (or existing configuration)
        if (options) {
            this.extend(this.iConf, options);
            this.extend(this.aConf, options);
        }

    }

    /**
     * Shows the popup with specified content and alignment target.
     * @param {string|HTMLElement|DocumentFragment} content        The content to display in the popup.
     * @param {Event|HTMLElement|DocumentFragment}  eventOrTarget  The event or element that triggered the popup.
     * @param {module:lib/ui/Popup~options}         [options]      Configuration overrides for this invocation.
     */
    show(content, eventOrTarget, options) {

        if (this.state === 'showing' || this.state === 'visible') { return; } // TODO better call update here?

        this.aConf = this.extend(this.iConf, options, true);

        if (this.aConf.onShow && this.aConf.onShow() === false) return;

        if (options && options.parentElement) {
            this.aConf.fixedPos = false;
            this.iConf.parentElement = options.parentElement;
        } else {
            this.iConf.parentElement = null;
        }

        this.attachContent(content);
        this.els.root.style.visibility = 'hidden';

        const attachEl = this.aConf.parentElement || document.body;
        attachEl.appendChild(this.els.root);

        function isDomNode(obj) { // eslint-disable-line jsdoc/require-jsdoc
            return typeof HTMLElement === 'object'
                ? obj instanceof HTMLElement
                : obj && typeof obj === 'object' && obj.nodeType === 1 && typeof obj.nodeName === 'string';
        }

        if (typeof eventOrTarget.detail !== 'undefined' && eventOrTarget.detail === 0 && this.aConf.focusTrap) this.aConf.restoreFocus = true;

        this.els.target = isDomNode(eventOrTarget) ? eventOrTarget : eventOrTarget.currentTarget || eventOrTarget.target;
        if (this.aConf.targetHoverClass) this.els.target.classList.add(this.aConf.targetHoverClass);
        this.state = 'showing';
        this.layout();
        this.els.root.style.visibility = 'visible';
        this.els.root.style.position = this.els.bg.style.position = this.aConf.fixedPos === true || !this.aConf.parentElement ? 'fixed' : 'absolute';

        if (this.hasTransitions && this.aConf.animate) {

            const viewClass = this.els.root.className;
            this.els.box.removeEventListener(this.transitionend, this.handlers.hidden);
            // this.els.box.removeEventListener(this.transitionend, this.handlers.onUpdateComplete);
            this.els.box.addEventListener(this.transitionend, this.handlers.visible);
            this.els.root.className = viewClass + (this.state === 'updating' ? '' : ' showing');
            this.els.root.clientHeight; // eslint-disable-line no-unused-expressions
            this.els.root.className = viewClass;

        } else {

            this.handlers.visible();

        }

    }

    /**
     * Hides the popup.
     * @param {?Event}                      [event]    The event that triggered the hide action.
     * @param {module:lib/ui/Popup~options} [options]  Additional options.
     */
    hide(event, options) {

        if (event && event.target !== this.els.bg && event !== 'location/changed' || this.state !== 'showing' && this.state !== 'visible') {
            return;
        }

        if (options) this.aConf = this.extend(this.iConf, options, true);

        if (this.aConf.onHide && this.aConf.onHide() === false) return;

        if (this.aConf.targetHoverClass) this.els.target.classList.remove(this.aConf.targetHoverClass);

        if (this.hasTransitions && this.aConf.animate) {

            this.state = 'hiding';
            const viewClass = this.els.root.className;
            // this.els.box.removeEventListener(this.transitionend, this.handlers.onUpdateComplete);
            this.els.box.removeEventListener(this.transitionend, this.handlers.visible);
            this.els.box.addEventListener(this.transitionend, this.handlers.hidden);
            this.els.root.clientHeight; // eslint-disable-line no-unused-expressions
            this.els.root.className = `${viewClass} hiding`;

        } else {

            this.onHidden();

        }

    }

    /**
     * Called when showing is completed (ie when transition ends).
     * @private
     */
    onVisible() {

        if (this.hasTransitions && this.aConf.animate) {
            this.els.box.removeEventListener(this.transitionend, this.handlers.visible);
        }

        this.els.bg.addEventListener('click', this.handlers.hide, false);
        if (this.aConf.resize) window.addEventListener('resize', this.handlers.resize);

        if (this.aConf.handleEscKey) {
            document.addEventListener('keydown', this.handlers.key);
        }

        if (this.aConf.fixedPos === true) {
            if (this.iOS) { publish('locklayer', this.els.bg, { async: false }); }
            publish('locklayer', this.els.cnt, { async: false });
        }

        if (this.aConf.focus) {
            this.els.cnt.tabIndex = '-1';
            this.els.cnt.focus();
        }

        if (this.aConf.onVisible) this.aConf.onVisible();

        if (this.aConf.focusTrap) {

            /**
             * A list of all tabbable/focusable elements inside the popup. Used to trap focus when `focusTrap` is enabled.
             * @private
             * @type {HTMLElement[]}
             */
            this.focusables = Array.from(
                this.els.cnt.querySelectorAll('a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])')
            );

            if (this.focusables.length > 1) this.els.cnt.addEventListener('keydown', this.handlers.onFocus);

        }

        this.state = 'visible';

    }

    /**
     * Called when hiding is completed (ie transition has ended).
     * @private
     */
    onHidden() {

        if (this.hasTransitions && this.aConf.animate) { this.els.box.removeEventListener(this.transitionend, this.handlers.hidden); }

        this.detachContent();
        window.removeEventListener('resize', this.handlers.resize);
        this.els.bg.removeEventListener('click', this.handlers.hide);

        if (this.aConf.handleEscKey) { document.removeEventListener('keydown', this.handlers.key); }
        if (this.aConf.fixedPos === true) {
            if (this.iOS) { publish('unlocklayer', this.els.bg, { async: false }); }
            publish('unlocklayer', this.els.cnt, { async: false });
        }

        const attachEl = this.aConf.parentElement || document.body;
        attachEl.removeChild(this.els.root);
        this.state = 'hidden';

        if (this.aConf.focus) {
            this.els.cnt.tabIndex = null;
            this.els.target.focus();
            if (!this.aConf.restoreFocus) this.els.target.blur();
        }

        if (this.aConf.focusTrap) {
            this.els.cnt.removeEventListener('keydown', this.handlers.onFocus);
        }

        if (this.aConf.onHidden) this.aConf.onHidden();

    }

    /**
     * Calculates and applies the layout based on the best orientation and dimensions.
     * @private
     */
    layout() { // eslint-disable-line max-lines-per-function

        // if (this.state !== "showing" && this.state !== "hiding" && this.state !== "visible") { return; }
        if (!this.els.target) return;

        // reset Classes
        this.els.pointer.className = this.aConf.pointerViewClass;
        this.els.root.className = `${this.aConf.baseViewClass} ${this.aConf.viewClass}`;

        this.measureLayout(); //  calculate dimensions and everything else needed for Layout
        this.layouts = []; // reset layouts array

        const { measurements } = this,
              { margins } = this.aConf,
              { pointers } = this.els; // shortcuts

        /**
         * Calculates how far the popup must be moved left or right to fit into the viewport.
         * @member {Function} calculateMove
         * @memberof module:lib/ui/Popup#layout
         * @param   {number} viewportTotal  Total width or height of the viewport.
         * @param   {number} popupTotal     Total width or height of the popup layer.
         * @param   {number} targetOffset   [description].
         * @param   {number} margin         Minimum margin relative top the viewport.
         * @returns {number}                Amount (in pixels) the popup should be moved.
         */
        const calculateMove = function(viewportTotal, popupTotal, targetOffset, margin) {

            const popupAdapted = viewportTotal - popupTotal < 0 ? viewportTotal : popupTotal,
                  offset1 = viewportTotal + margin - (popupAdapted / 2 + targetOffset),
                  offset2 = targetOffset - popupAdapted / 2 - margin;

            return offset1 < 0 ? offset1 : offset2 < 0 ? -offset2 : 0;

        };

        /**
         * Calculates various Layout params, like area, deltaWidth and Height (checks if Popup fits into free area) and if the popup needs to be moved to fit.
         * @param   {string}                           o  Desired orientation.
         * @returns {module:lib/ui/Popup~layoutObject}    Layout object.
         */
        const calculateLayout = function(o) {

            switch (o) {
                case 'top':
                    return {
                        o,
                        area: measurements.deltas.top * measurements.viewportWidth,
                        deltaHeight: measurements.deltas.top - measurements.popupHeight - pointers.top.height,
                        deltaWidth: measurements.viewportWidth - measurements.popupWidth,
                        moveX: calculateMove(measurements.viewportWidth, measurements.popupWidth, measurements.target.top.x, margins.left),
                        moveY: 0
                    };
                case 'bottom':
                    return {
                        o,
                        area: measurements.deltas.bottom * measurements.viewportWidth,
                        deltaHeight: measurements.deltas.bottom - measurements.popupHeight - pointers.bottom.height,
                        deltaWidth: measurements.viewportWidth - measurements.popupWidth,
                        moveX: calculateMove(measurements.viewportWidth, measurements.popupWidth, measurements.target.bottom.x, margins.left),
                        moveY: 0
                    };
                case 'right':
                    return {
                        o,
                        area: measurements.deltas.right * measurements.viewportHeight,
                        deltaHeight: measurements.viewportHeight - measurements.popupHeight,
                        deltaWidth: measurements.deltas.right - measurements.popupWidth - pointers.right.width,
                        moveX: 0,
                        moveY: calculateMove(measurements.viewportHeight, measurements.popupHeight, measurements.target.right.y, margins.top)
                    };
                case 'left':
                    return {
                        o,
                        area: measurements.deltas.left * measurements.viewportHeight,
                        deltaHeight: measurements.viewportHeight - measurements.popupHeight,
                        deltaWidth: measurements.deltas.left - measurements.popupWidth - pointers.left.width,
                        moveX: 0,
                        moveY: calculateMove(measurements.viewportHeight, measurements.popupHeight, measurements.target.left.y, margins.top)
                    };
                // no default
            }

        };

        let layout;

        const orients = this.aConf.orientation === 'auto' ? ['top', 'right', 'bottom', 'left'] : this.aConf.orientation;

        // first of all, measure layouts
        for (let i = 0, l = orients.length; i < l; i += 1) {
            layout = calculateLayout(orients[i]);
            this.layouts.push(layout);
            if (this.aConf.orientation !== 'auto' && layout.deltaWidth >= 0 && layout.deltaHeight >= 0) break;
        }

        // then, sort by total free area
        this.layouts.sort((v1, v2) => v2.area - v1.area);

        // map index on first sort to work around unstable sorting engines
        this.layouts = this.layouts.map((val, index) => {
            val.index = index;
            return val;
        });

        // then, sort by deltaWidth and Height (but only if deltas are < 0)
        this.layouts.sort((v1, v2) => {

            const v1t = (v1.deltaWidth >= 0 ? 0 : v1.deltaWidth * -1) + (v1.deltaHeight >= 0 ? 0 : v1.deltaHeight * -1),
                  v2t = (v2.deltaWidth >= 0 ? 0 : v2.deltaWidth * -1) + (v2.deltaHeight >= 0 ? 0 : v2.deltaHeight * -1);

            if (v1t === v2t) return v1.index - v2.index;
            return v1t - v2t;

        });

        // now we should have the optimal layout
        let bestFit = this.layouts[0];

        // check if popup width needs to be limited
        if (bestFit.deltaWidth < 0 && this.aConf.limitLayout !== false) {

            let newWidth;

            switch (bestFit.o) {
                case 'left':
                    newWidth = measurements.deltas.left - pointers.left.width;
                    break;
                case 'right':
                    newWidth = measurements.deltas.right - pointers.right.width;
                    break;
                case 'top':
                case 'bottom':
                    newWidth = measurements.viewportWidth;
                    break;
                // no default
            }

            measurements.popupWidth = newWidth;
            this.els.cnt.style.width = `${newWidth - measurements.cntDeltaWidth}px`;
            measurements.popupHeight = this.els.box.offsetHeight; // must recalculate new Height when changing width
            bestFit = calculateLayout(bestFit.o);

        }

        // check if popup height needs to be limited
        if (bestFit.deltaHeight < 0 && this.aConf.limitLayout !== false) {

            let newHeight;

            switch (bestFit.o) {
                case 'left':
                case 'right':
                    newHeight = measurements.viewportHeight;
                    break;
                case 'top':
                    newHeight = measurements.deltas.top - pointers.top.height;
                    break;
                case 'bottom':
                    newHeight = measurements.deltas.bottom - pointers.bottom.height;
                    break;
                // no default
            }

            measurements.popupHeight = newHeight;
            this.els.cnt.style.height = `${newHeight - measurements.cntDeltaHeight}px`;

        }

        // now on to the actual positioning

        let viewTop, viewLeft, viewBottom, pointerTop, pointerLeft, pointerHeight, pointerWidth;

        this.els.pointer.className = `${this.aConf.pointerViewClass} ${bestFit.o}`;
        this.els.root.className = `${this.aConf.baseViewClass} ${this.aConf.viewClass} ${bestFit.o}`;

        switch (bestFit.o) {

            case 'top':
                viewLeft = measurements.target.top.x - measurements.popupWidth / 2 + bestFit.moveX;
                // viewTop = measurements.target.top.y - measurements.popupHeight - pointers.top.height / 2;
                viewBottom = measurements.viewportPos.height - measurements.target.top.y;
                pointerLeft = measurements.popupWidth / 2 - pointers.top.width / 2 - bestFit.moveX;
                pointerWidth = pointers.top.width;
                pointerTop = null;
                break;

            case 'bottom':
                viewLeft = measurements.target.bottom.x - measurements.popupWidth / 2 + bestFit.moveX;
                viewTop = measurements.target.bottom.y;
                pointerLeft = measurements.popupWidth / 2 - pointers.bottom.width / 2 - bestFit.moveX;
                pointerWidth = pointers.bottom.width;
                pointerTop = null;
                break;

            case 'left':
                viewLeft = measurements.target.left.x - measurements.popupWidth - pointers.left.width / 2;
                viewTop = measurements.target.left.y - measurements.popupHeight / 2 + bestFit.moveY;
                pointerLeft = null;
                pointerTop = measurements.popupHeight / 2 - pointers.left.height / 2 - bestFit.moveY;
                pointerHeight = pointers.left.height;
                break;

            case 'right':
                viewLeft = measurements.target.right.x;
                viewTop = measurements.target.right.y - measurements.popupHeight / 2 + bestFit.moveY;
                pointerLeft = null;
                pointerTop = measurements.popupHeight / 2 - pointers.right.height / 2 - bestFit.moveY;
                pointerHeight = pointers.right.height;
                break;

            // no default
        }

        // make sure pointer placement does not overshoot popup
        if (pointerTop) {
            if (pointerTop < this.aConf.pointerEdgeDistance) {
                pointerTop = this.aConf.c;
            } else if (pointerTop + pointerHeight > measurements.popupHeight - this.aConf.pointerEdgeDistance) {
                pointerTop = measurements.popupHeight - this.aConf.pointerEdgeDistance - pointerHeight;
            }
        }

        if (pointerLeft) {
            if (pointerLeft < this.aConf.pointerEdgeDistance) {
                pointerLeft = this.aConf.pointerEdgeDistance;
            } else if (pointerLeft + pointerWidth > measurements.popupWidth - this.aConf.pointerEdgeDistance) {
                pointerLeft = measurements.popupWidth - this.aConf.pointerEdgeDistance - pointerWidth;
            }
        }

        /* this.els.box.style.transform = `translateX(${Math.round(viewLeft - measurements.parentPos.left)}px) translateY(${Math.round(viewTop - measurements.parentPos.top)}px)`;
        this.els.pointer.style.transform = `translateX(${pointerLeft ? `${pointerLeft}px` : 0}px) translateY(${pointerTop ? `${pointerTop}px` : 0}px)`; */

        this.els.box.style.left = `${Math.round(viewLeft - measurements.parentPos.left)}px`;

        if (viewBottom) {
            this.els.box.style.top = 'auto';
            this.els.box.style.bottom = `${Math.round(measurements.parentPos.top ? measurements.parentPos.top - measurements.target.top.y + measurements.parentRect.height : viewBottom)}px`;
        } else {
            this.els.box.style.bottom = 'auto';
            this.els.box.style.top = `${Math.round(viewTop - measurements.parentPos.top)}px`;
        }

        this.els.pointer.style.left = pointerLeft ? `${pointerLeft}px` : null;
        this.els.pointer.style.top = pointerTop ? `${pointerTop}px` : null;

    }

    /**
     * Helper function that calculates positions and widths used by the "layout" method.
     * @private
     */
    measureLayout() {

        const getPosition = ele => ele.getBoundingClientRect();

        // only calculate pointer measurement once
        if (!this.els.pointers.top.width) {

            const node = document.createElement('div');

            node.style.visibility = 'hidden';
            node.style.position = 'absolute';
            node.appendChild(this.els.pointers.top.ele);
            node.appendChild(this.els.pointers.right.ele);
            node.appendChild(this.els.pointers.bottom.ele);
            node.appendChild(this.els.pointers.left.ele);
            this.els.root.appendChild(node);

            for (const pointer of Object.values(this.els.pointers)) {
                pointer.width = pointer.ele.offsetWidth;
                pointer.height = pointer.ele.offsetHeight;
            }

            this.els.root.removeChild(node);

        }

        // reset old inline styles
        this.els.cnt.style.height = null;
        this.els.cnt.style.width = null;
        this.els.box.style.left = null;
        this.els.box.style.top = null;

        const parentRect = this.aConf.parentElement ? getPosition(this.aConf.parentElement) : null,
              parentPosition = parentRect || { top: 0, left: 0 },
              viewport = this.aConf.limitLayout && this.aConf.parentElement ? this.aConf.parentElement : document.documentElement,
              viewportWidth = viewport.clientWidth,
              viewportHeight = window.innerHeight && window.innerHeight < viewport.clientHeight ? window.innerHeight : viewport.clientHeight,
              viewportPos = this.aConf.limitLayout && this.aConf.parentElement ? parentPosition : { top: 0, left: 0, height: viewportHeight, width: viewportWidth },
              parentPos = !this.aConf.limitLayout && this.aConf.parentElement ? { top: parentPosition.top, left: parentPosition.left } : { top: 0, left: 0 },
              targetPos = this.els.target.type === 'mouse' || this.els.target.type === 'touch' ? this.els.target : getPosition(this.els.target),
              targetWidth = targetPos.width,
              targetHeight = targetPos.height,
              targetTop = targetPos.top - viewportPos.top + (window.innerHeight && window.innerHeight + 2 < viewport.clientHeight ? window.scrollY : 0), // iOS Soft keyboard
              targetLeft = targetPos.left - viewportPos.left,
              targetTopBottomX = targetLeft + targetWidth / 2,
              targetLeftRightY = targetTop + targetHeight / 2;

        this.measurements = {
            viewportPos,
            parentPos,
            parentRect,
            viewportWidth: viewportWidth - this.aConf.margins.left - this.aConf.margins.right,
            viewportHeight: viewportHeight - this.aConf.margins.top - this.aConf.margins.bottom,
            popupWidth: this.els.box.offsetWidth,
            popupHeight: this.els.box.offsetHeight,
            cntDeltaWidth: this.els.cntWrapper.offsetWidth - this.els.cnt.offsetWidth,
            cntDeltaHeight: this.els.cntWrapper.offsetHeight - this.els.cnt.offsetHeight,
            target: {
                top: {
                    x: targetTopBottomX,
                    y: targetTop - this.aConf.pointerDistance
                },
                bottom: {
                    x: targetTopBottomX,
                    y: targetTop + targetHeight + this.aConf.pointerDistance
                },
                left: {
                    x: targetLeft - this.aConf.pointerDistance,
                    y: targetLeftRightY
                },
                right: {
                    x: targetLeft + targetWidth + this.aConf.pointerDistance,
                    y: targetLeftRightY
                }
            },
            deltas: {
                top: targetTop - this.aConf.pointerDistance - this.aConf.margins.top,
                bottom: viewportHeight - targetTop - targetHeight - this.aConf.pointerDistance - this.aConf.margins.bottom,
                left: targetLeft - this.aConf.pointerDistance - this.aConf.margins.left,
                right: viewportWidth - targetLeft - targetWidth - this.aConf.pointerDistance - this.aConf.margins.right
            }
        };
    }

    /**
     * Keyboard handler for ESC key to close the popup.
     * @private
     * @param {KeyboardEvent} event  The original keyboard event.
     */
    onKey(event) {

        const { keyCode } = event;
        if (keyCode === 27) { this.hide(); } // ESC key

    }

    /**
     * Handles focus trapping inside the popup when `focusTrap` is enabled.
     * Ensures that `Tab` cycles between the first and last focusable elements within the popup.
     * @private
     * @param {KeyboardEvent} event  The keydown event used for detecting tab navigation.
     */
    onFocus(event) {

        if (event.key !== 'Tab') return;

        const first = this.focusables[0],
              last = this.focusables[this.focusables.length - 1];

        if (event.shiftKey && document.activeElement === first) {
            event.preventDefault();
            last.focus();
        } else if (!event.shiftKey && document.activeElement === last) {
            event.preventDefault();
            first.focus();
        }
    }

    /**
     * Replaces the popup content with new content.
     * @param {string|HTMLElement|DocumentFragment} content  New content to insert.
     */
    replaceContent(content) {

        this.detachContent();
        this.attachContent(content);
        this.layout();

    }

    /**
     * Fills the content area of the view with the desired content, depending on the type of content.
     * Also, if content is an Element which has a parent, the parent is saved so that the content can later be reinserted where it was when hiding the popup.
     * @private
     * @param  {string|Element|DocumentFragment} content  The content for the popup, can be a (html) string, an element or a DOM fragment.
     * @throws {Error}                                    If no valid content type was found.
     */
    attachContent(content) {

        if (this.aConf.onAttachment) { this.aConf.onAttachment(content); }

        if (content instanceof DocumentFragment || content instanceof Element) {

            if (content.parentNode) {

                //  save current Location
                this.savedParent = content.parentNode;
                this.savedSibling = content.nextElementSibling;
                this.savedStyle = typeof content.currentStyle === 'undefined' ? document.defaultView.getComputedStyle(content, null).display : content.currentStyle.display;

                if (this.savedStyle === 'none') {

                    switch (content.tagName) { // check for right display using tag names, when node was hidden ($$$$ VERY incomplete)
                        case 'SPAN':
                            content.style.display = 'inline';
                            break;
                        default:
                            content.style.display = this.aConf.display;
                            break;
                    }

                }

            }

            this.els.cnt.appendChild(content);

        } else if (typeof content === 'string' || content instanceof String) {

            this.els.cnt.textContent = content;

        } else {

            throw new Error('Popup: No valid content type, must be a string or DOM Element');

        }

    }

    /**
     * If an existing content parent position was saved before, put the element back where it was, otherwise just empty the content area.
     * @private
     */
    detachContent() {

        if (this.els.cnt.firstChild) {

            if (this.savedParent) {

                if (this.savedStyle === 'none') {
                    this.els.cnt.firstChild.style.display = 'none';
                }

                if (this.savedSibling) {
                    this.savedParent.insertBefore(this.els.cnt.firstChild, this.savedSibling);
                    this.savedSibling = null;
                } else {
                    this.savedParent.appendChild(this.els.cnt.firstChild);
                }

                this.savedParent = null;

            } else {

                // this.els.cnt.innerHTML = "";
                this.els.cnt.removeChild(this.els.cnt.firstChild);

            }

        }

        if (this.aConf.onDetachment) { this.aConf.onDetachment(); }

    }

    /**
     * Recalculates layout on viewport resize.
     * @private
     */
    resize() {

        this.layout();

    }

    /**
     * Removes the popup and detaches all event listeners.
     */
    remove() {

        if (this.state === 'visible' || this.state === 'showing') this.onHidden(); else this.detachContent();

        unsubscribe('location/changed', this.handlers.hide);
        document.removeEventListener('keydown', this.handlers.key);
        window.removeEventListener('resize', this.handlers.resize);
        this.els.cnt.removeEventListener('keydown', this.handlers.onFocus);
        this.els.bg.removeEventListener('click', this.handlers.hide, false);
        this.els.box.removeEventListener(this.transitionend, this.handlers.onUpdateComplete);
        this.els.box.removeEventListener(this.transitionend, this.handlers.visible);
        this.els.box.removeEventListener(this.transitionend, this.handlers.hidden);
        this.aConf.onHidden = null;
        this.els = this.handlers = null;

    }

    /**
     * Extends 'target' object with members from 'source'. Does only a simple shallow extension, and returns a copy of the target.
     * @private
     * @param   {Object}  target  Destination object.
     * @param   {Object}  source  Source object from which to extend.
     * @param   {boolean} clone   If set to true, a cloned copy of the target is returned.
     * @returns {Object}          Extended object.
     */
    extend(target, source, clone) {

        let result, i, key, keys;

        if (clone) {
            result = {};
            for (i = 0, key, keys = Object.keys(target); (key = keys[i]); i += 1) {
                result[key] = Array.isArray(target[key]) ? target[key].slice(0) : target[key];
            }
        } else {
            result = target;
        }

        if (source) {
            for (i = 0, key, keys = Object.keys(source); (key = keys[i]); i += 1) {
                if (key === 'margins') {
                    result[key] = this.extend(source[key], null, true);
                } else {
                    result[key] = Array.isArray(source[key]) ? source[key].slice(0) : source[key];
                }
            }
        }

        return result;

    }

}

/**
 * Holds the defaults for all instances.
 * @private
 * @type {module:lib/ui/Popup~options}
 */
Popup.defaults = {
    orientation: 'auto',
    margins: {
        top: 10,
        bottom: 10,
        right: 10,
        left: 10
    },
    pointerDistance: 10,
    pointerEdgeDistance: 15,
    animate: true,
    fixedPos: true,
    limitLayout: true,
    baseViewClass: 'pu',
    pointerViewClass: 'pu-pointer',
    viewClass: '',
    display: 'block',
    handleEscKey: true,
    onShow: null,
    onHide: null,
    onHidden: null,
    onVisible: null,
    onAttachment: null,
    onDetachment: null,
    focus: true,
    focusTrap: true,
    targetHoverClass: '',
    resize: true
};

/**
 * Configures the popup globally, so it must be called on the constructor: <code>Popup.configure(...)</code>. Uses defaults for options that are not specified.
 * Note that in contrast to the {@link module:lib/ui/Popup#configure} instance method, these options apply to *all* future instances of the overlay.
 * @param {module:lib/ui/Popup~options} options  The configuration object which applies to this instance.
 */
Popup.configure = function(options) {

    Popup.prototype.extend(Popup.defaults, options);

};

/**
 * @typedef  {Object} module:lib/ui/Popup~options    Structure of the Popup options
 * @property {HTMLElement}    [parentElement]                                The Element the popup DOM nodes should be attached to. Defaults to document.body.
 * @property {Array<string>}  [orientation="top","bottom","right","left"]    An Array holding the preferred orientation in relation to the target element, ie the element the popup points to. The orientations are checked in the order they appear in the array, an as soon as the popup would fit on the screen with the currently tested orientation,the appropriate layout is selected for display.
 * @property {Object<number>} [margins={top:10,bottom:10,right:10,left:10}]  Minimum margins from viewport, separate for all four edges.
 * @property {number}         [pointerDistance=5]                            Distance between popup and target element.
 * @property {number}         [pointerEdgeDistance=10]                       Minimum default distance between pointer graphic and the edge of the popup background.
 * @property {boolean}        [animate=true]                                 Determines if the popup should be animated.
 * @property {number}         [fixedPos=false]                               Indicates if Popup should have a fixed position.
 * @property {boolean}        [limitLayout=true]                             If true, the popup size is limited to the viewport when necessary, adding scrollbars.
 * @property {string}         [baseViewClass="pu"]                           Default classname of the main view.
 * @property {string}         [pointerViewClass="pu-pointer"]                Default classname of the pointer element.
 * @property {string}         [viewClass=""]                                 Additional classname of the main view.
 * @property {boolean}        [handleEscKey=true]                            If true, popup closes on ESC key.
 * @property {?Function}      [onHide=null]                                  Callback which is executed when the popup is about being hidden.  Cancels hiding if this callback returns `false`.
 * @property {?Function}      [onHidden=null]                                Callback which is executed when the popup has closed.
 * @property {?Function}      [onShow=null]                                  Callback which is executed when the popup is being shown. Cancels showing if this callback returns `false`.
 * @property {?Function}      [onVisible=null]                               Callback which is executed when the popup is visible.
 * @property {?Function}      [onAttachment=null]                            Callback which is executed when the content has been attached.
 * @property {?Function}      [onDetachment=null]                            Callback which is executed when the content has been detached.
 * @property {boolean}        [focus=true]                                   Automatically focuses popup as soon as it has opened. Helps with tabbing, in case the popup holds any suitable controls.
 * @property {boolean}        [focusTrap=true]                               If enabled, traps keyboard focus inside the popup until it is closed (useful for accessibility).
 * @property {boolean}        [resize=true]                                  If enabled, use internal resize function.
 */

/**
 * @typedef  {Object} module:lib/ui/Popup~layoutObject    Structure of the layout object
 * @property {string} o            Orientation for this layout.
 * @property {number} area         Area in square pixels occupied by this layout.
 * @property {number} deltaHeight  Delta between popup and vireport height.
 * @property {number} deltaWidth   Delta between popup and vireport height.
 * @property {number} index        Position of this layout in the array.
 * @property {number} moveX        Amount which the popup needs to be moved in horizontal direction so that it fits in the viewport.
 * @property {number} moveY        Amount which the popup needs to be moved in vertical direction so that it fits in the viewport.
 */