Skip to content

Commit

Permalink
Add keynav support to dropdown (angular-ui#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): Dedup code and handle differences if dropdown-menu used

Add keynav support to dropdown (angular-ui#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): Fix focus issue and add more tests

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

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

fix(dropdown): Update docs with example

feat(dropdown): Add keynav support (fix for angular-ui#1228)

feat(dropdown): Fix indentation issues and correct breaks.

fix(dropdown): undo indent goofyness.
  • Loading branch information
bleggett committed Jun 11, 2015
1 parent f02a24d commit 5513a1a
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)) {

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

Please sign in to comment.