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