Source: control2/bubble/Bubble.js

(function() {
    /**
     * @namespace bubble
     * @memberof Help4.control2
     */
    Help4.control2.bubble = {};
    /**
     * @namespace header
     * @memberof Help4.control2.bubble
     */
    Help4.control2.bubble.header = {};
    /**
     * @namespace content
     * @memberof Help4.control2.bubble
     */
    Help4.control2.bubble.content = {};
    /**
     * @namespace footer
     * @memberof Help4.control2.bubble
     */
    Help4.control2.bubble.footer = {};

    /** @enum {string} */
    Help4.control2.bubble.ANIMATION_TYPES = {
        'default': 'default',  // XXX: rename to "none"
        expand: 'expand',
        fade: 'fade',
        pulse: 'pulse',
        scaleup: 'scaleup',
        shake: 'shake',
        shrink: 'shrink',
        wobble: 'wobble'
    }

    /** @enum {string} */
    Help4.control2.bubble.SIZES = {
        xs: 'xs',
        s: 's',
        m: 'm',
        l: 'l',
        xl: 'xl'
    }

    /** @enum {string} */
    Help4.control2.bubble.HEADER_LAYOUT = {
        CaptionTranslateClose: 'CaptionTranslateClose',
        Panel: 'Panel'
    }

    /** @enum {string} */
    Help4.control2.bubble.CONTENT_LAYOUT = {
        Html: 'Html',
        Panel: 'Panel',
        Selection: 'Selection'
    }

    /** @enum {string} */
    Help4.control2.bubble.FOOTER_LAYOUT = {
        Checkbox: 'Checkbox',
        Panel: 'Panel',
        Close: 'Close',
        Apply: 'Apply'
    }

    /**
     * @typedef {Object} Help4.control2.bubble.AlignParams
     * @property {Help4.control2.AreaXYWH} [here] - possible static position
     * @property {boolean} [ignoreArea] - do ignore the area of the container
     * @property {*} [orientation] - orientation
     * @property {boolean} [allowFallbacks] - allow to use fallbacks
     */

    /**
     * @typedef {Object} Help4.control2.bubble.Alignment
     * @property {Help4.control2.PositionXY} point
     * @property {string} orientation
     */

    /**
     * @typedef {Help4.control2.Control.Params} Help4.control2.bubble.Bubble.Params
     * @property {boolean} [showArrow = false] - whether to show the arrow
     * @property {boolean} [showAfterAlign = true] - automatically set visible after align
     * @property {boolean} [resizableAlign = false] - allow content resize during align*
     * @property {Help4.control2.bubble.ANIMATION_TYPES} [animationType = 'default'] - the start animation type
     * @property {boolean} [enableTranslation = false] - whether to show translate button
     * @property {boolean} [activeTranslation = false] - whether translate button is active
     * @property {?Help4.control2.bubble.HEADER_LAYOUT} [headerLayout = null] - layout template for header
     * @property {?Help4.control2.bubble.CONTENT_LAYOUT} [contentLayout = null] - layout template for content
     * @property {?Help4.control2.bubble.FOOTER_LAYOUT} [footerLayout = null] - layout template for footer
     * @property {?Object} [parent = null] - parent bubble control
     * @property {Help4.control2.bubble.SIZES} [size] - bubble size
     * @property {boolean} [modal = false] - whether bubble is modal
     */

    /**
     * Base bubble control.
     * @abstract
     * @augments Help4.control2.Control
     * @property {boolean} showArrow - whether to show the arrow
     * @property {boolean} showAfterAlign - automatically set visible after align
     * @property {boolean} resizableAlign - allow content resize during align
     * @property {Help4.control2.bubble.ANIMATION_TYPES} animationType - the start animation type
     * @property {boolean} enableTranslation - whether to show translate button
     * @property {boolean} activeTranslation - whether translate button is active
     * @property {Help4.service.zoom.ZoomService} zoomService
     * @property {?Help4.control2.bubble.HEADER_LAYOUT} headerLayout - layout template for header
     * @property {?Help4.control2.bubble.CONTENT_LAYOUT} contentLayout - layout template for content
     * @property {?Help4.control2.bubble.FOOTER_LAYOUT} footerLayout - layout template for footer
     * @property {?Object} parent - parent bubble control
     * @property {Help4.control2.bubble.SIZES} size - bubble size
     * @property {boolean} modal - whether bubble is modal
     * @property {?Help4.control2.bubble.header.BubbleHeader} _header
     * @property {?Help4.control2.bubble.content.BubbleContent} _content
     * @property {?Help4.control2.bubble.footer.BubbleFooter} _footer
     * @property {?Help4.control2.bubble.BubbleCover} _cover
     * @property {?Help4.control2.bubble.BubbleArrow} _arrow
     * @property {?Help4.control2.bubble.Bubble} _child
     * @property {Function} _resizeObserver
     */
    Help4.control2.bubble.Bubble = class extends Help4.control2.Control {
        /**
         * @override
         * @param {Help4.control2.bubble.Bubble.Params} [params]
         * @param {Help4.jscore.ControlBase.Params} [derived]
         */
        constructor(params, derived) {
            const {ANIMATION_TYPES} = Help4.control2.bubble;
            params ||= {};
            params.animationType ||= ANIMATION_TYPES.default;

            const size = params.size || derived?.params?.size?.init || Help4.DEFAULTS.bubbleSize.d;
            let css = `control-bubble size-${size} ani-${params.animationType}`;
            if (params.enableTranslation) css += ' mltranslation';

            const resizeObserver = new ResizeObserver(() => this.resetAlignmentCache());

            const T = Help4.jscore.ControlBase.TYPES;
            super(params, {
                params: {
                    role:              {init: 'dialog'},
                    tabIndex:          {init: 0},
                    visible:           {init: false},  // will most often be shown after align to avoid jumping on screen

                    showArrow:         {type: T.boolean},
                    showAfterAlign:    {type: T.boolean, init: true, readonly: true},  // auto show after align
                    resizableAlign:    {type: T.boolean, readonly: true},  // resizable bubble-h during align
                    animationType:     {type: T.string, readonly: true},

                    // keep this information in sync with header, content, footer!
                    enableTranslation: {type: T.boolean},
                    activeTranslation: {type: T.boolean},

                    zoomService:       {type: T.instance, init: null, readonly: true},

                    headerLayout:      {type: T.string_null, readonly: true},
                    contentLayout:     {type: T.string_null, readonly: true},
                    footerLayout:      {type: T.string_null, readonly: true},

                    parent:            {type: T.instance, readonly: true},  // parent bubble

                    size:              {type: T.string, init: size, readonly: true},
                    modal:             {type: T.boolean, readonly: true},

                    autoFocus:         {init: true},
                    autoAlign:         {type: T.boolean, readonly: true}
                },
                statics: {
                    _header:         {},
                    _content:        {},
                    _footer:         {},
                    _cover:          {},
                    _arrow:          {},
                    _child:          {},
                    _resizeObserver: {init: resizeObserver, destroy: false}
                },
                config: {
                    css
                },
                derived
            });
        }

        /**
         * @param {Help4.service.zoom.ZoomService} zoomService
         * @param {Help4.control2.bubble.Bubble} bubble
         * @return {Promise<void>}
         */
        static async activateZoom(zoomService, bubble) {
            const {MediaWatcher} = Help4.jscore;
            const dom = bubble.getDom();
            const result = await MediaWatcher.observe(dom);  // XRAY-1180
            const images = /** @type {HTMLImageElement[]} */ result.filter(elem => elem.nodeName === 'IMG');
            zoomService.activate(images);
        }

        /**
         * @param {Help4.service.zoom.ZoomService} zoomService
         * @param {Help4.control2.bubble.Bubble} bubble
         * @return {Promise<void>}
         */
        static deactivateZoom(zoomService, bubble) {
            const images = /** @type {HTMLImageElement[]} */ [...bubble.getDom().getElementsByTagName('img')];
            zoomService.deactivate(images);
        }

        /** @override */
        _onBeforeDestroy() {
            const {_onWindowClick, _onScroll} = this;

            if (_onWindowClick) {
                Help4.Event.stopObserving(window, {
                    manual: true,
                    eventType: 'click',
                    callback: _onWindowClick
                });
                delete this._onWindowClick;
            }

            if (_onScroll) {
                Help4.Event.stopObserving(this.getDom(), {
                    manual: true,
                    eventType: 'scroll',
                    capture: true,
                    callback: _onScroll
                });
                delete this._onScroll;
            }

            this._resizeObserver.disconnect();

            this.resetAlignmentCache();
            _registerAtParent.call(this, null);

            const {zoomService, constructor} = this;
            zoomService && constructor.deactivateZoom(zoomService, this);
        }

        /**
         * @override
         * @param {HTMLElement} dom - control DOM
         */
        _onDomCreated(dom) {
            const onOutsideClick = data => {
                data.type = 'outsideClick';
                this._fireEvent(data);
            }

            const {BubbleCover, BubbleArrow} = Help4.control2.bubble;

            if (this.modal) {
                this._cover = this._createControl(BubbleCover, {bubble: this, visible: this.visible})
                .addListener('click', onOutsideClick);
            } else {
                Help4.Event.observe(window, {
                    manual: true,
                    eventType: 'click',
                    callback: this._onWindowClick = event => {
                        event = Help4.Event.normalize(event);
                        if (!this.visible || !event) return;

                        if (this.getDom().contains(event.target)) {
                            this._fireEvent({type: 'insideClick', data: event});
                        } else {
                            onOutsideClick({data: event});
                        }
                    }
                });
            }

            Help4.Event.observe(dom, {
                manual: true,
                eventType: 'scroll',
                capture: true,
                callback: this._onScroll = event => void this._fireEvent({type: 'scroll', data: event})
            });

            this._resizeObserver.observe(dom);

            this._arrow = this._createControl(BubbleArrow, {
                bubble: this,
                visible: this.showArrow
            });

            this._onCreateHeader();
            this._onCreateContent();
            this._onCreateFooter();

            dom.setAttribute('data-sap-ui-integration-popup-content', '');  // XRAY-2011
            _registerAtParent.call(this, this);
        }

        /**
         * @override
         */
        _onReady() {
            const {zoomService, constructor} = this;
            zoomService && constructor.activateZoom(zoomService, this);
        }

        /**
         * @param {Object} [derivedHeaderParams]
         * @protected
         */
        _onCreateHeader(derivedHeaderParams) {
            const {headerLayout, enableTranslation} = this;
            if (!headerLayout) return;

            const layoutClass = _getLayoutClass(headerLayout, Help4.control2.bubble.header) || _getLayoutClass(headerLayout, window);
            if (!layoutClass) return;

            this._header = this._createControl(layoutClass, {
                bubble: this,
                showTranslation: enableTranslation,
                ...derivedHeaderParams
            })
            .addListener(['click', 'mouseover', 'mouseout'], event => void this._fireEvent(event));  // XRAY-2615, XRAY-4628
        }

        /**
         * @param {Object} [derivedContentParams]
         * @protected
         */
        _onCreateContent(derivedContentParams) {
            const {contentLayout} = this;
            if (!contentLayout) return;

            const layoutClass = _getLayoutClass(contentLayout, Help4.control2.bubble.content) || _getLayoutClass(contentLayout, window);
            if (!layoutClass) return;

            this._content = this._createControl(layoutClass, {
                bubble: this,
                ...derivedContentParams
            })
            .addListener(['click', 'mouseover', 'mouseout'], event => void this._fireEvent(event))  // XRAY-2615, XRAY-4628
            .addListener('afterSetControlTexts', (event) => {
                // XRAY-5754: in case content is replaces by MLT translation we need to re-enable zoom functionality
                const {zoomService, constructor} = this;
                zoomService && constructor.activateZoom(zoomService, this);
            });
        }

        /**
         * @param {Object} [derivedFooterParams]
         * @protected
         */
        _onCreateFooter(derivedFooterParams) {
            const {footerLayout} = this;
            if (!footerLayout) return;

            const layoutClass = _getLayoutClass(footerLayout, Help4.control2.bubble.footer) || _getLayoutClass(footerLayout, window);
            if (!layoutClass) return;

            this._footer = this._createControl(layoutClass, {
                bubble: this,
                ...derivedFooterParams
            })
            .addListener(['mouseover', 'mouseout'], event => void this._fireEvent(event));  // XRAY-2615, XRAY-4628
        }

        /**
         * @override
         * @param {Help4.jscore.ControlBase.PropertyChangeEvent} event - the change event
         */
        _applyPropertyToDom({name, value, oldValue}) {
            const {_header} = this;
            switch (name) {
                case 'activeTranslation':
                    if (_header) _header[name] = value;
                    break;

                case 'enableTranslation':
                    if (_header) _header.showTranslation = value;
                    break;

                case 'showArrow':
                    const {_arrow} = this;
                    if (_arrow) _arrow.visible = value;
                    break;

                case 'visible':
                    const {_cover} = this;
                    if (this._child) {
                        // only show my bubble if I have no open child
                        if (_cover) _cover[name] = false;
                        super._applyPropertyToDom({name, value: false, oldValue: false});
                    } else {
                        if (_cover) _cover[name] = value;
                        super._applyPropertyToDom({name, value, oldValue});
                    }
                    break;

                default:
                    super._applyPropertyToDom({name, value, oldValue});
                    break;
            }
        }

        /**
         * @param {Help4.control2.bubble.Bubble} child
         * @returns {Help4.control2.bubble.Bubble}
         */
        setChild(child) {
            if (this._child !== child) {
                if (child == null || child instanceof Help4.control2.bubble.Bubble) {
                    this._child = child;
                    const value = this.visible;  // do not invoke getter twice
                    this._applyPropertyToDom({name: 'visible', value: value, oldValue: value});
                } else {
                    throw new Error('Child needs to be a Help4.control2.bubble.Bubble!');
                }
            }
            return this;
        }

        /**
         * @returns {boolean}
         */
        handleESC() {
            // forward ESC to child if available; otherwise close myself
            const {_child} = this;
            return _child
                ? _child.handleESC()
                : (void this._fireEvent({type: 'close'})) || true;
        }

        /**
         * @param {Help4.control2.ConnectionPoints} [connectionPoints]
         * @param {Help4.control2.bubble.AlignParams} [params]
         * @returns {?Help4.control2.bubble.Alignment}
         */
        align(connectionPoints, params) {
            connectionPoints ||= {};

            const {_content, _cover} = this;
            const {here, ignoreArea, orientation, allowFallbacks} = params || {};
            const contentDom = _content?.getDom();
            const scrollTop = contentDom ? contentDom.scrollTop : null;  // XRAY-1802

            let alignment = null;
            if (here) {
                // align exactly to a predefined area
                Help4.control2.bubble.alignToArea.call(this, here);
            } else {
                // align to one of a list of points
                alignment = Help4.control2.bubble.alignToPoints.call(this, {
                    connectionPoints,
                    ignoreArea: !!ignoreArea,
                    orientation,
                    allowFallbacks: allowFallbacks !== false
                });
            }

            if (scrollTop !== null) contentDom.scrollTop = scrollTop;  // XRAY-1802
            if (_cover) _cover.align();

            return alignment;
        }

        /**
         * @override
         * @returns {Help4.control2.bubble.Bubble}
         */
        focus() {
            if (this.mobile) return this;

            const {_child} = this;
            if (_child) return (void _child.focus()) || this;

            // prefer to focus content for screen readers
            // over just focussing the bubble
            const dom = this.getDom();
            Help4.Element.execAfterTransition(dom, () => !this.isDestroyed() && (this._content || dom).focus());

            return this;
        }

        /** @returns {Help4.control2.bubble.header.BubbleHeader} */
        getHeaderInstance() {
            return this._header;
        }

        /** @returns {Help4.control2.bubble.content.BubbleContent} */
        getContentInstance() {
            return this._content;
        }

        /** @returns {Help4.control2.bubble.footer.BubbleFooter} */
        getFooterInstance() {
            return this._footer;
        }
    }

    /**
     * @memberof Help4.control2.bubble.Bubble#
     * @param {string} layout
     * @param {Object} object
     * @returns {?Help4.control2.Control}
     * @private
     */
    function _getLayoutClass(layout, object) {
        const path = layout.split('.');

        let item;
        while (object && (item = path.shift())) {
            object = object[item];
        }

        return object;
    }

    /**
     * @memberof Help4.control2.bubble.Bubble#
     * @param {Help4.control2.bubble.Bubble} [bubble]
     * @private
     */
    function _registerAtParent(bubble) {
        const {parent} = this;
        if (!parent) return;

        if (parent instanceof Help4.control2.bubble.Bubble) {
            parent.setChild(bubble);
        } else if (typeof parent === 'string') {  // legacy
            const instance = Help4.control.Store.get(parent);
            instance?._setChild(bubble?.id || bubble);
        } else {
            throw new Error('Parent needs to be a Help4.control2.bubble.Bubble!');
        }
    }
})();