Source: control2/Select.js

(function() {
    /**
     * @typedef {Object} Help4.control2.Select.Data
     * @property {string} id - id
     * @property {string} text - text
     * @property {string} [icon] - icon -> not yet supported
     * @property {string} [css] - CSS class
     */

    /**
     * @typedef {Help4.control2.Control.Params} Help4.control2.Select.Params
     * @property {Array<Help4.control2.Select.Data>} - [data = []] - data that can be selected
     * @property {?string} - value - selected value
     * @property {?string} [placeholder = ''] - placeholder text
     * @property {?syncOpen} [syncOpen = true] - flag to sync open
     */

    /**
     * A control to offer selection to users
     * @augments Help4.control2.Control
     * @property {Help4.control2.Select.Data[]} data - data that can be selected
     * @property {?string} value - selected value
     * @property {?string} placeholder - placeholder text
     * @property {?boolean} syncOpen - flag to sync open
     * @property {?Help4.control2.bubble.Bubble} _bubble - bubble control
     * @property {?Help4.control2.button.Button} _button
     * @property {Object} _outsideClick - outside click event handler
     */
    Help4.control2.Select = class extends Help4.control2.Control {
        /**
         * @override
         * @param {Help4.control2.Select.Params} [params]
         * @param {Help4.jscore.ControlBase.Params} [derived]
         */
        constructor(params, derived) {
            const {jscore: {ControlBase}, Localization} = Help4;
            const T = ControlBase.TYPES;
            const TT = ControlBase.TEXT_TYPES;

            super(params, {
                params: {
                    data:        {type: T.array, mandatory: true},
                    value:       {type: T.string_null, init: null},
                    placeholder: {type: T.string, init: Localization.getText('placeholder.select'), readonly: true},
                    syncOpen:    {type: T.boolean, init: true, readonly: true},
                },
                config: {
                    css: 'control-select'
                },
                statics: {
                    _bubble: {},
                    _button: {},
                    _outsideClick: {destroy: false}
                },
                texts: {
                    text: TT.innerText
                },
                derived
            });
        }

        /**
         * @override
         * @returns {Help4.control2.Select}
         */
        focus() {
            this.getDom('-field').focus();
            return this;
        }

        /** @override */
        _onBeforeDestroy() {
            const dom = this.getDom('-val');
            if (dom) dom.onclick = null;

            if (this._outsideClick) {
                _observeOutsideClick.call(this, false);
                this._outsideClick = null;
            }

            super._onBeforeDestroy();
        }

        /**
         * @override
         * @param {HTMLElement} dom - control DOM
         */
        _onDomCreated(dom) {
            super._onDomCreated(dom);
            const {control2: {button: {APPEARANCES, Button}}} = Help4;
            const {ariaLabel, placeholder, id, syncOpen, value} = this;

            const open = () => {
                if (this._bubble) {
                    _closeBubble.call(this);
                } else {
                    this._fireEvent({type: 'beforeopen'});
                    if (syncOpen) _openBubble.call(this);
                }
            }

            const labelClick = (event) => {
                // triggers outside click event closing the list. so stop it from bubbling up
                event.stopPropagation();
                this._fireEvent({type: 'labelclick'});
                open();
            }

            const onHotkey = (event) => { // toggle hotkeys on enter or space/or
                const key = Help4.service.HotkeyService.getKey(event);
                key === 'enter' && labelClick(event);
            }

            const wrapper = this._createElement('div', {
                id: '-field',
                css: 'field',
                tabIndex: 0,  // only the wrapper is focusable
                role: 'combobox',
                ariaExpanded: false,
                ariaHaspopup: 'listbox',
                onclick: labelClick,
                ariaLabel
            });
            wrapper.onkeydown = onHotkey;

            this._createElement('span', {
                id: '-val',
                css: 'select inner',
                dom: wrapper,
                text: value || placeholder,
                tabIndex: -1
            });

            this._button = /** @type {Help4.control2.button.Button} */ this._createControl(Button, {
                id: `${id}-btn`,
                dom: wrapper,
                appearance: APPEARANCES.input,
                icon: 'down',
                css: 'icon input',
                tabIndex: -1
            });
            // wrapper click is handling the click for button
        }

        /**
         * @override
         * @param {Help4.jscore.ControlBase.PropertyChangeEvent} event - the change event
         */
        _applyPropertyToDom({name, value, oldValue}) {
            if (name !== 'value') {
                return super._applyPropertyToDom({name, value, oldValue});
            }

            const {Element} = Help4;
            const {data, placeholder} = this;
            const text = data.find(({id}) => id === value)?.text;
            const inner = this.getDom('-val');

            text
                ? Element.removeClass(inner, 'placeholder')
                : Element.addClass(inner, 'placeholder');

            inner.innerHTML = text || placeholder;
        }
    }

    /**
     * @memberof Help4.control2.Select#
     * @private
     */
    function _closeBubble() {
        if (!this._bubble) return;

        _observeOutsideClick.call(this, false);

        const {Element} = Help4;

        this._button.active = false;
        this._destroyControl('_bubble');
        Element.removeClass(this.getDom(), 'open');
        Element.setAttribute(this.getDom('-field'), {ariaExpanded: false});
        this.focus();
    }

    /**
     * @memberof Help4.control2.Select#
     * @returns {Object[]}
     * @private
     */
    function _getBubbleData() {
        const {data, id, value} = this;
        const bubbleId = `${id}-bub-`;
        const bubbleData = [];
        for (const [index, item] of data.entries()) {
            const {id, css} = item;
            const iCss = css ? css + ' ' : '';
            bubbleData.push(Help4.extendObject({
                _metadata: {id},
                controlType: 'button.Button',
                id: bubbleId + index,
                css: iCss + 'option',
                role: 'option'
            }, item, false));
        }
        return bubbleData;
    }

    /**
     * @memberof Help4.control2.Select#
     * @private
     */
    function _openBubble() {
        if (this._bubble) return;

        const {id, value, rtl, mobile, language, _button} = this;
        const data = _getBubbleData.call(this);
        const controller = Help4.getController();
        const bubble = this._bubble = controller.getService('bubble').add({
            id: `${id}-bub`,
            css: 'select',
            controlType: 'bubble.Container',
            containerType: 'container.Select',
            dom: controller.getDom(),
            size: 'xs',
            appearance: 'select',
            data,
            value: value,
            visible: true,
            tabIndex: -1,
            rtl,
            mobile,
            autoFocus: true,
            language,
            onselect: _onBubbleClick.bind(this)
        }, 'control');

        const dom = this.getDom();

        const align = () => {  // XRAY-1670: XRAY-3520
            if (!this.isDestroyed() && bubble && !bubble.isDestroyed()) {
                bubble.applySelectMaxHeight(dom.getBoundingClientRect());
                setTimeout(align, 100);
            }
        };
        align();

        const {Element} = Help4;
        Element.addClass('open');
        Element.setAttribute(this.getDom('-field'), {ariaExpanded: true});
        _button.active = true;

        _observeOutsideClick.call(this, true);
    }

    /**
     * @memberof Help4.control2.Select#
     * @param {string} eventType
     * @param {Help4.control.container.Container} bubble
     * @param {string} id
     * @private
     */
    function _onBubbleClick(eventType, bubble, id) {
        const value = bubble.get(id).getMetadata('id');
        this.value = value;
        this._fireEvent({type: 'change', value});
        // closes itself via outside click, while select is control2 but bubble is control
    }

    /**
     * @memberof Help4.control2.Select#
     * @param {boolean} observe
     * @private
     */
    function _observeOutsideClick(observe) {
        const {Event} = Help4;
        if (observe) {
            Event.observe(window, {
                manual: true,
                eventType: 'click',
                callback: this._outsideClick = event => {
                    if (!(event = Event.normalize(event))) return;
                    if (!this.getDom().contains(event.target)) _closeBubble.call(this);
                }
            });
        } else {
            const {_outsideClick} = this;
            if (_outsideClick) {
                Event.stopObserving(window, {
                    manual: true,
                    eventType: 'click',
                    callback: _outsideClick
                });
                delete this._outsideClick;
            }
        }
    }
})();