Skip to content

Commit

Permalink
feat(dropdown): Make Auto-Close Dropdowns optional.
Browse files Browse the repository at this point in the history
  • Loading branch information
mariocasciaro authored and fernando-sendMail committed Jul 16, 2015
1 parent 9b1a3c5 commit 796cd4e
Show file tree
Hide file tree
Showing 3 changed files with 267 additions and 11 deletions.
12 changes: 1 addition & 11 deletions src/dropdown/docs/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,8 @@
Dropdown is a simple directive which will toggle a dropdown menu on click or programmatically.
You can either use `is-open` to toggle or add inside a `<a dropdown-toggle>` element to toggle it when is clicked.
There is also the `on-toggle(open)` optional expression fired when dropdown changes state.

Add `dropdown-append-to-body` to the `dropdown` element to append to the inner `dropdown-menu` to the body.
This is useful when the dropdown button is inside a div with `overflow: hidden`, and the menu would otherwise be hidden.

Add `keyboard-nav` to the `dropdown` element to enable navigation of dropdown list elements with the arrow keys.

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:

* `always` - (Default) automatically closes the dropdown when any of its elements is clicked.
* `outsideClick` - closes the dropdown automatically only when the user clicks any element outside the dropdown.
* `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.

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.

Example: `<ul class="dropdown-menu" template-url="custom-dropdown.html"></ul>`.
* `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.
178 changes: 178 additions & 0 deletions src/dropdown/dropdown.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
angular.module('ui.bootstrap.dropdown', [])

.constant('dropdownConfig', {
openClass: 'open'
})

.service('dropdownService', ['$document', '$rootScope', function($document, $rootScope) {
var openScope = null;

this.open = function( dropdownScope ) {
if ( !openScope ) {
$document.bind('click', closeDropdown);
$document.bind('keydown', escapeKeyBind);
}

if ( openScope && openScope !== dropdownScope ) {
openScope.isOpen = false;
}

openScope = dropdownScope;
};

this.close = function( dropdownScope ) {
if ( openScope === dropdownScope ) {
openScope = null;
$document.unbind('click', closeDropdown);
$document.unbind('keydown', escapeKeyBind);
}
};

var closeDropdown = function( evt ) {
// This method may still be called during the same mouse event that
// unbound this event handler. So check openScope before proceeding.
if (!openScope) { return; }

if( evt && openScope.getAutoClose() === 'disabled' ) { return ; }

var toggleElement = openScope.getToggleElement();
if ( evt && toggleElement && toggleElement[0].contains(evt.target) ) {
return;
}

var $element = openScope.getElement();
if( evt && openScope.getAutoClose() === 'outsideClick' && $element && $element[0].contains(evt.target) ) {
return;
}

openScope.isOpen = false;

if (!$rootScope.$$phase) {
openScope.$apply();
}
};

var escapeKeyBind = function( evt ) {
if ( evt.which === 27 ) {
openScope.focusToggleElement();
closeDropdown();
}
};
}])

.controller('DropdownController', ['$scope', '$attrs', '$parse', 'dropdownConfig', 'dropdownService', '$animate', function($scope, $attrs, $parse, dropdownConfig, dropdownService, $animate) {
var self = this,
scope = $scope.$new(), // create a child scope so we are not polluting original one
openClass = dropdownConfig.openClass,
getIsOpen,
setIsOpen = angular.noop,
toggleInvoker = $attrs.onToggle ? $parse($attrs.onToggle) : angular.noop;

this.init = function( element ) {
self.$element = element;

if ( $attrs.isOpen ) {
getIsOpen = $parse($attrs.isOpen);
setIsOpen = getIsOpen.assign;

$scope.$watch(getIsOpen, function(value) {
scope.isOpen = !!value;
});
}
};

this.toggle = function( open ) {
return scope.isOpen = arguments.length ? !!open : !scope.isOpen;
};

// Allow other directives to watch status
this.isOpen = function() {
return scope.isOpen;
};

scope.getToggleElement = function() {
return self.toggleElement;
};

scope.getAutoClose = function() {
return $attrs.autoClose || 'always'; //or 'outsideClick' or 'disabled'
};

scope.getElement = function() {
return self.$element;
};

scope.focusToggleElement = function() {
if ( self.toggleElement ) {
self.toggleElement[0].focus();
}
};

scope.$watch('isOpen', function( isOpen, wasOpen ) {
$animate[isOpen ? 'addClass' : 'removeClass'](self.$element, openClass);

if ( isOpen ) {
scope.focusToggleElement();
dropdownService.open( scope );
} else {
dropdownService.close( scope );
}

setIsOpen($scope, isOpen);
if (angular.isDefined(isOpen) && isOpen !== wasOpen) {
toggleInvoker($scope, { open: !!isOpen });
}
});

$scope.$on('$locationChangeSuccess', function() {
scope.isOpen = false;
});

$scope.$on('$destroy', function() {
scope.$destroy();
});
}])

.directive('dropdown', function() {
return {
controller: 'DropdownController',
link: function(scope, element, attrs, dropdownCtrl) {
dropdownCtrl.init( element );
}
};
})

.directive('dropdownToggle', function() {
return {
require: '?^dropdown',
link: function(scope, element, attrs, dropdownCtrl) {
if ( !dropdownCtrl ) {
return;
}

dropdownCtrl.toggleElement = element;

var toggleDropdown = function(event) {
event.preventDefault();

if ( !element.hasClass('disabled') && !attrs.disabled ) {
scope.$apply(function() {
dropdownCtrl.toggle();
});
}
};

element.bind('click', toggleDropdown);

// WAI-ARIA
element.attr({ 'aria-haspopup': true, 'aria-expanded': false });
scope.$watch(dropdownCtrl.isOpen, function( isOpen ) {
element.attr('aria-expanded', !!isOpen);
});

scope.$on('$destroy', function() {
element.unbind('click', toggleDropdown);
});
}
};
});
88 changes: 88 additions & 0 deletions src/dropdown/test/dropdown.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -324,4 +324,92 @@ describe('dropdownToggle', function() {
expect($rootScope.toggleHandler).toHaveBeenCalledWith(false);
});
});

describe('`auto-close` option', function() {
function dropdown(autoClose) {
return $compile('<li dropdown ' +
(autoClose === void 0 ? '' : 'auto-close="'+autoClose+'"') +
'><a href dropdown-toggle></a><ul><li><a href>Hello</a></li></ul></li>')($rootScope);
}

it('should close on document click if no auto-close is specified', function() {
element = dropdown();
clickDropdownToggle();
expect(element.hasClass('open')).toBe(true);
$document.click();
expect(element.hasClass('open')).toBe(false);
});

it('should close on document click if empty auto-close is specified', function() {
element = dropdown('');
clickDropdownToggle();
expect(element.hasClass('open')).toBe(true);
$document.click();
expect(element.hasClass('open')).toBe(false);
});

it('auto-close="disabled"', function() {
element = dropdown('disabled');
clickDropdownToggle();
expect(element.hasClass('open')).toBe(true);
$document.click();
expect(element.hasClass('open')).toBe(true);
});

it('auto-close="outsideClick"', function() {
element = dropdown('outsideClick');
clickDropdownToggle();
expect(element.hasClass('open')).toBe(true);
element.find('ul li a').click();
expect(element.hasClass('open')).toBe(true);
$document.click();
expect(element.hasClass('open')).toBe(false);
});

it('control with is-open', function() {
$rootScope.isopen = true;
element = $compile('<li dropdown is-open="isopen" auto-close="disabled"><a href dropdown-toggle></a><ul><li>Hello</li></ul></li>')($rootScope);
$rootScope.$digest();

expect(element.hasClass('open')).toBe(true);
//should remain open
$document.click();
expect(element.hasClass('open')).toBe(true);
//now should close
$rootScope.isopen = false;
$rootScope.$digest();
expect(element.hasClass('open')).toBe(false);
});

it('should close anyway if toggle is clicked', function() {
element = dropdown('disabled');
clickDropdownToggle();
expect(element.hasClass('open')).toBe(true);
clickDropdownToggle();
expect(element.hasClass('open')).toBe(false);
});

it('should close anyway if esc is pressed', function() {
element = dropdown('disabled');
$document.find('body').append(element);
clickDropdownToggle();
triggerKeyDown($document, 27);
expect(element.hasClass('open')).toBe(false);
expect(isFocused(element.find('a'))).toBe(true);
element.remove();
});

it('should close anyway if another dropdown is opened', function() {
var elm1 = dropdown('disabled');
var elm2 = dropdown();
expect(elm1.hasClass('open')).toBe(false);
expect(elm2.hasClass('open')).toBe(false);
clickDropdownToggle(elm1);
expect(elm1.hasClass('open')).toBe(true);
expect(elm2.hasClass('open')).toBe(false);
clickDropdownToggle(elm2);
expect(elm1.hasClass('open')).toBe(false);
expect(elm2.hasClass('open')).toBe(true);
});
});
});

0 comments on commit 796cd4e

Please sign in to comment.