From 59993113a3c3048eb6269a67df224c149d6ec214 Mon Sep 17 00:00:00 2001
From: bleggett
Date: Sun, 10 May 2015 19:13:15 -0400
Subject: [PATCH] Add keynav support to dropdown (#1228)
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)
---
src/dropdown/docs/demo.html | 15 +++
src/dropdown/docs/readme.md | 2 +
src/dropdown/dropdown.js | 65 ++++++++++---
src/dropdown/test/dropdown.spec.js | 149 ++++++++++++++++++++++++++++-
4 files changed, 217 insertions(+), 14 deletions(-)
diff --git a/src/dropdown/docs/demo.html b/src/dropdown/docs/demo.html
index f4feaa9588..b051e6edc5 100644
--- a/src/dropdown/docs/demo.html
+++ b/src/dropdown/docs/demo.html
@@ -62,4 +62,19 @@
+
+
+
+
+
+
+
diff --git a/src/dropdown/docs/readme.md b/src/dropdown/docs/readme.md
index 02970d0a5f..6f1cb41810 100644
--- a/src/dropdown/docs/readme.md
+++ b/src/dropdown/docs/readme.md
@@ -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.
diff --git a/src/dropdown/dropdown.js b/src/dropdown/dropdown.js
index 8f03186160..2efb1a8120 100644
--- a/src/dropdown/dropdown.js
+++ b/src/dropdown/dropdown.js
@@ -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;
@@ -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);
}
};
@@ -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();
@@ -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;
@@ -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 );
@@ -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();
@@ -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);
diff --git a/src/dropdown/test/dropdown.spec.js b/src/dropdown/test/dropdown.spec.js
index 047ca09720..9d5e6d5163 100644
--- a/src/dropdown/test/dropdown.spec.js
+++ b/src/dropdown/test/dropdown.spec.js
@@ -213,7 +213,7 @@ describe('dropdownToggle', function() {
});
return $compile('' +
- '')($rootScope);
+ '')($rootScope);
}
beforeEach(function() {
@@ -350,8 +350,8 @@ describe('dropdownToggle', function() {
describe('`auto-close` option', function() {
function dropdown(autoClose) {
return $compile('')($rootScope);
+ (autoClose === void 0 ? '' : 'auto-close="'+autoClose+'"') +
+ '>')($rootScope);
}
it('should close on document click if no auto-close is specified', function() {
@@ -433,4 +433,147 @@ describe('dropdownToggle', function() {
expect(elm2.hasClass(dropdownConfig.openClass)).toBe(true);
});
});
+
+ describe('`keyboard-nav` option', function() {
+ function dropdown() {
+ return $compile('')($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('')($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);
+ });
+ });
});