mixin/responsiveMixIn.js

/**
 * @copyright 2017 Tridium, Inc. All Rights Reserved.
 * @author Gareth Johnson
 */

/* eslint-env browser */

/**
 * @module bajaux/mixin/responsiveMixIn
 */
define([ 'bajaux/Widget',
        'jquery',
        'Promise',
        'underscore' ], function (
         Widget,
         $,
         Promise,
         _) {
  
  'use strict';
  
  var MIXIN_NAME = 'responsive';

  /**
   * @typedef {Object} module:bajaux/mixin/responsiveMixIn~ResponsiveMediaInfo
   *
   * @property {number} width The width in pixels to use in responsive layout.
   * @property {number} height The height in pixels to use in responsive layout.
   */

  /**
   * An object that defines some conditions for the associated class to be added
   * to the widget's DOM element.
   *
   * @typedef {Object} module:bajaux/mixin/responsiveMixIn~ResponsiveCondition
   *
   * @property {Number} [maxWidth] The widget's width in pixels must be less
   * than or equal to this value.
   * @property {Number} [maxHeight] The widget's height in pixels must be less
   * than or equal to this value.
   * @property {Number} [minWidth] The widget's width in pixels must be greater
   * than or equal to this value.
   * @property {Number} [minHeight] The widget's height in pixels must be
   * greater than or equal to this value.
   */
  
  /**
   * A callback that returns true if the condition is met.
   *
   * @callback {Function} module:bajaux/mixin/responsiveMixIn~ResponsiveCallback
   *
   * @param {module:bajaux/mixin/responsiveMixIn~ResponsiveMediaInfo} info The
   * widget's current width, height, and any other associated media information.
   * @returns {boolean} true if the current media info satisfies the condition.
   */

  /**
   * Applies the `responsive` mixin to the target Widget.
   * 
   * This mixin provides responsive layout for a widget based upon its dimensions. 
   * It does this by adding or removing CSS classes to/from the widget's DOM element.
   *
   * The mixin injects itself by cross cutting the widget's layout behavior.
   *
   * As of Niagara 4.8, omit the `conditions` argument to use a set of default
   * classes:
   *
   * - `phone-only`: less than 600px wide
   * - `tablet-portrait-up`: 600px wide or greater
   * - `tablet-landscape-up`: 900px wide or greater
   * - `desktop-up`: 1200px wide or greater
   *
   * @example
   * <caption>Apply responsive layout to a widget</caption>
   * var ResponsiveWidget = function () {
   *   Widget.apply(this, arguments);
   *   responsiveMixIn(this, {
   *     'my-css-class-to-use-when-small': { maxHeight: 768, maxWidth: 1024 },
   *     'my-css-class-to-use-when-square': function (info) { return info.width === info.height; }
   *   });
   * };
   * ResponsiveWidget.prototype = Object.create(Widget.prototype);
   * ResponsiveWidget.prototype.doInitialize = function (dom) {
   *   dom.addClass('ResponsiveWidget');
   *   dom.html(myHtmlTemplate());
   * };
   *
   * //  in css:
   * // .ResponsiveWidget.my-css-class-to-use-when-small {
   * //   background-color: white;
   * // }
   *
   * @example
   * <caption>Use a set of default classes</caption>
   * // ...
   * responsiveMixIn(this);
   *
   * // in css:
   * // .ResponsiveWidget.tablet-portrait-up {
   * //   display: flex;
   * //   flex-flow: row wrap;
   * // }
   * // .ResponsiveWidget.tablet-landscape-up {
   * //   flex-flow: row nowrap;
   * // }
   * @class
   * @alias module:bajaux/mixin/responsiveMixIn
   * @extends module:bajaux/Widget
   * @param {module:bajaux/Widget} target The widget to apply the mixin to.
   * @param {Object.<String, module:bajaux/mixin/responsiveMixIn~ResponsiveCondition|module:bajaux/mixin/responsiveMixIn~ResponsiveCallback>} conditions An
   * object that maps class names to set of conditions. If all conditions are
   * met, the class name will be added to the widget's DOM element. If the
   * conditions aren't met, the class name will be removed.
   */
  var responsiveMixIn = function (target, conditions) {
    if (!(target instanceof Widget)) {
      throw new Error("Responsive mixin only applies to instances or sub-classes of Widget");
    }

    conditions = conditions || responsiveMixIn.DEFAULT_CONDITIONS;

    var mixins = target.$mixins,
        layout = target.layout;
    
    if (!_.contains(mixins, MIXIN_NAME)) {
      mixins.push(MIXIN_NAME);
    }

    target.$responsiveConditions = conditions;

    /**
     * Returns true if the class can be added/removed to/from a widget's
     * DOM element.
     * 
     * @function module:bajaux/mixin/responsiveMixIn#canApplyResponsiveClass
     * @param {String} className The name of the class to test for.
     * @param {module:bajaux/mixin/responsiveMixIn~ResponsiveMediaInfo} [mediaInfo=this.getResponsiveMediaInfo()]
     * The widget media info.
     * @returns {Boolean} Returns true if all the conditions for applying
     * the class are met.
     */
    target.canApplyResponsiveClass = target.canApplyResponsiveClass || function (className, info) {
      info = info || this.getResponsiveMediaInfo();

      var that = this,
          condition = that.$responsiveConditions[className],
          width = info.width,
          height = info.height;

      if (!condition) {
        return false;
      }

      if (typeof condition === 'function') {
        return condition.call(that, info);
      }

      if (isNumber(condition.maxWidth) && width > condition.maxWidth) {
        return false;
      }

      if (isNumber(condition.minWidth) && width < condition.minWidth) {
        return false;
      }

      if (isNumber(condition.maxHeight) && height > condition.maxHeight) {
        return false;
      }

      if (isNumber(condition.minHeight) && height < condition.minHeight) {
        return false;
      }

      return true;
    };

    /**
     * Return media information for the widget. This is used in deciding
     * whether to add/remove a CSS class to/from a widget when it's laid out.
     * The information will come from the widget's container, not the widget
     * itself. By default, it will look up the DOM hierarchy to find an element
     * with the class `bajaux-widget-container`, which will be provided to you
     * in most profiles (the HTML5 Hx Profile's content window, the element
     * containing a widget in a Px view, etc.). If none is found, will use the
     * dimensions of the browser window itself.
     *
     * This method returns width and height but could contain more properties
     * in future. Please note, any calculated pixel values should be
     * floored to the nearest integer.
     *
     * A developer can override this method to calculate the media information
     * differently.
     *
     * @function module:bajaux/mixin/responsiveMixIn#getResponsiveMediaInfo
     * @returns {module:bajaux/mixin/responsiveMixIn~ResponsiveMediaInfo} The media information for the widget.
     */
    target.getResponsiveMediaInfo = target.getResponsiveMediaInfo || function () {
      var container = this.jq().closest('.bajaux-widget-container');
      if (container.length) {
        return {
          width: Math.floor(container.innerWidth() || 0),
          height: Math.floor(container.innerHeight() || 0)
        };
      } else {
        var w = $(window);
        return {
          width: w.width(),
          height: w.height()
        };
      }
    };

    /**
     * Overrides the bajaux Widget's `layout` method. This will add/remove
     * class names to/from a widget's DOM element depending on whether
     * its responsive conditions are met.
     *
     * @see module:bajaux/Widget#layout
     * @see module:bajaux/Widget#doLayout
     *
     * @override
     * @function module:bajaux/mixin/responsiveMixIn#layout
     * @returns {Promise}
     */
    target.layout = function () {
      // Short circuit if the widget isn't initialized yet.
      if (!this.isInitialized()) { return Promise.resolve(); }

      var jq = this.jq(),
        info = this.getResponsiveMediaInfo(),
        conditions = this.$responsiveConditions;

      for (var className in conditions) {
        if (conditions.hasOwnProperty(className)) {
          jq.toggleClass(className, this.canApplyResponsiveClass(className, info));
        }
      }       

      return layout.apply(this, arguments);
    };
  }; // responsiveMixIn

  /**
   * Default conditions to use when conditions are not specified. Changing these
   * can potentially change the behavior of all Widgets that use them.
   * @private
   * @type {object}
   */
  responsiveMixIn.DEFAULT_CONDITIONS = {
    'phone-only': { maxWidth: 599 },
    'tablet-portrait-up': { minWidth: 600 },
    'tablet-landscape-up': { minWidth: 900 },
    'desktop-up': { minWidth: 1200 }
  };

  function isNumber(o) { return typeof o === 'number'; }

  return responsiveMixIn;
});