Skip to content

Commit a097bed

Browse files
committed
feat(collapse): add collapsing/collapsed/expanding/expanded callbacks
Closes angular-ui#5194
1 parent e8201d1 commit a097bed

File tree

3 files changed

+199
-41
lines changed

3 files changed

+199
-41
lines changed

Diff for: src/collapse/collapse.js

+50-37
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,86 @@
11
angular.module('ui.bootstrap.collapse', [])
22

3-
.directive('uibCollapse', ['$animate', '$injector', function($animate, $injector) {
3+
.directive('uibCollapse', ['$animate', '$q', '$parse', '$injector', function($animate, $q, $parse, $injector) {
44
var $animateCss = $injector.has('$animateCss') ? $injector.get('$animateCss') : null;
55
return {
66
link: function(scope, element, attrs) {
7+
var expandingExpr = $parse(attrs.expanding),
8+
expandedExpr = $parse(attrs.expanded),
9+
collapsingExpr = $parse(attrs.collapsing),
10+
collapsedExpr = $parse(attrs.collapsed);
11+
712
if (!scope.$eval(attrs.uibCollapse)) {
813
element.addClass('in')
914
.addClass('collapse')
1015
.css({height: 'auto'});
1116
}
1217

1318
function expand() {
14-
element.removeClass('collapse')
15-
.addClass('collapsing')
16-
.attr('aria-expanded', true)
17-
.attr('aria-hidden', false);
19+
$q.resolve(expandingExpr(scope))
20+
.then(function() {
21+
element.removeClass('collapse')
22+
.addClass('collapsing')
23+
.attr('aria-expanded', true)
24+
.attr('aria-hidden', false);
1825

19-
if ($animateCss) {
20-
$animateCss(element, {
21-
addClass: 'in',
22-
easing: 'ease',
23-
to: { height: element[0].scrollHeight + 'px' }
24-
}).start()['finally'](expandDone);
25-
} else {
26-
$animate.addClass(element, 'in', {
27-
to: { height: element[0].scrollHeight + 'px' }
28-
}).then(expandDone);
29-
}
26+
if ($animateCss) {
27+
$animateCss(element, {
28+
addClass: 'in',
29+
easing: 'ease',
30+
to: { height: element[0].scrollHeight + 'px' }
31+
}).start()['finally'](expandDone);
32+
} else {
33+
$animate.addClass(element, 'in', {
34+
to: { height: element[0].scrollHeight + 'px' }
35+
}).then(expandDone);
36+
}
37+
});
3038
}
3139

3240
function expandDone() {
3341
element.removeClass('collapsing')
3442
.addClass('collapse')
3543
.css({height: 'auto'});
44+
expandedExpr(scope);
3645
}
3746

3847
function collapse() {
3948
if (!element.hasClass('collapse') && !element.hasClass('in')) {
4049
return collapseDone();
4150
}
4251

43-
element
44-
// IMPORTANT: The height must be set before adding "collapsing" class.
45-
// Otherwise, the browser attempts to animate from height 0 (in
46-
// collapsing class) to the given height here.
47-
.css({height: element[0].scrollHeight + 'px'})
48-
// initially all panel collapse have the collapse class, this removal
49-
// prevents the animation from jumping to collapsed state
50-
.removeClass('collapse')
51-
.addClass('collapsing')
52-
.attr('aria-expanded', false)
53-
.attr('aria-hidden', true);
52+
$q.resolve(collapsingExpr(scope))
53+
.then(function() {
54+
element
55+
// IMPORTANT: The height must be set before adding "collapsing" class.
56+
// Otherwise, the browser attempts to animate from height 0 (in
57+
// collapsing class) to the given height here.
58+
.css({height: element[0].scrollHeight + 'px'})
59+
// initially all panel collapse have the collapse class, this removal
60+
// prevents the animation from jumping to collapsed state
61+
.removeClass('collapse')
62+
.addClass('collapsing')
63+
.attr('aria-expanded', false)
64+
.attr('aria-hidden', true);
5465

55-
if ($animateCss) {
56-
$animateCss(element, {
57-
removeClass: 'in',
58-
to: {height: '0'}
59-
}).start()['finally'](collapseDone);
60-
} else {
61-
$animate.removeClass(element, 'in', {
62-
to: {height: '0'}
63-
}).then(collapseDone);
64-
}
66+
if ($animateCss) {
67+
$animateCss(element, {
68+
removeClass: 'in',
69+
to: {height: '0'}
70+
}).start()['finally'](collapseDone);
71+
} else {
72+
$animate.removeClass(element, 'in', {
73+
to: {height: '0'}
74+
}).then(collapseDone);
75+
}
76+
});
6577
}
6678

6779
function collapseDone() {
6880
element.css({height: '0'}); // Required so that collapse works when animation is disabled
6981
element.removeClass('collapsing')
7082
.addClass('collapse');
83+
collapsedExpr(scope);
7184
}
7285

7386
scope.$watch(attrs.uibCollapse, function(shouldCollapse) {

Diff for: src/collapse/docs/readme.md

+20
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,23 @@
77
<i class="glyphicon glyphicon-eye-open"></i>
88
_(Default: `false`)_ -
99
Whether the element should be collapsed or not.
10+
11+
* `collapsing()`
12+
<small class="badge">$</small> -
13+
An optional expression called before the element begins collapsing.
14+
If the expression returns a promise, animation won't start until the promise resolves.
15+
If the returned promise is rejected, collapsing will be cancelled.
16+
17+
* `collapsed()`
18+
<small class="badge">$</small> -
19+
An optional expression called after the element finished collapsing.
20+
21+
* `expanding()`
22+
<small class="badge">$</small> -
23+
An optional expression called before the element begins expanding.
24+
If the expression returns a promise, animation won't start until the promise resolves.
25+
If the returned promise is rejected, expanding will be cancelled.
26+
27+
* `expanded()`
28+
<small class="badge">$</small> -
29+
An optional expression called after the element finished expanding.

Diff for: src/collapse/test/collapse.spec.js

+129-4
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
11
describe('collapse directive', function() {
2-
var element, compileFn, scope, $compile, $animate;
2+
var element, compileFn, scope, $compile, $animate, $q;
33

44
beforeEach(module('ui.bootstrap.collapse'));
55
beforeEach(module('ngAnimateMock'));
6-
beforeEach(inject(function(_$rootScope_, _$compile_, _$animate_) {
6+
beforeEach(inject(function(_$rootScope_, _$compile_, _$animate_, _$q_) {
77
scope = _$rootScope_;
88
$compile = _$compile_;
99
$animate = _$animate_;
10+
$q = _$q_;
1011
}));
1112

1213
beforeEach(function() {
13-
element = angular.element('<div uib-collapse="isCollapsed">Some Content</div>');
14+
element = angular.element(
15+
'<div uib-collapse="isCollapsed" '
16+
+ 'expanding="expanding()" '
17+
+ 'expanded="expanded()" '
18+
+ 'collapsing="collapsing()" '
19+
+ 'collapsed="collapsed()">'
20+
+ 'Some Content</div>');
1421
compileFn = $compile(element);
1522
angular.element(document.body).append(element);
1623
});
@@ -19,30 +26,53 @@ describe('collapse directive', function() {
1926
element.remove();
2027
});
2128

29+
function initCallbacks() {
30+
scope.collapsing = jasmine.createSpy('scope.collapsing');
31+
scope.collapsed = jasmine.createSpy('scope.collapsed');
32+
scope.expanding = jasmine.createSpy('scope.expanding');
33+
scope.expanded = jasmine.createSpy('scope.expanded');
34+
}
35+
36+
function assertCallbacks(expected) {
37+
['collapsing', 'collapsed', 'expanding', 'expanded'].forEach(function(cbName) {
38+
if (expected[cbName]) {
39+
expect(scope[cbName]).toHaveBeenCalled();
40+
} else {
41+
expect(scope[cbName]).not.toHaveBeenCalled();
42+
}
43+
});
44+
}
45+
2246
it('should be hidden on initialization if isCollapsed = true', function() {
47+
initCallbacks();
2348
scope.isCollapsed = true;
2449
compileFn(scope);
2550
scope.$digest();
2651
expect(element.height()).toBe(0);
52+
assertCallbacks({ collapsed: true });
2753
});
2854

2955
it('should collapse if isCollapsed = true on subsequent use', function() {
3056
scope.isCollapsed = false;
3157
compileFn(scope);
3258
scope.$digest();
3359
$animate.flush();
60+
initCallbacks();
3461
scope.isCollapsed = true;
3562
scope.$digest();
3663
$animate.flush();
3764
expect(element.height()).toBe(0);
65+
assertCallbacks({ collapsing: true, collapsed: true });
3866
});
3967

4068
it('should be shown on initialization if isCollapsed = false', function() {
69+
initCallbacks();
4170
scope.isCollapsed = false;
4271
compileFn(scope);
4372
scope.$digest();
4473
$animate.flush();
4574
expect(element.height()).not.toBe(0);
75+
assertCallbacks({ expanding: true, expanded: true });
4676
});
4777

4878
it('should expand if isCollapsed = false on subsequent use', function() {
@@ -53,13 +83,15 @@ describe('collapse directive', function() {
5383
scope.isCollapsed = true;
5484
scope.$digest();
5585
$animate.flush();
86+
initCallbacks();
5687
scope.isCollapsed = false;
5788
scope.$digest();
5889
$animate.flush();
5990
expect(element.height()).not.toBe(0);
91+
assertCallbacks({ expanding: true, expanded: true });
6092
});
6193

62-
it('should expand if isCollapsed = true on subsequent uses', function() {
94+
it('should collapse if isCollapsed = true on subsequent uses', function() {
6395
scope.isCollapsed = false;
6496
compileFn(scope);
6597
scope.$digest();
@@ -70,10 +102,12 @@ describe('collapse directive', function() {
70102
scope.isCollapsed = false;
71103
scope.$digest();
72104
$animate.flush();
105+
initCallbacks();
73106
scope.isCollapsed = true;
74107
scope.$digest();
75108
$animate.flush();
76109
expect(element.height()).toBe(0);
110+
assertCallbacks({ collapsing: true, collapsed: true });
77111
});
78112

79113
it('should change aria-expanded attribute', function() {
@@ -137,4 +171,95 @@ describe('collapse directive', function() {
137171
expect(element.height()).toBeLessThan(collapseHeight);
138172
});
139173
});
174+
175+
describe('expanding callback returning a promise', function() {
176+
var defer, collapsedHeight;
177+
178+
beforeEach(function() {
179+
defer = $q.defer();
180+
181+
scope.isCollapsed = true;
182+
scope.expanding = function() {
183+
return defer.promise;
184+
};
185+
compileFn(scope);
186+
scope.$digest();
187+
collapsedHeight = element.height();
188+
189+
// set flag to expand ...
190+
scope.isCollapsed = false;
191+
scope.$digest();
192+
193+
// ... shouldn't expand yet ...
194+
expect(element.attr('aria-expanded')).not.toBe('true');
195+
expect(element.height()).toBe(collapsedHeight);
196+
});
197+
198+
it('should wait for it to resolve before animating', function() {
199+
defer.resolve();
200+
201+
// should now expand
202+
scope.$digest();
203+
$animate.flush();
204+
205+
expect(element.attr('aria-expanded')).toBe('true');
206+
expect(element.height()).toBeGreaterThan(collapsedHeight);
207+
});
208+
209+
it('should not animate if it rejects', function() {
210+
defer.reject();
211+
212+
// should NOT expand
213+
scope.$digest();
214+
215+
expect(element.attr('aria-expanded')).not.toBe('true');
216+
expect(element.height()).toBe(collapsedHeight);
217+
});
218+
});
219+
220+
describe('collapsing callback returning a promise', function() {
221+
var defer, expandedHeight;
222+
223+
beforeEach(function() {
224+
defer = $q.defer();
225+
scope.isCollapsed = false;
226+
scope.collapsing = function() {
227+
return defer.promise;
228+
};
229+
compileFn(scope);
230+
scope.$digest();
231+
232+
expandedHeight = element.height();
233+
234+
// set flag to collapse ...
235+
scope.isCollapsed = true;
236+
scope.$digest();
237+
238+
// ... but it shouldn't collapse yet ...
239+
expect(element.attr('aria-expanded')).not.toBe('false');
240+
expect(element.height()).toBe(expandedHeight);
241+
});
242+
243+
it('should wait for it to resolve before animating', function() {
244+
defer.resolve();
245+
246+
// should now collapse
247+
scope.$digest();
248+
$animate.flush();
249+
250+
expect(element.attr('aria-expanded')).toBe('false');
251+
expect(element.height()).toBeLessThan(expandedHeight);
252+
});
253+
254+
it('should not animate if it rejects', function() {
255+
defer.reject();
256+
257+
// should NOT collapse
258+
scope.$digest();
259+
260+
expect(element.attr('aria-expanded')).not.toBe('false');
261+
expect(element.height()).toBe(expandedHeight);
262+
});
263+
});
264+
140265
});

0 commit comments

Comments
 (0)