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

feat(dropdown) Add keynav support (fix for #1228) #3685

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

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);
});
});
});