Keyboard navigation on dropdown implemented using <li>

Styling the default HTML dropdown is difficult. So, we create a custom HTML dropdown using button and list. While implementing custom dropdown menu, we need keyboard navigation support as well. Here is how to implement the same.

Usage

I have created two directives dropdownToggler and navigateMenuItems that will allow the keyboard navigation on the list.

dropdownToggler

This will be placed on the dropdown toggler item. This would allow to show and hide the dropdown list on ‘click’, ‘enter’ key press and ‘down arrow’ key press. Parameters:
// Dropdown toggle CSS selector
dropdown-selector="'.dropdown-toggle'"
// Dropdown toggle items CSS selector
dropdown-items-selector="'.dropdown-items li'"
// Scope attribute controlling the visibility of the dropdown
is-dropdown-visible="{'isVisible': isVisible, 'attrName': 'ddMenu'}"
// Is dropdown visible by default
show-dropdown-onload="false"

navigateMenuItems

This will be placed on the dropdown items. This would allow to navigate among list items, and hide dropdown on selection of a list item or on press of ‘esc’ key. Parameters:
// Dropdown toggle CSS selector
dropdown-selector="'.dropdown-toggle'"
// Dropdown toggle items CSS selector
dropdown-items-selector="'.dropdown-items li'"
// Scope attribute controlling the visibility of the dropdown
is-dropdown-visible="{'isVisible': isVisible, 'attrName': 'ddMenu'}" 
// Action on selection of list item
on-selection="onSelect($index)" 
NOTE: ‘is-dropdown-visible’ is passed as object literal notation as, directly passing boolean would not reflect changes outside ‘ng-repeat’. Since ‘ng-repeat’ creates isolated scope and our directive is nested within it. So, we pass the attribute as object not as primitive.

Demo

Code

HTML

<!DOCTYPE html>
<html>

<head>
  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css" />
  <script src="//cdnjs.cloudflare.com/ajax/libs/jquery/3.1.0/jquery.min.js"></script>
  <script src="//cdnjs.cloudflare.com/ajax/libs/angular.js/1.5.0/angular.min.js"></script>
  <link rel="stylesheet" href="style.css" />
  <style>
    button,
    input {
      padding: 10px;
      margin-top: 20px;
      width: 200px;
    }
    
    ul {
      list-style: none;
      padding: 0px;
      width: 200px;
    }
    
    li {
      border-bottom: 1px solid black;
      padding: 10px;
    }
    
    li:first-child {
      border-top: 1px solid black;
    }
    
    button:focus,
    li:focus {
      border: 1px solid red;
      outline: none;
    }
  </style>
  <script src="script.js"></script>
</head>

<body ng-app="KbrdNavgtn" ng-controller="PageController">
  <input type="text" placeholder="First element">

  <div class="navigable">
    <button class="dropdown-toggle" 
      dropdown-toggler dropdown-selector="'.dropdown-toggle'" 
      dropdown-items-selector="'.dropdown-items li'" 
      is-dropdown-visible="{'isVisible': isVisible, 'attrName': 'ddMenu'}" 
      show-dropdown-onload="false">
      <span class="pull-left">Navigable dropdown</span>
      <span class="pull-right">
        <span class="glyphicon glyphicon-menu-{{isVisible.ddMenu ? 'up' : 'down'}}" aria-hidden="true"></span>
      </span>
    </button>
    <ul class="dropdown-items" ng-show="isVisible.ddMenu">
      <li tabindex="0" ng-repeat="item in list" navigate-menu-items 
        is-dropdown-visible="{'isVisible': isVisible, 'attrName': 'ddMenu'}" 
        on-selection="onSelect($index)" dropdown-selector="'.dropdown-toggle'" 
        dropdown-items-selector="'.dropdown-items li'">{{item.name}}</li>
    </ul>
  </div>
</body>

</html>

JAVASCRIPT

angular.module('KbrdNavgtn', []).
controller('PageController', function ($scope) {
  $scope.isVisible = {
    ddMenu: false
  };
  $scope.list = [
    { name: 'Item 1' },
    { name: 'Item 2' },
    { name: 'Item 3' }
  ]
}).
/**
 * Toggle dropdown using keyboard and mouse click. Select first item of the dropdown items, if keyboard is used.
 * @param  {
 *   dropdownSelector: CSS selector for dropdown,
 *   dropdownItemsSelector: CSS selector for dropdown items,
 *   isDropdownVisible: scope attribute to control dropdown visibility,
 *   showDropdownOnload: is dropdown visible on load or not
 * } 
 * @return {[type]}   [description]
 */
directive('dropdownToggler', function() {
  return {
    restrict: 'A',
    link: function(scope, element, attribute) {
      scope.isDropdownVisible = scope.showDropdownOnload ? scope.showDropdownOnload : false;
      element.bind('keydown', function(event) {
        console.log('keydown event occured');
        var keycodes = [13, 40]
        if (keycodes.indexOf(event.keyCode) > -1) {
          event.stopPropagation();
          event.preventDefault();

          // On press of 'Enter' key
          if (event.keyCode == 13) {
            toggleDropdownVisibility();
            scope.$apply();
            if (scope.isDropdownVisible['isVisible'].attrName) {
              angular.element(scope.dropdownItemsSelector).eq(0).trigger('focus');
            } else {
              element.trigger('focus');
            }
          }
          // On press of 'Down arrow' key
          if (event.keyCode == 40) {
            toggleDropdownVisibility();
            scope.$apply();
            angular.element(scope.dropdownItemsSelector).eq(0).trigger('focus');
          }
        }
      });
      function toggleDropdownVisibility() {
        var isVisible = scope.isDropdownVisible['isVisible'];
        isVisible[scope.isDropdownVisible.attrName] = !isVisible[scope.isDropdownVisible.attrName];
      }
      element.bind('click', function(event) {
        console.log('Click event registerd')
        scope.isDropdownVisible = !scope.isDropdownVisible;
        scope.$apply();
      });
    },
    scope: {
      dropdownSelector: '=',
      dropdownItemsSelector: '=',
      isDropdownVisible: '=',
      showDropdownOnload: '='
    }
  }
}).
/**
 * Navigate menu items using keyboard
 * @param  {
 *   dropdownSelector: CSS selector for dropdown,
 *   dropdownItemsSelector: CSS selector for dropdown items,
 *   isDropdownVisible: scope attribute to control dropdown visibility,
 *   onSelection: action to be taken on selection of dropdown item
 * } 
 * @return {[type]}   [description]
 */
directive('navigateMenuItems', function() {
  return {
    restrict: 'A',
    link: function(scope, element, attribute) {
      function onSelect() {
        scope.onSelection();
        hideDropdown();
      }

      function hideDropdown() {
        var isVisible = scope.isDropdownVisible['isVisible'];
        isVisible[scope.isDropdownVisible.attrName] = false;
        angular.element(scope.dropdownSelector).trigger('focus');
        scope.$apply();
      }

      element.bind('keydown', function(event) {
        var keycodes = [9, 13, 27, 38, 40];
        if (keycodes.indexOf(event.keyCode) > -1) {
          event.stopPropagation();
          event.preventDefault();

          // On press of 'Enter' key
          if (event.keyCode == 13) {
            onSelect();
          }
          // On press of 'Esc' key
          if (event.keyCode == 27) {
            hideDropdown();
          }

          var items = angular.element(scope.dropdownItemsSelector);
          navigateItems(event, element, items);
        }
      });
      element.bind('click', function(event) {
        onSelect();
      });
    },
    scope: {
      dropdownSelector: '=',
      dropdownItemsSelector: '=',
      isDropdownVisible: '=',
      onSelection: '&'
    }
  }

  function navigateItems(e, current, items) {
    var index = items.index(angular.element(current));

    // On press of 'Tab' and 'Down arrow' key
    if ((event.shiftKey && event.keyCode == 9) || event.keyCode == 38) {
      index = index - 1;

      if (index >= 0) {
        items.eq(index).trigger('focus');
      }
    }
    // On press of 'Shift Tab' and 'Up arrow' key
    else if (event.keyCode == 9 || event.keyCode == 40) {
      index = index + 1;

      if (index < items.length) {
        items.eq(index).trigger('focus');
      }
    }
  }
});
Do share your feedback, if this helped you in any way. It helps keeping my mojo up !!!