Source: ui/Element.js

// (c) Andrew Wei

'use strict';

import ElementUpdateDelegate from './ElementUpdateDelegate';
import dom from '../dom';
import Directive from '../enums/Directive';
import DirtyType from '../enums/DirtyType';
import EventType from '../enums/EventType';
import NodeState from '../enums/NodeState';
import EventQueue from '../events/EventQueue';
import assert from '../helpers/assert';
import assertType from '../helpers/assertType';
import defineProperty from '../helpers/defineProperty';
import getDirectCustomChildren from '../helpers/getDirectCustomChildren';
import hasOwnValue from '../helpers/hasOwnValue';
import noval from '../helpers/noval';
import polyfillHTMLElements from '../polyfills/polyfillHTMLElements';
import getRect from '../utils/getRect';

polyfillHTMLElements();

/**
 * @class
 *
 * Abstract class of Node/classes that inherited from Node. Note that this class
 * is an abstract class and must be 'mixed' into an real class.
 *
 * @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes}
 *
 * @alias module:requiem~ui.Element
 */
const Element = (base, tag) => (class extends (typeof base !== 'string' && base || HTMLElement) {
  /**
   * Gets the tag name of this Element instance. This method is meant to be
   * overridden by sub-classes because this class merely provides the foundation
   * functionality of a custom element, hence this class does not register
   * directly with the element registry. This tag name is used by
   * `document.registerElement()`.
   *
   * @return {string} The tag name.
   * 
   * @alias module:requiem~ui.Element.tag
   */
  static get tag() { return (typeof base === 'string') && base || tag || undefined; }

  /**
   * Gets the existing native element which this custom element extends. This
   * value is used in the `options` for `document.registerElement()`.
   *
   * @return {string} The tag of the native element.
   * 
   * @alias module:requiem~ui.Element.extends
   */
  static get extends() { return null; }

  /**
   * Creates a new DOM element from this Element class.
   *
   * @return {Node}
   * 
   * @alias module:requiem~ui.Element.factory
   */
  static factory() { return new (dom.register(this))(); }

  /**
   * Instance name of this Element instance. Once set, it cannot be changed.
   *
   * @type {string}
   * 
   * @alias module:requiem~ui.Element#name
   */
  get name() {
    let s = this.getAttribute('name');
    if (!s || s === '') return null;
    return s;
  }
  set name(val) {
    // Once set, name cannot change.
    if (!this.name)
    super.setAttribute('name', val);
  }

  /**
   * State of this Element instance (depicted by Directive.State).
   *
   * @type {string}
   * 
   * @alias module:requiem~ui.Element#state
   */
  get state() {
    let s = this.getAttribute(Directive.STATE);
    if (!s || s === '') return null;
    return s;
  }
  set state(val) {
    if (this.state === val) return;

    let oldValue = this.state;

    if (val === null || val === undefined)
      this.removeAttribute(Directive.STATE);
    else
      this.setAttribute(Directive.STATE, val);

    this.updateDelegate.setDirty(DirtyType.STATE);

    let event = new CustomEvent(EventType.OBJECT.STATE, {
      detail: {
        property: 'state',
        oldValue: oldValue,
        newValue: val
      }
    });

    this.dispatchEvent(event);
  }

  /**
   * Rect of this Element instance.
   *
   * @type {Object}
   * 
   * @alias module:requiem~ui.Element#rect
   */
  get rect() { return getRect(this); }

  /**
   * Opacity of this Element instance.
   *
   * @type {number}
   * 
   * @alias module:requiem~ui.Element#opacity
   */
  get opacity() { return this.getStyle('opacity', true); }
  set opacity(val) { this.setStyle('opacity', val); }

  /** 
   * @inheritdoc 
   * @ignore
   */
  createdCallback() {
    // Define instance properties.
    this.__defineProperties__();

    // Check if this Element needs seed data from the data registry.
    this.setData(dom.getDataRegistry(this.getAttribute(Directive.REF)));

    // Scan for internal DOM element attributes prefixed with Directive.DATA
    // and generate data properties from them.
    let attributes = this.attributes;
    let nAtributes = attributes.length;
    let regex = new RegExp('^' + Directive.DATA, 'i');

    for (let i = 0; i < nAtributes; i++) {
      let attribute = attributes[i];

      if (hasOwnValue(Directive, attribute.name) || !regex.test(attribute.name)) continue;

      // Generate camel case property name from the attribute.
      let propertyName = attribute.name.replace(regex, '').replace(/-([a-z])/g, (g) => (g[1].toUpperCase()));
      this.setData(propertyName, this.getAttribute(attribute.name), true);
    }

    // Make element invisible until its first update.
    this.setStyle('visibility', 'hidden');
  }

  /** 
   * @inheritdoc 
   * @ignore
   */
  attachedCallback() {
    this.render();
    this.__setNodeState__(NodeState.INITIALIZED);
    this.updateDelegate.init();
  }

  /** 
   * @inheritdoc 
   * @ignore
   */
  detachedCallback() {
    this.destroy();
    this.removeAllEventListeners();
    this.updateDelegate.destroy();
    this.__setNodeState__(NodeState.DESTROYED);
  }

  /**
   * Method invoked every time after this element is rendered.
   * 
   * @alias module:requiem~ui.Element#init
   */
  init() {
    // Needs to be overridden.
  }

  /**
   * Method invoked every time before this element is rerendered.
   * 
   * @alias module:requiem~ui.Element#destroy
   */
  destroy() {
    if (this.__private__.eventQueue)
      this.__private__.eventQueue.kill();
  }

  /**
   * Handler invoked whenever a visual update is required.
   * 
   * @alias module:requiem~ui.Element#update
   */
  update() {
    if (this.nodeState > NodeState.UPDATED) return;

    if (this.isDirty(DirtyType.RENDER) && this.nodeState === NodeState.UPDATED) this.render();

    if (this.nodeState < NodeState.UPDATED) {
      this.__setNodeState__(NodeState.UPDATED);
      this.invisible = (this.invisible === undefined) ? false : this.invisible;
    }
  }

  /**
   * Renders the template of this element instance.
   * 
   * @alias module:requiem~ui.Element#render
   */
  render() {
    if (this.nodeState === NodeState.UPDATED)
      this.destroy();

    let d = {
      data: this.data,
      state: this.state,
      name: this.name
    };

    let t = this.template(d);
    if (typeof t === 'string') t = dom.createElement(t);

    assert(!t || (t instanceof Node), `Element generated from template() must be a Node instance`);

    if (t) {
      if (t instanceof HTMLTemplateElement) {
        t = document.importNode(t.content, true);

        // TODO: Add support for shadow DOM in the future when it's easier to style.
        if (false) {
          try {
            if (!this.shadowRoot) this.createShadowRoot();
            while (this.shadowRoot.lastChild) this.shadowRoot.removeChild(this.shadowRoot.lastChild);
            this.shadowRoot.appendChild(t);
          }
          catch (err) {}
        }
        else {
          while (this.lastChild) this.removeChild(this.lastChild);
          this.appendChild(t);
        }
      }
      else {
        let n = t.childNodes.length;

        while (this.lastChild) this.removeChild(this.lastChild);

        for (let i = 0; i < n; i++) {
          let node = document.importNode(t.childNodes[i], true);
          this.appendChild(node);
        }
      }
    }

    dom.sightread(this);

    let customChildren = getDirectCustomChildren(this, true);

    if (this.__private__.eventQueue) {
      this.__private__.eventQueue.removeAllEventListeners();
      this.__private__.eventQueue.kill();
    }

    this.__private__.eventQueue = new EventQueue();

    customChildren.forEach((child) => {
      if ((child.nodeState === undefined) || (child.nodeState < NodeState.INITIALIZED))
        this.__private__.eventQueue.enqueue(child, EventType.NODE.INITIALIZE);
    });

    this.__private__.eventQueue.addEventListener(EventType.OBJECT.COMPLETE, this.init.bind(this));
    this.__private__.eventQueue.start();
  }

  /** 
   * @see module:requiem~ui.ElementUpdateDelegate#initResponsiveness 
   * @alias module:requiem~ui.Element#respondsTo
   */
  respondsTo() { this.updateDelegate.initResponsiveness.apply(this.updateDelegate, arguments); }

  /** 
   * @see module:requiem~dom.addChild 
   * @alias module:requiem~ui.Element#addChild
   */
  addChild(child, name, prepend) { return dom.addChild(this, child, name, prepend); }

  /** 
   * @see module:requiem~dom.removeChild 
   * @alias module:requiem~ui.Element#removeChild
   */
  removeChild(child) {
    if ((child instanceof Node) && (child.parentNode === this)) {
      dom.removeFromChildRegistry(dom.getChildRegistry(this), child);
      return super.removeChild(child);
    }
    else {
      return dom.removeChild(this, child);
    }
  }

  /** 
   * @see module:requiem~dom.getChild 
   * @alias module:requiem~ui.Element#getChild
   */
  getChild(name, recursive) { return dom.getChild(this, name, recursive); }

  /** 
   * @see module:requiem~dom.hasChild 
   * @alias module:requiem~ui.Element#hasChild
   */
  hasChild(child) { return dom.hasChild(child, this); }

  /** 
   * @see module:requiem~dom.addClass 
   * @alias module:requiem~ui.Element#addClass
   */
  addClass(className) { return dom.addClass(this, className); }

  /** 
   * @see module:requiem~dom.removeClass 
   * @alias module:requiem~ui.Element#removeClass
   */
  removeClass(className) { return dom.removeClass(this, className); }

  /** 
   * @see module:requiem~dom.hasClass 
   * @alias module:requiem~ui.Element#hasClass
   */
  hasClass(className) { return dom.hasClass(this, className); }

  /** 
   * @inheritdoc 
   * @ignore
   */
  getAttribute(name) {
    let value = super.getAttribute(name);
    if (value === '') return true;
    if (value === undefined || value === null) return null;
    try {
      return JSON.parse(value);
    }
    catch (err) {
      return value;
    }
  }

  /** 
   * @inheritdoc 
   * @ignore
   */
  setAttribute(name, value) {
    switch (name) {
      case 'name':
        this.name = value;
        break;
      default:
        if (value === undefined || value === null || value === false)
          this.removeAttribute(name);
        else if (value === true)
          super.setAttribute(name, '');
        else
          super.setAttribute(name, value);
        if (name === 'disabled')
          this.setDirty(DirtyType.STATE);
    }
  }

  /** 
   * @see module:requiem~dom.hasAttribute 
   * @alias module:requiem~ui.Element#hasAttribute
   */
  hasAttribute(name) { return dom.hasAttribute(this, name); }

  /** 
   * @see module:requiem~dom.getStyle 
   * @alias module:requiem~ui.Element#getStyle
   */
  getStyle(key, isComputed, isolateUnits) { return dom.getStyle(this, key, isComputed, isolateUnits); }

  /** 
   * @see module:requiem~dom.setStyle 
   * @alias module:requiem~ui.Element#setStyle
   */
  setStyle(key, value) { return dom.setStyle(this, key, value); }

  /** 
   * @see module:requiem~dom.hasStyle 
   * @alias module:requiem~ui.Element#hasStyle
   */
  hasStyle(key) { return dom.hasStyle(this, key); }

  /** 
   * @inheritdoc 
   * @ignore
   */
  addEventListener() {
    let event = arguments[0];
    let listener = arguments[1];
    let useCapture = arguments[2] || false;

    if (!this.__private__.listenerRegistry[event]) {
      this.__private__.listenerRegistry[event] = [];
    }

    let m = this.__private__.listenerRegistry[event];
    let n = m.length;
    let b = true;

    if (event === EventType.MOUSE.CLICK_OUTSIDE) {
      let l = listener;
      listener = function(event) {
        if ((event.target !== this) && !this.hasChild(event.target)) {
          l(event);
        }
      }.bind(this);
    }

    for (let i = 0; i < n; i++) {
      let e = m[i];

      if (e.listener === listener) {
        b = false;
        break;
      }
    }

    if (b) {
      m.push({
        listener: listener,
        useCapture: useCapture
      });
    }

    if (event === EventType.MOUSE.CLICK_OUTSIDE) {
      window.addEventListener(EventType.MOUSE.CLICK, listener, useCapture);
    }
    else {
      super.addEventListener.apply(this, arguments);
    }
  }

  /** 
   * @see module:requiem~ui.Element#addEventListener 
   * @alias module:requiem~ui.Element#on
   */
  on() { this.addEventListener.apply(this, arguments); }

  /**
   * Determines if a particular listener (or any listener in the specified
   * event) exist in this Element instance.
   *
   * @param {string} event - Event name.
   * @param {Function} listener - Listener function.
   *
   * @return {boolean}
   * 
   * @alias module:requiem~ui.Element#hasEventListener
   */
  hasEventListener(event, listener) {
    if (!this.__private__.listenerRegistry) return false;
    if (!this.__private__.listenerRegistry[event]) return false;

    if (listener) {
      let m = this.__private__.listenerRegistry[event];
      let n = m.length;

      for (let i = 0; i < n; i++) {
        let e = m[i];

        if (e.listener === listener) return true;
      }

      return false;
    }
    else {
      return true;
    }
  }

  /** 
   * @inheritdoc 
   * @ignore
   */
  removeEventListener() {
    let event = arguments[0];
    let listener = arguments[1];
    let useCapture = arguments[2] || false;

    if (this.__private__.listenerRegistry && this.__private__.listenerRegistry[event]) {
      let m = this.__private__.listenerRegistry[event];
      let n = m.length;
      let s = -1;

      if (listener) {
        for (let i = 0; i < n; i++) {
          let e = m[i];

          if (e.listener === listener) {
            s = i;
            break;
          }
        }

        if (s > -1) {
          m.splice(s, 1);

          if (m.length === 0) {
            this.__private__.listenerRegistry[event] = null;
            delete this.__private__.listenerRegistry[event];
          }
        }
      }
      else {
        while (this.__private__.listenerRegistry[event] !== undefined) {
          this.removeEventListener(event, this.__private__.listenerRegistry[event][0].listener, this.__private__.listenerRegistry[event][0].useCapture);
        }
      }
    }

    if (listener) {
      if (window && event === EventType.MOUSE.CLICK_OUTSIDE) {
        window.removeEventListener(EventType.MOUSE.CLICK, listener, useCapture);
      }
      else {
        super.removeEventListener.apply(this, arguments);
      }
    }
  }

  /** 
   * @see module:requiem~ui.Element#removeEventListener 
   * @alias module:requiem~ui.Element#off
   */
  off() { this.removeEventListener.apply(this, arguments); }

  /**
   * Removes all cached event listeners from this Element instance.
   * 
   * @alias module:requiem~ui.Element#removeAllEventListeners
   */
  removeAllEventListeners() {
    if (this.__private__.listenerRegistry) {
      for (let event in this.__private__.listenerRegistry) {
        this.removeEventListener(event);
      }
    }
  }

  /**
   * Gets the value of the data property with the specified name.
   *
   * @param {string} key - Name of the data property.
   *
   * @return {*} Value of the data property.
   * 
   * @alias module:requiem~ui.Element#getData
   */
  getData(key) {
    return this.data[key];
  }

  /**
   * Checks to see if this Element instance has the data property of the
   * specified name.
   *
   * @param {string} key - Name of the data property.
   *
   * @return {boolean} True if data property exists, false othwerwise.
   * 
   * @alias module:requiem~ui.Element#hasData
   */
  hasData(key) {
    return this.data.hasOwnProperty(key);
  }

  /**
   * Defines multiple data properties if the first argument is an object literal
   * (hence using its key/value pairs) or sets a single data property of the
   * specified name with the specified value. If the data property does not
   * exist, it will be newly defined.
   *
   * @param {string|object} - Name of the data property if defining only one, or
   *                          an object literal containing key/value pairs to be
   *                          merged into this Element instance's data
   *                          properties.
   * @param {*} - Value of the data property (if defining only one).
   * @param {boolean} - If defining only one data property, specifies whether
   *                    the data property should also be a data attribute of the
   *                    element.
   * 
   * @alias module:requiem~ui.Element#setData
   */
  setData() {
    let descriptor = arguments[0];

    if (typeof descriptor === 'string') {
      let value = arguments[1];
      let attributed = arguments[2] || false;

      if (this.hasData(descriptor)) {
        this.data[descriptor] = value;
      }
      else {
        defineProperty(this, descriptor, {
          defaultValue: value,
          dirtyType: DirtyType.DATA,
          get: true,
          set: true,
          attributed: attributed
        }, 'data');
      }
    }
    else {
      assertType(descriptor, 'object', false);
      if (!descriptor) return;
      for (let key in descriptor) {
        this.setData(key, descriptor[key]);
      }
    }
  }

  /**
   * Creates the associated DOM element from a template.
   *
   * @return {Node|string}
   * 
   * @alias module:requiem~ui.Element#template
   */
  template(data) {
    return null;
  }

  /** 
   * @see ElementUpdateDelegate#isDirty 
   * @alias module:requiem~ui.Element#isDirty
   */
  isDirty() { return this.updateDelegate.isDirty.apply(this.updateDelegate, arguments); }

  /** 
   * @see ElementUpdateDelegate#setDirty 
   * @alias module:requiem~ui.Element#setDirty
   */
  setDirty() { return this.updateDelegate.setDirty.apply(this.updateDelegate, arguments); }

  /**
   * Shorthand for creating/accessing private properties.
   *
   * @param {string} propertyName - Name of private property.
   * @param {*} [defaultInitializer] - Optional default value/initializer to set
   *                                   the private property to if it doesn't
   *                                   exist.
   *
   * @return {*} Value of private property.
   * 
   * @alias module:requiem~ui.Element#get
   */
  get(propertyName, defaultInitializer) {
    assertType(propertyName, 'string', false);
    if (!this.__private__) this.__private__ = {};
    if (this.__private__[propertyName] === undefined) {
      if (typeof defaultInitializer === 'function')
        this.__private__[propertyName] = defaultInitializer();
      else
        this.__private__[propertyName] = defaultInitializer;
    }
    return this.__private__[propertyName];
  }

  /**
   * Shorthand for modifying private properties.
   *
   * @param {string} propertyName - Name of private property.
   * @param {*} value - Value of private property to be set.
   * 
   * @alias module:requiem~ui.Element#set
   */
  set(propertyName, value) {
    assertType(propertyName, 'string', false);
    if (!this.__private__) this.__private__ = {};
    this.__private__[propertyName] = value;
  }

  /**
   * Defines all properties.
   *
   * @private
   */
  __defineProperties__() {
    this.__private__ = {};
    this.__private__.childRegistry = {};
    this.__private__.listenerRegistry = {};

    /**
     * Current node state of this Element instance.
     *
     * @type {NodeState}
     */
    defineProperty(this, 'nodeState', { defaultValue: NodeState.IDLE, get: true });

    /**
     * Data properties.
     *
     * @type {Object}
     * @see module:requiem~enums.Directive.DATA
     */
    defineProperty(this, 'data', { defaultValue: {}, get: true });

    /**
     * ElementUpdateDelegate instance.
     *
     * @type {ElementUpdateDelegate}
     */
    defineProperty(this, 'updateDelegate', { defaultValue: new ElementUpdateDelegate(this), get: true });

    /**
     * Specifies whether this Element instance is invisible. This property
     * follows the rules of the CSS rule 'visibility: hidden'.
     *
     * @type {boolean}
     */
    defineProperty(this, 'invisible', {
      get: true,
      set: (value) => {
        assertType(value, 'boolean', false);

        if (this.nodeState === NodeState.UPDATED) {
          if (value) {
            this.setStyle('visibility', 'hidden');
          }
          else {
            if (this.getStyle('visibility') === 'hidden') {
              this.setStyle('visibility', null);
            }
          }
        }

        return value;
      }
    });

    if (this.disabled === undefined) {
      /**
       * Specifies whether this Element instance is disabled.
       *
       * @type {boolean}
       */
      Object.defineProperty(this, 'disabled', {
        get: () => (this.hasAttribute('disabled') ? this.getAttribute('disabled') : false),
        set: (value) => this.setAttribute('disabled', (value ? true : false))
      });
    }
  }

  /**
   * Sets the Element's node state.
   *
   * @param {NodeState} nodeState - Node state.
   *
   * @private
   */
  __setNodeState__(nodeState) {
    if (this.__private__.nodeState === nodeState) return;
    let oldVal = this.__private__.nodeState;
    this.__private__.nodeState = nodeState;

    if (nodeState === NodeState.INITIALIZED)
      this.dispatchEvent(new CustomEvent(EventType.NODE.INITIALIZE));
    else if (nodeState === NodeState.UPDATED)
      this.dispatchEvent(new CustomEvent(EventType.NODE.UPDATE));
    else if (nodeState === NodeState.DESTROYED)
      this.dispatchEvent(new CustomEvent(EventType.NODE.DESTROY));

    this.dispatchEvent(new CustomEvent(EventType.NODE.NODE_STATE, {
      detail: {
        oldValue: oldVal,
        newValue: nodeState
      }
    }));
  }
});

export default Element;