Skip to content

Commit 5999311

Browse files
committed
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): 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 (angular-ui#1228 and angular-ui#3212)
1 parent a469fc3 commit 5999311

File tree

4 files changed

+217
-14
lines changed

4 files changed

+217
-14
lines changed

Diff for: src/dropdown/docs/demo.html

+15
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,19 @@
6262
<button type="button" class="btn btn-warning btn-sm" ng-click="disabled = !disabled">Enable/Disable</button>
6363
</p>
6464

65+
<hr>
66+
<!-- Single button with keyboard nav -->
67+
<div class="btn-group" dropdown keyboard-nav>
68+
<button type="button" class="btn btn-primary dropdown-toggle" dropdown-toggle>
69+
Dropdown with keyboard navigation <span class="caret"></span>
70+
</button>
71+
<ul class="dropdown-menu" role="menu">
72+
<li><a href="#">Action</a></li>
73+
<li><a href="#">Another action</a></li>
74+
<li><a href="#">Something else here</a></li>
75+
<li class="divider"></li>
76+
<li><a href="#">Separated link</a></li>
77+
</ul>
78+
</div>
79+
6580
</div>

Diff for: 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.

Diff for: src/dropdown/dropdown.js

+54-11
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,22 +52,29 @@ 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', function($scope, $attrs, $parse, dropdownConfig, dropdownService, $animate, $position, $document) {
6469
var self = this,
65-
scope = $scope.$new(), // create a child scope so we are not polluting original one
66-
openClass = dropdownConfig.openClass,
67-
getIsOpen,
68-
setIsOpen = angular.noop,
69-
toggleInvoker = $attrs.onToggle ? $parse($attrs.onToggle) : angular.noop,
70-
appendToBody = false;
70+
scope = $scope.$new(), // create a child scope so we are not polluting original one
71+
openClass = dropdownConfig.openClass,
72+
getIsOpen,
73+
setIsOpen = angular.noop,
74+
toggleInvoker = $attrs.onToggle ? $parse($attrs.onToggle) : angular.noop,
75+
appendToBody = false,
76+
keynavEnabled =false,
77+
selectedOption = null;
7178

7279
this.init = function( element ) {
7380
self.$element = element;
@@ -82,6 +89,7 @@ angular.module('ui.bootstrap.dropdown', ['ui.bootstrap.position'])
8289
}
8390

8491
appendToBody = angular.isDefined($attrs.dropdownAppendToBody);
92+
keynavEnabled = angular.isDefined($attrs.keyboardNav);
8593

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

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

140183
setIsOpen($scope, isOpen);

Diff for: src/dropdown/test/dropdown.spec.js

+146-3
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ describe('dropdownToggle', function() {
213213
});
214214

215215
return $compile('<li dropdown><a href dropdown-toggle></a>' +
216-
'<ul><li><a href="#something">Hello</a></li></ul></li>')($rootScope);
216+
'<ul><li><a href="#something">Hello</a></li></ul></li>')($rootScope);
217217
}
218218

219219
beforeEach(function() {
@@ -350,8 +350,8 @@ describe('dropdownToggle', function() {
350350
describe('`auto-close` option', function() {
351351
function dropdown(autoClose) {
352352
return $compile('<li dropdown ' +
353-
(autoClose === void 0 ? '' : 'auto-close="'+autoClose+'"') +
354-
'><a href dropdown-toggle></a><ul><li><a href>Hello</a></li></ul></li>')($rootScope);
353+
(autoClose === void 0 ? '' : 'auto-close="'+autoClose+'"') +
354+
'><a href dropdown-toggle></a><ul><li><a href>Hello</a></li></ul></li>')($rootScope);
355355
}
356356

357357
it('should close on document click if no auto-close is specified', function() {
@@ -433,4 +433,147 @@ describe('dropdownToggle', function() {
433433
expect(elm2.hasClass(dropdownConfig.openClass)).toBe(true);
434434
});
435435
});
436+
437+
describe('`keyboard-nav` option', function() {
438+
function dropdown() {
439+
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);
440+
}
441+
beforeEach(function() {
442+
element = dropdown();
443+
});
444+
445+
it('should focus first list element when down arrow pressed', function() {
446+
$document.find('body').append(element);
447+
clickDropdownToggle();
448+
triggerKeyDown($document, 40);
449+
450+
expect(element.hasClass(dropdownConfig.openClass)).toBe(true);
451+
var focusEl = element.find('ul').eq(0).find('a').eq(0);
452+
expect(isFocused(focusEl)).toBe(true);
453+
});
454+
455+
it('should not focus first list element when up arrow pressed after dropdown toggled', function() {
456+
$document.find('body').append(element);
457+
clickDropdownToggle();
458+
expect(element.hasClass(dropdownConfig.openClass)).toBe(true);
459+
460+
triggerKeyDown($document, 38);
461+
var focusEl = element.find('ul').eq(0).find('a').eq(0);
462+
expect(isFocused(focusEl)).toBe(false);
463+
});
464+
465+
it('should not focus any list element when down arrow pressed if closed', function() {
466+
$document.find('body').append(element);
467+
triggerKeyDown($document, 40);
468+
469+
expect(element.hasClass(dropdownConfig.openClass)).toBe(false);
470+
var focusEl = element.find('ul').eq(0).find('a');
471+
expect(isFocused(focusEl[0])).toBe(false);
472+
expect(isFocused(focusEl[1])).toBe(false);
473+
});
474+
475+
it('should not change focus when other keys are pressed', function() {
476+
$document.find('body').append(element);
477+
clickDropdownToggle();
478+
triggerKeyDown($document, 37);
479+
480+
expect(element.hasClass(dropdownConfig.openClass)).toBe(true);
481+
var focusEl = element.find('ul').eq(0).find('a');
482+
expect(isFocused(focusEl[0])).toBe(false);
483+
expect(isFocused(focusEl[1])).toBe(false);
484+
});
485+
486+
it('should focus second list element when down arrow pressed twice', function() {
487+
$document.find('body').append(element);
488+
clickDropdownToggle();
489+
triggerKeyDown($document, 40);
490+
triggerKeyDown($document, 40);
491+
492+
expect(element.hasClass(dropdownConfig.openClass)).toBe(true);
493+
var focusEl = element.find('ul').eq(0).find('a').eq(1);
494+
expect(isFocused(focusEl)).toBe(true);
495+
});
496+
497+
it('should focus first list element when down arrow pressed 2x and up pressed 1x', function() {
498+
$document.find('body').append(element);
499+
clickDropdownToggle();
500+
triggerKeyDown($document, 40);
501+
triggerKeyDown($document, 40);
502+
503+
triggerKeyDown($document, 38);
504+
505+
expect(element.hasClass(dropdownConfig.openClass)).toBe(true);
506+
var focusEl = element.find('ul').eq(0).find('a').eq(0);
507+
expect(isFocused(focusEl)).toBe(true);
508+
});
509+
510+
it('should stay focused on final list element if down pressed at list end', function() {
511+
$document.find('body').append(element);
512+
clickDropdownToggle();
513+
triggerKeyDown($document, 40);
514+
triggerKeyDown($document, 40);
515+
516+
expect(element.hasClass(dropdownConfig.openClass)).toBe(true);
517+
var focusEl = element.find('ul').eq(0).find('a').eq(1);
518+
expect(isFocused(focusEl)).toBe(true);
519+
520+
triggerKeyDown($document, 40);
521+
expect(isFocused(focusEl)).toBe(true);
522+
});
523+
524+
it('should close if esc is pressed while focused', function() {
525+
element = dropdown('disabled');
526+
$document.find('body').append(element);
527+
clickDropdownToggle();
528+
529+
triggerKeyDown($document, 40);
530+
531+
expect(element.hasClass(dropdownConfig.openClass)).toBe(true);
532+
var focusEl = element.find('ul').eq(0).find('a').eq(0);
533+
expect(isFocused(focusEl)).toBe(true);
534+
535+
triggerKeyDown($document, 27);
536+
expect(element.hasClass(dropdownConfig.openClass)).toBe(false);
537+
});
538+
});
539+
540+
describe('`keyboard-nav` option with `dropdown-append-to-body` option', function() {
541+
function dropdown() {
542+
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);
543+
}
544+
545+
beforeEach(function() {
546+
element = dropdown();
547+
});
548+
549+
it('should focus first list element when down arrow pressed', function() {
550+
clickDropdownToggle();
551+
552+
triggerKeyDown($document, 40);
553+
554+
expect(element.hasClass(dropdownConfig.openClass)).toBe(true);
555+
var focusEl = $document.find('ul').eq(0).find('a');
556+
expect(isFocused(focusEl)).toBe(true);
557+
});
558+
559+
it('should not focus first list element when down arrow pressed if closed', function() {
560+
triggerKeyDown($document, 40);
561+
562+
expect(element.hasClass(dropdownConfig.openClass)).toBe(false);
563+
var focusEl = $document.find('ul').eq(0).find('a');
564+
expect(isFocused(focusEl)).toBe(false);
565+
});
566+
567+
it('should focus second list element when down arrow pressed twice', function() {
568+
clickDropdownToggle();
569+
triggerKeyDown($document, 40);
570+
triggerKeyDown($document, 40);
571+
572+
expect(element.hasClass(dropdownConfig.openClass)).toBe(true);
573+
var elem1 = $document.find('ul');
574+
var elem2 = elem1.find('a');
575+
var focusEl = $document.find('ul').eq(0).find('a').eq(1);
576+
expect(isFocused(focusEl)).toBe(true);
577+
});
578+
});
436579
});

0 commit comments

Comments
 (0)