From 150de2024f77a44ab0f3e6aa46ab48e6a1c3176c Mon Sep 17 00:00:00 2001 From: Glen Cheney Date: Mon, 18 Apr 2016 13:32:40 -0700 Subject: [PATCH] Remove `.es6` suffix for file name. Remove old grunt file. Only include jQuery on pages which request it. Use SRI hash for CDN version of jQuery. --- Gruntfile.js | 163 --- _includes/scripts.html | 4 +- _posts/2013-08-25-animated.html | 1 + src/shuffle.es6.js | 1199 --------------- src/shuffle.js | 2429 +++++++++++++------------------ webpack.config.js | 2 +- webpack.config.min.js | 2 +- 7 files changed, 982 insertions(+), 2818 deletions(-) delete mode 100644 Gruntfile.js delete mode 100644 src/shuffle.es6.js diff --git a/Gruntfile.js b/Gruntfile.js deleted file mode 100644 index c8cbd79..0000000 --- a/Gruntfile.js +++ /dev/null @@ -1,163 +0,0 @@ -module.exports = function(grunt) { - require('load-grunt-tasks')(grunt); - - var banner = [ - '/*!', - ' * Shuffle.js by @Vestride', - ' * Categorize, sort, and filter a responsive grid of items.', - ' * Dependencies: jQuery 1.9+, Modernizr 2.6.2+', - ' * @license MIT license', - ' * @version <%= pkg.version %>', - ' */\n' - ].join('\n'); - - - - // Project configuration. - grunt.initConfig({ - pkg: grunt.file.readJSON('package.json'), - - shell: { - serve: { - options: { - stdout: true, - stderr: true, - failOnError: true - }, - command: 'jekyll serve --watch --config _config.yml,_config_dev.yml' - } - }, - - watch: { - livereload: { - options: { - livereload: true, - interupt: true - }, - files: [ - '_site/**/*.{html,css,js,png,jpg,jpeg,gif,webp,svg,json}' - ] - }, - css: { - files: '_scss/*.scss', - tasks: ['compile-css'], - }, - src: { - files: 'src/*.js', - tasks: ['concat', 'test'] - }, - test: { - files: 'test/specs.js', - tasks: ['test'] - } - }, - - sass: { - main: { - options: { - style: 'expanded' - }, - files: { - 'temp/gallery.css': '_scss/gallery.scss', - 'temp/shuffle-styles.css': '_scss/shuffle-styles.scss', - 'temp/style.css': '_scss/style.scss' - } - } - }, - - autoprefixer: { - options: { - browsers: ['> 1%', 'last 2 versions'] - }, - - main: { - expand: true, - flatten: true, - src: 'temp/*.css', - dest: 'css/' - } - }, - - concat: { - options: { - banner: banner - }, - main: { - src: ['src/intro.js', 'src/shuffle.js', 'src/outro.js'], - dest: 'dist/jquery.shuffle.js' - }, - modernizr: { - src: ['src/modernizr.custom.min.js', 'src/intro.js', 'src/shuffle.js', 'src/outro.js'], - dest: 'dist/jquery.shuffle.modernizr.js' - } - }, - - uglify: { - options: { - preserveComments: false, - banner: banner, - report: 'min', - mangle: true, - compress: {} - }, - main: { - src: 'dist/jquery.shuffle.js', - dest: 'dist/jquery.shuffle.min.js' - }, - modernizr: { - src: 'dist/jquery.shuffle.modernizr.js', - dest: 'dist/jquery.shuffle.modernizr.min.js' - } - }, - - jasmine: { - main: { - src: 'dist/jquery.shuffle.js', - options: { - specs: 'test/specs.js', - vendor: [ - 'dist/modernizr.custom.min.js', - 'http://ajax.googleapis.com/ajax/libs/jquery/1.11.0/jquery.min.js', - 'bower_components/jasmine-jquery/lib/jasmine-jquery.js' - ], - outfile: 'test/_SpecRunner.html', - keepRunner: true - } - } - } - }); - - - grunt.registerTask('compile-css', 'compile and prefix css', function() { - grunt.task.run('sass:main'); - grunt.task.run('autoprefixer:main'); - }); - - // Use Jekyll to watch and rebuild files. - grunt.registerTask('serve', function() { - grunt.task.run(['build', 'shell:serve']); - }); - - - grunt.registerTask('build', function() { - // Copy over custom modernizr build. - grunt.file.copy('src/modernizr.custom.min.js', 'dist/modernizr.custom.min.js'); - - // Run concat and minfication. - grunt.task.run([ - 'concat:main', - 'concat:modernizr', - 'uglify:main', - 'uglify:modernizr', - 'test' - ]); - }); - - grunt.registerTask('test', function() { - grunt.task.run('jasmine:main'); - }); - - // Default task(s). - grunt.registerTask('default', ['serve']); - -}; diff --git a/_includes/scripts.html b/_includes/scripts.html index 6362fd6..277e6a1 100644 --- a/_includes/scripts.html +++ b/_includes/scripts.html @@ -1,5 +1,5 @@ -{% if page.jquery != false %} - +{% if page.jquery %} + {% endif %} {% if page.shuffle != false %} diff --git a/_posts/2013-08-25-animated.html b/_posts/2013-08-25-animated.html index b52c06c..dd36283 100644 --- a/_posts/2013-08-25-animated.html +++ b/_posts/2013-08-25-animated.html @@ -4,6 +4,7 @@ title: Animate In Demo description: When elements enter the viewport, they transition in from zero opacity and from the bottom. image: /demos/animated.jpg prism: true +jquery: true extraJS: [ "viewport.js", "demos/animate-in.js" ] ---
diff --git a/src/shuffle.es6.js b/src/shuffle.es6.js deleted file mode 100644 index 78034e3..0000000 --- a/src/shuffle.es6.js +++ /dev/null @@ -1,1199 +0,0 @@ -'use strict'; - -import 'custom-event-polyfill'; -import matches from 'matches-selector'; -import arrayUnique from 'array-uniq'; -import xtend from 'xtend'; -import throttle from 'throttleit'; -import Point from './point'; -import ShuffleItem from './shuffle-item'; -import Classes from './classes'; -import getNumberStyle from './get-number-style'; -import sorter from './sorter'; -import { onTransitionEnd, cancelTransitionEnd } from './on-transition-end'; - -function toArray(arrayLike) { - return Array.prototype.slice.call(arrayLike); -} - -function arrayMax(array) { - return Math.max.apply(Math, array); -} - -function arrayMin(array) { - return Math.min.apply(Math, array); -} - -function arrayIncludes(array, obj) { - if (arguments.length === 2) { - return arrayIncludes(array)(obj); - } - - return function (obj) { - return array.indexOf(obj) > -1; - }; -} - -// Used for unique instance variables -let id = 0; - -class Shuffle { - - /** - * Categorize, sort, and filter a responsive grid of items. - * - * @param {Element} element An element which is the parent container for the grid items. - * @param {Object} [options=Shuffle.options] Options object. - * @constructor - */ - constructor(element, options = {}) { - this.options = xtend(Shuffle.options, options); - - this.useSizer = false; - this.lastSort = {}; - this.lastFilter = Shuffle.ALL_ITEMS; - this.isEnabled = true; - this.isDestroyed = false; - this.isInitialized = false; - this._transitions = []; - this.isTransitioning = false; - this._queue = []; - - element = this._getElementOption(element); - - if (!element) { - throw new TypeError('Shuffle needs to be initialized with an element.'); - } - - this.element = element; - this.id = 'shuffle_' + id++; - - this._init(); - this.isInitialized = true; - } - - _init() { - this.items = this._getItems(); - - this.options.sizer = this._getElementOption(this.options.sizer); - - if (this.options.sizer) { - this.useSizer = true; - } - - // Add class and invalidate styles - this.element.classList.add(Shuffle.ClassName.BASE); - - // Set initial css for each item - this._initItems(); - - // Bind resize events - this._onResize = this._getResizeFunction(); - window.addEventListener('resize', this._onResize); - - // Get container css all in one request. Causes reflow - var containerCss = window.getComputedStyle(this.element, null); - var containerWidth = Shuffle.getSize(this.element).width; - - // Add styles to the container if it doesn't have them. - this._validateStyles(containerCss); - - // We already got the container's width above, no need to cause another - // reflow getting it again... Calculate the number of columns there will be - this._setColumns(containerWidth); - - // Kick off! - this.filter(this.options.group, this.options.initialSort); - - // The shuffle items haven't had transitions set on them yet so the user - // doesn't see the first layout. Set them now that the first layout is done. - // First, however, a synchronous layout must be caused for the previous - // styles to be applied without transitions. - this.element.offsetWidth; // jshint ignore: line - this._setTransitions(); - this.element.style.transition = 'height ' + this.options.speed + 'ms ' + this.options.easing; - } - - /** - * Returns a throttled and proxied function for the resize handler. - * @return {Function} - * @private - */ - _getResizeFunction() { - var resizeFunction = this._handleResize.bind(this); - return this.options.throttle ? - this.options.throttle(resizeFunction, this.options.throttleTime) : - resizeFunction; - } - - /** - * Retrieve an element from an option. - * @param {string|jQuery|Element} option The option to check. - * @return {?Element} The plain element or null. - * @private - */ - _getElementOption(option) { - // If column width is a string, treat is as a selector and search for the - // sizer element within the outermost container - if (typeof option === 'string') { - return this.element.querySelector(option); - - // Check for an element - } else if (option && option.nodeType && option.nodeType === 1) { - return option; - - // Check for jQuery object - } else if (option && option.jquery) { - return option[0]; - } - - return null; - } - - /** - * Ensures the shuffle container has the css styles it needs applied to it. - * @param {Object} styles Key value pairs for position and overflow. - * @private - */ - _validateStyles(styles) { - // Position cannot be static. - if (styles.position === 'static') { - this.element.style.position = 'relative'; - } - - // Overflow has to be hidden - if (styles.overflow !== 'hidden') { - this.element.style.overflow = 'hidden'; - } - } - - /** - * Filter the elements by a category. - * @param {string} [category] Category to filter by. If it's given, the last - * category will be used to filter the items. - * @param {Array} [collection] Optionally filter a collection. Defaults to - * all the items. - * @return {!{filtered: Array, concealed: Array}} - * @private - */ - _filter(category = this.lastFilter, collection = this.items) { - var set = this._getFilteredSets(category, collection); - - // Individually add/remove concealed/filtered classes - this._toggleFilterClasses(set); - - // Save the last filter in case elements are appended. - this.lastFilter = category; - - // This is saved mainly because providing a filter function (like searching) - // will overwrite the `lastFilter` property every time its called. - if (typeof category === 'string') { - this.options.group = category; - } - - return set; - } - - /** - * Returns an object containing the filtered and concealed elements. - * @param {string|Function} category Category or function to filter by. - * @param {Array.} items A collection of items to filter. - * @return {!{filtered: Array, concealed: Array}} - * @private - */ - _getFilteredSets(category, items) { - let filtered = []; - let concealed = []; - - // category === 'all', add filtered class to everything - if (category === Shuffle.ALL_ITEMS) { - filtered = items; - - // Loop through each item and use provided function to determine - // whether to hide it or not. - } else { - items.forEach((item) => { - if (this._doesPassFilter(category, item.element)) { - filtered.push(item); - } else { - concealed.push(item); - } - }); - } - - return { - filtered, - concealed, - }; - } - - /** - * Test an item to see if it passes a category. - * @param {string|Function} category Category or function to filter by. - * @param {Element} element An element to test. - * @return {boolean} Whether it passes the category/filter. - * @private - */ - _doesPassFilter(category, element) { - - if (typeof category === 'function') { - return category.call(element, element, this); - - // Check each element's data-groups attribute against the given category. - } else { - let attr = element.getAttribute('data-' + Shuffle.FILTER_ATTRIBUTE_KEY); - let groups = JSON.parse(attr); - let keys = this.delimeter && !Array.isArray(groups) ? - groups.split(this.delimeter) : - groups; - - if (Array.isArray(category)) { - return category.some(arrayIncludes(keys)); - } - - return arrayIncludes(keys, category); - } - } - - /** - * Toggles the filtered and concealed class names. - * @param {{filtered, concealed}} Object with filtered and concealed arrays. - * @private - */ - _toggleFilterClasses({ filtered, concealed }) { - filtered.forEach((item) => { - item.reveal(); - }); - - concealed.forEach((item) => { - item.conceal(); - }); - } - - /** - * Set the initial css for each item - * @param {Array.} [items] Optionally specifiy at set to initialize. - * @private - */ - _initItems(items = this.items) { - items.forEach((item) => { - item.init(); - }); - } - - /** - * Remove element reference and styles. - * @private - */ - _disposeItems(items = this.items) { - items.forEach((item) => { - item.dispose(); - }); - } - - /** - * Updates the filtered item count. - * @private - */ - _updateItemCount() { - this.visibleItems = this._getFilteredItems().length; - } - - /** - * Sets css transform transition on a group of elements. This is not executed - * at the same time as `item.init` so that transitions don't occur upon - * initialization of Shuffle. - * @param {Array.} items Shuffle items to set transitions on. - * @private - */ - _setTransitions(items = this.items) { - let speed = this.options.speed; - let easing = this.options.easing; - - var str; - if (this.options.useTransforms) { - str = 'transform ' + speed + 'ms ' + easing + - ', opacity ' + speed + 'ms ' + easing; - } else { - str = 'top ' + speed + 'ms ' + easing + - ', left ' + speed + 'ms ' + easing + - ', opacity ' + speed + 'ms ' + easing; - } - - items.forEach((item) => { - item.element.style.transition = str; - }); - } - - _getItems() { - return toArray(this.element.children) - .filter(el => matches(el, this.options.itemSelector)) - .map(el => new ShuffleItem(el)); - } - - /** - * When new elements are added to the shuffle container, update the array of - * items because that is the order `_layout` calls them. - */ - _updateItemsOrder() { - let children = this.element.children; - this.items = sorter(this.items, { - by(element) { - return Array.prototype.indexOf.call(children, element); - }, - }); - } - - _getFilteredItems() { - return this.items.filter(item => item.isVisible); - } - - _getConcealedItems() { - return this.items.filter(item => !item.isVisible); - } - - /** - * Returns the column size, based on column width and sizer options. - * @param {number} containerWidth Size of the parent container. - * @param {number} gutterSize Size of the gutters. - * @return {number} - * @private - */ - _getColumnSize(containerWidth, gutterSize) { - var size; - - // If the columnWidth property is a function, then the grid is fluid - if (typeof this.options.columnWidth === 'function') { - size = this.options.columnWidth(containerWidth); - - // columnWidth option isn't a function, are they using a sizing element? - } else if (this.useSizer) { - size = Shuffle.getSize(this.options.sizer).width; - - // if not, how about the explicitly set option? - } else if (this.options.columnWidth) { - size = this.options.columnWidth; - - // or use the size of the first item - } else if (this.items.length > 0) { - size = Shuffle.getSize(this.items[0].element, true).width; - - // if there's no items, use size of container - } else { - size = containerWidth; - } - - // Don't let them set a column width of zero. - if (size === 0) { - size = containerWidth; - } - - return size + gutterSize; - } - - /** - * Returns the gutter size, based on gutter width and sizer options. - * @param {number} containerWidth Size of the parent container. - * @return {number} - * @private - */ - _getGutterSize(containerWidth) { - var size; - if (typeof this.options.gutterWidth === 'function') { - size = this.options.gutterWidth(containerWidth); - } else if (this.useSizer) { - size = getNumberStyle(this.options.sizer, 'marginLeft'); - } else { - size = this.options.gutterWidth; - } - - return size; - } - - /** - * Calculate the number of columns to be used. Gets css if using sizer element. - * @param {number} [containerWidth] Optionally specify a container width if - * it's already available. - */ - _setColumns(containerWidth = Shuffle.getSize(this.element).width) { - var gutter = this._getGutterSize(containerWidth); - var columnWidth = this._getColumnSize(containerWidth, gutter); - var calculatedColumns = (containerWidth + gutter) / columnWidth; - - // Widths given from getStyles are not precise enough... - if (Math.abs(Math.round(calculatedColumns) - calculatedColumns) < - this.options.columnThreshold) { - // e.g. calculatedColumns = 11.998876 - calculatedColumns = Math.round(calculatedColumns); - } - - this.cols = Math.max(Math.floor(calculatedColumns), 1); - this.containerWidth = containerWidth; - this.colWidth = columnWidth; - } - - /** - * Adjust the height of the grid - */ - _setContainerSize() { - this.element.style.height = this._getContainerSize() + 'px'; - } - - /** - * Based on the column heights, it returns the biggest one. - * @return {number} - * @private - */ - _getContainerSize() { - return arrayMax(this.positions); - } - - /** - * Get the clamped stagger amount. - * @param {number} index Index of the item to be staggered. - * @return {number} - */ - _getStaggerAmount(index) { - return Math.min(index * this.options.staggerAmount, this.options.staggerAmountMax); - } - - /** - * @return {boolean} Whether the event was prevented or not. - */ - _dispatch(name, details = {}) { - if (this.isDestroyed) { - return; - } - - details.shuffle = this; - return !this.element.dispatchEvent(new CustomEvent(name, { - bubbles: true, - cancelable: false, - detail: details, - })); - } - - /** - * Zeros out the y columns array, which is used to determine item placement. - * @private - */ - _resetCols() { - var i = this.cols; - this.positions = []; - while (i--) { - this.positions.push(0); - } - } - - /** - * Loops through each item that should be shown and calculates the x, y position. - * @param {Array.} items Array of items that will be shown/layed - * out in order in their array. - */ - _layout(items) { - let count = 0; - items.forEach((item) => { - var currPos = item.point; - var currScale = item.scale; - var itemSize = Shuffle.getSize(item.element, true); - var pos = this._getItemPosition(itemSize); - - // If the item will not change its position, do not add it to the render - // queue. Transitions don't fire when setting a property to the same value. - if (Point.equals(currPos, pos) && currScale === ShuffleItem.Scale.VISIBLE) { - return; - } - - item.point = pos; - item.scale = ShuffleItem.Scale.VISIBLE; - - this._queue.push({ - item, - opacity: 1, - visibility: 'visible', - transitionDelay: this._getStaggerAmount(count), - }); - - count++; - }); - } - - /** - * Determine the location of the next item, based on its size. - * @param {{width: number, height: number}} itemSize Object with width and height. - * @return {Point} - * @private - */ - _getItemPosition(itemSize) { - var columnSpan = this._getColumnSpan(itemSize.width, this.colWidth, this.cols); - - var setY = this._getColumnSet(columnSpan, this.cols); - - // Finds the index of the smallest number in the set. - var shortColumnIndex = this._getShortColumn(setY, this.options.buffer); - - // Position the item - var point = new Point( - Math.round(this.colWidth * shortColumnIndex), - Math.round(setY[shortColumnIndex])); - - // Update the columns array with the new values for each column. - // e.g. before the update the columns could be [250, 0, 0, 0] for an item - // which spans 2 columns. After it would be [250, itemHeight, itemHeight, 0]. - var setHeight = setY[shortColumnIndex] + itemSize.height; - var setSpan = this.cols + 1 - setY.length; - for (var i = 0; i < setSpan; i++) { - this.positions[shortColumnIndex + i] = setHeight; - } - - return point; - } - - /** - * Determine the number of columns an items spans. - * @param {number} itemWidth Width of the item. - * @param {number} columnWidth Width of the column (includes gutter). - * @param {number} columns Total number of columns - * @return {number} - * @private - */ - _getColumnSpan(itemWidth, columnWidth, columns) { - var columnSpan = itemWidth / columnWidth; - - // If the difference between the rounded column span number and the - // calculated column span number is really small, round the number to - // make it fit. - if (Math.abs(Math.round(columnSpan) - columnSpan) < this.options.columnThreshold) { - // e.g. columnSpan = 4.0089945390298745 - columnSpan = Math.round(columnSpan); - } - - // Ensure the column span is not more than the amount of columns in the whole layout. - return Math.min(Math.ceil(columnSpan), columns); - } - - /** - * Retrieves the column set to use for placement. - * @param {number} columnSpan The number of columns this current item spans. - * @param {number} columns The total columns in the grid. - * @return {Array.} An array of numbers represeting the column set. - * @private - */ - _getColumnSet(columnSpan, columns) { - // The item spans only one column. - if (columnSpan === 1) { - return this.positions; - - // The item spans more than one column, figure out how many different - // places it could fit horizontally. - // The group count is the number of places within the positions this block - // could fit, ignoring the current positions of items. - // Imagine a 2 column brick as the second item in a 4 column grid with - // 10px height each. Find the places it would fit: - // [10, 0, 0, 0] - // | | | - // * * * - // - // Then take the places which fit and get the bigger of the two: - // max([10, 0]), max([0, 0]), max([0, 0]) = [10, 0, 0] - // - // Next, find the first smallest number (the short column). - // [10, 0, 0] - // | - // * - // - // And that's where it should be placed! - } else { - var groupCount = columns + 1 - columnSpan; - var groupY = []; - - // For how many possible positions for this item there are. - for (var i = 0; i < groupCount; i++) { - // Find the bigger value for each place it could fit. - groupY[i] = arrayMax(this.positions.slice(i, i + columnSpan)); - } - - return groupY; - } - } - - /** - * Find index of short column, the first from the left where this item will go. - * - * @param {Array.} positions The array to search for the smallest number. - * @param {number} buffer Optional buffer which is very useful when the height - * is a percentage of the width. - * @return {number} Index of the short column. - * @private - */ - _getShortColumn(positions, buffer) { - var minPosition = arrayMin(positions); - for (var i = 0, len = positions.length; i < len; i++) { - if (positions[i] >= minPosition - buffer && positions[i] <= minPosition + buffer) { - return i; - } - } - - return 0; - } - - /** - * Hides the elements that don't match our filter. - * @param {Array.} collection Collection to shrink. - * @private - */ - _shrink(collection = this._getConcealedItems()) { - let count = 0; - collection.forEach((item) => { - // Continuing would add a transitionend event listener to the element, but - // that listener would not execute because the transform and opacity would - // stay the same. - if (item.scale === ShuffleItem.Scale.FILTERED) { - return; - } - - item.scale = ShuffleItem.Scale.FILTERED; - - this._queue.push({ - item, - opacity: 0, - transitionDelay: this._getStaggerAmount(count), - callback() { - item.element.style.visibility = 'hidden'; - }, - }); - - count++; - }); - } - - /** - * Resize handler. - * @private - */ - _handleResize() { - // If shuffle is disabled, destroyed, don't do anything - if (!this.isEnabled || this.isDestroyed) { - return; - } - - // Will need to check height in the future if it's layed out horizontaly - var containerWidth = Shuffle.getSize(this.element).width; - - // containerWidth hasn't changed, don't do anything - if (containerWidth === this.containerWidth) { - return; - } - - this.update(); - } - - /** - * Returns styles which will be applied to the an item for a transition. - * @param {Object} obj Transition options. - * @return {!Object} Transforms for transitions, left/top for animate. - * @private - */ - _getStylesForTransition(obj) { - let item = obj.item; - let styles = { - opacity: obj.opacity, - visibility: obj.visibility, - transitionDelay: (obj.transitionDelay || 0) + 'ms', - }; - - let x = item.point.x; - let y = item.point.y; - - if (this.options.useTransforms) { - styles.transform = `translate(${x}px, ${y}px) scale(${item.scale})`; - } else { - styles.left = x + 'px'; - styles.top = y + 'px'; - } - - return styles; - } - - _whenTransitionDone(element, itemCallback) { - // TODO what happens when the transition is canceled and the promise never resolves? - return new Promise((resolve) => { - let id = onTransitionEnd(element, (evt) => { - evt.currentTarget.style.transitionDelay = ''; - - if (itemCallback) { - itemCallback(); - } - - resolve(); - }); - this._transitions.push(id); - }); - } - - _transition(opts) { - opts.item.applyCss(this._getStylesForTransition(opts)); - return this._whenTransitionDone(opts.item.element, opts.callback); - } - - /** - * Execute the styles gathered in the style queue. This applies styles to elements, - * triggering transitions. - * @private - */ - _processQueue() { - if (this.isTransitioning) { - this._cancelMovement(); - } - - // Iterate over the queue and keep track of ones that use transitions. - let immediates = []; - let transitions = []; - this._queue.forEach((obj) => { - if (!this.isInitialized || this.options.speed === 0) { - immediates.push(obj); - } else { - transitions.push(obj); - } - }); - - this._styleImmediately(immediates); - - if (transitions.length > 0 && this.options.speed > 0) { - this._startTransitions(transitions); - - // A call to layout happened, but none of the newly filtered items will - // change position. Asynchronously fire the callback here. - } else { - setTimeout(this._dispatchLayout.bind(this), 0); - } - - // Remove everything in the style queue - this._queue.length = 0; - } - - /** - * Create a promise for each transition and wait for all of them to complete, - * then emit the layout event. - * @param {Array.} transitions Array of transition objects. - */ - _startTransitions(transitions) { - // Set flag that shuffle is currently in motion. - this.isTransitioning = true; - - let promises = transitions.map(obj => this._transition(obj)); - Promise.all(promises).then(this._movementFinished.bind(this)); - } - - _cancelMovement() { - // Remove the transition end event for each listener. - this._transitions.forEach(cancelTransitionEnd); - - // Reset the array. - this._transitions.length = 0; - - // Show it's no longer active. - this.isTransitioning = false; - } - - /** - * Apply styles without a transition. - * @param {Array.} objects Array of transition objects. - * @private - */ - _styleImmediately(objects) { - if (objects.length) { - let elements = objects.map(obj => obj.item.element); - - Shuffle._skipTransitions(elements, () => { - objects.forEach((obj) => { - obj.item.applyCss(this._getStylesForTransition(obj)); - - if (obj.callback) { - obj.callback(); - } - }); - }); - } - } - - _movementFinished() { - this._transitions.length = 0; - this.isTransitioning = false; - this._dispatchLayout(); - } - - _dispatchLayout() { - this._dispatch(Shuffle.EventType.LAYOUT); - } - - /** - * The magic. This is what makes the plugin 'shuffle' - * @param {string|Function|Array.} [category] Category to filter by. - * Can be a function, string, or array of strings. - * @param {Object} [sortObj] A sort object which can sort the filtered set - */ - filter(category, sortObj) { - if (!this.isEnabled) { - return; - } - - if (!category || (category && category.length === 0)) { - category = Shuffle.ALL_ITEMS; - } - - this._filter(category); - - // Shrink each concealed item - this._shrink(); - - // How many filtered elements? - this._updateItemCount(); - - // Update transforms on .filtered elements so they will animate to their new positions - this.sort(sortObj); - } - - /** - * Gets the .filtered elements, sorts them, and passes them to layout. - * @param {Object} opts the options object for the sorted plugin - */ - sort(opts = this.lastSort) { - if (!this.isEnabled) { - return; - } - - this._resetCols(); - - var items = this._getFilteredItems(); - items = sorter(items, opts); - - this._layout(items); - - // `_layout` always happens after `_shrink`, so it's safe to process the style - // queue here with styles from the shrink method. - this._processQueue(); - - // Adjust the height of the container. - this._setContainerSize(); - - this.lastSort = opts; - } - - /** - * Reposition everything. - * @param {boolean} isOnlyLayout If true, column and gutter widths won't be - * recalculated. - */ - update(isOnlyLayout) { - if (this.isEnabled) { - - if (!isOnlyLayout) { - // Get updated colCount - this._setColumns(); - } - - // Layout items - this.sort(); - } - } - - /** - * Use this instead of `update()` if you don't need the columns and gutters updated - * Maybe an image inside `shuffle` loaded (and now has a height), which means calculations - * could be off. - */ - layout() { - this.update(true); - } - - /** - * New items have been appended to shuffle. Mix them in with the current - * filter or sort status. - * @param {Array.} newItems Collection of new items. - */ - add(newItems) { - newItems = arrayUnique(newItems).map(el => new ShuffleItem(el)); - - // Add classes and set initial positions. - this._initItems(newItems); - - // Add transition to each item. - this._setTransitions(newItems); - - // Update the list of items. - this.items = this.items.concat(newItems); - this._updateItemsOrder(); - this.filter(this.lastFilter); - } - - /** - * Disables shuffle from updating dimensions and layout on resize - */ - disable() { - this.isEnabled = false; - } - - /** - * Enables shuffle again - * @param {boolean} [isUpdateLayout=true] if undefined, shuffle will update columns and gutters - */ - enable(isUpdateLayout) { - this.isEnabled = true; - if (isUpdateLayout !== false) { - this.update(); - } - } - - /** - * Remove 1 or more shuffle items - * @param {Array.} collection An array containing one or more - * elements in shuffle - * @return {Shuffle} The shuffle object - */ - remove(collection) { - if (!collection.length) { - return; - } - - collection = arrayUnique(collection); - - let oldItems = collection - .map(element => this.getItemByElement(element)) - .filter(item => !!item); - - let handleLayout = () => { - this.element.removeEventListener(Shuffle.EventType.LAYOUT, handleLayout); - this._disposeItems(oldItems); - - // Remove the collection in the callback - collection.forEach((element) => { - element.parentNode.removeChild(element); - }); - - this._dispatch(Shuffle.EventType.REMOVED, { collection }); - - // Let it get garbage collected - collection = null; - oldItems = null; - }; - - // Hide collection first. - this._toggleFilterClasses({ - filtered: [], - concealed: oldItems, - }); - - this._shrink(oldItems); - - this.sort(); - - // Update the list of items here because `remove` could be called again - // with an item that is in the process of being removed. - this.items = this.items.filter(item => !arrayIncludes(oldItems, item)); - this._updateItemCount(); - - this.element.addEventListener(Shuffle.EventType.LAYOUT, handleLayout); - } - - /** - * Retrieve a shuffle item by its element. - * @param {Element} element Element to look for. - * @return {?ShuffleItem} A shuffle item or null if it's not found. - */ - getItemByElement(element) { - for (var i = this.items.length - 1; i >= 0; i--) { - if (this.items[i].element === element) { - return this.items[i]; - } - } - - return null; - } - - /** - * Destroys shuffle, removes events, styles, and classes - */ - destroy() { - this._cancelMovement(); - window.removeEventListener('resize', this._onResize); - - // Reset container styles - this.element.classList.remove('shuffle'); - this.element.removeAttribute('style'); - - // Reset individual item styles - this._disposeItems(); - - // Null DOM references - this.items = null; - this.options.sizer = null; - this.element = null; - this._transitions = null; - - // Set a flag so if a debounced resize has been triggered, - // it can first check if it is actually isDestroyed and not doing anything - this.isDestroyed = true; - } - - /** - * Returns the outer width of an element, optionally including its margins. - * - * There are a few different methods for getting the width of an element, none of - * which work perfectly for all Shuffle's use cases. - * - * 1. getBoundingClientRect() `left` and `right` properties. - * - Accounts for transform scaled elements, making it useless for Shuffle - * elements which have shrunk. - * 2. The `offsetWidth` property. - * - This value stays the same regardless of the elements transform property, - * however, it does not return subpixel values. - * 3. getComputedStyle() - * - This works great Chrome, Firefox, Safari, but IE<=11 does not include - * padding and border when box-sizing: border-box is set, requiring a feature - * test and extra work to add the padding back for IE and other browsers which - * follow the W3C spec here. - * - * @param {Element} element The element. - * @param {boolean} [includeMargins] Whether to include margins. Default is false. - * @return {{width: number, height: number}} The width and height. - */ - static getSize(element, includeMargins) { - // Store the styles so that they can be used by others without asking for it again. - var styles = window.getComputedStyle(element, null); - var width = getNumberStyle(element, 'width', styles); - var height = getNumberStyle(element, 'height', styles); - - if (includeMargins) { - var marginLeft = getNumberStyle(element, 'marginLeft', styles); - var marginRight = getNumberStyle(element, 'marginRight', styles); - var marginTop = getNumberStyle(element, 'marginTop', styles); - var marginBottom = getNumberStyle(element, 'marginBottom', styles); - width += marginLeft + marginRight; - height += marginTop + marginBottom; - } - - return { - width, - height, - }; - } - - /** - * Change a property or execute a function which will not have a transition - * @param {Array.} elements DOM elements that won't be transitioned. - * @param {Function} callback A function which will be called while transition - * is set to 0ms. - * @private - */ - static _skipTransitions(elements, callback) { - let zero = '0ms'; - - // Save current duration and delay. - let data = elements.map((element) => { - let style = element.style; - let duration = style.transitionDuration; - let delay = style.transitionDelay; - - // Set the duration to zero so it happens immediately - style.transitionDuration = zero; - style.transitionDelay = zero; - - return { - duration, - delay, - }; - }); - - callback(); - - // Cause reflow. - elements[0].offsetWidth; // jshint ignore:line - - // Put the duration back - elements.forEach((element, i) => { - element.style.transitionDuration = data[i].duration; - element.style.transitionDelay = data[i].delay; - }); - } -} - -Shuffle.ALL_ITEMS = 'all'; -Shuffle.FILTER_ATTRIBUTE_KEY = 'groups'; - -/** - * @enum {string} - */ -Shuffle.EventType = { - LAYOUT: 'shuffle:layout', - REMOVED: 'shuffle:removed', -}; - -/** @enum {string} */ -Shuffle.ClassName = Classes; - -// Overrideable options -Shuffle.options = { - // Initial filter group. - group: Shuffle.ALL_ITEMS, - - // Transition/animation speed (milliseconds). - speed: 250, - - // CSS easing function to use. - easing: 'ease', - - // e.g. '.picture-item'. - itemSelector: '*', - - // Element or selector string. Use an element to determine the size of columns - // and gutters. - sizer: null, - - // A static number or function that tells the plugin how wide the gutters - // between columns are (in pixels). - gutterWidth: 0, - - // A static number or function that returns a number which tells the plugin - // how wide the columns are (in pixels). - columnWidth: 0, - - // If your group is not json, and is comma delimeted, you could set delimeter - // to ','. - delimeter: null, - - // Useful for percentage based heights when they might not always be exactly - // the same (in pixels). - buffer: 0, - - // Reading the width of elements isn't precise enough and can cause columns to - // jump between values. - columnThreshold: 0.01, - - // Shuffle can be isInitialized with a sort object. It is the same object - // given to the sort method. - initialSort: null, - - // By default, shuffle will throttle resize events. This can be changed or - // removed. - throttle: throttle, - - // How often shuffle can be called on resize (in milliseconds). - throttleTime: 300, - - // Transition delay offset for each item in milliseconds. - staggerAmount: 15, - - // Maximum stagger delay in milliseconds. - staggerAmountMax: 250, - - // Whether to use transforms or absolute positioning. - useTransforms: true, -}; - -// Expose for testing. -Shuffle.Point = Point; -Shuffle.ShuffleItem = ShuffleItem; -Shuffle.sorter = sorter; - -module.exports = Shuffle; diff --git a/src/shuffle.js b/src/shuffle.js index e2feeb4..78034e3 100644 --- a/src/shuffle.js +++ b/src/shuffle.js @@ -1,1674 +1,1199 @@ - -// Validate Modernizr exists. -// Shuffle requires `csstransitions`, `csstransforms`, `csstransforms3d`, -// and `prefixed` to exist on the Modernizr object. -if (typeof Modernizr !== 'object') { - throw new Error('Shuffle.js requires Modernizr.\n' + - 'http://vestride.github.io/Shuffle/#dependencies'); +'use strict'; + +import 'custom-event-polyfill'; +import matches from 'matches-selector'; +import arrayUnique from 'array-uniq'; +import xtend from 'xtend'; +import throttle from 'throttleit'; +import Point from './point'; +import ShuffleItem from './shuffle-item'; +import Classes from './classes'; +import getNumberStyle from './get-number-style'; +import sorter from './sorter'; +import { onTransitionEnd, cancelTransitionEnd } from './on-transition-end'; + +function toArray(arrayLike) { + return Array.prototype.slice.call(arrayLike); } - -/** - * Returns css prefixed properties like `-webkit-transition` or `box-sizing` - * from `transition` or `boxSizing`, respectively. - * @param {(string|boolean)} prop Property to be prefixed. - * @return {string} The prefixed css property. - */ -function dashify( prop ) { - if (!prop) { - return ''; - } - - // Replace upper case with dash-lowercase, - // then fix ms- prefixes because they're not capitalized. - return prop.replace(/([A-Z])/g, function( str, m1 ) { - return '-' + m1.toLowerCase(); - }).replace(/^ms-/,'-ms-'); +function arrayMax(array) { + return Math.max.apply(Math, array); } -// Constant, prefixed variables. -var TRANSITION = Modernizr.prefixed('transition'); -var TRANSITION_DELAY = Modernizr.prefixed('transitionDelay'); -var TRANSITION_DURATION = Modernizr.prefixed('transitionDuration'); - -// Note(glen): Stock Android 4.1.x browser will fail here because it wrongly -// says it supports non-prefixed transitions. -// https://github.com/Modernizr/Modernizr/issues/897 -var TRANSITIONEND = { - 'WebkitTransition' : 'webkitTransitionEnd', - 'transition' : 'transitionend' -}[ TRANSITION ]; - -var TRANSFORM = Modernizr.prefixed('transform'); -var CSS_TRANSFORM = dashify(TRANSFORM); - -// Constants -var CAN_TRANSITION_TRANSFORMS = Modernizr.csstransforms && Modernizr.csstransitions; -var HAS_TRANSFORMS_3D = Modernizr.csstransforms3d; -var HAS_COMPUTED_STYLE = !!window.getComputedStyle; -var SHUFFLE = 'shuffle'; - -// Configurable. You can change these constants to fit your application. -// The default scale and concealed scale, however, have to be different values. -var ALL_ITEMS = 'all'; -var FILTER_ATTRIBUTE_KEY = 'groups'; -var DEFAULT_SCALE = 1; -var CONCEALED_SCALE = 0.001; - -// Underscore's throttle function. -function throttle(func, wait, options) { - var context, args, result; - var timeout = null; - var previous = 0; - options = options || {}; - var later = function() { - previous = options.leading === false ? 0 : $.now(); - timeout = null; - result = func.apply(context, args); - context = args = null; - }; - return function() { - var now = $.now(); - if (!previous && options.leading === false) { - previous = now; - } - var remaining = wait - (now - previous); - context = this; - args = arguments; - if (remaining <= 0 || remaining > wait) { - clearTimeout(timeout); - timeout = null; - previous = now; - result = func.apply(context, args); - context = args = null; - } else if (!timeout && options.trailing !== false) { - timeout = setTimeout(later, remaining); - } - return result; - }; +function arrayMin(array) { + return Math.min.apply(Math, array); } -function each(obj, iterator, context) { - for (var i = 0, length = obj.length; i < length; i++) { - if (iterator.call(context, obj[i], i, obj) === {}) { - return; - } +function arrayIncludes(array, obj) { + if (arguments.length === 2) { + return arrayIncludes(array)(obj); } -} - -function defer(fn, context, wait) { - return setTimeout( $.proxy( fn, context ), wait ); -} -function arrayMax( array ) { - return Math.max.apply( Math, array ); -} - -function arrayMin( array ) { - return Math.min.apply( Math, array ); -} - - -/** - * Always returns a numeric value, given a value. - * @param {*} value Possibly numeric value. - * @return {number} `value` or zero if `value` isn't numeric. - * @private - */ -function getNumber(value) { - return $.isNumeric(value) ? value : 0; + return function (obj) { + return array.indexOf(obj) > -1; + }; } -var getStyles = window.getComputedStyle || function() {}; - -/** - * Represents a coordinate pair. - * @param {number} [x=0] X. - * @param {number} [y=0] Y. - */ -var Point = function(x, y) { - this.x = getNumber( x ); - this.y = getNumber( y ); -}; - +// Used for unique instance variables +let id = 0; + +class Shuffle { + + /** + * Categorize, sort, and filter a responsive grid of items. + * + * @param {Element} element An element which is the parent container for the grid items. + * @param {Object} [options=Shuffle.options] Options object. + * @constructor + */ + constructor(element, options = {}) { + this.options = xtend(Shuffle.options, options); + + this.useSizer = false; + this.lastSort = {}; + this.lastFilter = Shuffle.ALL_ITEMS; + this.isEnabled = true; + this.isDestroyed = false; + this.isInitialized = false; + this._transitions = []; + this.isTransitioning = false; + this._queue = []; + + element = this._getElementOption(element); + + if (!element) { + throw new TypeError('Shuffle needs to be initialized with an element.'); + } -/** - * Whether two points are equal. - * @param {Point} a Point A. - * @param {Point} b Point B. - * @return {boolean} - */ -Point.equals = function(a, b) { - return a.x === b.x && a.y === b.y; -}; + this.element = element; + this.id = 'shuffle_' + id++; -var COMPUTED_SIZE_INCLUDES_PADDING = (function() { - if (!HAS_COMPUTED_STYLE) { - return false; + this._init(); + this.isInitialized = true; } - var parent = document.body || document.documentElement; - var e = document.createElement('div'); - e.style.cssText = 'width:10px;padding:2px;' + - '-webkit-box-sizing:border-box;box-sizing:border-box;'; - parent.appendChild(e); - - var width = getStyles(e, null).width; - var ret = width === '10px'; - - parent.removeChild(e); - - return ret; -}()); - - -// Used for unique instance variables -var id = 0; -var $window = $( window ); - - -/** - * Categorize, sort, and filter a responsive grid of items. - * - * @param {Element} element An element which is the parent container for the grid items. - * @param {Object} [options=Shuffle.options] Options object. - * @constructor - */ -var Shuffle = function( element, options ) { - options = options || {}; - $.extend( this, Shuffle.options, options, Shuffle.settings ); - - this.$el = $(element); - this.element = element; - this.unique = 'shuffle_' + id++; - - this._fire( Shuffle.EventType.LOADING ); - this._init(); - - // Dispatch the done event asynchronously so that people can bind to it after - // Shuffle has been initialized. - defer(function() { - this.initialized = true; - this._fire( Shuffle.EventType.DONE ); - }, this, 16); -}; + _init() { + this.items = this._getItems(); + this.options.sizer = this._getElementOption(this.options.sizer); -/** - * Events the container element emits with the .shuffle namespace. - * For example, "done.shuffle". - * @enum {string} - */ -Shuffle.EventType = { - LOADING: 'loading', - DONE: 'done', - LAYOUT: 'layout', - REMOVED: 'removed' -}; + if (this.options.sizer) { + this.useSizer = true; + } + // Add class and invalidate styles + this.element.classList.add(Shuffle.ClassName.BASE); -/** @enum {string} */ -Shuffle.ClassName = { - BASE: SHUFFLE, - SHUFFLE_ITEM: 'shuffle-item', - FILTERED: 'filtered', - CONCEALED: 'concealed' -}; + // Set initial css for each item + this._initItems(); + // Bind resize events + this._onResize = this._getResizeFunction(); + window.addEventListener('resize', this._onResize); -// Overrideable options -Shuffle.options = { - group: ALL_ITEMS, // Initial filter group. - speed: 250, // Transition/animation speed (milliseconds). - easing: 'ease-out', // CSS easing function to use. - itemSelector: '', // e.g. '.picture-item'. - sizer: null, // Sizer element. Use an element to determine the size of columns and gutters. - gutterWidth: 0, // A static number or function that tells the plugin how wide the gutters between columns are (in pixels). - columnWidth: 0, // A static number or function that returns a number which tells the plugin how wide the columns are (in pixels). - delimeter: null, // If your group is not json, and is comma delimeted, you could set delimeter to ','. - buffer: 0, // Useful for percentage based heights when they might not always be exactly the same (in pixels). - columnThreshold: HAS_COMPUTED_STYLE ? 0.01 : 0.1, // Reading the width of elements isn't precise enough and can cause columns to jump between values. - initialSort: null, // Shuffle can be initialized with a sort object. It is the same object given to the sort method. - throttle: throttle, // By default, shuffle will throttle resize events. This can be changed or removed. - throttleTime: 300, // How often shuffle can be called on resize (in milliseconds). - sequentialFadeDelay: 150, // Delay between each item that fades in when adding items. - supported: CAN_TRANSITION_TRANSFORMS // Whether to use transforms or absolute positioning. -}; + // Get container css all in one request. Causes reflow + var containerCss = window.getComputedStyle(this.element, null); + var containerWidth = Shuffle.getSize(this.element).width; + // Add styles to the container if it doesn't have them. + this._validateStyles(containerCss); -// Not overrideable -Shuffle.settings = { - useSizer: false, - itemCss : { // default CSS for each item - position: 'absolute', - top: 0, - left: 0, - visibility: 'visible' - }, - revealAppendedDelay: 300, - lastSort: {}, - lastFilter: ALL_ITEMS, - enabled: true, - destroyed: false, - initialized: false, - _animations: [], - _transitions: [], - _isMovementCanceled: false, - styleQueue: [] -}; + // We already got the container's width above, no need to cause another + // reflow getting it again... Calculate the number of columns there will be + this._setColumns(containerWidth); + // Kick off! + this.filter(this.options.group, this.options.initialSort); -// Expose for testing. -Shuffle.Point = Point; - - -/** - * Static methods. - */ - -/** - * If the browser has 3d transforms available, build a string with those, - * otherwise use 2d transforms. - * @param {Point} point X and Y positions. - * @param {number} scale Scale amount. - * @return {string} A normalized string which can be used with the transform style. - * @private - */ -Shuffle._getItemTransformString = function(point, scale) { - if ( HAS_TRANSFORMS_3D ) { - return 'translate3d(' + point.x + 'px, ' + point.y + 'px, 0) scale3d(' + scale + ', ' + scale + ', 1)'; - } else { - return 'translate(' + point.x + 'px, ' + point.y + 'px) scale(' + scale + ')'; + // The shuffle items haven't had transitions set on them yet so the user + // doesn't see the first layout. Set them now that the first layout is done. + // First, however, a synchronous layout must be caused for the previous + // styles to be applied without transitions. + this.element.offsetWidth; // jshint ignore: line + this._setTransitions(); + this.element.style.transition = 'height ' + this.options.speed + 'ms ' + this.options.easing; } -}; + /** + * Returns a throttled and proxied function for the resize handler. + * @return {Function} + * @private + */ + _getResizeFunction() { + var resizeFunction = this._handleResize.bind(this); + return this.options.throttle ? + this.options.throttle(resizeFunction, this.options.throttleTime) : + resizeFunction; + } -/** - * Retrieve the computed style for an element, parsed as a float. - * @param {Element} element Element to get style for. - * @param {string} style Style property. - * @param {CSSStyleDeclaration} [styles] Optionally include clean styles to - * use instead of asking for them again. - * @return {number} The parsed computed value or zero if that fails because IE - * will return 'auto' when the element doesn't have margins instead of - * the computed style. - * @private - */ -Shuffle._getNumberStyle = function( element, style, styles ) { - if ( HAS_COMPUTED_STYLE ) { - styles = styles || getStyles( element, null ); - var value = Shuffle._getFloat( styles[ style ] ); - - // Support IE<=11 and W3C spec. - if ( !COMPUTED_SIZE_INCLUDES_PADDING && style === 'width' ) { - value += Shuffle._getFloat( styles.paddingLeft ) + - Shuffle._getFloat( styles.paddingRight ) + - Shuffle._getFloat( styles.borderLeftWidth ) + - Shuffle._getFloat( styles.borderRightWidth ); - } else if ( !COMPUTED_SIZE_INCLUDES_PADDING && style === 'height' ) { - value += Shuffle._getFloat( styles.paddingTop ) + - Shuffle._getFloat( styles.paddingBottom ) + - Shuffle._getFloat( styles.borderTopWidth ) + - Shuffle._getFloat( styles.borderBottomWidth ); + /** + * Retrieve an element from an option. + * @param {string|jQuery|Element} option The option to check. + * @return {?Element} The plain element or null. + * @private + */ + _getElementOption(option) { + // If column width is a string, treat is as a selector and search for the + // sizer element within the outermost container + if (typeof option === 'string') { + return this.element.querySelector(option); + + // Check for an element + } else if (option && option.nodeType && option.nodeType === 1) { + return option; + + // Check for jQuery object + } else if (option && option.jquery) { + return option[0]; } - return value; - } else { - return Shuffle._getFloat( $( element ).css( style ) ); + return null; } -}; - - -/** - * Parse a string as an float. - * @param {string} value String float. - * @return {number} The string as an float or zero. - * @private - */ -Shuffle._getFloat = function(value) { - return getNumber( parseFloat( value ) ); -}; + /** + * Ensures the shuffle container has the css styles it needs applied to it. + * @param {Object} styles Key value pairs for position and overflow. + * @private + */ + _validateStyles(styles) { + // Position cannot be static. + if (styles.position === 'static') { + this.element.style.position = 'relative'; + } -/** - * Returns the outer width of an element, optionally including its margins. - * - * There are a few different methods for getting the width of an element, none of - * which work perfectly for all Shuffle's use cases. - * - * 1. getBoundingClientRect() `left` and `right` properties. - * - Accounts for transform scaled elements, making it useless for Shuffle - * elements which have shrunk. - * 2. The `offsetWidth` property (or jQuery's CSS). - * - This value stays the same regardless of the elements transform property, - * however, it does not return subpixel values. - * 3. getComputedStyle() - * - This works great Chrome, Firefox, Safari, but IE<=11 does not include - * padding and border when box-sizing: border-box is set, requiring a feature - * test and extra work to add the padding back for IE and other browsers which - * follow the W3C spec here. - * - * @param {Element} element The element. - * @param {boolean} [includeMargins] Whether to include margins. Default is false. - * @return {number} The width. - */ -Shuffle._getOuterWidth = function( element, includeMargins ) { - // Store the styles so that they can be used by others without asking for it again. - var styles = getStyles( element, null ); - var width = Shuffle._getNumberStyle( element, 'width', styles ); - - // Use jQuery here because it uses getComputedStyle internally and is - // cross-browser. Using the style property of the element will only work - // if there are inline styles. - if ( includeMargins ) { - var marginLeft = Shuffle._getNumberStyle( element, 'marginLeft', styles ); - var marginRight = Shuffle._getNumberStyle( element, 'marginRight', styles ); - width += marginLeft + marginRight; + // Overflow has to be hidden + if (styles.overflow !== 'hidden') { + this.element.style.overflow = 'hidden'; + } } - return width; -}; - + /** + * Filter the elements by a category. + * @param {string} [category] Category to filter by. If it's given, the last + * category will be used to filter the items. + * @param {Array} [collection] Optionally filter a collection. Defaults to + * all the items. + * @return {!{filtered: Array, concealed: Array}} + * @private + */ + _filter(category = this.lastFilter, collection = this.items) { + var set = this._getFilteredSets(category, collection); + + // Individually add/remove concealed/filtered classes + this._toggleFilterClasses(set); + + // Save the last filter in case elements are appended. + this.lastFilter = category; + + // This is saved mainly because providing a filter function (like searching) + // will overwrite the `lastFilter` property every time its called. + if (typeof category === 'string') { + this.options.group = category; + } -/** - * Returns the outer height of an element, optionally including its margins. - * @param {Element} element The element. - * @param {boolean} [includeMargins] Whether to include margins. Default is false. - * @return {number} The height. - */ -Shuffle._getOuterHeight = function( element, includeMargins ) { - var styles = getStyles( element, null ); - var height = Shuffle._getNumberStyle( element, 'height', styles ); - - if ( includeMargins ) { - var marginTop = Shuffle._getNumberStyle( element, 'marginTop', styles ); - var marginBottom = Shuffle._getNumberStyle( element, 'marginBottom', styles ); - height += marginTop + marginBottom; + return set; } - return height; -}; - - -/** - * Change a property or execute a function which will not have a transition - * @param {Element} element DOM element that won't be transitioned - * @param {Function} callback A function which will be called while transition - * is set to 0ms. - * @param {Object} [context] Optional context for the callback function. - * @private - */ -Shuffle._skipTransition = function( element, callback, context ) { - var duration = element.style[ TRANSITION_DURATION ]; - - // Set the duration to zero so it happens immediately - element.style[ TRANSITION_DURATION ] = '0ms'; // ms needed for firefox! - - callback.call( context ); - - // Force reflow - var reflow = element.offsetWidth; - // Avoid jshint warnings: unused variables and expressions. - reflow = null; - - // Put the duration back - element.style[ TRANSITION_DURATION ] = duration; -}; - - -/** - * Instance methods. - */ - -Shuffle.prototype._init = function() { - this.$items = this._getItems(); - - this.sizer = this._getElementOption( this.sizer ); + /** + * Returns an object containing the filtered and concealed elements. + * @param {string|Function} category Category or function to filter by. + * @param {Array.} items A collection of items to filter. + * @return {!{filtered: Array, concealed: Array}} + * @private + */ + _getFilteredSets(category, items) { + let filtered = []; + let concealed = []; + + // category === 'all', add filtered class to everything + if (category === Shuffle.ALL_ITEMS) { + filtered = items; + + // Loop through each item and use provided function to determine + // whether to hide it or not. + } else { + items.forEach((item) => { + if (this._doesPassFilter(category, item.element)) { + filtered.push(item); + } else { + concealed.push(item); + } + }); + } - if ( this.sizer ) { - this.useSizer = true; + return { + filtered, + concealed, + }; } - // Add class and invalidate styles - this.$el.addClass( Shuffle.ClassName.BASE ); - - // Set initial css for each item - this._initItems(); - - // Bind resize events - // http://stackoverflow.com/questions/1852751/window-resize-event-firing-in-internet-explorer - $window.on('resize.' + SHUFFLE + '.' + this.unique, this._getResizeFunction()); + /** + * Test an item to see if it passes a category. + * @param {string|Function} category Category or function to filter by. + * @param {Element} element An element to test. + * @return {boolean} Whether it passes the category/filter. + * @private + */ + _doesPassFilter(category, element) { - // Get container css all in one request. Causes reflow - var containerCSS = this.$el.css(['position', 'overflow']); - var containerWidth = Shuffle._getOuterWidth( this.element ); + if (typeof category === 'function') { + return category.call(element, element, this); - // Add styles to the container if it doesn't have them. - this._validateStyles( containerCSS ); - - // We already got the container's width above, no need to cause another reflow getting it again... - // Calculate the number of columns there will be - this._setColumns( containerWidth ); - - // Kick off! - this.shuffle( this.group, this.initialSort ); + // Check each element's data-groups attribute against the given category. + } else { + let attr = element.getAttribute('data-' + Shuffle.FILTER_ATTRIBUTE_KEY); + let groups = JSON.parse(attr); + let keys = this.delimeter && !Array.isArray(groups) ? + groups.split(this.delimeter) : + groups; + + if (Array.isArray(category)) { + return category.some(arrayIncludes(keys)); + } - // The shuffle items haven't had transitions set on them yet - // so the user doesn't see the first layout. Set them now that the first layout is done. - if ( this.supported ) { - defer(function() { - this._setTransitions(); - this.element.style[ TRANSITION ] = 'height ' + this.speed + 'ms ' + this.easing; - }, this); + return arrayIncludes(keys, category); + } } -}; - - -/** - * Returns a throttled and proxied function for the resize handler. - * @return {Function} - * @private - */ -Shuffle.prototype._getResizeFunction = function() { - var resizeFunction = $.proxy( this._onResize, this ); - return this.throttle ? - this.throttle( resizeFunction, this.throttleTime ) : - resizeFunction; -}; + /** + * Toggles the filtered and concealed class names. + * @param {{filtered, concealed}} Object with filtered and concealed arrays. + * @private + */ + _toggleFilterClasses({ filtered, concealed }) { + filtered.forEach((item) => { + item.reveal(); + }); -/** - * Retrieve an element from an option. - * @param {string|jQuery|Element} option The option to check. - * @return {?Element} The plain element or null. - * @private - */ -Shuffle.prototype._getElementOption = function( option ) { - // If column width is a string, treat is as a selector and search for the - // sizer element within the outermost container - if ( typeof option === 'string' ) { - return this.$el.find( option )[0] || null; - - // Check for an element - } else if ( option && option.nodeType && option.nodeType === 1 ) { - return option; - - // Check for jQuery object - } else if ( option && option.jquery ) { - return option[0]; + concealed.forEach((item) => { + item.conceal(); + }); } - return null; -}; - - -/** - * Ensures the shuffle container has the css styles it needs applied to it. - * @param {Object} styles Key value pairs for position and overflow. - * @private - */ -Shuffle.prototype._validateStyles = function(styles) { - // Position cannot be static. - if ( styles.position === 'static' ) { - this.element.style.position = 'relative'; + /** + * Set the initial css for each item + * @param {Array.} [items] Optionally specifiy at set to initialize. + * @private + */ + _initItems(items = this.items) { + items.forEach((item) => { + item.init(); + }); } - // Overflow has to be hidden - if ( styles.overflow !== 'hidden' ) { - this.element.style.overflow = 'hidden'; + /** + * Remove element reference and styles. + * @private + */ + _disposeItems(items = this.items) { + items.forEach((item) => { + item.dispose(); + }); } -}; - -/** - * Filter the elements by a category. - * @param {string} [category] Category to filter by. If it's given, the last - * category will be used to filter the items. - * @param {ArrayLike} [$collection] Optionally filter a collection. Defaults to - * all the items. - * @return {jQuery} Filtered items. - * @private - */ -Shuffle.prototype._filter = function( category, $collection ) { - category = category || this.lastFilter; - $collection = $collection || this.$items; - - var set = this._getFilteredSets( category, $collection ); - - // Individually add/remove concealed/filtered classes - this._toggleFilterClasses( set.filtered, set.concealed ); - - // Save the last filter in case elements are appended. - this.lastFilter = category; - - // This is saved mainly because providing a filter function (like searching) - // will overwrite the `lastFilter` property every time its called. - if ( typeof category === 'string' ) { - this.group = category; + /** + * Updates the filtered item count. + * @private + */ + _updateItemCount() { + this.visibleItems = this._getFilteredItems().length; } - return set.filtered; -}; - + /** + * Sets css transform transition on a group of elements. This is not executed + * at the same time as `item.init` so that transitions don't occur upon + * initialization of Shuffle. + * @param {Array.} items Shuffle items to set transitions on. + * @private + */ + _setTransitions(items = this.items) { + let speed = this.options.speed; + let easing = this.options.easing; + + var str; + if (this.options.useTransforms) { + str = 'transform ' + speed + 'ms ' + easing + + ', opacity ' + speed + 'ms ' + easing; + } else { + str = 'top ' + speed + 'ms ' + easing + + ', left ' + speed + 'ms ' + easing + + ', opacity ' + speed + 'ms ' + easing; + } -/** - * Returns an object containing the filtered and concealed elements. - * @param {string|Function} category Category or function to filter by. - * @param {ArrayLike.} $items A collection of items to filter. - * @return {!{filtered: jQuery, concealed: jQuery}} - * @private - */ -Shuffle.prototype._getFilteredSets = function( category, $items ) { - var $filtered = $(); - var $concealed = $(); - - // category === 'all', add filtered class to everything - if ( category === ALL_ITEMS ) { - $filtered = $items; - - // Loop through each item and use provided function to determine - // whether to hide it or not. - } else { - each($items, function( el ) { - var $item = $(el); - if ( this._doesPassFilter( category, $item ) ) { - $filtered = $filtered.add( $item ); - } else { - $concealed = $concealed.add( $item ); - } - }, this); + items.forEach((item) => { + item.element.style.transition = str; + }); } - return { - filtered: $filtered, - concealed: $concealed - }; -}; - - -/** - * Test an item to see if it passes a category. - * @param {string|Function} category Category or function to filter by. - * @param {jQuery} $item A single item, wrapped with jQuery. - * @return {boolean} Whether it passes the category/filter. - * @private - */ -Shuffle.prototype._doesPassFilter = function( category, $item ) { - if ( $.isFunction( category ) ) { - return category.call( $item[0], $item, this ); - - // Check each element's data-groups attribute against the given category. - } else { - var groups = $item.data( FILTER_ATTRIBUTE_KEY ); - var keys = this.delimeter && !$.isArray( groups ) ? - groups.split( this.delimeter ) : - groups; - var categories = []; - categories = categories.concat(category); - for (var i = 0; i < categories.length; i++) { - var categoryIsInKeys = $.inArray(categories[i], keys) > -1; - if(!categoryIsInKeys) { - return false; - } - } - return true; + _getItems() { + return toArray(this.element.children) + .filter(el => matches(el, this.options.itemSelector)) + .map(el => new ShuffleItem(el)); } -}; - - -/** - * Toggles the filtered and concealed class names. - * @param {jQuery} $filtered Filtered set. - * @param {jQuery} $concealed Concealed set. - * @private - */ -Shuffle.prototype._toggleFilterClasses = function( $filtered, $concealed ) { - $filtered - .removeClass( Shuffle.ClassName.CONCEALED ) - .addClass( Shuffle.ClassName.FILTERED ); - $concealed - .removeClass( Shuffle.ClassName.FILTERED ) - .addClass( Shuffle.ClassName.CONCEALED ); -}; - - -/** - * Set the initial css for each item - * @param {jQuery} [$items] Optionally specifiy at set to initialize - */ -Shuffle.prototype._initItems = function( $items ) { - $items = $items || this.$items; - $items.addClass([ - Shuffle.ClassName.SHUFFLE_ITEM, - Shuffle.ClassName.FILTERED - ].join(' ')); - $items.css( this.itemCss ).data('point', new Point()).data('scale', DEFAULT_SCALE); -}; - - -/** - * Updates the filtered item count. - * @private - */ -Shuffle.prototype._updateItemCount = function() { - this.visibleItems = this._getFilteredItems().length; -}; - - -/** - * Sets css transform transition on a an element. - * @param {Element} element Element to set transition on. - * @private - */ -Shuffle.prototype._setTransition = function( element ) { - element.style[ TRANSITION ] = CSS_TRANSFORM + ' ' + this.speed + 'ms ' + - this.easing + ', opacity ' + this.speed + 'ms ' + this.easing; -}; - -/** - * Sets css transform transition on a group of elements. - * @param {ArrayLike.} $items Elements to set transitions on. - * @private - */ -Shuffle.prototype._setTransitions = function( $items ) { - $items = $items || this.$items; - each($items, function( el ) { - this._setTransition( el ); - }, this); -}; - - -/** - * Sets a transition delay on a collection of elements, making each delay - * greater than the last. - * @param {ArrayLike.} $collection Array to iterate over. - */ -Shuffle.prototype._setSequentialDelay = function( $collection ) { - if ( !this.supported ) { - return; + /** + * When new elements are added to the shuffle container, update the array of + * items because that is the order `_layout` calls them. + */ + _updateItemsOrder() { + let children = this.element.children; + this.items = sorter(this.items, { + by(element) { + return Array.prototype.indexOf.call(children, element); + }, + }); } - // $collection can be an array of dom elements or jquery object - each($collection, function( el, i ) { - // This works because the transition-property: transform, opacity; - el.style[ TRANSITION_DELAY ] = '0ms,' + ((i + 1) * this.sequentialFadeDelay) + 'ms'; - }, this); -}; - - -Shuffle.prototype._getItems = function() { - return this.$el.children( this.itemSelector ); -}; - - -Shuffle.prototype._getFilteredItems = function() { - return this.$items.filter('.' + Shuffle.ClassName.FILTERED); -}; - + _getFilteredItems() { + return this.items.filter(item => item.isVisible); + } -Shuffle.prototype._getConcealedItems = function() { - return this.$items.filter('.' + Shuffle.ClassName.CONCEALED); -}; + _getConcealedItems() { + return this.items.filter(item => !item.isVisible); + } + /** + * Returns the column size, based on column width and sizer options. + * @param {number} containerWidth Size of the parent container. + * @param {number} gutterSize Size of the gutters. + * @return {number} + * @private + */ + _getColumnSize(containerWidth, gutterSize) { + var size; + + // If the columnWidth property is a function, then the grid is fluid + if (typeof this.options.columnWidth === 'function') { + size = this.options.columnWidth(containerWidth); + + // columnWidth option isn't a function, are they using a sizing element? + } else if (this.useSizer) { + size = Shuffle.getSize(this.options.sizer).width; + + // if not, how about the explicitly set option? + } else if (this.options.columnWidth) { + size = this.options.columnWidth; + + // or use the size of the first item + } else if (this.items.length > 0) { + size = Shuffle.getSize(this.items[0].element, true).width; + + // if there's no items, use size of container + } else { + size = containerWidth; + } -/** - * Returns the column size, based on column width and sizer options. - * @param {number} containerWidth Size of the parent container. - * @param {number} gutterSize Size of the gutters. - * @return {number} - * @private - */ -Shuffle.prototype._getColumnSize = function( containerWidth, gutterSize ) { - var size; + // Don't let them set a column width of zero. + if (size === 0) { + size = containerWidth; + } - // If the columnWidth property is a function, then the grid is fluid - if ( $.isFunction( this.columnWidth ) ) { - size = this.columnWidth(containerWidth); + return size + gutterSize; + } - // columnWidth option isn't a function, are they using a sizing element? - } else if ( this.useSizer ) { - size = Shuffle._getOuterWidth(this.sizer); + /** + * Returns the gutter size, based on gutter width and sizer options. + * @param {number} containerWidth Size of the parent container. + * @return {number} + * @private + */ + _getGutterSize(containerWidth) { + var size; + if (typeof this.options.gutterWidth === 'function') { + size = this.options.gutterWidth(containerWidth); + } else if (this.useSizer) { + size = getNumberStyle(this.options.sizer, 'marginLeft'); + } else { + size = this.options.gutterWidth; + } - // if not, how about the explicitly set option? - } else if ( this.columnWidth ) { - size = this.columnWidth; + return size; + } - // or use the size of the first item - } else if ( this.$items.length > 0 ) { - size = Shuffle._getOuterWidth(this.$items[0], true); + /** + * Calculate the number of columns to be used. Gets css if using sizer element. + * @param {number} [containerWidth] Optionally specify a container width if + * it's already available. + */ + _setColumns(containerWidth = Shuffle.getSize(this.element).width) { + var gutter = this._getGutterSize(containerWidth); + var columnWidth = this._getColumnSize(containerWidth, gutter); + var calculatedColumns = (containerWidth + gutter) / columnWidth; + + // Widths given from getStyles are not precise enough... + if (Math.abs(Math.round(calculatedColumns) - calculatedColumns) < + this.options.columnThreshold) { + // e.g. calculatedColumns = 11.998876 + calculatedColumns = Math.round(calculatedColumns); + } - // if there's no items, use size of container - } else { - size = containerWidth; + this.cols = Math.max(Math.floor(calculatedColumns), 1); + this.containerWidth = containerWidth; + this.colWidth = columnWidth; } - // Don't let them set a column width of zero. - if ( size === 0 ) { - size = containerWidth; + /** + * Adjust the height of the grid + */ + _setContainerSize() { + this.element.style.height = this._getContainerSize() + 'px'; } - return size + gutterSize; -}; - - -/** - * Returns the gutter size, based on gutter width and sizer options. - * @param {number} containerWidth Size of the parent container. - * @return {number} - * @private - */ -Shuffle.prototype._getGutterSize = function( containerWidth ) { - var size; - if ( $.isFunction( this.gutterWidth ) ) { - size = this.gutterWidth(containerWidth); - } else if ( this.useSizer ) { - size = Shuffle._getNumberStyle(this.sizer, 'marginLeft'); - } else { - size = this.gutterWidth; + /** + * Based on the column heights, it returns the biggest one. + * @return {number} + * @private + */ + _getContainerSize() { + return arrayMax(this.positions); } - return size; -}; - - -/** - * Calculate the number of columns to be used. Gets css if using sizer element. - * @param {number} [theContainerWidth] Optionally specify a container width if it's already available. - */ -Shuffle.prototype._setColumns = function( theContainerWidth ) { - var containerWidth = theContainerWidth || Shuffle._getOuterWidth( this.element ); - var gutter = this._getGutterSize( containerWidth ); - var columnWidth = this._getColumnSize( containerWidth, gutter ); - var calculatedColumns = (containerWidth + gutter) / columnWidth; - - // Widths given from getStyles are not precise enough... - if ( Math.abs(Math.round(calculatedColumns) - calculatedColumns) < this.columnThreshold ) { - // e.g. calculatedColumns = 11.998876 - calculatedColumns = Math.round( calculatedColumns ); + /** + * Get the clamped stagger amount. + * @param {number} index Index of the item to be staggered. + * @return {number} + */ + _getStaggerAmount(index) { + return Math.min(index * this.options.staggerAmount, this.options.staggerAmountMax); } - this.cols = Math.max( Math.floor(calculatedColumns), 1 ); - this.containerWidth = containerWidth; - this.colWidth = columnWidth; -}; - -/** - * Adjust the height of the grid - */ -Shuffle.prototype._setContainerSize = function() { - this.$el.css( 'height', this._getContainerSize() ); -}; + /** + * @return {boolean} Whether the event was prevented or not. + */ + _dispatch(name, details = {}) { + if (this.isDestroyed) { + return; + } + details.shuffle = this; + return !this.element.dispatchEvent(new CustomEvent(name, { + bubbles: true, + cancelable: false, + detail: details, + })); + } -/** - * Based on the column heights, it returns the biggest one. - * @return {number} - * @private - */ -Shuffle.prototype._getContainerSize = function() { - return arrayMax( this.positions ); -}; + /** + * Zeros out the y columns array, which is used to determine item placement. + * @private + */ + _resetCols() { + var i = this.cols; + this.positions = []; + while (i--) { + this.positions.push(0); + } + } + /** + * Loops through each item that should be shown and calculates the x, y position. + * @param {Array.} items Array of items that will be shown/layed + * out in order in their array. + */ + _layout(items) { + let count = 0; + items.forEach((item) => { + var currPos = item.point; + var currScale = item.scale; + var itemSize = Shuffle.getSize(item.element, true); + var pos = this._getItemPosition(itemSize); + + // If the item will not change its position, do not add it to the render + // queue. Transitions don't fire when setting a property to the same value. + if (Point.equals(currPos, pos) && currScale === ShuffleItem.Scale.VISIBLE) { + return; + } -/** - * Fire events with .shuffle namespace - */ -Shuffle.prototype._fire = function( name, args ) { - this.$el.trigger( name + '.' + SHUFFLE, args && args.length ? args : [ this ] ); -}; + item.point = pos; + item.scale = ShuffleItem.Scale.VISIBLE; + this._queue.push({ + item, + opacity: 1, + visibility: 'visible', + transitionDelay: this._getStaggerAmount(count), + }); -/** - * Zeros out the y columns array, which is used to determine item placement. - * @private - */ -Shuffle.prototype._resetCols = function() { - var i = this.cols; - this.positions = []; - while (i--) { - this.positions.push( 0 ); + count++; + }); } -}; + /** + * Determine the location of the next item, based on its size. + * @param {{width: number, height: number}} itemSize Object with width and height. + * @return {Point} + * @private + */ + _getItemPosition(itemSize) { + var columnSpan = this._getColumnSpan(itemSize.width, this.colWidth, this.cols); + + var setY = this._getColumnSet(columnSpan, this.cols); + + // Finds the index of the smallest number in the set. + var shortColumnIndex = this._getShortColumn(setY, this.options.buffer); + + // Position the item + var point = new Point( + Math.round(this.colWidth * shortColumnIndex), + Math.round(setY[shortColumnIndex])); + + // Update the columns array with the new values for each column. + // e.g. before the update the columns could be [250, 0, 0, 0] for an item + // which spans 2 columns. After it would be [250, itemHeight, itemHeight, 0]. + var setHeight = setY[shortColumnIndex] + itemSize.height; + var setSpan = this.cols + 1 - setY.length; + for (var i = 0; i < setSpan; i++) { + this.positions[shortColumnIndex + i] = setHeight; + } -/** - * Loops through each item that should be shown and calculates the x, y position. - * @param {Array.} items Array of items that will be shown/layed out in order in their array. - * Because jQuery collection are always ordered in DOM order, we can't pass a jq collection. - * @param {boolean} [isOnlyPosition=false] If true this will position the items with zero opacity. - */ -Shuffle.prototype._layout = function( items, isOnlyPosition ) { - each(items, function( item ) { - this._layoutItem( item, !!isOnlyPosition ); - }, this); + return point; + } - // `_layout` always happens after `_shrink`, so it's safe to process the style - // queue here with styles from the shrink method. - this._processStyleQueue(); + /** + * Determine the number of columns an items spans. + * @param {number} itemWidth Width of the item. + * @param {number} columnWidth Width of the column (includes gutter). + * @param {number} columns Total number of columns + * @return {number} + * @private + */ + _getColumnSpan(itemWidth, columnWidth, columns) { + var columnSpan = itemWidth / columnWidth; + + // If the difference between the rounded column span number and the + // calculated column span number is really small, round the number to + // make it fit. + if (Math.abs(Math.round(columnSpan) - columnSpan) < this.options.columnThreshold) { + // e.g. columnSpan = 4.0089945390298745 + columnSpan = Math.round(columnSpan); + } - // Adjust the height of the container. - this._setContainerSize(); -}; + // Ensure the column span is not more than the amount of columns in the whole layout. + return Math.min(Math.ceil(columnSpan), columns); + } + /** + * Retrieves the column set to use for placement. + * @param {number} columnSpan The number of columns this current item spans. + * @param {number} columns The total columns in the grid. + * @return {Array.} An array of numbers represeting the column set. + * @private + */ + _getColumnSet(columnSpan, columns) { + // The item spans only one column. + if (columnSpan === 1) { + return this.positions; + + // The item spans more than one column, figure out how many different + // places it could fit horizontally. + // The group count is the number of places within the positions this block + // could fit, ignoring the current positions of items. + // Imagine a 2 column brick as the second item in a 4 column grid with + // 10px height each. Find the places it would fit: + // [10, 0, 0, 0] + // | | | + // * * * + // + // Then take the places which fit and get the bigger of the two: + // max([10, 0]), max([0, 0]), max([0, 0]) = [10, 0, 0] + // + // Next, find the first smallest number (the short column). + // [10, 0, 0] + // | + // * + // + // And that's where it should be placed! + } else { + var groupCount = columns + 1 - columnSpan; + var groupY = []; -/** - * Calculates the position of the item and pushes it onto the style queue. - * @param {Element} item Element which is being positioned. - * @param {boolean} isOnlyPosition Whether to position the item, but with zero - * opacity so that it can fade in later. - * @private - */ -Shuffle.prototype._layoutItem = function( item, isOnlyPosition ) { - var $item = $(item); - var itemData = $item.data(); - var currPos = itemData.point; - var currScale = itemData.scale; - var itemSize = { - width: Shuffle._getOuterWidth( item, true ), - height: Shuffle._getOuterHeight( item, true ) - }; - var pos = this._getItemPosition( itemSize ); + // For how many possible positions for this item there are. + for (var i = 0; i < groupCount; i++) { + // Find the bigger value for each place it could fit. + groupY[i] = arrayMax(this.positions.slice(i, i + columnSpan)); + } - // If the item will not change its position, do not add it to the render - // queue. Transitions don't fire when setting a property to the same value. - if ( Point.equals(currPos, pos) && currScale === DEFAULT_SCALE ) { - return; + return groupY; + } } - // Save data for shrink - itemData.point = pos; - itemData.scale = DEFAULT_SCALE; - - this.styleQueue.push({ - $item: $item, - point: pos, - scale: DEFAULT_SCALE, - opacity: isOnlyPosition ? 0 : 1, - // Set styles immediately if there is no transition speed. - skipTransition: isOnlyPosition || this.speed === 0, - callfront: function() { - if ( !isOnlyPosition ) { - $item.css( 'visibility', 'visible' ); - } - }, - callback: function() { - if ( isOnlyPosition ) { - $item.css( 'visibility', 'hidden' ); + /** + * Find index of short column, the first from the left where this item will go. + * + * @param {Array.} positions The array to search for the smallest number. + * @param {number} buffer Optional buffer which is very useful when the height + * is a percentage of the width. + * @return {number} Index of the short column. + * @private + */ + _getShortColumn(positions, buffer) { + var minPosition = arrayMin(positions); + for (var i = 0, len = positions.length; i < len; i++) { + if (positions[i] >= minPosition - buffer && positions[i] <= minPosition + buffer) { + return i; } } - }); -}; - -/** - * Determine the location of the next item, based on its size. - * @param {{width: number, height: number}} itemSize Object with width and height. - * @return {Point} - * @private - */ -Shuffle.prototype._getItemPosition = function( itemSize ) { - var columnSpan = this._getColumnSpan( itemSize.width, this.colWidth, this.cols ); - - var setY = this._getColumnSet( columnSpan, this.cols ); - - // Finds the index of the smallest number in the set. - var shortColumnIndex = this._getShortColumn( setY, this.buffer ); - - // Position the item - var point = new Point( - Math.round( this.colWidth * shortColumnIndex ), - Math.round( setY[shortColumnIndex] )); - - // Update the columns array with the new values for each column. - // e.g. before the update the columns could be [250, 0, 0, 0] for an item - // which spans 2 columns. After it would be [250, itemHeight, itemHeight, 0]. - var setHeight = setY[shortColumnIndex] + itemSize.height; - var setSpan = this.cols + 1 - setY.length; - for ( var i = 0; i < setSpan; i++ ) { - this.positions[ shortColumnIndex + i ] = setHeight; + return 0; } - return point; -}; + /** + * Hides the elements that don't match our filter. + * @param {Array.} collection Collection to shrink. + * @private + */ + _shrink(collection = this._getConcealedItems()) { + let count = 0; + collection.forEach((item) => { + // Continuing would add a transitionend event listener to the element, but + // that listener would not execute because the transform and opacity would + // stay the same. + if (item.scale === ShuffleItem.Scale.FILTERED) { + return; + } + item.scale = ShuffleItem.Scale.FILTERED; -/** - * Determine the number of columns an items spans. - * @param {number} itemWidth Width of the item. - * @param {number} columnWidth Width of the column (includes gutter). - * @param {number} columns Total number of columns - * @return {number} - * @private - */ -Shuffle.prototype._getColumnSpan = function( itemWidth, columnWidth, columns ) { - var columnSpan = itemWidth / columnWidth; - - // If the difference between the rounded column span number and the - // calculated column span number is really small, round the number to - // make it fit. - if ( Math.abs(Math.round( columnSpan ) - columnSpan ) < this.columnThreshold ) { - // e.g. columnSpan = 4.0089945390298745 - columnSpan = Math.round( columnSpan ); + this._queue.push({ + item, + opacity: 0, + transitionDelay: this._getStaggerAmount(count), + callback() { + item.element.style.visibility = 'hidden'; + }, + }); + + count++; + }); } - // Ensure the column span is not more than the amount of columns in the whole layout. - return Math.min( Math.ceil( columnSpan ), columns ); -}; + /** + * Resize handler. + * @private + */ + _handleResize() { + // If shuffle is disabled, destroyed, don't do anything + if (!this.isEnabled || this.isDestroyed) { + return; + } + // Will need to check height in the future if it's layed out horizontaly + var containerWidth = Shuffle.getSize(this.element).width; -/** - * Retrieves the column set to use for placement. - * @param {number} columnSpan The number of columns this current item spans. - * @param {number} columns The total columns in the grid. - * @return {Array.} An array of numbers represeting the column set. - * @private - */ -Shuffle.prototype._getColumnSet = function( columnSpan, columns ) { - // The item spans only one column. - if ( columnSpan === 1 ) { - return this.positions; - - // The item spans more than one column, figure out how many different - // places it could fit horizontally. - // The group count is the number of places within the positions this block - // could fit, ignoring the current positions of items. - // Imagine a 2 column brick as the second item in a 4 column grid with - // 10px height each. Find the places it would fit: - // [10, 0, 0, 0] - // | | | - // * * * - // - // Then take the places which fit and get the bigger of the two: - // max([10, 0]), max([0, 0]), max([0, 0]) = [10, 0, 0] - // - // Next, find the first smallest number (the short column). - // [10, 0, 0] - // | - // * - // - // And that's where it should be placed! - } else { - var groupCount = columns + 1 - columnSpan; - var groupY = []; - - // For how many possible positions for this item there are. - for ( var i = 0; i < groupCount; i++ ) { - // Find the bigger value for each place it could fit. - groupY[i] = arrayMax( this.positions.slice( i, i + columnSpan ) ); + // containerWidth hasn't changed, don't do anything + if (containerWidth === this.containerWidth) { + return; } - return groupY; + this.update(); } -}; - -/** - * Find index of short column, the first from the left where this item will go. - * - * @param {Array.} positions The array to search for the smallest number. - * @param {number} buffer Optional buffer which is very useful when the height - * is a percentage of the width. - * @return {number} Index of the short column. - * @private - */ -Shuffle.prototype._getShortColumn = function( positions, buffer ) { - var minPosition = arrayMin( positions ); - for (var i = 0, len = positions.length; i < len; i++) { - if ( positions[i] >= minPosition - buffer && positions[i] <= minPosition + buffer ) { - return i; + /** + * Returns styles which will be applied to the an item for a transition. + * @param {Object} obj Transition options. + * @return {!Object} Transforms for transitions, left/top for animate. + * @private + */ + _getStylesForTransition(obj) { + let item = obj.item; + let styles = { + opacity: obj.opacity, + visibility: obj.visibility, + transitionDelay: (obj.transitionDelay || 0) + 'ms', + }; + + let x = item.point.x; + let y = item.point.y; + + if (this.options.useTransforms) { + styles.transform = `translate(${x}px, ${y}px) scale(${item.scale})`; + } else { + styles.left = x + 'px'; + styles.top = y + 'px'; } + + return styles; } - return 0; -}; + _whenTransitionDone(element, itemCallback) { + // TODO what happens when the transition is canceled and the promise never resolves? + return new Promise((resolve) => { + let id = onTransitionEnd(element, (evt) => { + evt.currentTarget.style.transitionDelay = ''; -/** - * Hides the elements that don't match our filter. - * @param {jQuery} $collection jQuery collection to shrink. - * @private - */ -Shuffle.prototype._shrink = function( $collection ) { - var $concealed = $collection || this._getConcealedItems(); + if (itemCallback) { + itemCallback(); + } - each($concealed, function( item ) { - var $item = $(item); - var itemData = $item.data(); + resolve(); + }); + this._transitions.push(id); + }); + } - // Continuing would add a transitionend event listener to the element, but - // that listener would not execute because the transform and opacity would - // stay the same. - if ( itemData.scale === CONCEALED_SCALE ) { - return; - } + _transition(opts) { + opts.item.applyCss(this._getStylesForTransition(opts)); + return this._whenTransitionDone(opts.item.element, opts.callback); + } - itemData.scale = CONCEALED_SCALE; + /** + * Execute the styles gathered in the style queue. This applies styles to elements, + * triggering transitions. + * @private + */ + _processQueue() { + if (this.isTransitioning) { + this._cancelMovement(); + } - this.styleQueue.push({ - $item: $item, - point: itemData.point, - scale : CONCEALED_SCALE, - opacity: 0, - callback: function() { - $item.css( 'visibility', 'hidden' ); + // Iterate over the queue and keep track of ones that use transitions. + let immediates = []; + let transitions = []; + this._queue.forEach((obj) => { + if (!this.isInitialized || this.options.speed === 0) { + immediates.push(obj); + } else { + transitions.push(obj); } }); - }, this); -}; + this._styleImmediately(immediates); -/** - * Resize handler. - * @private - */ -Shuffle.prototype._onResize = function() { - // If shuffle is disabled, destroyed, don't do anything - if ( !this.enabled || this.destroyed ) { - return; - } + if (transitions.length > 0 && this.options.speed > 0) { + this._startTransitions(transitions); - // Will need to check height in the future if it's layed out horizontaly - var containerWidth = Shuffle._getOuterWidth( this.element ); + // A call to layout happened, but none of the newly filtered items will + // change position. Asynchronously fire the callback here. + } else { + setTimeout(this._dispatchLayout.bind(this), 0); + } - // containerWidth hasn't changed, don't do anything - if ( containerWidth === this.containerWidth ) { - return; + // Remove everything in the style queue + this._queue.length = 0; } - this.update(); -}; - - -/** - * Returns styles for either jQuery animate or transition. - * @param {Object} opts Transition options. - * @return {!Object} Transforms for transitions, left/top for animate. - * @private - */ -Shuffle.prototype._getStylesForTransition = function( opts ) { - var styles = { - opacity: opts.opacity - }; + /** + * Create a promise for each transition and wait for all of them to complete, + * then emit the layout event. + * @param {Array.} transitions Array of transition objects. + */ + _startTransitions(transitions) { + // Set flag that shuffle is currently in motion. + this.isTransitioning = true; - if ( this.supported ) { - styles[ TRANSFORM ] = Shuffle._getItemTransformString( opts.point, opts.scale ); - } else { - styles.left = opts.point.x; - styles.top = opts.point.y; + let promises = transitions.map(obj => this._transition(obj)); + Promise.all(promises).then(this._movementFinished.bind(this)); } - return styles; -}; + _cancelMovement() { + // Remove the transition end event for each listener. + this._transitions.forEach(cancelTransitionEnd); + // Reset the array. + this._transitions.length = 0; -/** - * Transitions an item in the grid - * - * @param {Object} opts options. - * @param {jQuery} opts.$item jQuery object representing the current item. - * @param {Point} opts.point A point object with the x and y coordinates. - * @param {number} opts.scale Amount to scale the item. - * @param {number} opts.opacity Opacity of the item. - * @param {Function} opts.callback Complete function for the animation. - * @param {Function} opts.callfront Function to call before transitioning. - * @private - */ -Shuffle.prototype._transition = function( opts ) { - var styles = this._getStylesForTransition( opts ); - this._startItemAnimation( opts.$item, styles, opts.callfront || $.noop, opts.callback || $.noop ); -}; - + // Show it's no longer active. + this.isTransitioning = false; + } -Shuffle.prototype._startItemAnimation = function( $item, styles, callfront, callback ) { - var _this = this; - // Transition end handler removes its listener. - function handleTransitionEnd( evt ) { - // Make sure this event handler has not bubbled up from a child. - if ( evt.target === evt.currentTarget ) { - $( evt.target ).off( TRANSITIONEND, handleTransitionEnd ); - _this._removeTransitionReference(reference); - callback(); + /** + * Apply styles without a transition. + * @param {Array.} objects Array of transition objects. + * @private + */ + _styleImmediately(objects) { + if (objects.length) { + let elements = objects.map(obj => obj.item.element); + + Shuffle._skipTransitions(elements, () => { + objects.forEach((obj) => { + obj.item.applyCss(this._getStylesForTransition(obj)); + + if (obj.callback) { + obj.callback(); + } + }); + }); } } - var reference = { - $element: $item, - handler: handleTransitionEnd - }; - - callfront(); - - // Transitions are not set until shuffle has loaded to avoid the initial transition. - if ( !this.initialized ) { - $item.css( styles ); - callback(); - return; + _movementFinished() { + this._transitions.length = 0; + this.isTransitioning = false; + this._dispatchLayout(); } - // Use CSS Transforms if we have them - if ( this.supported ) { - $item.css( styles ); - $item.on( TRANSITIONEND, handleTransitionEnd ); - this._transitions.push(reference); - - // Use jQuery to animate left/top - } else { - // Save the deferred object which jQuery returns. - var anim = $item.stop( true ).animate( styles, this.speed, 'swing', callback ); - // Push the animation to the list of pending animations. - this._animations.push( anim.promise() ); + _dispatchLayout() { + this._dispatch(Shuffle.EventType.LAYOUT); } -}; - -/** - * Execute the styles gathered in the style queue. This applies styles to elements, - * triggering transitions. - * @param {boolean} noLayout Whether to trigger a layout event. - * @private - */ -Shuffle.prototype._processStyleQueue = function( noLayout ) { - if ( this.isTransitioning ) { - this._cancelMovement(); - } - - var $transitions = $(); - - // Iterate over the queue and keep track of ones that use transitions. - each(this.styleQueue, function( transitionObj ) { - if ( transitionObj.skipTransition ) { - this._styleImmediately( transitionObj ); - } else { - $transitions = $transitions.add( transitionObj.$item ); - this._transition( transitionObj ); + /** + * The magic. This is what makes the plugin 'shuffle' + * @param {string|Function|Array.} [category] Category to filter by. + * Can be a function, string, or array of strings. + * @param {Object} [sortObj] A sort object which can sort the filtered set + */ + filter(category, sortObj) { + if (!this.isEnabled) { + return; } - }, this); - - - if ( $transitions.length > 0 && this.initialized && this.speed > 0 ) { - // Set flag that shuffle is currently in motion. - this.isTransitioning = true; - - if ( this.supported ) { - this._whenCollectionDone( $transitions, TRANSITIONEND, this._movementFinished ); - // The _transition function appends a promise to the animations array. - // When they're all complete, do things. - } else { - this._whenAnimationsDone( this._movementFinished ); + if (!category || (category && category.length === 0)) { + category = Shuffle.ALL_ITEMS; } - // A call to layout happened, but none of the newly filtered items will - // change position. Asynchronously fire the callback here. - } else if ( !noLayout ) { - defer( this._layoutEnd, this ); - } - - // Remove everything in the style queue - this.styleQueue.length = 0; -}; - -Shuffle.prototype._cancelMovement = function() { - if (this.supported) { - // Remove the transition end event for each listener. - each(this._transitions, function( transition ) { - transition.$element.off( TRANSITIONEND, transition.handler ); - }); - } else { - // Even when `stop` is called on the jQuery animation, its promise will - // still be resolved. Since it cannot be determine from within that callback - // whether the animation was stopped or not, a flag is set here to distinguish - // between the two states. - this._isMovementCanceled = true; - this.$items.stop(true); - this._isMovementCanceled = false; - } + this._filter(category); - // Reset the array. - this._transitions.length = 0; + // Shrink each concealed item + this._shrink(); - // Show it's no longer active. - this.isTransitioning = false; -}; + // How many filtered elements? + this._updateItemCount(); -Shuffle.prototype._removeTransitionReference = function(ref) { - var indexInArray = $.inArray(ref, this._transitions); - if (indexInArray > -1) { - this._transitions.splice(indexInArray, 1); + // Update transforms on .filtered elements so they will animate to their new positions + this.sort(sortObj); } -}; - -/** - * Apply styles without a transition. - * @param {Object} opts Transitions options object. - * @private - */ -Shuffle.prototype._styleImmediately = function( opts ) { - Shuffle._skipTransition(opts.$item[0], function() { - opts.$item.css( this._getStylesForTransition( opts ) ); - }, this); -}; - -Shuffle.prototype._movementFinished = function() { - this.isTransitioning = false; - this._layoutEnd(); -}; - -Shuffle.prototype._layoutEnd = function() { - this._fire( Shuffle.EventType.LAYOUT ); -}; + /** + * Gets the .filtered elements, sorts them, and passes them to layout. + * @param {Object} opts the options object for the sorted plugin + */ + sort(opts = this.lastSort) { + if (!this.isEnabled) { + return; + } -Shuffle.prototype._addItems = function( $newItems, addToEnd, isSequential ) { - // Add classes and set initial positions. - this._initItems( $newItems ); + this._resetCols(); - // Add transition to each item. - this._setTransitions( $newItems ); + var items = this._getFilteredItems(); + items = sorter(items, opts); - // Update the list of - this.$items = this._getItems(); + this._layout(items); - // Shrink all items (without transitions). - this._shrink( $newItems ); - each(this.styleQueue, function( transitionObj ) { - transitionObj.skipTransition = true; - }); + // `_layout` always happens after `_shrink`, so it's safe to process the style + // queue here with styles from the shrink method. + this._processQueue(); - // Apply shrink positions, but do not cause a layout event. - this._processStyleQueue( true ); + // Adjust the height of the container. + this._setContainerSize(); - if ( addToEnd ) { - this._addItemsToEnd( $newItems, isSequential ); - } else { - this.shuffle( this.lastFilter ); + this.lastSort = opts; } -}; + /** + * Reposition everything. + * @param {boolean} isOnlyLayout If true, column and gutter widths won't be + * recalculated. + */ + update(isOnlyLayout) { + if (this.isEnabled) { + + if (!isOnlyLayout) { + // Get updated colCount + this._setColumns(); + } -Shuffle.prototype._addItemsToEnd = function( $newItems, isSequential ) { - // Get ones that passed the current filter - var $passed = this._filter( null, $newItems ); - var passed = $passed.get(); - - // How many filtered elements? - this._updateItemCount(); - - this._layout( passed, true ); + // Layout items + this.sort(); + } + } - if ( isSequential && this.supported ) { - this._setSequentialDelay( passed ); + /** + * Use this instead of `update()` if you don't need the columns and gutters updated + * Maybe an image inside `shuffle` loaded (and now has a height), which means calculations + * could be off. + */ + layout() { + this.update(true); } - this._revealAppended( passed ); -}; + /** + * New items have been appended to shuffle. Mix them in with the current + * filter or sort status. + * @param {Array.} newItems Collection of new items. + */ + add(newItems) { + newItems = arrayUnique(newItems).map(el => new ShuffleItem(el)); + // Add classes and set initial positions. + this._initItems(newItems); -/** - * Triggers appended elements to fade in. - * @param {ArrayLike.} $newFilteredItems Collection of elements. - * @private - */ -Shuffle.prototype._revealAppended = function( newFilteredItems ) { - defer(function() { - each(newFilteredItems, function( el ) { - var $item = $( el ); - this._transition({ - $item: $item, - opacity: 1, - point: $item.data('point'), - scale: DEFAULT_SCALE - }); - }, this); + // Add transition to each item. + this._setTransitions(newItems); - this._whenCollectionDone($(newFilteredItems), TRANSITIONEND, function() { - $(newFilteredItems).css( TRANSITION_DELAY, '0ms' ); - this._movementFinished(); - }); - }, this, this.revealAppendedDelay); -}; + // Update the list of items. + this.items = this.items.concat(newItems); + this._updateItemsOrder(); + this.filter(this.lastFilter); + } + /** + * Disables shuffle from updating dimensions and layout on resize + */ + disable() { + this.isEnabled = false; + } -/** - * Execute a function when an event has been triggered for every item in a collection. - * @param {jQuery} $collection Collection of elements. - * @param {string} eventName Event to listen for. - * @param {Function} callback Callback to execute when they're done. - * @private - */ -Shuffle.prototype._whenCollectionDone = function( $collection, eventName, callback ) { - var done = 0; - var items = $collection.length; - var self = this; - - function handleEventName( evt ) { - if ( evt.target === evt.currentTarget ) { - $( evt.target ).off( eventName, handleEventName ); - done++; - - // Execute callback if all items have emitted the correct event. - if ( done === items ) { - self._removeTransitionReference(reference); - callback.call( self ); - } + /** + * Enables shuffle again + * @param {boolean} [isUpdateLayout=true] if undefined, shuffle will update columns and gutters + */ + enable(isUpdateLayout) { + this.isEnabled = true; + if (isUpdateLayout !== false) { + this.update(); } } - var reference = { - $element: $collection, - handler: handleEventName - }; - - // Bind the event to all items. - $collection.on( eventName, handleEventName ); - - // Keep track of transitionend events so they can be removed. - this._transitions.push(reference); -}; - - -/** - * Execute a callback after jQuery `animate` for a collection has finished. - * @param {Function} callback Callback to execute when they're done. - * @private - */ -Shuffle.prototype._whenAnimationsDone = function( callback ) { - $.when.apply( null, this._animations ).always( $.proxy( function() { - this._animations.length = 0; - if (!this._isMovementCanceled) { - callback.call( this ); + /** + * Remove 1 or more shuffle items + * @param {Array.} collection An array containing one or more + * elements in shuffle + * @return {Shuffle} The shuffle object + */ + remove(collection) { + if (!collection.length) { + return; } - }, this )); -}; + collection = arrayUnique(collection); -/** - * Public Methods - */ - -/** - * The magic. This is what makes the plugin 'shuffle' - * @param {string|Function} [category] Category to filter by. Can be a function - * @param {Object} [sortObj] A sort object which can sort the filtered set - */ -Shuffle.prototype.shuffle = function( category, sortObj ) { - if ( !this.enabled ) { - return; - } - - if ( !category ) { - category = ALL_ITEMS; - } + let oldItems = collection + .map(element => this.getItemByElement(element)) + .filter(item => !!item); - this._filter( category ); + let handleLayout = () => { + this.element.removeEventListener(Shuffle.EventType.LAYOUT, handleLayout); + this._disposeItems(oldItems); - // How many filtered elements? - this._updateItemCount(); + // Remove the collection in the callback + collection.forEach((element) => { + element.parentNode.removeChild(element); + }); - // Shrink each concealed item - this._shrink(); + this._dispatch(Shuffle.EventType.REMOVED, { collection }); - // Update transforms on .filtered elements so they will animate to their new positions - this.sort( sortObj ); -}; + // Let it get garbage collected + collection = null; + oldItems = null; + }; + // Hide collection first. + this._toggleFilterClasses({ + filtered: [], + concealed: oldItems, + }); -/** - * Gets the .filtered elements, sorts them, and passes them to layout. - * @param {Object} opts the options object for the sorted plugin - */ -Shuffle.prototype.sort = function( opts ) { - if ( this.enabled ) { - this._resetCols(); + this._shrink(oldItems); - var sortOptions = opts || this.lastSort; - var items = this._getFilteredItems().sorted( sortOptions ); + this.sort(); - this._layout( items ); + // Update the list of items here because `remove` could be called again + // with an item that is in the process of being removed. + this.items = this.items.filter(item => !arrayIncludes(oldItems, item)); + this._updateItemCount(); - this.lastSort = sortOptions; + this.element.addEventListener(Shuffle.EventType.LAYOUT, handleLayout); } -}; - - -/** - * Reposition everything. - * @param {boolean} isOnlyLayout If true, column and gutter widths won't be - * recalculated. - */ -Shuffle.prototype.update = function( isOnlyLayout ) { - if ( this.enabled ) { - if ( !isOnlyLayout ) { - // Get updated colCount - this._setColumns(); + /** + * Retrieve a shuffle item by its element. + * @param {Element} element Element to look for. + * @return {?ShuffleItem} A shuffle item or null if it's not found. + */ + getItemByElement(element) { + for (var i = this.items.length - 1; i >= 0; i--) { + if (this.items[i].element === element) { + return this.items[i]; + } } - // Layout items - this.sort(); + return null; } -}; - - -/** - * Use this instead of `update()` if you don't need the columns and gutters updated - * Maybe an image inside `shuffle` loaded (and now has a height), which means calculations - * could be off. - */ -Shuffle.prototype.layout = function() { - this.update( true ); -}; + /** + * Destroys shuffle, removes events, styles, and classes + */ + destroy() { + this._cancelMovement(); + window.removeEventListener('resize', this._onResize); -/** - * New items have been appended to shuffle. Fade them in sequentially - * @param {jQuery} $newItems jQuery collection of new items - * @param {boolean} [addToEnd=false] If true, new items will be added to the end / bottom - * of the items. If not true, items will be mixed in with the current sort order. - * @param {boolean} [isSequential=true] If false, new items won't sequentially fade in - */ -Shuffle.prototype.appended = function( $newItems, addToEnd, isSequential ) { - this._addItems( $newItems, addToEnd === true, isSequential !== false ); -}; + // Reset container styles + this.element.classList.remove('shuffle'); + this.element.removeAttribute('style'); + // Reset individual item styles + this._disposeItems(); -/** - * Disables shuffle from updating dimensions and layout on resize - */ -Shuffle.prototype.disable = function() { - this.enabled = false; -}; + // Null DOM references + this.items = null; + this.options.sizer = null; + this.element = null; + this._transitions = null; - -/** - * Enables shuffle again - * @param {boolean} [isUpdateLayout=true] if undefined, shuffle will update columns and gutters - */ -Shuffle.prototype.enable = function( isUpdateLayout ) { - this.enabled = true; - if ( isUpdateLayout !== false ) { - this.update(); + // Set a flag so if a debounced resize has been triggered, + // it can first check if it is actually isDestroyed and not doing anything + this.isDestroyed = true; } -}; + /** + * Returns the outer width of an element, optionally including its margins. + * + * There are a few different methods for getting the width of an element, none of + * which work perfectly for all Shuffle's use cases. + * + * 1. getBoundingClientRect() `left` and `right` properties. + * - Accounts for transform scaled elements, making it useless for Shuffle + * elements which have shrunk. + * 2. The `offsetWidth` property. + * - This value stays the same regardless of the elements transform property, + * however, it does not return subpixel values. + * 3. getComputedStyle() + * - This works great Chrome, Firefox, Safari, but IE<=11 does not include + * padding and border when box-sizing: border-box is set, requiring a feature + * test and extra work to add the padding back for IE and other browsers which + * follow the W3C spec here. + * + * @param {Element} element The element. + * @param {boolean} [includeMargins] Whether to include margins. Default is false. + * @return {{width: number, height: number}} The width and height. + */ + static getSize(element, includeMargins) { + // Store the styles so that they can be used by others without asking for it again. + var styles = window.getComputedStyle(element, null); + var width = getNumberStyle(element, 'width', styles); + var height = getNumberStyle(element, 'height', styles); + + if (includeMargins) { + var marginLeft = getNumberStyle(element, 'marginLeft', styles); + var marginRight = getNumberStyle(element, 'marginRight', styles); + var marginTop = getNumberStyle(element, 'marginTop', styles); + var marginBottom = getNumberStyle(element, 'marginBottom', styles); + width += marginLeft + marginRight; + height += marginTop + marginBottom; + } -/** - * Remove 1 or more shuffle items - * @param {jQuery} $collection A jQuery object containing one or more element in shuffle - * @return {Shuffle} The shuffle object - */ -Shuffle.prototype.remove = function( $collection ) { - - // If this isn't a jquery object, exit - if ( !$collection.length || !$collection.jquery ) { - return; + return { + width, + height, + }; } - function handleRemoved() { - // Remove the collection in the callback - $collection.remove(); + /** + * Change a property or execute a function which will not have a transition + * @param {Array.} elements DOM elements that won't be transitioned. + * @param {Function} callback A function which will be called while transition + * is set to 0ms. + * @private + */ + static _skipTransitions(elements, callback) { + let zero = '0ms'; + + // Save current duration and delay. + let data = elements.map((element) => { + let style = element.style; + let duration = style.transitionDuration; + let delay = style.transitionDelay; + + // Set the duration to zero so it happens immediately + style.transitionDuration = zero; + style.transitionDelay = zero; + + return { + duration, + delay, + }; + }); - // Update things now that elements have been removed. - this.$items = this._getItems(); - this._updateItemCount(); + callback(); - this._fire( Shuffle.EventType.REMOVED, [ $collection, this ] ); + // Cause reflow. + elements[0].offsetWidth; // jshint ignore:line - // Let it get garbage collected - $collection = null; + // Put the duration back + elements.forEach((element, i) => { + element.style.transitionDuration = data[i].duration; + element.style.transitionDelay = data[i].delay; + }); } +} - // Hide collection first. - this._toggleFilterClasses( $(), $collection ); - this._shrink( $collection ); - - this.sort(); - - this.$el.one( Shuffle.EventType.LAYOUT + '.' + SHUFFLE, $.proxy( handleRemoved, this ) ); -}; - +Shuffle.ALL_ITEMS = 'all'; +Shuffle.FILTER_ATTRIBUTE_KEY = 'groups'; /** - * Destroys shuffle, removes events, styles, and classes + * @enum {string} */ -Shuffle.prototype.destroy = function() { - // If there is more than one shuffle instance on the page, - // removing the resize handler from the window would remove them - // all. This is why a unique value is needed. - $window.off('.' + this.unique); - - // Reset container styles - this.$el - .removeClass( SHUFFLE ) - .removeAttr('style') - .removeData( SHUFFLE ); - - // Reset individual item styles - this.$items - .removeAttr('style') - .removeData('point') - .removeData('scale') - .removeClass([ - Shuffle.ClassName.CONCEALED, - Shuffle.ClassName.FILTERED, - Shuffle.ClassName.SHUFFLE_ITEM - ].join(' ')); - - // Null DOM references - this.$items = null; - this.$el = null; - this.sizer = null; - this.element = null; - this._transitions = null; - - // Set a flag so if a debounced resize has been triggered, - // it can first check if it is actually destroyed and not doing anything - this.destroyed = true; -}; - - -// Plugin definition -$.fn.shuffle = function( opts ) { - var args = Array.prototype.slice.call( arguments, 1 ); - return this.each(function() { - var $this = $( this ); - var shuffle = $this.data( SHUFFLE ); - - // If we don't have a stored shuffle, make a new one and save it - if ( !shuffle ) { - shuffle = new Shuffle( this, opts ); - $this.data( SHUFFLE, shuffle ); - } else if ( typeof opts === 'string' && shuffle[ opts ] ) { - shuffle[ opts ].apply( shuffle, args ); - } - }); +Shuffle.EventType = { + LAYOUT: 'shuffle:layout', + REMOVED: 'shuffle:removed', }; +/** @enum {string} */ +Shuffle.ClassName = Classes; -// http://stackoverflow.com/a/962890/373422 -function randomize( array ) { - var tmp, current; - var top = array.length; - - if ( !top ) { - return array; - } - - while ( --top ) { - current = Math.floor( Math.random() * (top + 1) ); - tmp = array[ current ]; - array[ current ] = array[ top ]; - array[ top ] = tmp; - } +// Overrideable options +Shuffle.options = { + // Initial filter group. + group: Shuffle.ALL_ITEMS, - return array; -} + // Transition/animation speed (milliseconds). + speed: 250, + // CSS easing function to use. + easing: 'ease', -// You can return `undefined` from the `by` function to revert to DOM order -// This plugin does NOT return a jQuery object. It returns a plain array because -// jQuery sorts everything in DOM order. -$.fn.sorted = function(options) { - var opts = $.extend({}, $.fn.sorted.defaults, options); - var arr = this.get(); - var revert = false; + // e.g. '.picture-item'. + itemSelector: '*', - if ( !arr.length ) { - return []; - } + // Element or selector string. Use an element to determine the size of columns + // and gutters. + sizer: null, - if ( opts.randomize ) { - return randomize( arr ); - } + // A static number or function that tells the plugin how wide the gutters + // between columns are (in pixels). + gutterWidth: 0, - // Sort the elements by the opts.by function. - // If we don't have opts.by, default to DOM order - if ( $.isFunction( opts.by ) ) { - arr.sort(function(a, b) { + // A static number or function that returns a number which tells the plugin + // how wide the columns are (in pixels). + columnWidth: 0, - // Exit early if we already know we want to revert - if ( revert ) { - return 0; - } + // If your group is not json, and is comma delimeted, you could set delimeter + // to ','. + delimeter: null, - var valA = opts.by($(a)); - var valB = opts.by($(b)); + // Useful for percentage based heights when they might not always be exactly + // the same (in pixels). + buffer: 0, - // If both values are undefined, use the DOM order - if ( valA === undefined && valB === undefined ) { - revert = true; - return 0; - } + // Reading the width of elements isn't precise enough and can cause columns to + // jump between values. + columnThreshold: 0.01, - if ( valA < valB || valA === 'sortFirst' || valB === 'sortLast' ) { - return -1; - } + // Shuffle can be isInitialized with a sort object. It is the same object + // given to the sort method. + initialSort: null, - if ( valA > valB || valA === 'sortLast' || valB === 'sortFirst' ) { - return 1; - } + // By default, shuffle will throttle resize events. This can be changed or + // removed. + throttle: throttle, - return 0; - }); - } + // How often shuffle can be called on resize (in milliseconds). + throttleTime: 300, - // Revert to the original array if necessary - if ( revert ) { - return this.get(); - } + // Transition delay offset for each item in milliseconds. + staggerAmount: 15, - if ( opts.reverse ) { - arr.reverse(); - } + // Maximum stagger delay in milliseconds. + staggerAmountMax: 250, - return arr; + // Whether to use transforms or absolute positioning. + useTransforms: true, }; +// Expose for testing. +Shuffle.Point = Point; +Shuffle.ShuffleItem = ShuffleItem; +Shuffle.sorter = sorter; -$.fn.sorted.defaults = { - reverse: false, // Use array.reverse() to reverse the results - by: null, // Sorting function - randomize: false // If true, this will skip the sorting and return a randomized order in the array -}; - -return Shuffle; +module.exports = Shuffle; diff --git a/webpack.config.js b/webpack.config.js index 0e9388d..9ee9efc 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -3,7 +3,7 @@ const path = require('path'); module.exports = { name: 'build', devtool: 'source-map', - entry: './src/shuffle.es6.js', + entry: './src/shuffle.js', output: { filename: 'shuffle.js', path: './dist', diff --git a/webpack.config.min.js b/webpack.config.min.js index f4515b6..ea5232a 100644 --- a/webpack.config.min.js +++ b/webpack.config.min.js @@ -4,7 +4,7 @@ const webpack = require('webpack'); module.exports = { name: 'minified', devtool: 'source-map', - entry: './src/shuffle.es6.js', + entry: './src/shuffle.js', output: { filename: 'shuffle.min.js', path: './dist',