Skip to content
This repository was archived by the owner on May 29, 2019. It is now read-only.

feat(collapse): add collapsing/collapsed/expanding/expanded callbacks #5226

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 50 additions & 37 deletions src/collapse/collapse.js
Original file line number Diff line number Diff line change
@@ -1,73 +1,86 @@
angular.module('ui.bootstrap.collapse', [])

.directive('uibCollapse', ['$animate', '$injector', function($animate, $injector) {
.directive('uibCollapse', ['$animate', '$q', '$parse', '$injector', function($animate, $q, $parse, $injector) {
var $animateCss = $injector.has('$animateCss') ? $injector.get('$animateCss') : null;
return {
link: function(scope, element, attrs) {
var expandingExpr = $parse(attrs.expanding),
expandedExpr = $parse(attrs.expanded),
collapsingExpr = $parse(attrs.collapsing),
collapsedExpr = $parse(attrs.collapsed);

if (!scope.$eval(attrs.uibCollapse)) {
element.addClass('in')
.addClass('collapse')
.css({height: 'auto'});
}

function expand() {
element.removeClass('collapse')
.addClass('collapsing')
.attr('aria-expanded', true)
.attr('aria-hidden', false);
$q.resolve(expandingExpr(scope))
.then(function() {
element.removeClass('collapse')
.addClass('collapsing')
.attr('aria-expanded', true)
.attr('aria-hidden', false);

if ($animateCss) {
$animateCss(element, {
addClass: 'in',
easing: 'ease',
to: { height: element[0].scrollHeight + 'px' }
}).start()['finally'](expandDone);
} else {
$animate.addClass(element, 'in', {
to: { height: element[0].scrollHeight + 'px' }
}).then(expandDone);
}
if ($animateCss) {
$animateCss(element, {
addClass: 'in',
easing: 'ease',
to: { height: element[0].scrollHeight + 'px' }
}).start()['finally'](expandDone);
} else {
$animate.addClass(element, 'in', {
to: { height: element[0].scrollHeight + 'px' }
}).then(expandDone);
}
});
}

function expandDone() {
element.removeClass('collapsing')
.addClass('collapse')
.css({height: 'auto'});
expandedExpr(scope);
}

function collapse() {
if (!element.hasClass('collapse') && !element.hasClass('in')) {
return collapseDone();
}

element
// IMPORTANT: The height must be set before adding "collapsing" class.
// Otherwise, the browser attempts to animate from height 0 (in
// collapsing class) to the given height here.
.css({height: element[0].scrollHeight + 'px'})
// initially all panel collapse have the collapse class, this removal
// prevents the animation from jumping to collapsed state
.removeClass('collapse')
.addClass('collapsing')
.attr('aria-expanded', false)
.attr('aria-hidden', true);
$q.resolve(collapsingExpr(scope))
.then(function() {
element
// IMPORTANT: The height must be set before adding "collapsing" class.
// Otherwise, the browser attempts to animate from height 0 (in
// collapsing class) to the given height here.
.css({height: element[0].scrollHeight + 'px'})
// initially all panel collapse have the collapse class, this removal
// prevents the animation from jumping to collapsed state
.removeClass('collapse')
.addClass('collapsing')
.attr('aria-expanded', false)
.attr('aria-hidden', true);

if ($animateCss) {
$animateCss(element, {
removeClass: 'in',
to: {height: '0'}
}).start()['finally'](collapseDone);
} else {
$animate.removeClass(element, 'in', {
to: {height: '0'}
}).then(collapseDone);
}
if ($animateCss) {
$animateCss(element, {
removeClass: 'in',
to: {height: '0'}
}).start()['finally'](collapseDone);
} else {
$animate.removeClass(element, 'in', {
to: {height: '0'}
}).then(collapseDone);
}
});
}

function collapseDone() {
element.css({height: '0'}); // Required so that collapse works when animation is disabled
element.removeClass('collapsing')
.addClass('collapse');
collapsedExpr(scope);
}

scope.$watch(attrs.uibCollapse, function(shouldCollapse) {
Expand Down
20 changes: 20 additions & 0 deletions src/collapse/docs/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,23 @@
<i class="glyphicon glyphicon-eye-open"></i>
_(Default: `false`)_ -
Whether the element should be collapsed or not.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Look at the uib-tab documentation for what to put in this block: default should be null, callback could also be an expression and not a function.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check that, @wesleycho, what do you think about that last bit? Function vs. expression?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it should read expression.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went off the uib-alert documentation which uses:

* `close`
  <small class="badge">$</small>
  _(Default: `none`)_ -
  A callback function that gets fired when an `alert` is closed. If the attribute exists, a close button is displayed as well.

But I'm happy to go with the format used by the uib-tab directive, if you prefer.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, looks like uib-alert documentation is the exception. How does the following look?

* `collapsing()`
  <small class="badge">$</small> -
  An optional expression called before the element begins collapsing.
  If the expression returns a promise, animation won't start until the promise resolves.
  If the returned promise is rejected, collapsing will be cancelled.

* `collapsed()`
  <small class="badge">$</small> -
  An optional expression called after the element finished collapsing.

* `expanding()`
  <small class="badge">$</small> -
  An optional expression called before the element begins expanding.
  If the expression returns a promise, animation won't start until the promise resolves.
  If the returned promise is rejected, expanding will be cancelled.

* `expanded()`
  <small class="badge">$</small> -
  An optional expression called after the element finished expanding.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's be honest in here. Some callbacks has default and other doesn't. Leave it as is, I prefer this way.

* `collapsing()`
<small class="badge">$</small> -
An optional expression called before the element begins collapsing.
If the expression returns a promise, animation won't start until the promise resolves.
If the returned promise is rejected, collapsing will be cancelled.

* `collapsed()`
<small class="badge">$</small> -
An optional expression called after the element finished collapsing.

* `expanding()`
<small class="badge">$</small> -
An optional expression called before the element begins expanding.
If the expression returns a promise, animation won't start until the promise resolves.
If the returned promise is rejected, expanding will be cancelled.

* `expanded()`
<small class="badge">$</small> -
An optional expression called after the element finished expanding.
133 changes: 129 additions & 4 deletions src/collapse/test/collapse.spec.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
describe('collapse directive', function() {
var element, compileFn, scope, $compile, $animate;
var element, compileFn, scope, $compile, $animate, $q;

beforeEach(module('ui.bootstrap.collapse'));
beforeEach(module('ngAnimateMock'));
beforeEach(inject(function(_$rootScope_, _$compile_, _$animate_) {
beforeEach(inject(function(_$rootScope_, _$compile_, _$animate_, _$q_) {
scope = _$rootScope_;
$compile = _$compile_;
$animate = _$animate_;
$q = _$q_;
}));

beforeEach(function() {
element = angular.element('<div uib-collapse="isCollapsed">Some Content</div>');
element = angular.element(
'<div uib-collapse="isCollapsed" '
+ 'expanding="expanding()" '
+ 'expanded="expanded()" '
+ 'collapsing="collapsing()" '
+ 'collapsed="collapsed()">'
+ 'Some Content</div>');
compileFn = $compile(element);
angular.element(document.body).append(element);
});
Expand All @@ -19,30 +26,53 @@ describe('collapse directive', function() {
element.remove();
});

function initCallbacks() {
scope.collapsing = jasmine.createSpy('scope.collapsing');
scope.collapsed = jasmine.createSpy('scope.collapsed');
scope.expanding = jasmine.createSpy('scope.expanding');
scope.expanded = jasmine.createSpy('scope.expanded');
}

function assertCallbacks(expected) {
['collapsing', 'collapsed', 'expanding', 'expanded'].forEach(function(cbName) {
if (expected[cbName]) {
expect(scope[cbName]).toHaveBeenCalled();
} else {
expect(scope[cbName]).not.toHaveBeenCalled();
}
});
}

it('should be hidden on initialization if isCollapsed = true', function() {
initCallbacks();
scope.isCollapsed = true;
compileFn(scope);
scope.$digest();
expect(element.height()).toBe(0);
assertCallbacks({ collapsed: true });
});

it('should collapse if isCollapsed = true on subsequent use', function() {
scope.isCollapsed = false;
compileFn(scope);
scope.$digest();
$animate.flush();
initCallbacks();
scope.isCollapsed = true;
scope.$digest();
$animate.flush();
expect(element.height()).toBe(0);
assertCallbacks({ collapsing: true, collapsed: true });
});

it('should be shown on initialization if isCollapsed = false', function() {
initCallbacks();
scope.isCollapsed = false;
compileFn(scope);
scope.$digest();
$animate.flush();
expect(element.height()).not.toBe(0);
assertCallbacks({ expanding: true, expanded: true });
});

it('should expand if isCollapsed = false on subsequent use', function() {
Expand All @@ -53,13 +83,15 @@ describe('collapse directive', function() {
scope.isCollapsed = true;
scope.$digest();
$animate.flush();
initCallbacks();
scope.isCollapsed = false;
scope.$digest();
$animate.flush();
expect(element.height()).not.toBe(0);
assertCallbacks({ expanding: true, expanded: true });
});

it('should expand if isCollapsed = true on subsequent uses', function() {
it('should collapse if isCollapsed = true on subsequent uses', function() {
scope.isCollapsed = false;
compileFn(scope);
scope.$digest();
Expand All @@ -70,10 +102,12 @@ describe('collapse directive', function() {
scope.isCollapsed = false;
scope.$digest();
$animate.flush();
initCallbacks();
scope.isCollapsed = true;
scope.$digest();
$animate.flush();
expect(element.height()).toBe(0);
assertCallbacks({ collapsing: true, collapsed: true });
});

it('should change aria-expanded attribute', function() {
Expand Down Expand Up @@ -137,4 +171,95 @@ describe('collapse directive', function() {
expect(element.height()).toBeLessThan(collapseHeight);
});
});

describe('expanding callback returning a promise', function() {
var defer, collapsedHeight;

beforeEach(function() {
defer = $q.defer();

scope.isCollapsed = true;
scope.expanding = function() {
return defer.promise;
};
compileFn(scope);
scope.$digest();
collapsedHeight = element.height();

// set flag to expand ...
scope.isCollapsed = false;
scope.$digest();

// ... shouldn't expand yet ...
expect(element.attr('aria-expanded')).not.toBe('true');
expect(element.height()).toBe(collapsedHeight);
});

it('should wait for it to resolve before animating', function() {
defer.resolve();

// should now expand
scope.$digest();
$animate.flush();

expect(element.attr('aria-expanded')).toBe('true');
expect(element.height()).toBeGreaterThan(collapsedHeight);
});

it('should not animate if it rejects', function() {
defer.reject();

// should NOT expand
scope.$digest();

expect(element.attr('aria-expanded')).not.toBe('true');
expect(element.height()).toBe(collapsedHeight);
});
});

describe('collapsing callback returning a promise', function() {
var defer, expandedHeight;

beforeEach(function() {
defer = $q.defer();
scope.isCollapsed = false;
scope.collapsing = function() {
return defer.promise;
};
compileFn(scope);
scope.$digest();

expandedHeight = element.height();

// set flag to collapse ...
scope.isCollapsed = true;
scope.$digest();

// ... but it shouldn't collapse yet ...
expect(element.attr('aria-expanded')).not.toBe('false');
expect(element.height()).toBe(expandedHeight);
});

it('should wait for it to resolve before animating', function() {
defer.resolve();

// should now collapse
scope.$digest();
$animate.flush();

expect(element.attr('aria-expanded')).toBe('false');
expect(element.height()).toBeLessThan(expandedHeight);
});

it('should not animate if it rejects', function() {
defer.reject();

// should NOT collapse
scope.$digest();

expect(element.attr('aria-expanded')).not.toBe('false');
expect(element.height()).toBe(expandedHeight);
});
});

});