/*! * @preserve * * Readmore.js jQuery plugin * Author: @jed_foster * Project home: http://jedfoster.github.io/Readmore.js * Licensed under the MIT license * * Debounce function from http://davidwalsh.name/javascript-debounce-function */ /* global jQuery */ (function(factory) { if (typeof define === 'function' && define.amd) { // AMD define(['jquery'], factory); } else if (typeof exports === 'object') { // CommonJS module.exports = factory(require('jquery')); } else { // Browser globals factory(jQuery); } }(function($) { 'use strict'; var readmore = 'readmore', defaults = { speed: 100, collapsedHeight: 200, heightMargin: 16, moreLink: 'Read More', lessLink: 'Read Less', embedCSS: true, blockCSS: 'display: block; width: 100%;', startOpen: false, // callbacks blockProcessed: function() {}, beforeToggle: function() {}, afterToggle: function() {} }, cssEmbedded = {}, uniqueIdCounter = 0; function debounce(func, wait, immediate) { var timeout; return function() { var context = this, args = arguments; var later = function() { timeout = null; if (! immediate) { func.apply(context, args); } }; var callNow = immediate && !timeout; clearTimeout(timeout); timeout = setTimeout(later, wait); if (callNow) { func.apply(context, args); } }; } function uniqueId(prefix) { var id = ++uniqueIdCounter; return String(prefix == null ? 'rmjs-' : prefix) + id; } function setBoxHeights(element) { var el = element.clone().css({ height: 'auto', width: element.width(), maxHeight: 'none', overflow: 'hidden' }).insertAfter(element), expandedHeight = el.outerHeight(), cssMaxHeight = parseInt(el.css({maxHeight: ''}).css('max-height').replace(/[^-\d\.]/g, ''), 10), defaultHeight = element.data('defaultHeight'); el.remove(); var collapsedHeight = cssMaxHeight || element.data('collapsedHeight') || defaultHeight; // Store our measurements. element.data({ expandedHeight: expandedHeight, maxHeight: cssMaxHeight, collapsedHeight: collapsedHeight }) // and disable any `max-height` property set in CSS .css({ maxHeight: 'none' }); } var resizeBoxes = debounce(function() { $('[data-readmore]').each(function() { var current = $(this), isExpanded = (current.attr('aria-expanded') === 'true'); setBoxHeights(current); current.css({ height: current.data( (isExpanded ? 'expandedHeight' : 'collapsedHeight') ) }); }); }, 100); function embedCSS(options) { if (! cssEmbedded[options.selector]) { var styles = ' '; if (options.embedCSS && options.blockCSS !== '') { styles += options.selector + ' + [data-readmore-toggle], ' + options.selector + '[data-readmore]{' + options.blockCSS + '}'; } // Include the transition CSS even if embedCSS is false styles += options.selector + '[data-readmore]{' + 'transition: height ' + options.speed + 'ms;' + 'overflow: hidden;' + '}'; (function(d, u) { var css = d.createElement('style'); css.type = 'text/css'; if (css.styleSheet) { css.styleSheet.cssText = u; } else { css.appendChild(d.createTextNode(u)); } d.getElementsByTagName('head')[0].appendChild(css); }(document, styles)); cssEmbedded[options.selector] = true; } } function Readmore(element, options) { this.element = element; this.options = $.extend({}, defaults, options); embedCSS(this.options); this._defaults = defaults; this._name = readmore; this.init(); // IE8 chokes on `window.addEventListener`, so need to test for support. if (window.addEventListener) { // Need to resize boxes when the page has fully loaded. window.addEventListener('load', resizeBoxes); window.addEventListener('resize', resizeBoxes); } else { window.attachEvent('load', resizeBoxes); window.attachEvent('resize', resizeBoxes); } } Readmore.prototype = { init: function() { var current = $(this.element); current.data({ defaultHeight: this.options.collapsedHeight, heightMargin: this.options.heightMargin }); setBoxHeights(current); var collapsedHeight = current.data('collapsedHeight'), heightMargin = current.data('heightMargin'); if (current.outerHeight(true) <= collapsedHeight + heightMargin) { // The block is shorter than the limit, so there's no need to truncate it. if (this.options.blockProcessed && typeof this.options.blockProcessed === 'function') { this.options.blockProcessed(current, false); } return true; } else { var id = current.attr('id') || uniqueId(), useLink = this.options.startOpen ? this.options.lessLink : this.options.moreLink; current.attr({ 'data-readmore': '', 'aria-expanded': this.options.startOpen, 'id': id }); current.after($(useLink) .on('click', (function(_this) { return function(event) { _this.toggle(this, current[0], event); }; })(this)) .attr({ 'data-readmore-toggle': id, 'aria-controls': id })); if (! this.options.startOpen) { current.css({ height: collapsedHeight }); } if (this.options.blockProcessed && typeof this.options.blockProcessed === 'function') { this.options.blockProcessed(current, true); } } }, toggle: function(trigger, element, event) { if (event) { event.preventDefault(); } if (! trigger) { trigger = $('[aria-controls="' + this.element.id + '"]')[0]; } if (! element) { element = this.element; } var $element = $(element), newHeight = '', newLink = '', expanded = false, collapsedHeight = $element.data('collapsedHeight'); if ($element.height() <= collapsedHeight) { newHeight = $element.data('expandedHeight') + 'px'; newLink = 'lessLink'; expanded = true; } else { newHeight = collapsedHeight; newLink = 'moreLink'; } // Fire beforeToggle callback // Since we determined the new "expanded" state above we're now out of sync // with our true current state, so we need to flip the value of `expanded` if (this.options.beforeToggle && typeof this.options.beforeToggle === 'function') { this.options.beforeToggle(trigger, $element, ! expanded); } $element.css({'height': newHeight}); // Fire afterToggle callback $element.on('transitionend', (function(_this) { return function() { if (_this.options.afterToggle && typeof _this.options.afterToggle === 'function') { _this.options.afterToggle(trigger, $element, expanded); } $(this).attr({ 'aria-expanded': expanded }).off('transitionend'); } })(this)); $(trigger).replaceWith($(this.options[newLink]) .on('click', (function(_this) { return function(event) { _this.toggle(this, element, event); }; })(this)) .attr({ 'data-readmore-toggle': $element.attr('id'), 'aria-controls': $element.attr('id') })); }, destroy: function() { $(this.element).each(function() { var current = $(this); current.attr({ 'data-readmore': null, 'aria-expanded': null }) .css({ maxHeight: '', height: '' }) .next('[data-readmore-toggle]') .remove(); current.removeData(); }); } }; $.fn.readmore = function(options) { var args = arguments, selector = this.selector; options = options || {}; if (typeof options === 'object') { return this.each(function() { if ($.data(this, 'plugin_' + readmore)) { var instance = $.data(this, 'plugin_' + readmore); instance.destroy.apply(instance); } options.selector = selector; $.data(this, 'plugin_' + readmore, new Readmore(this, options)); }); } else if (typeof options === 'string' && options[0] !== '_' && options !== 'init') { return this.each(function () { var instance = $.data(this, 'plugin_' + readmore); if (instance instanceof Readmore && typeof instance[options] === 'function') { instance[options].apply(instance, Array.prototype.slice.call(args, 1)); } }); } }; }));