diff --git a/src/collapse/collapse.js b/src/collapse/collapse.js index d0a33b454f..3c0574c231 100644 --- a/src/collapse/collapse.js +++ b/src/collapse/collapse.js @@ -9,12 +9,15 @@ angular.module('ui.bootstrap.collapse',['ui.bootstrap.transition']) // The fix is to remove the "collapse" CSS class while changing the height back to auto - phew! var fixUpHeight = function(scope, element, height) { // We remove the collapse CSS class to prevent a transition when we change to height: auto + var collapse = element.hasClass('collapse'); element.removeClass('collapse'); element.css({ height: height }); // It appears that reading offsetWidth makes the browser realise that we have changed the // height already :-/ var x = element[0].offsetWidth; - element.addClass('collapse'); + if(collapse) { + element.addClass('collapse'); + } }; return { @@ -46,48 +49,202 @@ angular.module('ui.bootstrap.collapse',['ui.bootstrap.transition']) expand(); } }); + + // Some jQuery-like functionality, based on implementation in Prototype. + // + // There is a problem with these: We're instantiating them for every + // instance of the directive, and that's not very good. + // + // But we do need a more robust way to calculate dimensions of an item, + // scrollWidth/scrollHeight is not super reliable, and we can't rely on + // jQuery or Prototype or any other framework being used. + var helpers = { + style: function(element, prop) { + var elem = element; + if(typeof elem.length === 'number') { + elem = elem[0]; + } + function camelcase(name) { + return name.replace(/-+(.)?/g, function(match, chr) { + return chr ? chr.toUpperCase() : ''; + }); + } + prop = prop === 'float' ? 'cssFloat' : camelcase(prop); + var value = elem.style[prop]; + if (!value || value === 'auto') { + var css = window.getComputedStyle(elem, null); + value = css ? css[prop] : null; + } + if (prop === 'opacity') { + return value ? parseFloat(value) : 1.0; + } + return value === 'auto' ? null : value; + }, + + size: function(element) { + var dom = element[0]; + var display = helpers.style(element, 'display'); + + if (display && display !== 'none') { + // Fast case: rely on offset dimensions + return { width: dom.offsetWidth, height: dom.offsetHeight }; + } + + // Slow case -- Save original CSS properties, update the CSS, and then + // use offset dimensions, and restore the original CSS + var currentStyle = dom.style; + var originalStyles = { + visibility: currentStyle.visibility, + position: currentStyle.position, + display: currentStyle.display + }; + + var newStyles = { + visibility: 'hidden', + display: 'block' + }; + + // Switching `fixed` to `absolute` causes issues in Safari. + if (originalStyles.position !== 'fixed') { + newStyles.position = 'absolute'; + } + + // Quickly swap-in styles which would allow us to utilize offset + // dimensions + element.css(newStyles); + + var dimensions = { + width: dom.offsetWidth, + height: dom.offsetHeight + }; + + // And restore the original styles + element.css(originalStyles); + + return dimensions; + }, + + width: function(element, value) { + if(typeof value === 'number' || typeof value === 'string') { + if(typeof value === 'number') { + value = value + 'px'; + } + element.css({ 'width': value }); + return; + } + return helpers.size(element).width; + }, + height: function(element, value) { + if(typeof value === 'number' || typeof value === 'string') { + if(typeof value === 'number') { + value = value + 'px'; + } + element.css({ 'height': value }); + return; + } + return helpers.size(element).height; + }, + + dimension: function() { + var hasWidth = element.hasClass('width'); + return hasWidth ? 'width' : 'height'; + } + }; + + var events = { + beforeShow: function(dimension, dimensions) { + element + .removeClass('collapse') + .removeClass('collapsed') + .addClass('collapsing'); + helpers[dimension](element, 0); + }, + + beforeHide: function(dimension, dimensions) { + // Read offsetHeight and reset height: + helpers[dimension](element, dimensions[dimension] + "px"); + var unused = element[0].offsetWidth, + unused2 = element[0].offsetHeight; + element + .addClass('collapsing') + .removeClass('collapse') + .removeClass('in'); + }, + + afterShow: function(dimension) { + element + .removeClass('collapsing') + .addClass('in'); + helpers[dimension](element, 'auto'); + isCollapsed = false; + }, + + afterHide: function(dimension) { + element + .removeClass('collapsing') + .addClass('collapsed') + .addClass('collapse'); + isCollapsed = true; + } + }; var currentTransition; - var doTransition = function(change) { - if ( currentTransition ) { - currentTransition.cancel(); + var doTransition = function(showing, pixels) { + if (currentTransition || showing === element.hasClass('in')) { + return; } - currentTransition = $transition(element,change); + + var dimension = helpers.dimension(); + var dimensions = helpers.size(element); + var name = showing ? 'Show' : 'Hide'; + + events['before' + name](dimension, dimensions); + + var query = {}; + var makeUpper = function(name) { + return name.charAt(0).toUpperCase() + name.slice(1); + }; + if(pixels==='scroll') { + pixels = element[0][pixels + makeUpper(dimension)]; + } + if(typeof pixels === 'number') { + pixels = pixels + "px"; + } + query[dimension] = pixels; + currentTransition = $transition(element,query).emulateTransitionEnd(350); currentTransition.then( - function() { currentTransition = undefined; }, - function() { currentTransition = undefined; } + function() { + events['after' + name](dimension); + currentTransition = undefined; + }, + function(reason) { + var descr = showing ? 'expansion' : 'collapse'; + currentTransition = undefined; + } ); return currentTransition; }; var expand = function() { - if (initialAnimSkip) { + if (initialAnimSkip || !$transition.transitionEndEventName) { initialAnimSkip = false; - if ( !isCollapsed ) { - fixUpHeight(scope, element, 'auto'); - } + var dimension = helpers.dimension(); + helpers[dimension](element, 'auto'); + events.afterShow(dimension); } else { - doTransition({ height : element[0].scrollHeight + 'px' }) - .then(function() { - // This check ensures that we don't accidentally update the height if the user has closed - // the group while the animation was still running - if ( !isCollapsed ) { - fixUpHeight(scope, element, 'auto'); - } - }); + doTransition(true, 'scroll'); } - isCollapsed = false; }; var collapse = function() { - isCollapsed = true; - if (initialAnimSkip) { + if (initialAnimSkip || !$transition.transitionEndEventName) { initialAnimSkip = false; - fixUpHeight(scope, element, 0); + var dimension = helpers.dimension(); + helpers[dimension](element, 0); + events.afterHide(dimension); } else { - fixUpHeight(scope, element, element[0].scrollHeight + 'px'); - doTransition({'height':'0'}); + doTransition(false, '0'); } }; } diff --git a/src/collapse/test/collapse.spec.js b/src/collapse/test/collapse.spec.js index d04a8b3c90..18298be62d 100644 --- a/src/collapse/test/collapse.spec.js +++ b/src/collapse/test/collapse.spec.js @@ -49,6 +49,7 @@ describe('collapse directive', function () { scope.$digest(); scope.isCollapsed = true; scope.$digest(); + $timeout.flush(); scope.isCollapsed = false; scope.$digest(); $timeout.flush(); @@ -92,6 +93,7 @@ describe('collapse directive', function () { scope.$digest(); scope.isCollapsed = true; scope.$digest(); + $timeout.flush(); scope.isCollapsed = false; scope.$digest(); $timeout.flush(); diff --git a/src/transition/test/transition.spec.js b/src/transition/test/transition.spec.js index 34f744b037..9dfff8797d 100644 --- a/src/transition/test/transition.spec.js +++ b/src/transition/test/transition.spec.js @@ -54,6 +54,23 @@ describe('$transition', function() { expect(triggerFunction).toHaveBeenCalledWith(element); }); + // transitionend emulation + describe('emulateTransitionEnd', function() { + it('should emit transition end-event after the specified duration', function() { + var element = angular.element('
'); + var transitionEndHandler = jasmine.createSpy('transitionEndHandler'); + + // There is no transition property, so transitionend could not be fired + // on its own. + var promise = $transition(element, {height: '100px'}); + promise.then(transitionEndHandler); + promise.emulateTransitionEnd(1); + + $timeout.flush(); + expect(transitionEndHandler).toHaveBeenCalledWith(element); + }); + }); + // Versions of Internet Explorer before version 10 do not have CSS transitions if ( !ie || ie > 9 ) { describe('transitionEnd event', function() { diff --git a/src/transition/transition.js b/src/transition/transition.js index c23d3f76f7..4636ee1832 100644 --- a/src/transition/transition.js +++ b/src/transition/transition.js @@ -52,6 +52,31 @@ angular.module('ui.bootstrap.transition', []) deferred.reject('Transition cancelled'); }; + // Emulate transitionend event, useful when support is assumed to be + // available, but may not actually be used due to a transition property + // not being used in CSS (for example, in versions of firefox prior to 16, + // only -moz-transition is supported -- and is not used in Bootstrap3's CSS + // -- As such, no transitionend event would be fired due to no transition + // ever taking place. This method allows a fallback for such browsers.) + deferred.promise.emulateTransitionEnd = function(duration) { + var called = false; + deferred.promise.then( + function() { called = true; }, + function() { called = true; } + ); + + var callback = function() { + if ( !called ) { + // If we got here, we probably aren't going to get a real + // transitionend event. Emit a dummy to the handler. + element.triggerHandler(endEventName); + } + }; + + $timeout(callback, duration); + return deferred.promise; + }; + return deferred.promise; };