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

Commit

Permalink
Add keynav support to dropdown (#1228)
Browse files Browse the repository at this point in the history
fix(dropdown): Fixed indexing corner cases and filter key events.

fix(dropdown): Try using document.bind instead

fix(dropdown): Add optional attrib for keyboard-nav.

fix(dropdown): Dedup code and handle differences if dropdown-menu used

fix(dropdown): Fix focus issue and add more tests

fix(dropdown): Update docs with example

fix(dropdown): Revert accidental change to misc/demo/index.html

fix(dropdown): Revert accidental indent changes to dropdown demo.html

feat(dropdown): Add keynav support for dropdown menus (#1228 and #3212)
  • Loading branch information
bleggett committed Jun 3, 2015
1 parent a469fc3 commit 5999311
Show file tree
Hide file tree
Showing 4 changed files with 217 additions and 14 deletions.
15 changes: 15 additions & 0 deletions src/dropdown/docs/demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,19 @@
<button type="button" class="btn btn-warning btn-sm" ng-click="disabled = !disabled">Enable/Disable</button>
</p>

<hr>
<!-- Single button with keyboard nav -->
<div class="btn-group" dropdown keyboard-nav>
<button type="button" class="btn btn-primary dropdown-toggle" dropdown-toggle>
Dropdown with keyboard navigation <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><a href="#">Something else here</a></li>
<li class="divider"></li>
<li><a href="#">Separated link</a></li>
</ul>
</div>

</div>
2 changes: 2 additions & 0 deletions src/dropdown/docs/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ There is also the `on-toggle(open)` optional expression fired when dropdown chan
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.
Expand Down
65 changes: 54 additions & 11 deletions src/dropdown/dropdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ angular.module('ui.bootstrap.dropdown', ['ui.bootstrap.position'])
this.open = function( dropdownScope ) {
if ( !openScope ) {
$document.bind('click', closeDropdown);
$document.bind('keydown', escapeKeyBind);
$document.bind('keydown', keybindFilter);
}

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

openScope = dropdownScope;
Expand All @@ -24,7 +24,7 @@ angular.module('ui.bootstrap.dropdown', ['ui.bootstrap.position'])
if ( openScope === dropdownScope ) {
openScope = null;
$document.unbind('click', closeDropdown);
$document.unbind('keydown', escapeKeyBind);
$document.unbind('keydown', keybindFilter);
}
};

Expand All @@ -37,7 +37,7 @@ angular.module('ui.bootstrap.dropdown', ['ui.bootstrap.position'])

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

var $element = openScope.getElement();
Expand All @@ -52,22 +52,29 @@ angular.module('ui.bootstrap.dropdown', ['ui.bootstrap.position'])
}
};

var escapeKeyBind = function( evt ) {
var keybindFilter = function( evt ) {
if ( evt.which === 27 ) {
openScope.focusToggleElement();
closeDropdown();
}
else if ( openScope.isKeynavEnabled() && /(38|40)/.test(evt.which) && openScope.isOpen ) {
evt.preventDefault();
evt.stopPropagation();
openScope.focusDropdownEntry(evt.which);
}
};
}])

.controller('DropdownController', ['$scope', '$attrs', '$parse', 'dropdownConfig', 'dropdownService', '$animate', '$position', '$document', function($scope, $attrs, $parse, dropdownConfig, dropdownService, $animate, $position, $document) {
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,
appendToBody = false;
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,
appendToBody = false,
keynavEnabled =false,
selectedOption = null;

this.init = function( element ) {
self.$element = element;
Expand All @@ -82,6 +89,7 @@ angular.module('ui.bootstrap.dropdown', ['ui.bootstrap.position'])
}

appendToBody = angular.isDefined($attrs.dropdownAppendToBody);
keynavEnabled = angular.isDefined($attrs.keyboardNav);

if ( appendToBody && self.dropdownMenu ) {
$document.find('body').append( self.dropdownMenu );
Expand Down Expand Up @@ -112,6 +120,40 @@ angular.module('ui.bootstrap.dropdown', ['ui.bootstrap.position'])
return self.$element;
};

scope.isKeynavEnabled = function() {
return keynavEnabled;
};

scope.focusDropdownEntry = function(keyCode) {
var elems = self.dropdownMenu ? //If append to body is used.
(angular.element(self.dropdownMenu).find('a')) :
(angular.element(self.$element).find('ul').eq(0).find('a'));

switch (keyCode) {
case (40): {
if ( !angular.isNumber(self.selectedOption)) {
self.selectedOption = 0;
} else {
self.selectedOption = (self.selectedOption === elems.length -1 ?
self.selectedOption :
self.selectedOption+1);
}
}
break;
case (38): {
if ( !angular.isNumber(self.selectedOption)) {
return;
} else {
self.selectedOption = (self.selectedOption === 0 ?
0 :
self.selectedOption-1);
}
}
break;
}
elems[self.selectedOption].focus();
};

scope.focusToggleElement = function() {
if ( self.toggleElement ) {
self.toggleElement[0].focus();
Expand All @@ -135,6 +177,7 @@ angular.module('ui.bootstrap.dropdown', ['ui.bootstrap.position'])
dropdownService.open( scope );
} else {
dropdownService.close( scope );
self.selectedOption = null;
}

setIsOpen($scope, isOpen);
Expand Down
149 changes: 146 additions & 3 deletions src/dropdown/test/dropdown.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ describe('dropdownToggle', function() {
});

return $compile('<li dropdown><a href dropdown-toggle></a>' +
'<ul><li><a href="#something">Hello</a></li></ul></li>')($rootScope);
'<ul><li><a href="#something">Hello</a></li></ul></li>')($rootScope);
}

beforeEach(function() {
Expand Down Expand Up @@ -350,8 +350,8 @@ describe('dropdownToggle', function() {
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);
(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() {
Expand Down Expand Up @@ -433,4 +433,147 @@ describe('dropdownToggle', function() {
expect(elm2.hasClass(dropdownConfig.openClass)).toBe(true);
});
});

describe('`keyboard-nav` option', function() {
function dropdown() {
return $compile('<li dropdown keyboard-nav><a href dropdown-toggle></a><ul><li><a href>Hello</a></li><li><a href>Hello Again</a></li></ul></li>')($rootScope);
}
beforeEach(function() {
element = dropdown();
});

it('should focus first list element when down arrow pressed', function() {
$document.find('body').append(element);
clickDropdownToggle();
triggerKeyDown($document, 40);

expect(element.hasClass(dropdownConfig.openClass)).toBe(true);
var focusEl = element.find('ul').eq(0).find('a').eq(0);
expect(isFocused(focusEl)).toBe(true);
});

it('should not focus first list element when up arrow pressed after dropdown toggled', function() {
$document.find('body').append(element);
clickDropdownToggle();
expect(element.hasClass(dropdownConfig.openClass)).toBe(true);

triggerKeyDown($document, 38);
var focusEl = element.find('ul').eq(0).find('a').eq(0);
expect(isFocused(focusEl)).toBe(false);
});

it('should not focus any list element when down arrow pressed if closed', function() {
$document.find('body').append(element);
triggerKeyDown($document, 40);

expect(element.hasClass(dropdownConfig.openClass)).toBe(false);
var focusEl = element.find('ul').eq(0).find('a');
expect(isFocused(focusEl[0])).toBe(false);
expect(isFocused(focusEl[1])).toBe(false);
});

it('should not change focus when other keys are pressed', function() {
$document.find('body').append(element);
clickDropdownToggle();
triggerKeyDown($document, 37);

expect(element.hasClass(dropdownConfig.openClass)).toBe(true);
var focusEl = element.find('ul').eq(0).find('a');
expect(isFocused(focusEl[0])).toBe(false);
expect(isFocused(focusEl[1])).toBe(false);
});

it('should focus second list element when down arrow pressed twice', function() {
$document.find('body').append(element);
clickDropdownToggle();
triggerKeyDown($document, 40);
triggerKeyDown($document, 40);

expect(element.hasClass(dropdownConfig.openClass)).toBe(true);
var focusEl = element.find('ul').eq(0).find('a').eq(1);
expect(isFocused(focusEl)).toBe(true);
});

it('should focus first list element when down arrow pressed 2x and up pressed 1x', function() {
$document.find('body').append(element);
clickDropdownToggle();
triggerKeyDown($document, 40);
triggerKeyDown($document, 40);

triggerKeyDown($document, 38);

expect(element.hasClass(dropdownConfig.openClass)).toBe(true);
var focusEl = element.find('ul').eq(0).find('a').eq(0);
expect(isFocused(focusEl)).toBe(true);
});

it('should stay focused on final list element if down pressed at list end', function() {
$document.find('body').append(element);
clickDropdownToggle();
triggerKeyDown($document, 40);
triggerKeyDown($document, 40);

expect(element.hasClass(dropdownConfig.openClass)).toBe(true);
var focusEl = element.find('ul').eq(0).find('a').eq(1);
expect(isFocused(focusEl)).toBe(true);

triggerKeyDown($document, 40);
expect(isFocused(focusEl)).toBe(true);
});

it('should close if esc is pressed while focused', function() {
element = dropdown('disabled');
$document.find('body').append(element);
clickDropdownToggle();

triggerKeyDown($document, 40);

expect(element.hasClass(dropdownConfig.openClass)).toBe(true);
var focusEl = element.find('ul').eq(0).find('a').eq(0);
expect(isFocused(focusEl)).toBe(true);

triggerKeyDown($document, 27);
expect(element.hasClass(dropdownConfig.openClass)).toBe(false);
});
});

describe('`keyboard-nav` option with `dropdown-append-to-body` option', function() {
function dropdown() {
return $compile('<li dropdown dropdown-append-to-body keyboard-nav><a href dropdown-toggle></a><ul class="dropdown-menu" id="dropdown-menu"><li><a href>Hello On Body</a></li><li><a href>Hello Again</a></li></ul></li>')($rootScope);
}

beforeEach(function() {
element = dropdown();
});

it('should focus first list element when down arrow pressed', function() {
clickDropdownToggle();

triggerKeyDown($document, 40);

expect(element.hasClass(dropdownConfig.openClass)).toBe(true);
var focusEl = $document.find('ul').eq(0).find('a');
expect(isFocused(focusEl)).toBe(true);
});

it('should not focus first list element when down arrow pressed if closed', function() {
triggerKeyDown($document, 40);

expect(element.hasClass(dropdownConfig.openClass)).toBe(false);
var focusEl = $document.find('ul').eq(0).find('a');
expect(isFocused(focusEl)).toBe(false);
});

it('should focus second list element when down arrow pressed twice', function() {
clickDropdownToggle();
triggerKeyDown($document, 40);
triggerKeyDown($document, 40);

expect(element.hasClass(dropdownConfig.openClass)).toBe(true);
var elem1 = $document.find('ul');
var elem2 = elem1.find('a');
var focusEl = $document.find('ul').eq(0).find('a').eq(1);
expect(isFocused(focusEl)).toBe(true);
});
});
});

0 comments on commit 5999311

Please sign in to comment.