bronycub/sugarcub

View on GitHub
core/static/vendor/jquery-expander/jquery.expander.js

Summary

Maintainability
D
2 days
Test Coverage
/* jshint -W003 */
/*!
 * jQuery Expander Plugin - v1.7.0 - 2016-03-12
 * http://plugins.learningjquery.com/expander/
 * Copyright (c) 2016 Karl Swedberg
 * Licensed MIT (http://www.opensource.org/licenses/mit-license.php)
 */

(function(factory) {
  if (typeof define === 'function' && define.amd) {
    define(['jquery'], factory);
  } else if (typeof module === 'object' && typeof module.exports === 'object') {
    module.exports = factory;
  } else {
    factory(jQuery);
  }
})(function($) {
  $.expander = {
    version: '1.7.0',
    defaults: {
      // the number of characters at which the contents will be sliced into two parts.
      slicePoint: 100,

      // a string of characters at which to slice the contents into two parts,
      // but only if the string appears before slicePoint
      // Useful for slicing at the first line break, e.g. {sliceOn: '<br'}
      sliceOn: null,

      // whether to keep the last word of the summary whole (true) or let it slice in the middle of a word (false)
      preserveWords: true,

      // whether to normalize the whitespace in the data to display (true) or not (false)
      normalizeWhitespace: true,

      // whether to count and display the number of words inside the collapsed text
      showWordCount: false,

      // text to include between summary and detail. Default ' ' prevents appearance of
      // collapsing two words into one.
      // Was hard-coded in script; now exposed as an option to fix issue #106.
      detailPrefix: ' ',

      // What to display around the counted number of words, set to '{{count}}' to show only the number
      wordCountText: ' ({{count}} words)',

      // a threshold of sorts for whether to initially hide/collapse part of the element's contents.
      // If after slicing the contents in two there are fewer words in the second part than
      // the value set by widow, we won't bother hiding/collapsing anything.
      widow: 4,

      // text displayed in a link instead of the hidden part of the element.
      // clicking this will expand/show the hidden/collapsed text
      expandText: 'read more',
      expandPrefix: '&hellip; ',

      expandAfterSummary: false,

      // Possible word endings to test against for when preserveWords: true
      wordEnd: /(&(?:[^;]+;)?|[0-9a-zA-Z\u00C0-\u0100]+|[^\u0000-\u007F]+)$/,

      // class names for summary element and detail element
      summaryClass: 'summary',
      detailClass: 'details',

      // class names for <span> around "read-more" link and "read-less" link
      moreClass: 'read-more',
      lessClass: 'read-less',

      // class names for <a> around "read-more" link and "read-less" link
      moreLinkClass: 'more-link',
      lessLinkClass: 'less-link',

      // number of milliseconds after text has been expanded at which to collapse the text again.
      // when 0, no auto-collapsing
      collapseTimer: 0,

      // effects for expanding and collapsing
      expandEffect: 'slideDown',
      expandSpeed: 250,
      collapseEffect: 'slideUp',
      collapseSpeed: 200,

      // allow the user to re-collapse the expanded text.
      userCollapse: true,

      // text to use for the link to re-collapse the text
      userCollapseText: 'read less',
      userCollapsePrefix: ' ',

      // all callback functions have the this keyword mapped to the element in the jQuery set when .expander() is called
      onSlice: null, // function() {}
      beforeExpand: null, // function() {},
      afterExpand: null, // function() {},
      onCollapse: null, // function(byUser) {}
      afterCollapse: null // function() {}
    }
  };

  $.fn.expander = function(options) {
    var meth = 'init';

    if (typeof options === 'string') {
      meth = options;
      options = {};
    }

    var opts = $.extend({}, $.expander.defaults, options);
    var rSelfClose = /^<(?:area|br|col|embed|hr|img|input|link|meta|param).*>$/i;
    var rAmpWordEnd = opts.wordEnd;
    var rOpenCloseTag = /<\/?(\w+)[^>]*>/g;
    var rOpenTag = /<(\w+)[^>]*>/g;
    var rCloseTag = /<\/(\w+)>/g;
    var rLastCloseTag = /(<\/([^>]+)>)\s*$/;
    var rTagPlus = /^(<[^>]+>)+.?/;
    var rMultiSpace = /\s\s+/g;
    var delayedCollapse;

    var removeSpaces = function(str) {
      return opts.normalizeWhitespace ? $.trim(str || '').replace(rMultiSpace, ' ') : str;
    };

    var methods = {
      init: function() {
        this.each(function() {
          var i, l, tmp, newChar, summTagless, summOpens, summCloses,
              lastCloseTag, detailText, detailTagless, html, expand;
          var $thisDetails, $readMore;
          var slicePointChanged;
          var openTagsForDetails = [];
          var closeTagsForsummaryText = [];
          var strayChars = '';
          var defined = {};
          var thisEl = this;
          var $this = $(this);
          var $summEl = $([]);
          var o = $.extend({}, opts, $this.data('expander') || $.meta && $this.data() || {});
          var hasDetails = !!$this.find('.' + o.detailClass).length;
          var hasBlocks = !!$this.find('*').filter(function() {
            var display = $(this).css('display');

            return (/^block|table|list/).test(display);
          }).length;
          var el = hasBlocks ? 'div' : 'span';
          var detailSelector = el + '.' + o.detailClass;
          var moreClass = o.moreClass + '';
          var lessClass = o.lessClass + '';
          var expandSpeed = o.expandSpeed || 0;
          var allHtml = removeSpaces($this.html());
          var summaryText = allHtml.slice(0, o.slicePoint);

          // allow multiple classes for more/less links
          o.moreSelector = 'span.' + moreClass.split(' ').join('.');
          o.lessSelector = 'span.' + lessClass.split(' ').join('.');
          // bail out if we've already set up the expander on this element
          if ($.data(this, 'expanderInit')) {
            return;
          }

          $.data(this, 'expanderInit', true);
          $.data(this, 'expander', o);
          // determine which callback functions are defined
          $.each(['onSlice','beforeExpand', 'afterExpand', 'onCollapse', 'afterCollapse'], function(index, val) {
            defined[val] = $.isFunction(o[val]);
          });

          // back up if we're in the middle of a tag or word
          summaryText = backup(summaryText);

          // summary text sans tags length
          summTagless = summaryText.replace(rOpenCloseTag, '').length;

          // add more characters to the summary, one for each character in the tags
          while (summTagless < o.slicePoint) {
            newChar = allHtml.charAt(summaryText.length);

            if (newChar === '<') {
              newChar = allHtml.slice(summaryText.length).match(rTagPlus)[0];
            }
            summaryText += newChar;
            summTagless++;
          }

          // SliceOn script, Closes #16, resolves #59
          // Original SliceEarlierAt code (since modfied): Sascha Peilicke @saschpe
          if (o.sliceOn) {
            slicePointChanged = changeSlicePoint({
              sliceOn: o.sliceOn,
              slicePoint: o.slicePoint,
              allHtml: allHtml,
              summaryText: summaryText
            });

            summaryText = slicePointChanged.summaryText;
          }

          summaryText = backup(summaryText, o.preserveWords && allHtml.slice(summaryText.length).length);

          // separate open tags from close tags and clean up the lists
          summOpens = summaryText.match(rOpenTag) || [];
          summCloses = summaryText.match(rCloseTag) || [];

          // filter out self-closing tags
          tmp = [];
          $.each(summOpens, function(index, val) {
            if (!rSelfClose.test(val)) {
              tmp.push(val);
            }
          });
          summOpens = tmp;

          // strip close tags to just the tag name
          l = summCloses.length;

          for (i = 0; i < l; i++) {
            summCloses[i] = summCloses[i].replace(rCloseTag, '$1');
          }
          // tags that start in summary and end in detail need:
          // a). close tag at end of summary
          // b). open tag at beginning of detail
          $.each(summOpens, function(index, val) {
            var thisTagName = val.replace(rOpenTag, '$1');
            var closePosition = $.inArray(thisTagName, summCloses);

            if (closePosition === -1) {
              openTagsForDetails.push(val);
              closeTagsForsummaryText.push('</' + thisTagName + '>');

            } else {
              summCloses.splice(closePosition, 1);
            }
          });

          // reverse the order of the close tags for the summary so they line up right
          closeTagsForsummaryText.reverse();

          // create necessary summary and detail elements if they don't already exist
          if (!hasDetails) {

            // end script if there is no detail text or if detail has fewer words than widow option
            detailText = allHtml.slice(summaryText.length);
            detailTagless = $.trim(detailText.replace(rOpenCloseTag, ''));

            if (detailTagless === '' || detailTagless.split(/\s+/).length < o.widow) {
              return;
            }
            // otherwise, continue...
            lastCloseTag = closeTagsForsummaryText.pop() || '';
            summaryText += closeTagsForsummaryText.join('');
            detailText = openTagsForDetails.join('') + detailText;
          } else {
            // assume that even if there are details, we still need readMore/readLess/summary elements
            // (we already bailed out earlier when readMore el was found)
            // but we need to create els differently

            // remove the detail from the rest of the content
            detailText = $this.find(detailSelector).remove().html();

            // The summary is what's left
            summaryText = $this.html();

            // allHtml is the summary and detail combined (this is needed when content has block-level elements)
            allHtml = summaryText + detailText;

            lastCloseTag = '';
          }
          o.moreLabel = $this.find(o.moreSelector).length ? '' : buildMoreLabel(o, detailText);

          if (hasBlocks) {
            detailText = allHtml;
            // Fixes issue #89; Tested by 'split html escapes'
          } else if (summaryText.charAt(summaryText.length - 1) === '&') {
            strayChars = /^[#\w\d\\]+;/.exec(detailText);

            if (strayChars) {
              detailText = detailText.slice(strayChars[0].length);
              summaryText += strayChars[0];
            }
          }
          summaryText += lastCloseTag;

          // onSlice callback
          o.summary = summaryText;
          o.details = detailText;
          o.lastCloseTag = lastCloseTag;

          if (defined.onSlice) {
            // user can choose to return a modified options object
            // one last chance for user to change the options. sneaky, huh?
            // but could be tricky so use at your own risk.
            tmp = o.onSlice.call(thisEl, o);

            // so, if the returned value from the onSlice function is an object with a details property, we'll use that!
            o = tmp && tmp.details ? tmp : o;
          }

          // build the html with summary and detail and use it to replace old contents
          html = buildHTML(o, hasBlocks);

          $this.empty().append(html);

          // set up details and summary for expanding/collapsing
          $thisDetails = $this.find(detailSelector);
          $readMore = $this.find(o.moreSelector);

          // Hide details span using collapseEffect unless
          // expandEffect is NOT slideDown and collapseEffect IS slideUp.
          // The slideUp effect sets span's "default" display to
          // inline-block. This is necessary for slideDown, but
          // problematic for other "showing" animations.
          // Fixes #46
          if (o.collapseEffect === 'slideUp' && o.expandEffect !== 'slideDown' || $this.is(':hidden')) {
            $thisDetails.css({display: 'none'});
          } else {
            $thisDetails[o.collapseEffect](0);
          }

          $summEl = $this.find('div.' + o.summaryClass);

          expand = function(event) {
            event.preventDefault();
            var exSpeed = event.startExpanded ? 0 : expandSpeed;
            $readMore.hide();
            $summEl.hide();

            if (defined.beforeExpand) {
              o.beforeExpand.call(thisEl);
            }

            $thisDetails.stop(false, true)[o.expandEffect](exSpeed, function() {
              $thisDetails.css({zoom: ''});

              if (defined.afterExpand) {
                o.afterExpand.call(thisEl);
              }
              delayCollapse(o, $thisDetails, thisEl);
            });
          };

          $readMore.find('a').unbind('click.expander').bind('click.expander', expand);

          if (o.userCollapse && !$this.find(o.lessSelector).length) {
            $this
            .find(detailSelector)
            .append('<span class="' + o.lessClass + '">' + o.userCollapsePrefix + '<a href="#" class="' + o.lessLinkClass + '">' + o.userCollapseText + '</a></span>');
          }

          $this
          .find(o.lessSelector + ' a')
          .unbind('click.expander')
          .bind('click.expander', function(event) {
            event.preventDefault();
            clearTimeout(delayedCollapse);
            var $detailsCollapsed = $(this).closest(detailSelector);
            reCollapse(o, $detailsCollapsed);

            if (defined.onCollapse) {
              o.onCollapse.call(thisEl, true);
            }
          });

          if (o.startExpanded) {
            expand({
              preventDefault: function() {},
              startExpanded: true
            });
          }

        }); // this.each
      },
      destroy: function() {

        this.each(function() {
          var o, details;
          var $this = $(this);

          if (!$this.data('expanderInit')) {
            return;
          }

          o = $.extend({}, $this.data('expander') || {}, opts);
          details = $this.find('.' + o.detailClass).contents();

          $this.removeData('expanderInit');
          $this.removeData('expander');

          $this.find(o.moreSelector).remove();
          $this.find('.' + o.summaryClass).remove();
          $this.find('.' + o.detailClass).after(details).remove();
          $this.find(o.lessSelector).remove();

        });
      }
    };

    // run the methods (almost always "init")
    if (methods[meth]) {
      methods[ meth ].call(this);
    }

    // utility functions
    function buildHTML(o, blocks) {
      var el = 'span';
      var summary = o.summary;
      var closingTagParts = rLastCloseTag.exec(summary);
      var closingTag = closingTagParts ? closingTagParts[2].toLowerCase() : '';

      if (blocks) {
        el = 'div';

        // if summary ends with a close tag, tuck the moreLabel inside it
        if (closingTagParts && closingTag !== 'a' && !o.expandAfterSummary) {
          summary = summary.replace(rLastCloseTag, o.moreLabel + '$1');
        } else {
          // otherwise (e.g. if ends with self-closing tag) just add moreLabel after summary
          // fixes #19
          summary += o.moreLabel;
        }

        // and wrap it in a div
        summary = '<div class="' + o.summaryClass + '">' + summary + '</div>';
      } else {
        summary += o.moreLabel;
      }

      return [
        summary,

        // after summary, add an optional prefix. Default single space prevents last word of summary
        // and first word of detail from collapsing together into what looks like a single word.
        // (could also be done with CSS, but this feels more natural)
        // Prefix made optional to fix issue #106
        o.detailPrefix || '',
        '<',
        el + ' class="' + o.detailClass + '"',
        '>',
        o.details,
        '</' + el + '>'
      ].join('');
    }

    function buildMoreLabel(o, detailText) {
      var ret = '<span class="' + o.moreClass + '">' + o.expandPrefix;

      if (o.showWordCount) {

        o.wordCountText = o.wordCountText.replace(/\{\{count\}\}/, detailText.replace(rOpenCloseTag, '').replace(/\&(?:amp|nbsp);/g, '').replace(/(?:^\s+|\s+$)/, '').match(/\w+/g).length);

      } else {
        o.wordCountText = '';
      }
      ret += '<a href="#" class="' + o.moreLinkClass + '">' + o.expandText + o.wordCountText + '</a></span>';

      return ret;
    }

    function backup(txt, preserveWords) {
      if (txt.lastIndexOf('<') > txt.lastIndexOf('>')) {
        txt = txt.slice(0, txt.lastIndexOf('<'));
      }

      if (preserveWords) {
        txt = txt.replace(rAmpWordEnd, '');
      }

      return $.trim(txt);
    }

    function reCollapse(o, el) {
      el.stop(true, true)[o.collapseEffect](o.collapseSpeed, function() {
        var prevMore = el.prev('span.' + o.moreClass).show();

        if (!prevMore.length) {
          el.parent().children('div.' + o.summaryClass).show()
            .find('span.' + o.moreClass).show();
        }

        if (o.afterCollapse) {
          o.afterCollapse.call(el);
        }
      });
    }

    function delayCollapse(option, $collapseEl, thisEl) {
      if (option.collapseTimer) {
        delayedCollapse = setTimeout(function() {
          reCollapse(option, $collapseEl);

          if ($.isFunction(option.onCollapse)) {
            option.onCollapse.call(thisEl, false);
          }
        }, option.collapseTimer);
      }
    }

    function changeSlicePoint(info) {
      // Create placeholder string text
      var sliceOnTemp = 'ExpandMoreHere374216623';

      // Replace sliceOn with placeholder unaffected by .text() cleaning
      // (in case sliceOn contains html)
      var summaryTextClean = info.summaryText.replace(info.sliceOn, sliceOnTemp);
      summaryTextClean = $('<div>' + summaryTextClean + '</div>').text();

      // Find true location of sliceOn placeholder
      var sliceOnIndexClean = summaryTextClean.indexOf(sliceOnTemp);

      // Store location of html version too
      var sliceOnIndexHtml = info.summaryText.indexOf(info.sliceOn);

      // Base condition off of true sliceOn location...
      if (sliceOnIndexClean !== -1 && sliceOnIndexClean < info.slicePoint) {
        // ...but keep html in summaryText
        info.summaryText = info.allHtml.slice(0, sliceOnIndexHtml);
      }

      return info;
    }

    return this;
  };

  // plugin defaults
  $.fn.expander.defaults = $.expander.defaults;
});