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

Commit

Permalink
feat(dropdown): add keynav support to dropdown
Browse files Browse the repository at this point in the history
Add `keyboard-nav` option for dropdowns for navigating the dropdown menu with the keyboard

Closes #3685
Closes #3212
Fixes #1228
  • Loading branch information
bleggett authored and wesleycho committed Jun 26, 2015
1 parent 49e73a8 commit 6235937
Show file tree
Hide file tree
Showing 4 changed files with 294 additions and 14 deletions.
19 changes: 17 additions & 2 deletions src/dropdown/docs/demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,23 @@
<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">

<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>

<script type="text/ng-template" id="dropdown.html">
<ul class="dropdown-menu" role="menu">
<li><a href="#">Action in Template</a></li>
<li><a href="#">Another action in Template</a></li>
Expand Down
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
105 changes: 93 additions & 12 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,23 +52,30 @@ 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', '$compile', '$templateRequest', function($scope, $attrs, $parse, dropdownConfig, dropdownService, $animate, $position, $document, $compile, $templateRequest) {
var self = this,
scope = $scope.$new(), // create a child scope so we are not polluting original one
templateScope,
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
templateScope,
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 @@ -83,6 +90,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 @@ -113,6 +121,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 Down Expand Up @@ -156,6 +198,7 @@ angular.module('ui.bootstrap.dropdown', ['ui.bootstrap.position'])
}

dropdownService.close( scope );
self.selectedOption = null;
}

setIsOpen($scope, isOpen);
Expand Down Expand Up @@ -203,6 +246,44 @@ angular.module('ui.bootstrap.dropdown', ['ui.bootstrap.position'])
};
})

.directive('keyboardNav', function() {
return {
restrict: 'A',
require: '?^dropdown',
link: function (scope, element, attrs, dropdownCtrl) {

element.bind('keydown', function(e) {

if ( /(38|40)/.test(e.which)) {

This comment has been minimized.

Copy link
@uecasm

uecasm Jul 22, 2015

I question the performance of using a regex test instead of a simple || test here. Also this may spuriously match eg. 140.

This comment has been minimized.

Copy link
@wesleycho

wesleycho Jul 22, 2015

Contributor

Good catch - I'll patch this right into master.


e.preventDefault();
e.stopPropagation();

var elems = angular.element(element).find('a');

switch (e.keyCode) {
case (40): { // Down
if ( !angular.isNumber(dropdownCtrl.selectedOption)) {
dropdownCtrl.selectedOption = 0;
} else {
dropdownCtrl.selectedOption = (dropdownCtrl.selectedOption === elems.length -1 ? dropdownCtrl.selectedOption : dropdownCtrl.selectedOption+1);
}

}
break;
case (38): { // Up
dropdownCtrl.selectedOption = (dropdownCtrl.selectedOption === 0 ? 0 : dropdownCtrl.selectedOption-1);
}
break;
}
elems[dropdownCtrl.selectedOption].focus();
}
});
}

};
})

.directive('dropdownToggle', function() {
return {
require: '?^dropdown',
Expand Down
182 changes: 182 additions & 0 deletions src/dropdown/test/dropdown.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -468,4 +468,186 @@ describe('dropdownToggle', function() {
expect(elm1.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 optionEl = element.find('ul').eq(0).find('a').eq(0);
expect(isFocused(optionEl)).toBe(true);
});

it('should not focus first 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').eq(0);
expect(isFocused(focusEl)).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);
});
});

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 6235937

Please sign in to comment.