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

feat(dropdown): add nesting capability #3776

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
22 changes: 20 additions & 2 deletions src/dropdown/docs/demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
<li><a href="#">Separated link</a></li>
</ul>
</div>

<!-- Single button using template-url -->
<div class="btn-group" dropdown>
<button type="button" class="btn btn-primary dropdown-toggle" dropdown-toggle ng-disabled="disabled">
Expand All @@ -65,12 +65,30 @@
</ul>
</div>

<!-- Single button with nesting and auto-clise set to outsideClick -->
<div class="btn-group" dropdown auto-close="outsideClick">
<button type="button" class="btn btn-primary dropdown-toggle" dropdown-toggle ng-disabled="disabled">
Nested dropdown <span class="caret"></span>
</button>
<ul class="dropdown-menu" role="menu">
<li><a href="#">Action</a></li>
<li><a href="#">Another action</a></li>
<li dropdown>
<a href="#" dropdown-toggle>Open nested Dropdown</a>
<ul class="dropdown-menu" role="menu">
<li><a href="#">Nested action</a></li>
<li><a href="#">Another nested action</a></li>
</ul>
</li>
</ul>
</div>

<hr />
<p>
<button type="button" class="btn btn-default btn-sm" ng-click="toggleDropdown($event)">Toggle button dropdown</button>
<button type="button" class="btn btn-warning btn-sm" ng-click="disabled = !disabled">Enable/Disable</button>
</p>

<script type="text/ng-template" id="dropdown.html">
<ul class="dropdown-menu" role="menu">
<li><a href="#">Action in Template</a></li>
Expand Down
1 change: 1 addition & 0 deletions src/dropdown/docs/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
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.
Dropdowns can be nested to reflect hierarchies in menus. Make sure to nest the dropdowns in the DOM to utilize this feature.

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.
Expand Down
96 changes: 67 additions & 29 deletions src/dropdown/dropdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,57 +4,95 @@ angular.module('ui.bootstrap.dropdown', ['ui.bootstrap.position'])
openClass: 'open'
})

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

this.open = function( dropdownScope ) {
if ( !openScope ) {
this.open = function(dropdownScope) {
var upperScope = getUpperScope();

if (!upperScope) {
$document.bind('click', closeDropdown);
$document.bind('keydown', escapeKeyBind);
}

if ( openScope && openScope !== dropdownScope ) {
openScope.isOpen = false;
while(upperScope && upperScope !== dropdownScope) {
// contained dropdown, do not close dropdown
if (upperScope.getElement()[0].contains(dropdownScope.getElement()[0])) {
break;
}

openScopes.pop().isOpen = false;
upperScope = getUpperScope();
}

openScope = dropdownScope;
openScopes.push(dropdownScope);
};

this.close = function( dropdownScope ) {
if ( openScope === dropdownScope ) {
openScope = null;
this.close = function(dropdownScope) {

if (openScopes.indexOf(dropdownScope) > -1) {
var upperScope = getUpperScope();
while (upperScope) {
openScopes.pop().isOpen = false;
if (upperScope === dropdownScope) {
break;
}
}
}

if ( !getUpperScope() ) {
$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; }
var getUpperScope = function() {
if (openScopes.length === 0) {
return null;
}

if( evt && openScope.getAutoClose() === 'disabled' ) { return ; }
return openScopes[openScopes.length-1];
};

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

var $element = openScope.getElement();
if( evt && openScope.getAutoClose() === 'outsideClick' && $element && $element[0].contains(evt.target) ) {
return;
}
// This method may still be called during the same mouse event that
// unbound this event handler. So check upperScope before proceeding.
if (!upperScope) { break; }

if (evt && upperScope.getAutoClose() === 'disabled' ) { break; }

openScope.isOpen = false;
var toggleElement = upperScope.getToggleElement();
if (evt && toggleElement && toggleElement[0].contains(evt.target) ) {
break;
}

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

upperScope.isOpen = false;
openScopes.pop();

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

// just close upper dropdown if ESC was pressed
if (angular.isUndefined(evt)) {
break;
}

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

var escapeKeyBind = function( evt ) {
if ( evt.which === 27 ) {
openScope.focusToggleElement();
var escapeKeyBind = function(evt) {
if (evt.which === 27) {
getUpperScope().focusToggleElement();
closeDropdown();
}
};
Expand Down
97 changes: 94 additions & 3 deletions src/dropdown/test/dropdown.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ describe('dropdownToggle', function() {

var clickDropdownToggle = function(elm) {
elm = elm || element;
elm.find('a[dropdown-toggle]').click();
elm.find('a[dropdown-toggle]')[0].click();
};

var triggerKeyDown = function (element, keyCode) {
Expand Down Expand Up @@ -183,7 +183,7 @@ describe('dropdownToggle', function() {
expect(element.hasClass(dropdownConfig.openClass)).toBe(false);
});
});

describe('using dropdownMenuTemplate', function() {
function dropdown() {
$templateCache.put('custom.html', '<ul class="dropdown-menu"><li>Item 1</li></ul>');
Expand All @@ -194,7 +194,7 @@ describe('dropdownToggle', function() {
beforeEach(function() {
element = dropdown();
});

it('should apply custom template for dropdown menu', function() {
element.find('a').click();
expect(element.find('ul.dropdown-menu').eq(0).find('li').eq(0).text()).toEqual('Item 1');
Expand Down Expand Up @@ -468,4 +468,95 @@ describe('dropdownToggle', function() {
expect(elm1.hasClass(dropdownConfig.openClass)).toBe(true);
});
});

describe('nesting', function () {
var outerElement, innerElement, otherElement;

beforeEach(function() {
innerElement = dropdown($rootScope);
outerElement = dropdown($rootScope.$new());
otherElement = dropdown($rootScope.$new());
wrapDropdown(outerElement, innerElement);
});

function dropdown(scope) {
return $compile('<li dropdown><a href dropdown-toggle></a><ul><li><a href>Hello</a></li></ul></li>')(scope);
}

function wrapDropdown(outerElement, innerElement) {
angular.element(outerElement.find('ul')[0]).append(innerElement);
}

it('should open nested dropdown', function () {
expect(outerElement.hasClass(dropdownConfig.openClass)).toBe(false);
expect(innerElement.hasClass(dropdownConfig.openClass)).toBe(false);
clickDropdownToggle(outerElement);
expect(outerElement.hasClass(dropdownConfig.openClass)).toBe(true);
expect(innerElement.hasClass(dropdownConfig.openClass)).toBe(false);
clickDropdownToggle(innerElement);
expect(outerElement.hasClass(dropdownConfig.openClass)).toBe(true);
expect(innerElement.hasClass(dropdownConfig.openClass)).toBe(true);
});

it('should toggle inner dropdown', function () {
clickDropdownToggle(outerElement);
clickDropdownToggle(innerElement);
expect(outerElement.hasClass(dropdownConfig.openClass)).toBe(true);
expect(innerElement.hasClass(dropdownConfig.openClass)).toBe(true);
clickDropdownToggle(innerElement);
expect(outerElement.hasClass(dropdownConfig.openClass)).toBe(true);
expect(innerElement.hasClass(dropdownConfig.openClass)).toBe(false);
});

it('should close on document click', function () {
clickDropdownToggle(outerElement);
clickDropdownToggle(innerElement);
expect(outerElement.hasClass(dropdownConfig.openClass)).toBe(true);
expect(innerElement.hasClass(dropdownConfig.openClass)).toBe(true);
$document.click();
expect(outerElement.hasClass(dropdownConfig.openClass)).toBe(false);
expect(innerElement.hasClass(dropdownConfig.openClass)).toBe(false);
});

it('should close inner on escape key & focus inner toggle element', function() {
$document.find('body').append(outerElement);
clickDropdownToggle(outerElement);
clickDropdownToggle(innerElement);
triggerKeyDown($document, 27);
expect(outerElement.hasClass(dropdownConfig.openClass)).toBe(true);
expect(innerElement.hasClass(dropdownConfig.openClass)).toBe(false);
expect(isFocused(innerElement.find('a'))).toBe(true);
});

it('should not close on backspace key', function() {
clickDropdownToggle(outerElement);
clickDropdownToggle(innerElement);
triggerKeyDown($document, 8);
expect(outerElement.hasClass(dropdownConfig.openClass)).toBe(true);
expect(innerElement.hasClass(dropdownConfig.openClass)).toBe(true);
});

it('should close on $location change', function() {
clickDropdownToggle(outerElement);
clickDropdownToggle(innerElement);
expect(outerElement.hasClass(dropdownConfig.openClass)).toBe(true);
expect(innerElement.hasClass(dropdownConfig.openClass)).toBe(true);
$rootScope.$broadcast('$locationChangeSuccess');
$rootScope.$apply();
expect(outerElement.hasClass(dropdownConfig.openClass)).toBe(false);
expect(innerElement.hasClass(dropdownConfig.openClass)).toBe(false);
});

it('should close nested dropdowns and open other dropdown', function () {
clickDropdownToggle(outerElement);
clickDropdownToggle(innerElement);
expect(outerElement.hasClass(dropdownConfig.openClass)).toBe(true);
expect(innerElement.hasClass(dropdownConfig.openClass)).toBe(true);
expect(otherElement.hasClass(dropdownConfig.openClass)).toBe(false);
clickDropdownToggle(otherElement);
expect(outerElement.hasClass(dropdownConfig.openClass)).toBe(false);
expect(innerElement.hasClass(dropdownConfig.openClass)).toBe(false);
expect(otherElement.hasClass(dropdownConfig.openClass)).toBe(true);
});
});
});