Source: jscore/ControlBase.js

(function() {
    /**
     * all param types currently available
     * @typedef {Object} Help4.jscore.ControlBase.Types
     * @property {'atomic'} atomic - atomic data type
     * @property {'array'} array - array data type
     * @property {'array_null'} array_null - array that can be null
     * @property {'boolean'} boolean - boolean data type
     * @property {'boolean_null'} boolean_null - boolean that can be null
     * @property {'dataContainer'} dataContainer - data container data type
     * @property {'string'} string - string
     * @property {'string_null'} string_null - string that can be null
     * @property {'number'} number - number data type
     * @property {'number_null'} number_null - number that can be null
     * @property {'element'} element - element data type
     * @property {'instance'} instance - any instance
     * @property {'instanceArray'} instanceArray - an array of instances
     * @property {'json'} json - json data type
     * @property {'json_null'} json_null - json that can be null
     * @property {'leftTop'} leftTop - {left: number, top: number}
     * @property {'leftTop_null'} leftTop_null - {left: number, top: number} | null
     * @property {'object'} object - any object
     * @property {'object_null'} object_null - objects that can be null
     * @property {'widthHeight'} widthHeight - {width: number, height: number}
     * @property {'widthHeight_null'} widthHeight_null - {width: number, height: number} | null
     * @property {'wh'} wh - {w: number, h: number}
     * @property {'wh_null'} wh_null - {w: number, h: number} | null
     * @property {'xy'} xy - {x: number, y: number}
     * @property {'xy_null'} xy_null - {x: number, y: number} | null
     * @property {'xywh'} xywh - {x: number, y: number, w: number, h: number}
     * @property {'xywh_null'} xywh_null - {x: number, y: number, w: number, h: number} | null
     */

    /**
     * all text types currently available
     * @typedef {Object} Help4.jscore.ControlBase.TextTypes
     * @property {'innerText'} innerText - node.innerText
     * @property {'innerHTML'} innerHTML - node.innerHTML
     * @property {'title'} title - node.title
     * @property {'placeholder'} placeholder - node.placeholder
     * @property {'alt'} alt - image.getAttribute('alt')
     */

    /**
     * the function to set defined properties
     * @typedef {Function} Help4.jscore.ControlBase.FunSet
     * @param {Object} data - the data source
     * @param {string} name - the attribute name
     * @param {*} value - the value to be stored in data[name]
     * @param {*} [def] - default value; internal use only
     * @returns {boolean|undefined} whether data has been modified
     */

    /**
     * the function to get defined properties
     * @typedef {Function} Help4.jscore.ControlBase.FunGet
     * @param {Object} data - the data source
     * @param {string} name - the attribute name
     * @returns {*} the value for data[name]
     */

    /**
     * the function to compare defined properties
     * @typedef {Function} Help4.jscore.ControlBase.FunEquals
     * @param {*} value1
     * @param {*} value2
     * @returns {boolean} whether both values are equal
     */

    /**
     * the function to set texts to the DOM
     * @typedef {Function} Help4.jscore.ControlBase.FunTextSet
     * @param {HTMLElement} element - the HTML element
     * @param {string} text - the to-be-applied text
     * @returns {boolean}
     */

    /**
     * the function to get texts from the DOM
     * @typedef {Function} Help4.jscore.ControlBase.FunTextGet
     * @param {HTMLElement} element - the HTML element
     * @returns {string} the text of that element
     */

    /**
     * the property change event
     * @typedef {Object} Help4.jscore.ControlBase.PropertyChangeEvent
     * @property {Help4.jscore.ControlBase|Help4.jscore.ControlBase[]} [target] - the control (chain) responsible for the value change
     * @property {string} name - the property name
     * @property {*} value - new value of the property
     * @property {*} [oldValue] - old value of the property
     */

    /**
     * the function that is called on property change
     * @typedef {Function} Help4.jscore.ControlBase.OnPropertyChange
     * @param {Help4.jscore.ControlBase.PropertyChangeEvent} event - the change event
     */

    /**
     * the configuration of one parameter:
     * - accessible like attributes w/o using getter and setter functions;
     * - internally and externally accessible via <instance>.<key>;
     * - will provide high performance auto-clone to prevent copy-by-reference issues;
     * - will provide on update notification in case a value changes
     * @typedef {Object} Help4.jscore.ControlBase.Param
     * @property {Help4.jscore.ControlBase.Types} type - the property type
     * @property {*} [init] - the init value
     * @property {boolean} [readonly = false] - whether the property is readonly
     * @property {boolean} [mandatory = false] - whether the property must be defined while class instantiation
     * @property {boolean} [private = false] - whether the property is meant to be publicly accessible
     */

    /**
     * additional class configuration
     * @typedef {Object} Help4.jscore.ControlBase.Config
     * @property {string} [css] - css classes of this control
     * @property {Help4.jscore.ControlBase.OnPropertyChange} [onPropertyChange] - sync listener for property change events
     */

    /**
     * the class configuration
     * @typedef {Help4.jscore.Base.Params} Help4.jscore.ControlBase.Params
     * @property {?Object} [params] - class parameters; each is of type {@link Help4.jscore.ControlBase.Param}
     * @property {Help4.jscore.ControlBase.Config} [config] - class configuration
     * @property {Object} [texts] - class text configuration: &lt;id&gt;: {@link Help4.jscore.ControlBase.TextTypes}
     * @property {Object} [dataFunctions] - can be used to override dataFunctions with custom functionality
     */

    /**
     * @typedef {Help4.jscore.Base.ClassConfig} Help4.jscore.ControlBase.ClassConfig
     * @property {?Object} [params] - dynamic class params; each is of type {@link Help4.jscore.ControlBase.Param}
     * @property {string[]} _param_keys - keys of all dynamic class params
     * @property {Function} _dataChangeListener - for simplified dynamic param change monitoring
     * @property {Object} _data
     */

    /**
     * UI controls base class
     * @augments Help4.jscore.Base
     * @abstract
     * @property {Help4.jscore.ControlBase.ClassConfig} ____classConfig - class configuration
     * @property {Help4.jscore.DataFunctions.Embedded} dataFunctions
     */
    Help4.jscore.ControlBase = class extends Help4.jscore.Base {
        /**
         * @override
         * @param {Object} [params]
         * @param {Help4.jscore.ControlBase.Params} [classConfig]
         * @param {boolean} [isDerived = false]
         */
        constructor(params, classConfig, isDerived = false) {
            super(classConfig, true);

            const {DATA_KEY, DATA_CHANGE_LISTENER_KEY} = this.constructor;
            classConfig = this.____getClassConfig();
            classConfig[DATA_KEY] = {};

            classConfig[DATA_CHANGE_LISTENER_KEY] = event => this._listeners?.dataChange?.async?.onEvent(event);

            if (!isDerived) {  // derived classes execute other code
                this.____resolveDerivedParams(classConfig);
                params = _initParams(params || {}, classConfig.config);

                this.____defineProperties(params);
                this.____defineStaticProperties();
                this.____defineDataFunctions();
            }
        }

        static CUSTOM_TYPE = 'custom';
        static PARAM_KEYS = '_param_keys';
        static DATA_KEY = '_data';
        static DATA_CHANGE_LISTENER_KEY = '_dataChangeListener';

        /**
         * @memberof Help4.jscore.ControlBase
         * @type {Help4.jscore.ControlBase.Types}
         */
        static TYPES = {};
        static SET = {};
        static GET = {};
        static EQUALS = {};

        /**
         * @memberof Help4.jscore.ControlBase
         * @type {Help4.jscore.ControlBase.TextTypes}
         */
        static TEXT_TYPES = {};
        static TEXT_CONTENT_TYPE = {};
        static TEXT_SET = {};
        static TEXT_GET = {};

        /**
         * register a new data type with setter, getter, equals
         * @param {string} name - the data type name; see {@link Help4.jscore.ControlBase.Types}
         * @param {Help4.jscore.ControlBase.FunSet} setter - setter function
         * @param {Help4.jscore.ControlBase.FunGet} getter - getter function
         * @param {Help4.jscore.ControlBase.FunEquals} equals - equals function
         */
        static addDataType(name, setter, getter, equals) {
            const {TYPES, SET, GET, EQUALS} = Help4.jscore.ControlBase;
            TYPES[name] = name;
            SET[name] = setter;
            GET[name] = getter;
            EQUALS[name] = equals;
        }

        /**
         * register a new text type with setter, getter
         * @param {string} name - text type
         * @param {string} contentType - content type for this text; see {@link Help4.jscore.ControlBase.TextTypes}
         * @param {Help4.jscore.ControlBase.FunTextSet} setter - setter function
         * @param {Help4.jscore.ControlBase.FunTextGet} getter - getter function
         */
        static addTextType(name, contentType, setter, getter) {
            const {TEXT_TYPES, TEXT_CONTENT_TYPE, TEXT_SET, TEXT_GET} = Help4.jscore.ControlBase;
            TEXT_TYPES[name] = name;
            TEXT_CONTENT_TYPE[name] = contentType;
            TEXT_SET[name] = setter;
            TEXT_GET[name] = getter;
        }

        /** destroys this instance */
        destroy() {
            this.dataFunctions?.onChange?.destroy();
            delete this.dataFunctions;

            const classConfig = this.____getClassConfig();
            for (const [key, info] of Object.entries(classConfig?.params || {})) {
                const accessKey = info.private ? '__' + key : key;
                delete this[accessKey];
            }

            super.destroy();
        }

        /**
         * @override
         * @param {string|string[]} eventType - event type to be observed
         * @param {Help4.EmbeddedEvent.Listener} listener - the callback function
         * @returns {Help4.jscore.ControlBase}
         */
        addListener(eventType, listener) {
            if (Help4.isArray(eventType)) {
                eventType.forEach(type => this.addListener(type, listener));
            } else {
                if (typeof listener === 'function' && eventType === 'dataChange') {
                    // "dataChange" listeners are shortcuts
                    // original: this.dataFunctions.onChange.addListener(listener)
                    // shortcut: this.addListener('dataChange', listener)
                    const listeners = this._listeners[eventType] ||= {};
                    const embeddedEvent = listeners.async ||= new Help4.EmbeddedEvent();

                    const nbr = embeddedEvent
                    .addListener(listener)
                    .countListeners();

                    if (nbr === 1) {
                        const {DATA_CHANGE_LISTENER_KEY} = this.constructor;
                        const classConfig = this.____getClassConfig();
                        this.dataFunctions.onChange.addListener(classConfig[DATA_CHANGE_LISTENER_KEY]);
                    }
                } else {
                    super.addListener(eventType, listener);
                }
            }
            return this;
        }

        /**
         * @override
         * @param {string|string[]} eventType
         * @param {Help4.EmbeddedEvent.Listener} listener
         * @returns {Help4.jscore.ControlBase}
         */
        removeListener(eventType, listener) {
            if (Help4.isArray(eventType)) {
                eventType.forEach(type => this.removeListener(type, listener))
            } else {
                if (typeof listener === 'function' && eventType === 'dataChange') {
                    // "dataChange" listeners are shortcuts
                    // original: this.dataFunctions.onChange.addListener(listener)
                    // shortcut: this.addListener('dataChange', listener)
                    const {_listeners} = this;
                    const embeddedEvent = _listeners[eventType]?.async;

                    const nbr = embeddedEvent
                    ?.removeListener(listener)
                    ?.countListeners();

                    if (nbr === 0) {
                        const {DATA_CHANGE_LISTENER_KEY} = this.constructor;
                        const classConfig = this.____getClassConfig();
                        this.dataFunctions.onChange.removeListener(classConfig[DATA_CHANGE_LISTENER_KEY]);

                        embeddedEvent.destroy();
                        delete _listeners[eventType];
                    }
                } else {
                    super.removeListener(eventType, listener);
                }
            }
            return this;
        }

        /**
         * @returns {Object}
         * @protected
         */
        ____getData() {
            return this.____getClassConfig()[this.constructor.DATA_KEY];
        }

        /**
         * @param {Object} dest
         * @param {Object} src
         * @returns {Object}
         * @protected
         */
        ____extendParams(dest, src) {
            for (const [key, value] of Object.entries(src)) {
                if (value.type && !value.allowTypeOverride && dest[key]?.type) {
                    throw new Error(`Type override not allowed for "${key}" without "allowTypeOverride"!`);
                }

                dest[key] = Help4.extendObject(dest[key] || {}, value);
            }
            return dest;
        }

        /**
         * @override
         * @param {Help4.jscore.ControlBase.Params} level0
         * @param {string} type
         * @param {*} value
         */
        ____mergeDerivedParam(level0, type, value) {
            const extendConfig = (dest, src) => {
                for (const [key, value] of Object.entries(src)) {
                    if (key === 'css' && dest[key]) {
                        dest[key] += ' ' + value;
                    } else {
                        dest[key] = value;
                    }
                }
                return dest;
            }

            switch (type) {
                case 'params':
                    level0[type] = this.____extendParams(level0[type] || {}, value);
                    break;
                case 'config':
                    level0[type] = extendConfig(level0[type] || {}, value);
                    break;
                case 'texts':
                case 'dataFunctions':
                    level0[type] = Help4.extendObject(level0[type] || {}, value);
                    break;
                default:
                    super.____mergeDerivedParam(level0, type, value);
                    break;
            }
        }

        /**
         * @param {Object} params
         * @param {?Object} [derived = null]
         * @param {Object} derived.GET
         * @param {Object} derived.SET
         * @protected
         */
        ____defineProperties(params, derived = null) {
            const {PARAM_KEYS, CUSTOM_TYPE} = this.constructor;

            const classConfig = /** @type {Help4.jscore.ControlBase.ClassConfig} */ this.____getClassConfig();
            const paramKeys = classConfig[PARAM_KEYS] = Object.keys(classConfig.params || {});
            const {GET, SET} =  derived || this.constructor;

            for (const key of paramKeys) {
                const info = classConfig.params[key];
                const {type, get, set} = info;
                if (!type) throw new Error(`Property "${key}" need to define a type!`);

                const funGet = type === CUSTOM_TYPE ? get : GET[type];
                const funSet = type === CUSTOM_TYPE ? set : SET[type];

                if (funGet && funSet) {
                    this.____defineProperty({key, info, funGet, funSet});
                } else {
                    throw new Error(`Invalid type "${type}" for property or missing getter or setter!`);
                }

                this.____setInitValue({key, info, params, funSet});
            }
        }

        /**
         * @param {Object} args
         * @param {string} [args.type = 'param'] - for Help4.jscore.DataBase
         * @param {string} args.key
         * @param {Help4.jscore.ControlBase.Param} args.info
         * @param {Help4.jscore.ControlBase.FunGet} args.funGet
         * @param {Help4.jscore.ControlBase.FunSet} args.funSet
         * @protected
         */
        ____defineProperty({type = 'param', key, info, funGet, funSet}) {
            const accessKey = type === 'param' && info.private ? `__${key}` : key;

            Object.defineProperty(this, accessKey, {
                get: function() {
                    const data = this.____getData();
                    return funGet.call(this, data, key);
                },

                set: function(value) {
                    if (this.isDestroyed()) return;

                    if (info.readonly) throw new Error(`${key} is readonly`);

                    const data = this.____getData();
                    const oldValue = data[key];
                    const modified = !!funSet.call(this, data, key, value);

                    if (modified) {
                        /** @type {Help4.jscore.ControlBase.PropertyChangeEvent} */
                        const event = {
                            target: this,
                            name: key,
                            // do not use value here, as funSet could have changed it, e.g. due to type conversion
                            // do not use data[name] here, as this would allow access to the reference
                            value: funGet.call(this, data, key),
                            oldValue: oldValue
                        };

                        this.____getClassConfig().config?.onPropertyChange?.(event);  // sync!
                        this.dataFunctions.onChange.onEvent(event);  // async!
                    }
                },

                configurable: true,
                enumerable: true
            });
        }

        /**
         * @param {Object} args
         * @param {string} args.key
         * @param {Help4.jscore.ControlBase.Param} args.info
         * @param {Object} args.params
         * @param {Help4.jscore.ControlBase.FunSet} args.funSet
         * @protected
         */
        ____setInitValue({key, info, params, funSet}) {
            const data = this.____getData();

            if (!data.hasOwnProperty(key)) {
                data[key] = info.init ?? null;
            }

            if (params[key] !== undefined) {
                funSet.call(this, data, key, params[key]);
            } else if (info.mandatory) {
                throw new Error(`Parameter "${key}" is mandatory!`);
            }

            funSet.call(this, data, key, data[key]);  // make sure that defaults are set
        }

        /** @protected */
        ____defineDataFunctions() {
            const {DataFunctions} = Help4.jscore;
            const {dataFunctions = {}} = this.____getClassConfig();

            this.dataFunctions = {
                toObject: () => {
                    return dataFunctions.toObject
                        ? dataFunctions.toObject(DataFunctions.toObject.bind(this))
                        : DataFunctions.toObject.call(this);
                },
                equals: (instance) => {
                    return dataFunctions.equals
                        ? dataFunctions.equals(instance, DataFunctions.equals.bind(this))
                        : DataFunctions.equals.call(this, instance);
                },
                get: (...keys) => {
                    return dataFunctions.get
                        ? dataFunctions.get(keys, DataFunctions.get.bind(this))
                        : DataFunctions.get.call(this, keys);
                },
                set: (json, params = {}) => {
                    return dataFunctions.set
                        ? dataFunctions.set(json, params, DataFunctions.set.bind(this))
                        : DataFunctions.set.call(this, json, params);
                },
                clone: () => {
                    return dataFunctions.clone
                        ? dataFunctions.clone(DataFunctions.clone.bind(this))
                        : DataFunctions.clone.call(this);
                },
                getKeys: () => {
                    return dataFunctions.getKeys
                        ? dataFunctions.getKeys(DataFunctions.getKeys.bind(this))
                        : DataFunctions.getKeys.call(this);
                },
                getConfig: (name) => {
                    return dataFunctions.getConfig
                        ? dataFunctions.getConfig(name, DataFunctions.getConfig.bind(this))
                        : DataFunctions.getConfig.call(this, name);
                },
                forEach: (executor) => {
                    return dataFunctions.forEach
                        ? dataFunctions.forEach(executor, DataFunctions.forEach.bind(this))
                        : DataFunctions.forEach.call(this, executor);
                },
                every: (executor) => {
                    return dataFunctions.every
                        ? dataFunctions.every(executor, DataFunctions.every.bind(this))
                        : DataFunctions.every.call(this, executor);
                },
                merge: (instance, mergeFn) => {
                    return dataFunctions.merge
                        ? dataFunctions.merge(instance, mergeFn, DataFunctions.merge.bind(this))
                        : DataFunctions.merge.call(this, instance, mergeFn);
                },
                getTexts: () => {
                    return DataFunctions.getTexts.call(this);
                },
                onChange: dataFunctions.onChange || new Help4.EmbeddedEvent()
            };
        }
    }

    /**
     * @memberof Help4.jscore.ControlBase#
     * @param {Object} params
     * @param {Help4.jscore.ControlBase.Config} config
     * @returns {Object}
     * @private
     */
    function _initParams(params, {css}) {
        if (css) {
            params.css = params.css
                ? params.css + ' ' + css
                : css;
        }

        // remove duplicates
        params.css &&= [...new Set(params.css.split(/\s+/))].join(' ');

        return params;
    }
})();