Skip to content

Commit 796cd4e

Browse files
mariocasciarofernando-sendMail
authored andcommitted
feat(dropdown): Make Auto-Close Dropdowns optional.
Fixes angular-ui#2218 Closes angular-ui#3045
1 parent 9b1a3c5 commit 796cd4e

File tree

3 files changed

+267
-11
lines changed

3 files changed

+267
-11
lines changed

src/dropdown/docs/readme.md

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,8 @@
22
Dropdown is a simple directive which will toggle a dropdown menu on click or programmatically.
33
You can either use `is-open` to toggle or add inside a `<a dropdown-toggle>` element to toggle it when is clicked.
44
There is also the `on-toggle(open)` optional expression fired when dropdown changes state.
5-
6-
Add `dropdown-append-to-body` to the `dropdown` element to append to the inner `dropdown-menu` to the body.
7-
This is useful when the dropdown button is inside a div with `overflow: hidden`, and the menu would otherwise be hidden.
8-
9-
Add `keyboard-nav` to the `dropdown` element to enable navigation of dropdown list elements with the arrow keys.
10-
115
By default the dropdown will automatically close if any of its elements is clicked, you can change this behavior by setting the `auto-close` option as follows:
126

137
* `always` - (Default) automatically closes the dropdown when any of its elements is clicked.
148
* `outsideClick` - closes the dropdown automatically only when the user clicks any element outside the dropdown.
15-
* `disabled` - disables the auto close. You can then control the open/close status of the dropdown manually, by using `is-open`. Please notice that the dropdown will still close if the toggle is clicked, the `esc` key is pressed or another dropdown is open. The dropdown will no longer close on `$locationChangeSuccess` events.
16-
17-
Optionally, you may specify a template for the dropdown menu using the `template-url` attribute. This is especially useful when you have multiple similar dropdowns in a repeater and you want to keep your HTML output lean and your number of scopes to a minimum. The template has full access to the scope in which the dropdown lies.
18-
19-
Example: `<ul class="dropdown-menu" template-url="custom-dropdown.html"></ul>`.
9+
* `disabled` - disables the auto close. You can then control the open/close status of the dropdown manually, by using `is-open`. Please notice that the dropdown will still close if the toggle is clicked, the `esc` key is pressed or another dropdown is open.

src/dropdown/dropdown.js

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
angular.module('ui.bootstrap.dropdown', [])
2+
3+
.constant('dropdownConfig', {
4+
openClass: 'open'
5+
})
6+
7+
.service('dropdownService', ['$document', '$rootScope', function($document, $rootScope) {
8+
var openScope = null;
9+
10+
this.open = function( dropdownScope ) {
11+
if ( !openScope ) {
12+
$document.bind('click', closeDropdown);
13+
$document.bind('keydown', escapeKeyBind);
14+
}
15+
16+
if ( openScope && openScope !== dropdownScope ) {
17+
openScope.isOpen = false;
18+
}
19+
20+
openScope = dropdownScope;
21+
};
22+
23+
this.close = function( dropdownScope ) {
24+
if ( openScope === dropdownScope ) {
25+
openScope = null;
26+
$document.unbind('click', closeDropdown);
27+
$document.unbind('keydown', escapeKeyBind);
28+
}
29+
};
30+
31+
var closeDropdown = function( evt ) {
32+
// This method may still be called during the same mouse event that
33+
// unbound this event handler. So check openScope before proceeding.
34+
if (!openScope) { return; }
35+
36+
if( evt && openScope.getAutoClose() === 'disabled' ) { return ; }
37+
38+
var toggleElement = openScope.getToggleElement();
39+
if ( evt && toggleElement && toggleElement[0].contains(evt.target) ) {
40+
return;
41+
}
42+
43+
var $element = openScope.getElement();
44+
if( evt && openScope.getAutoClose() === 'outsideClick' && $element && $element[0].contains(evt.target) ) {
45+
return;
46+
}
47+
48+
openScope.isOpen = false;
49+
50+
if (!$rootScope.$$phase) {
51+
openScope.$apply();
52+
}
53+
};
54+
55+
var escapeKeyBind = function( evt ) {
56+
if ( evt.which === 27 ) {
57+
openScope.focusToggleElement();
58+
closeDropdown();
59+
}
60+
};
61+
}])
62+
63+
.controller('DropdownController', ['$scope', '$attrs', '$parse', 'dropdownConfig', 'dropdownService', '$animate', function($scope, $attrs, $parse, dropdownConfig, dropdownService, $animate) {
64+
var self = this,
65+
scope = $scope.$new(), // create a child scope so we are not polluting original one
66+
openClass = dropdownConfig.openClass,
67+
getIsOpen,
68+
setIsOpen = angular.noop,
69+
toggleInvoker = $attrs.onToggle ? $parse($attrs.onToggle) : angular.noop;
70+
71+
this.init = function( element ) {
72+
self.$element = element;
73+
74+
if ( $attrs.isOpen ) {
75+
getIsOpen = $parse($attrs.isOpen);
76+
setIsOpen = getIsOpen.assign;
77+
78+
$scope.$watch(getIsOpen, function(value) {
79+
scope.isOpen = !!value;
80+
});
81+
}
82+
};
83+
84+
this.toggle = function( open ) {
85+
return scope.isOpen = arguments.length ? !!open : !scope.isOpen;
86+
};
87+
88+
// Allow other directives to watch status
89+
this.isOpen = function() {
90+
return scope.isOpen;
91+
};
92+
93+
scope.getToggleElement = function() {
94+
return self.toggleElement;
95+
};
96+
97+
scope.getAutoClose = function() {
98+
return $attrs.autoClose || 'always'; //or 'outsideClick' or 'disabled'
99+
};
100+
101+
scope.getElement = function() {
102+
return self.$element;
103+
};
104+
105+
scope.focusToggleElement = function() {
106+
if ( self.toggleElement ) {
107+
self.toggleElement[0].focus();
108+
}
109+
};
110+
111+
scope.$watch('isOpen', function( isOpen, wasOpen ) {
112+
$animate[isOpen ? 'addClass' : 'removeClass'](self.$element, openClass);
113+
114+
if ( isOpen ) {
115+
scope.focusToggleElement();
116+
dropdownService.open( scope );
117+
} else {
118+
dropdownService.close( scope );
119+
}
120+
121+
setIsOpen($scope, isOpen);
122+
if (angular.isDefined(isOpen) && isOpen !== wasOpen) {
123+
toggleInvoker($scope, { open: !!isOpen });
124+
}
125+
});
126+
127+
$scope.$on('$locationChangeSuccess', function() {
128+
scope.isOpen = false;
129+
});
130+
131+
$scope.$on('$destroy', function() {
132+
scope.$destroy();
133+
});
134+
}])
135+
136+
.directive('dropdown', function() {
137+
return {
138+
controller: 'DropdownController',
139+
link: function(scope, element, attrs, dropdownCtrl) {
140+
dropdownCtrl.init( element );
141+
}
142+
};
143+
})
144+
145+
.directive('dropdownToggle', function() {
146+
return {
147+
require: '?^dropdown',
148+
link: function(scope, element, attrs, dropdownCtrl) {
149+
if ( !dropdownCtrl ) {
150+
return;
151+
}
152+
153+
dropdownCtrl.toggleElement = element;
154+
155+
var toggleDropdown = function(event) {
156+
event.preventDefault();
157+
158+
if ( !element.hasClass('disabled') && !attrs.disabled ) {
159+
scope.$apply(function() {
160+
dropdownCtrl.toggle();
161+
});
162+
}
163+
};
164+
165+
element.bind('click', toggleDropdown);
166+
167+
// WAI-ARIA
168+
element.attr({ 'aria-haspopup': true, 'aria-expanded': false });
169+
scope.$watch(dropdownCtrl.isOpen, function( isOpen ) {
170+
element.attr('aria-expanded', !!isOpen);
171+
});
172+
173+
scope.$on('$destroy', function() {
174+
element.unbind('click', toggleDropdown);
175+
});
176+
}
177+
};
178+
});

src/dropdown/test/dropdown.spec.js

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,4 +324,92 @@ describe('dropdownToggle', function() {
324324
expect($rootScope.toggleHandler).toHaveBeenCalledWith(false);
325325
});
326326
});
327+
328+
describe('`auto-close` option', function() {
329+
function dropdown(autoClose) {
330+
return $compile('<li dropdown ' +
331+
(autoClose === void 0 ? '' : 'auto-close="'+autoClose+'"') +
332+
'><a href dropdown-toggle></a><ul><li><a href>Hello</a></li></ul></li>')($rootScope);
333+
}
334+
335+
it('should close on document click if no auto-close is specified', function() {
336+
element = dropdown();
337+
clickDropdownToggle();
338+
expect(element.hasClass('open')).toBe(true);
339+
$document.click();
340+
expect(element.hasClass('open')).toBe(false);
341+
});
342+
343+
it('should close on document click if empty auto-close is specified', function() {
344+
element = dropdown('');
345+
clickDropdownToggle();
346+
expect(element.hasClass('open')).toBe(true);
347+
$document.click();
348+
expect(element.hasClass('open')).toBe(false);
349+
});
350+
351+
it('auto-close="disabled"', function() {
352+
element = dropdown('disabled');
353+
clickDropdownToggle();
354+
expect(element.hasClass('open')).toBe(true);
355+
$document.click();
356+
expect(element.hasClass('open')).toBe(true);
357+
});
358+
359+
it('auto-close="outsideClick"', function() {
360+
element = dropdown('outsideClick');
361+
clickDropdownToggle();
362+
expect(element.hasClass('open')).toBe(true);
363+
element.find('ul li a').click();
364+
expect(element.hasClass('open')).toBe(true);
365+
$document.click();
366+
expect(element.hasClass('open')).toBe(false);
367+
});
368+
369+
it('control with is-open', function() {
370+
$rootScope.isopen = true;
371+
element = $compile('<li dropdown is-open="isopen" auto-close="disabled"><a href dropdown-toggle></a><ul><li>Hello</li></ul></li>')($rootScope);
372+
$rootScope.$digest();
373+
374+
expect(element.hasClass('open')).toBe(true);
375+
//should remain open
376+
$document.click();
377+
expect(element.hasClass('open')).toBe(true);
378+
//now should close
379+
$rootScope.isopen = false;
380+
$rootScope.$digest();
381+
expect(element.hasClass('open')).toBe(false);
382+
});
383+
384+
it('should close anyway if toggle is clicked', function() {
385+
element = dropdown('disabled');
386+
clickDropdownToggle();
387+
expect(element.hasClass('open')).toBe(true);
388+
clickDropdownToggle();
389+
expect(element.hasClass('open')).toBe(false);
390+
});
391+
392+
it('should close anyway if esc is pressed', function() {
393+
element = dropdown('disabled');
394+
$document.find('body').append(element);
395+
clickDropdownToggle();
396+
triggerKeyDown($document, 27);
397+
expect(element.hasClass('open')).toBe(false);
398+
expect(isFocused(element.find('a'))).toBe(true);
399+
element.remove();
400+
});
401+
402+
it('should close anyway if another dropdown is opened', function() {
403+
var elm1 = dropdown('disabled');
404+
var elm2 = dropdown();
405+
expect(elm1.hasClass('open')).toBe(false);
406+
expect(elm2.hasClass('open')).toBe(false);
407+
clickDropdownToggle(elm1);
408+
expect(elm1.hasClass('open')).toBe(true);
409+
expect(elm2.hasClass('open')).toBe(false);
410+
clickDropdownToggle(elm2);
411+
expect(elm1.hasClass('open')).toBe(false);
412+
expect(elm2.hasClass('open')).toBe(true);
413+
});
414+
});
327415
});

0 commit comments

Comments
 (0)