Source: ui/dom.js

var _ = require("lodash");
var rect = require("../utils/rect");
var validate = require("../utils/validate");

/**
 * DOM manipulation helper module to encapsulate browser and DOM API version
 * differences.
 *
 * @module decks/ui/dom
 */
module.exports = {

  /**
   * Wrapper for querySelectorAll
   *
   * @param {!string} selector - DOM selector
   * @param {?HTMLElement} [context=document] - context element for DOM query
   * @return {NodeList}
   */
  query: function query(selector, context) {
    validate(selector, "selector", { isString: true });

    context = context || document;

    return context.querySelectorAll(selector);
  },

  /**
   * Wrapper for querySelector
   *
   * @param {!string} selector - DOM selector
   * @param {?Element} [context=document] - context element for DOM query
   * @return {undefined}
   */
  querySingle: function querySingle(selector, context) {
    validate(selector, "selector", { isString: true });

    context = context || document;

    return context.querySelector(selector);
  },

  /**
   * Creates a DOM element by name (e.g. "div").
   *
   * @param {!String} type - the type of DOM element to create (e.g. "div")
   * @returns {Element} - the DOM element
   */
  create: function create(type, options) {
    validate(type, "type", { isString: true });
    options = options || {};

    var element = document.createElement(type);

    if (_.has(options, "id")) {
      element.id = options.id;
    }

    if (_.has(options, "className")) {
      element.className = options.className;
    }

    if (_.has(options, "styles")) {
      this.setStyles(element, options.styles);
    }

    if (_.has(options, "attrs")) {
      this.setAttrs(element, options.attrs);
    }

    return element;
  },

  /**
   * Gets or sets an element's innerHTML.
   *
   * @param {!Element} element - the Element for which to get or set HTML.
   * @param {?String} data - the HTML to set, or if not specified, the method will return the HTML.
   * @return {String} - the element's innerHTML
   */
  html: function html(element, data) {
    if (!data) {
      return element.innerHTML;
    }

    if (_.isElement(data)) {
      element.innerHTML = data.outerHTML;
      return;
    }

    if (_.isString(data)) {
      element.innerHTML = data;
      return;
    }

    throw new Error("dom.create: cannot set element html");
  },

  /**
   * Parses an HTML string, and returns the resulting Element or Elements.
   *
   * @param {!string} html - the HTML string
   * @param {?Object} [options={}] - additional options
   * @param {?boolean} [options.multiple=false] - whether to return a all top-level sibling elements
   * @return {Element|NodeList} - the resulting Element or NodeList of elements
   */
  parse: function parse(html, options) {
    options = options || {};
    var element = this.create("dom");
    element.innerHTML = html;

    // Default to returning firstChild, unless options.multiple === true
    if (options.multiple) {
      return element.children;
    }

    return element.firstChild;
  },

  /**
   * Gets or sets the textContent/innerText of the Element
   *
   * @param element
   * @param data
   * @return {undefined}
   */
  text: function text(element, data) {
    if (!_.isString(data)) {
      return element.textContent || element.innerText;
    }

    if (!_.isUndefined(element.textContent)) {
      element.textContent = data;
    } else {
      element.innerText = data;
    }
  },

  /**
   * Empties an element by removing all children
   *
   * @param element
   * @return {undefined}
   */
  empty: function empty(element) {
    while (element.firstChild) {
      element.removeChild(element.firstChild);
    }
  },

  /**
   * Appends a child element to a parent element
   *
   * @param parent
   * @param child
   * @return {undefined}
   */
  append: function append(parent, child) {
    parent.appendChild(child);
  },

  /**
   * Prepends a child element in a parent element
   *
   * @param parent
   * @param child
   * @return {undefined}
   */
  prepend: function prepend(parent, child) {
    parent.insertBefore(child, parent.firstChild);
  },

  /**
   * Removes a child element from a parent element, or removes the parent element
   * from its parent if no child specified.
   *
   * @param parent
   * @param child
   * @return {undefined}
   */
  remove: function remove(parent, child) {
    if (!child) {
      return parent.parentNode.removeChild(parent);
    } else {
      return parent.removeChild(child);
    }
  },

  /**
   * Gets or sets an Element attribute value
   *
   * @param element
   * @param name
   * @param value
   * @return {undefined}
   */
  attr: function attr(element, name, value) {
    if (_.isUndefined(value)) {
      return this.getAttr(element, name);
    }
    this.setAttr(element, name, value);
  },

  /**
   * Gets an Element's attribute value by name
   *
   * @param element
   * @param name
   * @return {undefined}
   */
  getAttr: function getAttr(element, name) {
    return element.getAttribute(name);
  },

  /**
   * Sets an Element's attribute value by name
   *
   * @param element
   * @param name
   * @param value
   * @return {undefined}
   */
  setAttr: function setAttr(element, key, value) {
    element.setAttribute(key, value);
  },

  setAttrs: function setAttrs(element, attrs) {
    _.each(attrs, function(value, key) {
      this.setAttr(element, key, value);
    }, this);
  },

  /**
   * Indicates if an Element has the given class
   *
   * @param element
   * @param className
   * @return {undefined}
   */
  hasClass: function hasClass(element, className) {
    if (element.classList) {
      return element.classList.contains(className);
    } else {
      return new RegExp('(^| )' + className + '( |$)', 'gi').test(element.className);
    }
  },

  addClass: function addClass(element, className) {
    var classNames = _.map(className.split(" "), function(name) {
      return name.trim();
    });

    _.each(classNames, function(className) {
      if (this.hasClass(element, className)) { return; }
      if (element.classList) {
        element.classList.add(className);
      } else {
        element.className += ' ' + className;
      }
    }, this);
  },

  removeClass: function removeClass(element, className) {
    var classNames = _.map(className.split(" "), function(className) {
      return className.trim();
    });

    _.each(classNames, function(className) {
      if (!this.hasClass(element, className)) { return; }
      if (element.classList) {
        element.classList.remove(className);
      } else {
        element.className = element.className.replace(new RegExp('(^|\\b)' + className.split(' ').join('|') + '(\\b|$)', 'gi'), ' ');
      }
    }, this);
  },

  toggleClass: function toggleClass(element, className) {
    if (element.classList) {
      element.classList.toggle(className);
    } else {
      var classes = element.className.split(' ');
      var existingIndex = -1;
      for (var i = classes.length; i--;) {
        if (classes[i] === className) {
          existingIndex = i;
        }
      }

      if (existingIndex >= 0) {
        classes.splice(existingIndex, 1);
      } else {
        classes.push(className);
      }

      element.className = classes.join(' ');
    }
  },

  getStyle: function getStyle(element, name, options) {
    options = options || {};

    var value = element.style[name];

    if (options.parseInt) {
      value = _.parseInt(value);
    }

    if (options.parseFloat) {
      value = parseFloat(value);
    }

    return value;
  },

  setStyle: function setStyle(element, name, value) {
    if (_.isNumber(value)) {
      var unit = this.autoUnits[name];
      if (unit) {
        value = value + unit;
      }
    }
    element.style[name] = value;
  },

  setStyles: function setStyles(element, styles) {
    _.each(styles, function(value, key) {
      this.setStyle(element, key, value);
    }, this);
  },

  removeStyle: function removeStyle(element, name) {
    element.style[name] = "";
  },

  isPositioned: function isPositioned(element) {
    var position = this.getStyle(element, "position");
    return _.contains(["absolute", "relative", "fixed"], position);
  },

  isVisible: function(element) {
    validate(element, "element", { isElement: true });

    return element.style.display !== "none" &&
      element.style.visibility !== "hidden";
  },

  closest: function closest(element, predicate) {
    if (element && predicate(element)) {
      return element;
    }

    if (element.parentNode) {
      return this.closest(element.parentNode, predicate);
    }

    return null;
  },

  closestWithClass: function closestWithClass(element, className) {
    var self = this;
    return self.closest(element, function(el) {
      return self.hasClass(el, className);
    });
  },

  /**
   * Get the element whose bounding rect top/left is nearest to the given point in
   * distance.
   *
   * @param {!Object} point - the point to compare all elements to (in top/left or x/y)
   * @param {!(Element[]|NodeList)} elements - the elements to check
   * @return {Element} - the element whose top/left is nearest to point
   */
  nearest: function nearest(point, elements, options) {
    options = options || {};

    var minDistance = Infinity;

    if (options.ignoreInvisibleElements) {
      elements = _.filter(elements, function(element) {
        return this.isVisible(element);
      }, this);
    }

    return _.reduce(elements, function(nearestElement, element) {
      var elementRect = rect.normalize(element);
      var distance = rect.distance(point, elementRect);
      if (distance < minDistance) {
        minDistance = distance;
        return element;
      }
      return nearestElement;
    }, null);
  },

  /**
   * Default tolerance value for isOverflowed methods
   */
  defaultOverflowTolerance: 2,

  /**
   * Indicates if an element is overflowing it's parent in the horizontal direction.
   *
   * @param {!Element} element - element to check
   * @param {?number} [tolerance=2] - tolerance to add to element.clientWidth
   * @return {boolean} - whether element is overflowed horizontally
   */
  isOverflowedX: function isOverflowedX(element, tolerance) {
    tolerance = _.isNumber(tolerance) ? tolerance : this.defaultOverflowTolerance;
    return element.clientWidth + tolerance < element.scrollWidth;
  },

  /**
   * Indicates if an element is overflowing it's parent in the vertical direction.
   *
   * @param {!Element} element - element to check
   * @param {?number} [tolerance=2] - tolerance to add to element.clientHeight
   * @return {boolean} - whether element is overflowed vertically
   */
  isOverflowedY: function isOverflowedY(element, tolerance) {
    tolerance = _.isNumber(tolerance) ? tolerance : this.defaultOverflowTolerance;
    return element.clientHeight + tolerance < element.scrollHeight;
  },

  /**
   * Indicates if an element is overflowing it's parent in any direction.
   *
   * @param {!Element} element - element to check
   * @param {?number} [tolerance=2] - tolerance to add to element.clientHeight and element.clientWidth
   * @return {boolean} - whether element is overflowed
   */
  isOverflowed: function isOverflowed(element, tolerance) {
    return this.isOverflowedX(element, tolerance) || this.isOverflowedY(element, tolerance);
  },

  autoUnits: {
    "top": "px",
    "bottom": "px",
    "left": "px",
    "right": "px",
    "width": "px",
    "height": "px"
  }
};