Manual Reference Source Test

src/elements/htmlelement.js

/**
*  @file HtmlElement definition class
*  @author  Liqueur de Toile <contact@liqueurdetoile.com>
*  @license Apache-2.0 {@link https://www.apache.org/licenses/LICENSE-2.0}
*/

import ObjectArray from 'dot-object-array';
import Element from 'element';
import Collection from 'collection';
import EventifiedElement from 'elements/eventifiedelement';

import Q from 'query';

/**
*  This the common class for all other elements. It provides all functionalities to :
*  - Explore and manipulate the DOM
*  - Create, edit, delete properties on the object
*  - Style the object
*  - Fade in and out
*  - Get and set position
*  - Add event with a global events manager
*
*  __You cannot directly create an instance of the class.__
*
*  You must use the {@link Element} constructor or its alias {@link Q}
*  that will then instantiate the right class given the node type (HTML, Form...).
*
*  The `options` passed to the Element constructor are then passed to this class constructor.
*
*  @since 1.0.0
*  @version 1.0.0
*  @author Liqueur de Toile <contact@liqueurdetoile.com>
*
*/

export default class HtmlElement extends EventifiedElement {
  /**
  *  Constructor for HTMLElement class
  *
  *  You can pass any attributes as options. There are a few reserved keywords
  *  for specific options.
  *
  *  @private
  *  @param {Element} node Element to enhance
  *  @param {keyValueObject} [options={}]
  *  Options for the HTMLElement object and/or attributes for
  *  the underlying native HTMLElement Object. Excepting `data`, `events`,
  *  `innerhtml` and `options`, any property of this object will be
  *  added as attribute to the underlying HTML object.
  *     @property
  *     {String|Array}
  *     [options.class]
  *     Class(es) to be set as value for the `class` attribute.
  *
  *     @property
  *     {String|keyValueObject}
  *     [options.style]
  *     Style(s) to be set as value for the `style` attribute.
  *
  *     @property
  *     {keyValueObject}
  *     [options.data]
  *     An object with key/value pairs
  *
  *     @property {keyValueObject} [options.events]
  *     Events to add to the element under the format : `{eventName: callback, ...}`
  *
  *  @returns {HtmlElement} New HTMLElement object
  */
  constructor(node, options = {}) {

    super();

    /**
    *  The underlying DOM node object
    *
    *  @type {Node}
    *  @since 1.0.0
    */
    this.element = node;

    /**
    *  Convenience property to check if the object
    *  is an instance of this class
    *
    *  @type {Boolean}
    *  @since 1.0.0
    */
    this.enhanced = true;

    /**
    *  The length property if useful to quickly check what kind
    *  of underlying node object have been returned :
    *  - 0 means it's a DocumentFragment
    *  - 1 means it's a node (HTMLElement...)
    *  - More than 1 means it's a Collection (@see elementify.Collection)
    *
    *  @type {Number}
    *  @since 1.0.0
    */
    this.length = node.nodeType === 11 ? 0 : 1;

    /**
    *  The options passed to the constructor
    *  @type {ObjectArray}
    *  @since 1.0.0
    */
    this.options = new ObjectArray(options);

    // Set attributes
    if (this.node.setAttribute) {
      this.options.forEach((value, attr) => {
        attr = attr.toLowerCase();
        if (attr !== 'data' && attr !== 'innerhtml' && attr !== 'events' && attr !== 'options') {
          if (attr === 'class' && value instanceof Array) value = value.join(' ');
          if (attr === 'style' && typeof value !== 'string') value = new ObjectArray(value).stylesToString();
          this.node.setAttribute(attr, value);
        }
      });
    }

    if (options.innerHTML) this.node.innerHTML = options.innerHTML;
    if (options.data) this.data(options.data);
    if (options.events) this.registerEvents(options.events);
  }

  /**
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @description
  *  Implemented for compatibility with Collection class {@see Collection} for chaining purposes
  *
  *  @param {forEachCallback} callback Callback function to be run on each element.
  *  @returns {void}
  */
  forEach(callback) {
    callback.call(this, this, 0);
  }

  /**
  *  Get or set given attribute(s) of the element.
  *
  *  The getter can be used in two ways :
  *  - A string will return an attribute value
  *  - Array of attributes names will return a keyValueObject
  *
  *
  *  The setter can be used in three ways :
  *  - A string + value will set the attribute
  *  - A string + null will delete the attribute
  *  - A keyValueObject of attributes/values for multiple settings
  *
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @param {Void|String|Array|keyValueObject} [a] Attribute(s)
  *  @param {Void|Null|Number|String} v Value for the attribute
  *  @returns {HtmlElement|Number|String|keyValueObject} Self for chaining for setter or attributes for getter
  */
  attr(a, v) {
    if (typeof a === 'undefined') {
      let attrs = this.node.attributes;
      let ret = {};

      for (let i = 0; i < attrs.length; i++) ret[attrs[i].name] = attrs[i].value;
      return ret;
    }
    if (a instanceof Array) {
      let ret = {};

      a.forEach(function (attr) { ret[attr] = this.attr(attr); }.bind(this));
      return ret;
    } else if (a instanceof Object) {
      a = new ObjectArray(a);
      a.forEach((value, attr) => { this.attr(attr, value); });
    } else {
      if (v === null) this.node.removeAttribute(a);
      if (v) this.node.setAttribute(a, v);
      else return this.node.getAttribute(a);
    }
    return this;
  }

  /**
  *  Get or set data attribute(s) of the element.
  *
  *  With no arguments, it will return a keyValueObject with all data attributes.
  *
  *  Data items name will be automatically converted from dash to camel-case if needed.
  *
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @param {undefined|String|Array|Object|ObjectArray} [a] Data attribute name (without leading `data-`)
  *  @param {Number|String|Object} v Value for the attribute
  *  @returns {HtmlElement|Number|String|Object|keyValueObject}
  *  Self for chaining for setter or data attribute(s) values for getter
  */
  data(a, v) {
    const data = new ObjectArray(this.attr());

    // Load data attributes
    data.forEach((v, k) => {
      if (k.indexOf('data-') === -1) data.remove(k);
      else data.push(k.replace('data-', ''), v).remove(k);
    });

    // Check case
    if (typeof a === 'undefined') return data.dataset();
    else if (typeof a === 'string') {
      // Dashize a for camel-cased key request
      a = data.dashize(a);

      if (typeof v === 'undefined') return data.pull(a, undefined, false);
      else if (v === 'null') {
        this.attr('data-' + a, null);
      } else {
        this.attr('data-' + a, v);
        return this;
      }
    } else if (a instanceof Array) {
      let ret = {};

      a.forEach(function (v) { ret[v] = this.data(v); }.bind(this));
      return ret;
    } else if (a instanceof Object) {
      a = new ObjectArray(a);
      a.forEach(function (v, k) { this.data(k, v); }.bind(this));
    } return this;
  }

  /**
  *  Matches returns true if the element matches the query selector
  *
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @param {String} selector Valid DOM query selector
  *  @returns {Boolean} `true` if Element matches selector
  */
  matches(selector) {
    var matches, i;

    if (this.node.matches) return this.node.matches(selector);
    matches = (document || this.node.ownerDocument).querySelectorAll(selector);
    i = matches.length;
    while (--i >= 0 && matches.item(i) !== this.node) {}
    return i > -1;
  }

  /**
  *  Element's n-th parent finder
  *
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @param {Number|String}  [n=1]   Distance to the current Element if number. Query selector if string.
  *  @returns {HtmlElement|DocumentFragment}  If the parent level is above top level, it will returns a DocumentFragment
  */
  parent(n = 1) {
    var parent = new Element(this.node.parentNode);

    if (typeof n === 'string') return this.parents(n);
    while (n > 1 && parent.length) {
      parent = new Element(parent.node.parentNode);
      n--;
    }
    return parent;
  }

  /**
  *  Element's parent finder based on a DOM query selector
  *
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @param {String} selector A valid DOM query selector
  *  @returns {Collection|HtmlElement|DocumentFragment}
  *  HtmlElement or DocumentFragment if no parent matches the selector
  */
  parents(selector) {
    let coll = new Collection();
    let parent = this.parent();

    while (parent.length) {
      if (parent.matches(selector)) coll.push(parent);
      parent = parent.parent();
    }

    if (coll.length === 0) return new Element();
    if (coll.length === 1) return coll.elements[0];
    return coll;
  }

  /**
  *  Element's nth child finder
  *
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @param {Number}  [n=1]   Distance to the current Element
  *  @returns {HtmlElement|DocumentFragment}  If the child is unreachable, it will returns a DocumentFragment
  */
  child(n = 1) {
    let childs = this.node.children || this.node.childNodes;

    if (typeof n === 'string') return this.childs(n);

    if (childs && childs[n - 1]) return new Element(childs[n - 1]);
    return new Element();
  }

  /**
  *  Element's childs finder based on a DOM query selector
  *
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @param {String} selector A valid DOM query selector
  *  @returns {Collection|HtmlElement|DocumentFragment} HtmlElement or DocumentFragment if no child matches the selector
  */
  childs(selector) {
    return Q(selector, this);
  }

  /**
  *  Element's previous sibling finder
  *
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @param {Qtype} [n=1] Distance to current Element
  *  @returns {HtmlElement|DocumentFragment} If the sibling is unreachable, it will returns a DocumentFragment
  */
  previousSibling(n = 1) {
    let ps = this.node.previousSibling;
    let index = 0;

    if (typeof n === 'string') return this.previousSiblings(n);

    while (ps !== null) {
      if (ps.nodeType === 1) index++;
      if (index === n) break;
      ps = ps.previousSibling;
    }

    return new Element(ps);
  }

  /**
  *  Element's previous siblings finder based on a DOM query selector
  *
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @param {String} selector A valid DOM query selector
  *  @returns {Collection|HtmlElement|DocumentFragment} DocumentFragment if no siblings matches the selector
  */
  previousSiblings(selector) {
    let coll = new Collection();
    let ps = this.previousSibling();

    while (ps.length) {
      if (ps.matches(selector)) coll.push(ps);
      ps = ps.previousSibling();
    }

    if (coll.length === 0) return document.createDocumentFragment();
    if (coll.length === 1) return coll.elements[0];
    return coll;
  }

  /**
  *  Element's next sibling finder
  *
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @param {Qtype} [n=1] Distance to current Element
  *  @returns {HtmlElement|DocumentFragment} If the sibling is unreachable, it will returns a DocumentFragment
  */
  nextSibling(n = 1) {
    let ns = this.node.nextSibling;
    let index = 0;

    if (typeof n === 'string') return this.nextSiblings(n);

    while (ns !== null) {
      if (ns.nodeType === 1) index++;
      if (index === n) break;
      ns = ns.nextSibling;
    }

    return new Element(ns);
  }

  /**
  *  Element's next siblings finder based on a DOM query selector
  *
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @param {String} selector A valid DOM query selector
  *  @returns {Collection|HtmlElement|DocumentFragment} DocumentFragment if no siblings matches the selector
  */
  nextSiblings(selector) {
    let coll = new Collection();
    let ns = this.nextSibling();

    while (ns.length) {
      if (ns.matches(selector)) coll.push(ns);
      ns = ns.previousSibling();
    }

    if (coll.length === 0) return document.createDocumentFragment();
    if (coll.length === 1) return coll.elements[0];
    return coll;
  }

  /**
  *  Append an element to another
  *
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @param {String|Node|HtmlElement} e Element to append (enhanced or not)
  *  @returns {HtmlElement} Return self for chaining
  */
  append(e) {
    if (!e.enhanced) e = new Element(e);
    this.node.appendChild(e.node);
    return this;
  }

  /**
  *  Prepend an element to another
  *
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @param {String|Node|HtmlElement} e Element to prepend (enhanced or not)
  *  @returns {HtmlElement} Return self for chaining
  */
  prepend(e) {
    if (!e.enhanced) e = new Element(e);
    if (this.node.children.length) this.node.insertBefore(e.node, this.node.firstChild);
    else this.node.appendChild(e.node);
    return this;
  }

  /**
  *  Insert an element before another. A Documentfragement wrapper will be
  *  inserted if Element have no parent node.
  *
  *  The second parameter can be used to get the parent element for
  *  chaining instead of the element itself.
  *
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @param {String|Node|HtmlElement} e Element to insert
  *  @param {Boolean} [returnself=true]  If `true`, it will return the Element.
  *  If `false`, it will return the parent Element.
  *  @returns {HtmlElement} Return for chaining
  */
  before(e, returnself = true) {
    if (!e.enhanced) e = new Element(e);
    if (!this.node.parentNode) this.wrap(new Element());
    this.node.parentNode.insertBefore(e.node, this.node);
    return (returnself ? this : new Element(this.node.parentNode));
  }

  /**
  *  Insert an element before another. A Documentfragement wrapper will be
  *  inserted if Element have no parent node.
  *
  *  The second parameter can be used to get the parent element for
  *  chaining instead of the element itself.
  *
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @param {String|Node|HtmlElement} e Element to insert
  *  @param {Boolean} [returnself=true]  If `true`, it will return the Element.
  *  If `false`, it will return the parent Element.
  *  @returns {HtmlElement} Return for chaining
  */
  after(e, returnself = true) {
    if (!e.enhanced) e = new Element(e);
    if (!this.node.parentNode) this.wrap(new Element());
    this.node.parentNode.insertBefore(e.node, e.node.nextSibling);
    return (returnself ? this : new Element(this.node.parentNode));
  }

  /**
  *  Wrap an Element into another.
  *  If no Element provided, the wrapper will be set to
  *  a DocumentFragment node.
  *
  *  The second parameter can be used to get the parent element for
  *  chaining instead of the element itself.
  *
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @param {String|Node|HtmlElement} e=null Wrapping Element
  *  @param {Boolean} [returnself=true] If `true`, it will return the Element.
  *  If `false`, it will return the parent Element.
  *  @returns {HtmlElement} Return for chaining
  */
  wrap(e = null, returnself = true) {
    if (!e.enhanced) e = new Element(e);
    this.after(e);
    e.append(this);
    return (returnself ? this : e);
  }

  /**
  *  Unwrap an Element (and its siblings) and delete its parentNode
  *
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @returns {HtmlElement} Return for chaining
  */
  unwrap() {
    if (this.parent().length) {
      let parent = this.parent();

      this.parent().before(this);
      parent.remove();
    }
    return this;
  }

  /**
  *  Empty an Element
  *
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @returns {HtmlElement} Return for chaining
  */
  empty() {
    while (this.node.firstChild) this.node.removeChild(this.node.firstChild);
    return this;
  }

  /**
  *  Remove an Element
  *
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @returns {HtmlElement} Return for chaining
  */
  remove() {
    if (this.node.parentNode) this.node.parentNode.removeChild(this.node);
    return this;
  }

  /**
  *  Get/Set the HTML content of the node
  *
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @param {String} [v] Value for HTML content
  *  @returns {HTMLElement|string} Setter returns self for chaining, getter returns the string
  */
  html(v) {
    if (v) {
      this.node.innerHTML = v;
      return this;
    }
    return this.node.innerHTML;
  }

  /**
  *  Get the outerHTML string of the underlying node
  *
  *  @type {String}
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  */
  get outerHTML() {
    var ret, wrapper = new Element('span');

    if (!this.length) return this.node;
    this.wrap(wrapper);
    ret = this.node.parentNode.innerHTML;
    this.unwrap();
    return ret;
  }

  /**
  *  Returns a deep clone of the HtmlElement. The clone will
  *  contains the same child nodes.
  *
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @returns {HtmlElement} Clone of the current HtmlElement
  */
  clone() {
    return new Element(this.node.cloneNode(true));
  }

  /**
  *  Returns a shallow copy of the HtmlElement
  *
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @returns {HtmlElement} Shallow copy of the current HtmlElement
  */
  shallow() {
    return new Element(this.node.cloneNode(false));
  }

  /**
  *  Check if the HtmlElement is present in DOM
  *
  *  @type {Boolean}
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  */
  get isInDom() {
    return this.root === document;
  }

  /**
  *  Alias for the element property member that
  *  returns `null` instead of `undefined`
  *
  *  @type {Node}
  *  @see {@link HtmlElement#element}
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  */
  get node() { // Alias for element
    return (this.element) ? this.element : null;
  }

  /**
  *  Get the root Node element for the HtmlElement
  *  The result is not enhanced as an HtmlElement
  *  because it will mostly be a `document` object
  *  which is not a Node object.
  *
  *  @type {Document|Node}
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  */
  get root() {
    var root;

    if (!this.length) return this.node;

    root = this.node;
    while (root.parentNode) root = root.parentNode;
    return root;
  }

  /**
  *  Get the computed styles of the underlying node.
  *  __Use with care : It can be painfully slow and must be avoided
  *  for huge set of automated ops on style__
  *
  *  @type {CSSStyleDeclaration}
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  */
  get styles() {
    return getComputedStyle(this.node);
  }

  /**
  *  Get/Set style properties of an HtmlElement based on its
  *  node `style` attribute.
  *
  *  Properties name will be camelized when read from style string and
  *  dashed when set to style string.
  *
  *  Many syntaxes are available. See examples.
  *
  *  You can remove a style property by setting its value to `null`
  *
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @param {String|Array|Object|ObjectArray}  [a]  get/set request on style attribute
  *
  *  - Empty: style will returns all styles set in the stye attribute
  *  - String : Name or the style property to get or set if second parameter is provided
  *  - Array: Array of properties names to get
  *  - Object|ObjectArray : Properties names and values to set. If second parameter is set
  *  to true, it will replace all styles properties defined in the style attribute. Otherwise
  *  it will merge them
  *
  *  @param {String} [v] Value of the style or enable replace mode
  *  @returns {HtmlElement|String|Object}
  *  Returns self for chaining for set cases,
  *  String or Object {styleKey: styleValue, ...} for get cases
  *  @example
  *  var e = new Element('<div style="color:red;margin:1em"></div>');
  *  e.style('color'); // returns 'red'
  *  e.style('color', 'yellow'); // Set color to yellow and returns e for chaining
  *  e.style(['color', 'margin']); // returns {color:'red', margin: '1em'}
  *  e.style({color: 'yellow', padding: '5px'}); // Set color to yellow, padding to 5px and returns e for chaining
  *  e.style({paddingLeft: '10px', margin: '5px'}); // Set color to yellow, padding to 5px and returns e for chaining
  */
  style(a, v) {
    let o = new ObjectArray();

    // undefined case (getter)
    if (typeof a === 'undefined') {
      o.stringToStyles(this.attr('style'));
      return o.data;
    }
    // String case (getter or setter)
    if (typeof a === 'string') {
      a = o.camelize(a); // camelize property name
      if (typeof v === 'undefined') return this.node.style[a];
      this.node.style[a] = v;
      return this;
    }
    // Array case (getter)
    if (a instanceof Array) {
      let ret = {};

      o.stringToStyles(this.attr('style'));
      a.forEach(function (k) {
        k = o.camelize(k);
        ret[k] = o.dataset(k);
      });
      return ret;
    }
    // Object or ArrayObject case (setter)
    if (a instanceof Object) {
      if (v) this.attr('style', o.import(a).stylesToString());
      else this.attr('style', o.stringToStyles(this.attr('style')).import(a).stylesToString());
      return this;
    }

    return undefined;
  }

  /**
  *  Get the visibility of an HtmlElement. It will only return
  *  false if the element is out of the DOM or with
  *  a display style property set to none.
  *
  *  __Use with care : It can be painfully slow and must be avoided
  *  for huge set of automated ops on elements if display property is not set inline__
  *
  *  @type {Number}
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  */
  get visible() {
    return (this.isInDom && this.style('display') !== undefined && this.style('display') !== 'none') ||
      (this.isInDom && this.styles.display !== 'none');
  }

  /**
  *  Set the display style property of an HtmlElement
  *
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @param {String}  [v]   Value for display property
  *  @returns {HtmlElement} Self for chaining
  */
  display(v) {
    if (this.node.style) this.node.style.display = v;
    return this;
  }

  /**
  *  Hide an HtmlElement by setting its display property to 'none'.
  *
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @returns {HtmlElement} Self for chaining
  */
  hide() {
    return this.display('none');
  }

  /**
  *  Show an HtmlElement by setting its display property.
  *  The property value can be override for custom results (e.g. 'flex')
  *
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @param {String}  [v='block']   Value for display property
  *  @returns {HtmlElement} Self for chaining
  */
  show(v = 'block') {
    return this.display(v);
  }

  /**
  *  Toggle an HtmlElement visibility by setting its display property.
  *  The "show" property value can be override for custom results (e.g. 'flex')
  *
  *  __Use with care : It can be painfully slow and must be avoided
  *  for huge set of automated ops on elements if display property is not set inline__
  *
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @param {String}  [v='block']   Value for display property when set to visible
  *  @returns {HtmlElement} Self for chaining
  */
  toggle(v = 'block') {
    if (this.node.style) {
      if (this.styles.display === 'none') this.show(v);
      else this.hide();
    }
  }

  /**
  *  Check if an HtmLElement have given class(es)
  *
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @param {String}  cls   Class(es) name(s)
  *  @returns {Boolean} True if class(es) are present
  */
  hasClass(...cls) {
    let className = this.node.className ? this.node.className.split(' ') : [];
    let ret = true;

    cls.forEach(function (c) { if (className.indexOf(c) === -1) ret = false; });
    return ret;
  }

  /**
  *  Add class(es) to an HtmlElement
  *
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @param {String}  cls   Class(es) name(s)
  *  @returns {HtmlElement} Self for chaining
  */
  addClass(...cls) {
    let className = this.node.className ? this.node.className.split(' ') : [];

    cls.forEach(c => { if (className.indexOf(c) === -1) className.push(c);});
    this.node.className = className.join(' ');

    return this;
  }

  /**
  *  Remove class(es) from an HtmlElement
  *
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @param {String}  cls   Class(es) name(s)
  *  @returns {HtmlElement} Self for chaining
  */
  removeClass(...cls) {
    let className = this.node.className ? this.node.className.split(' ') : [];

    cls.forEach((c) => {
      let i = className.indexOf(c);

      if (i >= 0) className.splice(i, 1);
    });
    this.node.className = className.join(' ');

    return this;
  }

  /**
  *  Toggle class(es) from an HtmlElement
  *
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @param {String}  cls   Class(es) name(s)
  *  @returns {HtmlElement} Self for chaining
  */
  toggleClass(...cls) {
    cls.forEach(c => {
      if (this.hasClass(c)) this.removeClass(c);
      else this.addClass(c);
    });
    return this;
  }

  /**
  *  Fade In element given a linear easing
  *
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @param {ObjectArray} options Options for the animation
  *  @param {String}  [options.display='block'] Value for the display CSS property
  *  @param {Number}  [options.duration=400]  Duration of the animation in milliseconds
  *  @param {Callback}  callback  Animation callback
  *  @returns {this | Promise} Chainable or animation promise if available
  */
  fadeIn(options = {}, callback = null) {
    var p, _this = this;

    function animationPromise(resolve, reject) {
      var start = null;

      function animate(timestamp) {
        var opacity;

        if (start === null) start = timestamp;
        // Linear easing
        opacity = (timestamp - start) / options.duration;
        _this.element.style.opacity = opacity;
        if (opacity < 1) window.requestAnimationFrame(animate);
        else {
          _this.element.style.opacity = 1;
          if (callback instanceof Function) callback(_this);
          resolve(_this);
        }
      }

      window.requestAnimationFrame(animate);
    }

    this.element.style.opacity = 0;
    this.show(options.display);
    options.duration = options.duration || 400;

    if (typeof Promise !== 'undefined') p = new Promise(animationPromise);
    else animationPromise(() => {});

    return p ? p : this;
  }

  /**
  *  Fade Out element given a linear easing
  *
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @param {ObjectArray} options Options for the animation
  *  @param {String}  [options.display='none'] Value for the display CSS property
  *  at the end of the animation
  *  @param {Number}  [options.duration=400]  Duration of the animation in milliseconds
  *  @param {Callback}  callback  Animation callback
  *  @returns {this | Promise} Chainable or animation promise if available
  */
  fadeOut(options = {}, callback = null) {
    var p, _this = this;

    function animationPromise(resolve, reject) {
      var start = null;

      function animate(timestamp) {
        var opacity;

        if (start === null) start = timestamp;
        // Linear easing
        opacity = 1 - (timestamp - start) / options.duration;
        _this.element.style.opacity = opacity;
        if (opacity > 0) window.requestAnimationFrame(animate);
        else {
          _this.element.style.opacity = 0;
          _this.display(options.display);
          if (callback instanceof Function) callback(_this);
          resolve(_this);
        }
      }

      window.requestAnimationFrame(animate);
    }

    this.element.style.opacity = 0;
    this.show(options.display);
    options.duration = options.duration || 400;

    if (typeof Promise !== 'undefined') p = new Promise(animationPromise);
    else animationPromise(() => {});

    return p ? p : this;
  }

  /**
  *  Refresh position object at first call or after
  *  a resize / changeOrientation event
  *
  *  @private
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  *
  *  @returns {void}
  */
  _metricsUpdate() {
    let metrics = this.node.getBoundingClientRect();

    this._metrics = {};
    for (let k in metrics) if (typeof metrics[k] === 'number') this._metrics[k] = metrics[k];
    this._metrics.outerWidth = this._metrics.right - this._metrics.left;
    this._metrics.outerHeight = this._metrics.bottom - this._metrics.top;
    this._metrics.width = this.node.clientWidth;
    this._metrics.height = this.node.clientHeight;

    // Update right property to match right offset
    this._metrics.right = document.body.clientWidth - this._metrics.right;
    // Update bottom property to match bottom offset
    this._metrics.bottom = document.body.clientHeight - this._metrics.bottom;

    // Call scroll update
    this._scrollUpdate();
  }

  _scrollUpdate() {
    if (typeof (this._metrics !== 'undefined')) {
      this._metrics.scrollX =
        (window.pageXOffset !== undefined) ?
          window.pageXOffset :
          (document.documentElement || document.body.parentNode || document.body).scrollLeft;

      this._metrics.scrollY =
        (window.pageYOffset !== undefined) ?
          window.pageYOffset :
          (document.documentElement || document.body.parentNode || document.body).scrollTop;
    }
  }

  _parseDims(v) {
    if (v === null || typeof v === 'undefined') return '';
    else if (v === parseInt(v, 10)) return v + 'px';
    else if (typeof v === 'string') return v;
    throw new TypeError('Invalid value for NodeMetrics');
  }

  /**
  *  Get the position property of the element. The calculation only occurs
  *  at first call or when window is resized, rotated or scrolled.
  *
  *  __ Warning : Calculations can be pretty slow on huge amount of elements __
  *
  *  A cached version of the result is available in the `_metrics` property of the
  *  HtmlElement. It will be only be generated after a first call to position or another
  *  getter/setter. It will then be regenerated after events that affect window size or scroll
  *
  *  If the element is not in DOM, all values will return undefined.
  *
  *  @type {NodeMetrics}
  *  @readonly
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  */
  get position() {
    if (!this.isInDom) {
      return {
        left: undefined,
        right: undefined,
        width: undefined,
        top: undefined,
        bottom: undefined,
        height: undefined,
        scrollX: undefined,
        scrollY: undefined
      };
    }

    if (typeof this._metrics === 'undefined') {
      this._metricsUpdate();
      window.addEventListener('resize', this._metricsUpdate.bind(this));
      window.addEventListener('orientationchange', this._metricsUpdate.bind(this));
      window.addEventListener('scroll', this._scrollUpdate.bind(this));
      window.addEventListener('touchmove', this._scrollUpdate.bind(this));
    }

    return this._metrics;
  }

  /**
  *  Get scroll X offset of the window in pixels
  *
  *  @see {@link position}
  *  @type {Number|undefined}
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  */
  get scrollX() {
    return this.position.scrollX;
  }

  /**
  *  Get scroll Y offset of the window in pixels
  *
  *  @see {@link position}
  *  @type {Number|undefined}
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  */
  get scrollY() {
    return this.position.scrollY;
  }

  /**
  *  Get left offset in pixels (relative to wiewport)
  *
  *  @see {@link position}
  *  @type {Number|undefined}
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  */
  get left() {
    return this.position.left;
  }

  /**
  *  Set left offset in pixels (relative to wiewport)
  *
  *  You can set value by assigning any of :
  *
  *  - `null`, `undefined` : Remove value
  *  - {Number} : Value will be interpreted in pixels
  *  - {String} : any css acceptable value for the property (e.g '5vi', '1em'...)
  *
  *  @see {@link position}
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  */
  set left(v) {
    this.node.style.left = this._parseDims(v);
    return this;
  }

  /**
  *  Get/Set right offset in pixels (relative to wiewport)
  *  You can set value by assigning any of :
  *
  *  - `null`, `undefined` : Remove value
  *  - {Number} : Value will be interpreted in pixels
  *  - {String} : any css acceptable value for the property (e.g '5vi', '1em'...)
  *
  *  @see {@link position}
  *  @type Number|undefined
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  */
  get right() {
    return this.position.right;
  }

  set right(v) {
    this.node.style.right = this._parseDims(v);
    return this;
  }

  /**
  *  Get/Set top offset in pixels (relative to wiewport)
  *  You can set value by assigning any of :
  *
  *  - `null`, `undefined` : Remove value
  *  - {Number} : Value will be interpreted in pixels
  *  - {String} : any css acceptable value for the property (e.g '5vi', '1em'...)
  *
  *  @see {@link position}
  *  @type Number|undefined
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  */
  get top() {
    return this.position.top;
  }

  set top(v) {
    this.node.style.top = this._parseDims(v);
    return this;
  }

  /**
  *  Get/Set bottom offset in pixels (relative to wiewport)
  *  You can set value by assigning any of :
  *
  *  - `null`, `undefined` : Remove value
  *  - {Number} : Value will be interpreted in pixels
  *  - {String} : any css acceptable value for the property (e.g '5vi', '1em'...)
  *
  *  @see {@link position}
  *  @type Number|undefined
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  */
  get bottom() {
    return this.position.bottom;
  }

  set bottom(v) {
    this.node.style.bottom = this._parseDims(v);
    return this;
  }

  /**
  *  Get/Set width in pixels
  *  You can set value by assigning any of :
  *
  *  - `null`, `undefined` : Remove value
  *  - {Number} : Value will be interpreted in pixels
  *  - {String} : any css acceptable value for the property (e.g '5vi', '1em'...)
  *
  *  @see {@link position}
  *  @type Number|undefined
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  */
  get width() {
    return this.position.width;
  }

  set width(v) {
    this.node.style.width = this._parseDims(v);
    return this;
  }

  /**
  *  Get/Set height in pixels
  *  You can set value by assigning any of :
  *
  *  - `null`, `undefined` : Remove value
  *  - {Number} : Value will be interpreted in pixels
  *  - {String} : any css acceptable value for the property (e.g '5vi', '1em'...)
  *
  *  @see {@link position}
  *  @type Number|undefined
  *  @since 1.0.0
  *  @version 1.0.0
  *  @author Liqueur de Toile <contact@liqueurdetoile.com>
  */
  get height() {
    return this.position.height;
  }

  set height(v) {
    this.node.style.height = this._parseDims(v);
    return this;
  }
}