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

Commit 6235937

Browse files
bleggettwesleycho
authored andcommitted
feat(dropdown): add keynav support to dropdown
Add `keyboard-nav` option for dropdowns for navigating the dropdown menu with the keyboard Closes #3685 Closes #3212 Fixes #1228
1 parent 49e73a8 commit 6235937

File tree

4 files changed

+294
-14
lines changed

4 files changed

+294
-14
lines changed

src/dropdown/docs/demo.html

+17-2
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,23 @@
7070
<button type="button" class="btn btn-default btn-sm" ng-click="toggleDropdown($event)">Toggle button dropdown</button>
7171
<button type="button" class="btn btn-warning btn-sm" ng-click="disabled = !disabled">Enable/Disable</button>
7272
</p>
73-
74-
<script type="text/ng-template" id="dropdown.html">
73+
74+
<hr>
75+
<!-- Single button with keyboard nav -->
76+
<div class="btn-group" dropdown keyboard-nav>
77+
<button type="button" class="btn btn-primary dropdown-toggle" dropdown-toggle>
78+
Dropdown with keyboard navigation <span class="caret"></span>
79+
</button>
80+
<ul class="dropdown-menu" role="menu">
81+
<li><a href="#">Action</a></li>
82+
<li><a href="#">Another action</a></li>
83+
<li><a href="#">Something else here</a></li>
84+
<li class="divider"></li>
85+
<li><a href="#">Separated link</a></li>
86+
</ul>
87+
</div>
88+
89+
<script type="text/ng-template" id="dropdown.html">
7590
<ul class="dropdown-menu" role="menu">
7691
<li><a href="#">Action in Template</a></li>
7792
<li><a href="#">Another action in Template</a></li>

src/dropdown/docs/readme.md

+2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ There is also the `on-toggle(open)` optional expression fired when dropdown chan
66
Add `dropdown-append-to-body` to the `dropdown` element to append to the inner `dropdown-menu` to the body.
77
This is useful when the dropdown button is inside a div with `overflow: hidden`, and the menu would otherwise be hidden.
88

9+
Add `keyboard-nav` to the `dropdown` element to enable navigation of dropdown list elements with the arrow keys.
10+
911
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:
1012

1113
* `always` - (Default) automatically closes the dropdown when any of its elements is clicked.

src/dropdown/dropdown.js

+93-12
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ angular.module('ui.bootstrap.dropdown', ['ui.bootstrap.position'])
1010
this.open = function( dropdownScope ) {
1111
if ( !openScope ) {
1212
$document.bind('click', closeDropdown);
13-
$document.bind('keydown', escapeKeyBind);
13+
$document.bind('keydown', keybindFilter);
1414
}
1515

1616
if ( openScope && openScope !== dropdownScope ) {
17-
openScope.isOpen = false;
17+
openScope.isOpen = false;
1818
}
1919

2020
openScope = dropdownScope;
@@ -24,7 +24,7 @@ angular.module('ui.bootstrap.dropdown', ['ui.bootstrap.position'])
2424
if ( openScope === dropdownScope ) {
2525
openScope = null;
2626
$document.unbind('click', closeDropdown);
27-
$document.unbind('keydown', escapeKeyBind);
27+
$document.unbind('keydown', keybindFilter);
2828
}
2929
};
3030

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

3838
var toggleElement = openScope.getToggleElement();
3939
if ( evt && toggleElement && toggleElement[0].contains(evt.target) ) {
40-
return;
40+
return;
4141
}
4242

4343
var $element = openScope.getElement();
@@ -52,23 +52,30 @@ angular.module('ui.bootstrap.dropdown', ['ui.bootstrap.position'])
5252
}
5353
};
5454

55-
var escapeKeyBind = function( evt ) {
55+
var keybindFilter = function( evt ) {
5656
if ( evt.which === 27 ) {
5757
openScope.focusToggleElement();
5858
closeDropdown();
5959
}
60+
else if ( openScope.isKeynavEnabled() && /(38|40)/.test(evt.which) && openScope.isOpen ) {
61+
evt.preventDefault();
62+
evt.stopPropagation();
63+
openScope.focusDropdownEntry(evt.which);
64+
}
6065
};
6166
}])
6267

6368
.controller('DropdownController', ['$scope', '$attrs', '$parse', 'dropdownConfig', 'dropdownService', '$animate', '$position', '$document', '$compile', '$templateRequest', function($scope, $attrs, $parse, dropdownConfig, dropdownService, $animate, $position, $document, $compile, $templateRequest) {
6469
var self = this,
65-
scope = $scope.$new(), // create a child scope so we are not polluting original one
66-
templateScope,
67-
openClass = dropdownConfig.openClass,
68-
getIsOpen,
69-
setIsOpen = angular.noop,
70-
toggleInvoker = $attrs.onToggle ? $parse($attrs.onToggle) : angular.noop,
71-
appendToBody = false;
70+
scope = $scope.$new(), // create a child scope so we are not polluting original one
71+
templateScope,
72+
openClass = dropdownConfig.openClass,
73+
getIsOpen,
74+
setIsOpen = angular.noop,
75+
toggleInvoker = $attrs.onToggle ? $parse($attrs.onToggle) : angular.noop,
76+
appendToBody = false,
77+
keynavEnabled =false,
78+
selectedOption = null;
7279

7380
this.init = function( element ) {
7481
self.$element = element;
@@ -83,6 +90,7 @@ angular.module('ui.bootstrap.dropdown', ['ui.bootstrap.position'])
8390
}
8491

8592
appendToBody = angular.isDefined($attrs.dropdownAppendToBody);
93+
keynavEnabled = angular.isDefined($attrs.keyboardNav);
8694

8795
if ( appendToBody && self.dropdownMenu ) {
8896
$document.find('body').append( self.dropdownMenu );
@@ -113,6 +121,40 @@ angular.module('ui.bootstrap.dropdown', ['ui.bootstrap.position'])
113121
return self.$element;
114122
};
115123

124+
scope.isKeynavEnabled = function() {
125+
return keynavEnabled;
126+
};
127+
128+
scope.focusDropdownEntry = function(keyCode) {
129+
var elems = self.dropdownMenu ? //If append to body is used.
130+
(angular.element(self.dropdownMenu).find('a')) :
131+
(angular.element(self.$element).find('ul').eq(0).find('a'));
132+
133+
switch (keyCode) {
134+
case (40): {
135+
if ( !angular.isNumber(self.selectedOption)) {
136+
self.selectedOption = 0;
137+
} else {
138+
self.selectedOption = (self.selectedOption === elems.length -1 ?
139+
self.selectedOption :
140+
self.selectedOption + 1);
141+
}
142+
break;
143+
}
144+
case (38): {
145+
if ( !angular.isNumber(self.selectedOption)) {
146+
return;
147+
} else {
148+
self.selectedOption = (self.selectedOption === 0 ?
149+
0 :
150+
self.selectedOption - 1);
151+
}
152+
break;
153+
}
154+
}
155+
elems[self.selectedOption].focus();
156+
};
157+
116158
scope.focusToggleElement = function() {
117159
if ( self.toggleElement ) {
118160
self.toggleElement[0].focus();
@@ -156,6 +198,7 @@ angular.module('ui.bootstrap.dropdown', ['ui.bootstrap.position'])
156198
}
157199

158200
dropdownService.close( scope );
201+
self.selectedOption = null;
159202
}
160203

161204
setIsOpen($scope, isOpen);
@@ -203,6 +246,44 @@ angular.module('ui.bootstrap.dropdown', ['ui.bootstrap.position'])
203246
};
204247
})
205248

249+
.directive('keyboardNav', function() {
250+
return {
251+
restrict: 'A',
252+
require: '?^dropdown',
253+
link: function (scope, element, attrs, dropdownCtrl) {
254+
255+
element.bind('keydown', function(e) {
256+
257+
if ( /(38|40)/.test(e.which)) {
258+
259+
e.preventDefault();
260+
e.stopPropagation();
261+
262+
var elems = angular.element(element).find('a');
263+
264+
switch (e.keyCode) {
265+
case (40): { // Down
266+
if ( !angular.isNumber(dropdownCtrl.selectedOption)) {
267+
dropdownCtrl.selectedOption = 0;
268+
} else {
269+
dropdownCtrl.selectedOption = (dropdownCtrl.selectedOption === elems.length -1 ? dropdownCtrl.selectedOption : dropdownCtrl.selectedOption+1);
270+
}
271+
272+
}
273+
break;
274+
case (38): { // Up
275+
dropdownCtrl.selectedOption = (dropdownCtrl.selectedOption === 0 ? 0 : dropdownCtrl.selectedOption-1);
276+
}
277+
break;
278+
}
279+
elems[dropdownCtrl.selectedOption].focus();
280+
}
281+
});
282+
}
283+
284+
};
285+
})
286+
206287
.directive('dropdownToggle', function() {
207288
return {
208289
require: '?^dropdown',

src/dropdown/test/dropdown.spec.js

+182
Original file line numberDiff line numberDiff line change
@@ -468,4 +468,186 @@ describe('dropdownToggle', function() {
468468
expect(elm1.hasClass(dropdownConfig.openClass)).toBe(true);
469469
});
470470
});
471+
472+
describe('`keyboard-nav` option', function() {
473+
function dropdown() {
474+
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);
475+
}
476+
beforeEach(function() {
477+
element = dropdown();
478+
});
479+
480+
it('should focus first list element when down arrow pressed', function() {
481+
$document.find('body').append(element);
482+
clickDropdownToggle();
483+
triggerKeyDown($document, 40);
484+
485+
expect(element.hasClass(dropdownConfig.openClass)).toBe(true);
486+
var optionEl = element.find('ul').eq(0).find('a').eq(0);
487+
expect(isFocused(optionEl)).toBe(true);
488+
});
489+
490+
it('should not focus first list element when down arrow pressed if closed', function() {
491+
$document.find('body').append(element);
492+
triggerKeyDown($document, 40);
493+
494+
expect(element.hasClass(dropdownConfig.openClass)).toBe(false);
495+
var focusEl = element.find('ul').eq(0).find('a').eq(0);
496+
expect(isFocused(focusEl)).toBe(false);
497+
});
498+
499+
it('should focus second list element when down arrow pressed twice', function() {
500+
$document.find('body').append(element);
501+
clickDropdownToggle();
502+
triggerKeyDown($document, 40);
503+
triggerKeyDown($document, 40);
504+
505+
expect(element.hasClass(dropdownConfig.openClass)).toBe(true);
506+
var focusEl = element.find('ul').eq(0).find('a').eq(1);
507+
expect(isFocused(focusEl)).toBe(true);
508+
});
509+
});
510+
511+
describe('`keyboard-nav` option', function() {
512+
function dropdown() {
513+
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);
514+
}
515+
beforeEach(function() {
516+
element = dropdown();
517+
});
518+
519+
it('should focus first list element when down arrow pressed', function() {
520+
$document.find('body').append(element);
521+
clickDropdownToggle();
522+
triggerKeyDown($document, 40);
523+
524+
expect(element.hasClass(dropdownConfig.openClass)).toBe(true);
525+
var focusEl = element.find('ul').eq(0).find('a').eq(0);
526+
expect(isFocused(focusEl)).toBe(true);
527+
});
528+
529+
it('should not focus first list element when up arrow pressed after dropdown toggled', function() {
530+
$document.find('body').append(element);
531+
clickDropdownToggle();
532+
expect(element.hasClass(dropdownConfig.openClass)).toBe(true);
533+
534+
triggerKeyDown($document, 38);
535+
var focusEl = element.find('ul').eq(0).find('a').eq(0);
536+
expect(isFocused(focusEl)).toBe(false);
537+
});
538+
539+
it('should not focus any list element when down arrow pressed if closed', function() {
540+
$document.find('body').append(element);
541+
triggerKeyDown($document, 40);
542+
543+
expect(element.hasClass(dropdownConfig.openClass)).toBe(false);
544+
var focusEl = element.find('ul').eq(0).find('a');
545+
expect(isFocused(focusEl[0])).toBe(false);
546+
expect(isFocused(focusEl[1])).toBe(false);
547+
});
548+
549+
it('should not change focus when other keys are pressed', function() {
550+
$document.find('body').append(element);
551+
clickDropdownToggle();
552+
triggerKeyDown($document, 37);
553+
554+
expect(element.hasClass(dropdownConfig.openClass)).toBe(true);
555+
var focusEl = element.find('ul').eq(0).find('a');
556+
expect(isFocused(focusEl[0])).toBe(false);
557+
expect(isFocused(focusEl[1])).toBe(false);
558+
});
559+
560+
it('should focus second list element when down arrow pressed twice', function() {
561+
$document.find('body').append(element);
562+
clickDropdownToggle();
563+
triggerKeyDown($document, 40);
564+
triggerKeyDown($document, 40);
565+
566+
expect(element.hasClass(dropdownConfig.openClass)).toBe(true);
567+
var focusEl = element.find('ul').eq(0).find('a').eq(1);
568+
expect(isFocused(focusEl)).toBe(true);
569+
});
570+
571+
it('should focus first list element when down arrow pressed 2x and up pressed 1x', function() {
572+
$document.find('body').append(element);
573+
clickDropdownToggle();
574+
triggerKeyDown($document, 40);
575+
triggerKeyDown($document, 40);
576+
577+
triggerKeyDown($document, 38);
578+
579+
expect(element.hasClass(dropdownConfig.openClass)).toBe(true);
580+
var focusEl = element.find('ul').eq(0).find('a').eq(0);
581+
expect(isFocused(focusEl)).toBe(true);
582+
});
583+
584+
it('should stay focused on final list element if down pressed at list end', function() {
585+
$document.find('body').append(element);
586+
clickDropdownToggle();
587+
triggerKeyDown($document, 40);
588+
triggerKeyDown($document, 40);
589+
590+
expect(element.hasClass(dropdownConfig.openClass)).toBe(true);
591+
var focusEl = element.find('ul').eq(0).find('a').eq(1);
592+
expect(isFocused(focusEl)).toBe(true);
593+
594+
triggerKeyDown($document, 40);
595+
expect(isFocused(focusEl)).toBe(true);
596+
});
597+
598+
it('should close if esc is pressed while focused', function() {
599+
element = dropdown('disabled');
600+
$document.find('body').append(element);
601+
clickDropdownToggle();
602+
603+
triggerKeyDown($document, 40);
604+
605+
expect(element.hasClass(dropdownConfig.openClass)).toBe(true);
606+
var focusEl = element.find('ul').eq(0).find('a').eq(0);
607+
expect(isFocused(focusEl)).toBe(true);
608+
609+
triggerKeyDown($document, 27);
610+
expect(element.hasClass(dropdownConfig.openClass)).toBe(false);
611+
});
612+
});
613+
614+
describe('`keyboard-nav` option with `dropdown-append-to-body` option', function() {
615+
function dropdown() {
616+
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);
617+
}
618+
619+
beforeEach(function() {
620+
element = dropdown();
621+
});
622+
623+
it('should focus first list element when down arrow pressed', function() {
624+
clickDropdownToggle();
625+
626+
triggerKeyDown($document, 40);
627+
628+
expect(element.hasClass(dropdownConfig.openClass)).toBe(true);
629+
var focusEl = $document.find('ul').eq(0).find('a');
630+
expect(isFocused(focusEl)).toBe(true);
631+
});
632+
633+
it('should not focus first list element when down arrow pressed if closed', function() {
634+
triggerKeyDown($document, 40);
635+
636+
expect(element.hasClass(dropdownConfig.openClass)).toBe(false);
637+
var focusEl = $document.find('ul').eq(0).find('a');
638+
expect(isFocused(focusEl)).toBe(false);
639+
});
640+
641+
it('should focus second list element when down arrow pressed twice', function() {
642+
clickDropdownToggle();
643+
triggerKeyDown($document, 40);
644+
triggerKeyDown($document, 40);
645+
646+
expect(element.hasClass(dropdownConfig.openClass)).toBe(true);
647+
var elem1 = $document.find('ul');
648+
var elem2 = elem1.find('a');
649+
var focusEl = $document.find('ul').eq(0).find('a').eq(1);
650+
expect(isFocused(focusEl)).toBe(true);
651+
});
652+
});
471653
});

0 commit comments

Comments
 (0)