Navigate the UI using only keyboard

后端 未结 5 1574
故里飘歌
故里飘歌 2020-12-31 15:21

I\'m trying to navigate thru a list of records using only keyboard. When the page loads, the default \"focus\" should be on the first record, when the user clicks the down a

相关标签:
5条回答
  • 2020-12-31 15:43

    You could create a table navigation service which tracks the current row and exposes navigation methods to modify the current row's value and sets focus to the row.

    Then all you would need to do is create a key binding directive where you could track key down events and fire the exposed methods from the table navigation service, on key up or key down.

    I have used a controller to link the service methods to the key binding directive via a configuration object called 'keyDefinitions'.

    You can extend the keyDefinitions to include the Enter key (Code: 13) and hook on to the selected $index value via the service property 'tableNavigationService.currentRow' or '$scope.data', then pass it as a parameter to your own custom submit() function.

    I hope that this is helpful to somebody.

    I have posted my solution to this issue at the following plunker location:

    Keyboard Navigation Service Demo

    HTML:

    <div key-watch>
      <table st-table="rowCollection" id="tableId" class="table table-striped">
        <thead>
          <tr>
            <th st-sort="firstName">first name</th>
            <th st-sort="lastName">last name</th>
            <th st-sort="birthDate">birth date</th>
            <th st-sort="balance" st-skip-natural="true">balance</th>
            <th>email</th>
          </tr>
        </thead>
        <tbody>
          <!-- ADD CONDITIONAL STYLING WITH ng-class TO ASSIGN THE selected CLASS TO THE ACTIVE ROW -->
          <tr ng-repeat="row in rowCollection track by $index" tabindex="{{$index + 1}}" ng-class="{'selected': activeRowIn($index)}">
            <td>{{row.firstName | uppercase}}</td>
            <td>{{row.lastName}}</td>
            <td>{{row.birthDate | date}}</td>
            <td>{{row.balance | currency}}</td>
            <td>
              <a ng-href="mailto:{{row.email}}">email</a>
            </td>
          </tr>
        </tbody>
      </table>
    </div>
    

    CONTROLLER:

      app.controller('navigationDemoController', [
        '$scope',
        'tableNavigationService',
        navigationDemoController
      ]);
    
      function navigationDemoController($scope, tableNavigationService) {
        $scope.data = tableNavigationService.currentRow;
    
        $scope.keyDefinitions = {
          'UP': navigateUp,
          'DOWN': navigateDown
        }
    
        $scope.rowCollection = [
          {
            firstName: 'Chris',
            lastName: 'Oliver',
            birthDate: '1980-01-01',
            balance: 100,
            email: 'chris@email.com'
          },
          {
            firstName: 'John',
            lastName: 'Smith',
            birthDate: '1976-05-25',
            balance: 100,
            email: 'chris@email.com'
          },
          {
            firstName: 'Eric',
            lastName: 'Beatson',
            birthDate: '1990-06-11',
            balance: 100,
            email: 'chris@email.com'
          },
          {
            firstName: 'Mike',
            lastName: 'Davids',
            birthDate: '1968-12-14',
            balance: 100,
            email: 'chris@email.com'
          }
        ];
    
        $scope.activeRowIn = function(index) {
          return index === tableNavigationService.currentRow;
        };
    
        function navigateUp() {
          tableNavigationService.navigateUp();
        };
    
        function navigateDown() {
          tableNavigationService.navigateDown();
        };
    
        function init() {
          tableNavigationService.setRow(0);
        };
    
        init();
      };
    })();
    

    SERVICE AND DIRECTIVE:

    (function () {
      'use strict';
    
      var app = angular.module('tableNavigation', []);
    
      app.service('tableNavigationService', [
        '$document',
        tableNavigationService
      ]);
      app.directive('keyWatch', [
        '$document',
        keyWatch
      ]);
    
      // TABLE NAVIGATION SERVICE FOR NAVIGATING UP AND DOWN THE TABLE
      function tableNavigationService($document) {
        var service = {};
    
        // Your current selected row
        service.currentRow = 0;
        service.table = 'tableId';
        service.tableRows = $document[0].getElementById(service.table).getElementsByTagName('tbody')[0].getElementsByTagName('tr');
    
        // Exposed method for navigating up
        service.navigateUp = function () {
            if (service.currentRow) {
                var index = service.currentRow - 1;
    
                service.setRow(index);
            }
        };
    
        // Exposed method for navigating down
        service.navigateDown = function () {
            var index = service.currentRow + 1;
    
            if (index === service.tableRows.length) return;
    
            service.setRow(index);
        };
    
        // Expose a method for altering the current row and focus on demand
        service.setRow = function (i) {
            service.currentRow = i;
            scrollRow(i);
        }
    
        // Set focus to the active table row if it exists
        function scrollRow(index) {
            if (service.tableRows[index]) {
                service.tableRows[index].focus();
            }
        };
    
        return service;
      };
    
      // KEY WATCH DIRECTIVE TO MONITOR KEY DOWN EVENTS
      function keyWatch($document) {
        return {
          restrict: 'A',
          link: function(scope) {
            $document.unbind('keydown').bind('keydown', function(event) {
              var keyDefinitions = scope.keyDefinitions;
              var key = '';
    
              var keys = {
                  UP: 38,
                  DOWN: 40,
              };
    
              if (event && keyDefinitions) {
    
                for (var k in keys) {
                  if (keys.hasOwnProperty(k) && keys[k] === event.keyCode) {
                      key = k;
                  }
                }
    
                if (!key) return;
    
                var navigationFunction = keyDefinitions[key];
    
                if (!navigationFunction) {
                  console.log('Undefined key: ' + key);
                  return;
                }
    
                  event.preventDefault();
                    scope.$apply(navigationFunction());
                    return;
              }
              return;
            });
          }
        }
      }
    })();
    
    0 讨论(0)
  • 2020-12-31 15:54

    This is the directive below that I had once build for a similar problem. This directive listens to the keyboard events and changes the row selection.

    This link has a complete explanation on how to build it. Change row selection using arrows.

    Here is the directive

    foodApp.directive('arrowSelector',['$document',function($document){
    return{
        restrict:'A',
        link:function(scope,elem,attrs,ctrl){
            var elemFocus = false;             
            elem.on('mouseenter',function(){
                elemFocus = true;
            });
            elem.on('mouseleave',function(){
                elemFocus = false;
            });
            $document.bind('keydown',function(e){
                if(elemFocus){
                    if(e.keyCode == 38){
                        console.log(scope.selectedRow);
                        if(scope.selectedRow == 0){
                            return;
                        }
                        scope.selectedRow--;
                        scope.$apply();
                        e.preventDefault();
                    }
                    if(e.keyCode == 40){
                        if(scope.selectedRow == scope.foodItems.length - 1){
                            return;
                        }
                        scope.selectedRow++;
                        scope.$apply();
                        e.preventDefault();
                    }
                }
            });
        }
    };
    

    }]);

    <table class="table table-bordered" arrow-selector>....</table>
    

    And your repeater

         <tr ng-repeat="item in foodItems" ng-class="{'selected':$index == selectedRow}">
    
    0 讨论(0)
  • 2020-12-31 15:55

    Here is the example what you could choose to do: http://plnkr.co/edit/XRGPYCk6auOxmylMe0Uu?p=preview

    <body key-trap>
      <div ng-controller="testCtrl">
        <li ng-repeat="record in records">
          <div class="record"
               ng-class="{'record-highlight': record.navIndex == focu sIndex}">
            {{ record.name }}
          </div>
        </li>
      </div>
    </body>
    

    This is the simplest approach I could think of. It binds a directive keyTrap to the body which catches the keydown event and $broadcast message to child scopes. The element holder scope will catch the message and simply increment or decrement the focusIndex or fire an open function if hitting enter.

    EDIT

    http://plnkr.co/edit/rwUDTtkQkaQ0dkIFflcy?p=preview

    now supports, ordered / filtered list.

    Event handling part has not changed, but now uses $index and also filtered list caching technique combined to track which item is getting focused.

    0 讨论(0)
  • 2020-12-31 15:55

    All of the solutions offered up so far have a single common problem. The directives are not reusable, they require knowledge of variables created in the parent $scope provided by the controller. That means if you wanted to use the same directive in a different view you would need to re-implement everything you did the previous controller and ensure you are using the same variable names for things, since the directives basically have hard coded $scope variable names in them. You definitely wouldn’t be able to use the same directive twice within the same parent scope.

    The way around this is to use isolated scope in the directive. By doing this you can make the directive reusable regardless of the parent $scope by generically parameterizing items required from the parent scope.

    In my solution the only thing that the controller needs to do is provide a selectedIndex variable that the directive uses to track which row in the table is currently selected. I could have isolated the responsibility of this variable to the directive but by making the controller provide the variable it allows you to manipulate the currently selected row in the table outside of the directive. For example you could implement “on click select row” in your controller while still using the arrow keys for navigation in the directive.

    The Directive:

    angular
        .module('myApp')
        .directive('cdArrowTable', cdArrowTable);
        .directive('cdArrowRow', cdArrowRow);
    
    function cdArrowTable() {
        return {
            restrict:'A',
            scope: {
                collection: '=cdArrowTable',
                selectedIndex: '=selectedIndex',
                onEnter: '&onEnter'
            },
            link: function(scope, element, attrs, ctrl) {
                // Ensure the selectedIndex doesn't fall outside the collection
                scope.$watch('collection.length', function(newValue, oldValue) {
                    if (scope.selectedIndex > newValue - 1) {
                        scope.selectedIndex = newValue - 1;
                    } else if (oldValue <= 0) {
                        scope.selectedIndex = 0;
                    }
                });
    
                element.bind('keydown', function(e) {
                    if (e.keyCode == 38) {  // Up Arrow
                        if (scope.selectedIndex == 0) {
                            return;
                        }
                        scope.selectedIndex--;
                        e.preventDefault();
                    } else if (e.keyCode == 40) {  // Down Arrow
                        if (scope.selectedIndex == scope.collection.length - 1) {
                            return;
                        }
                        scope.selectedIndex++;
                        e.preventDefault();
                    } else if (e.keyCode == 13) {  // Enter
                        if (scope.selectedIndex >= 0) {
                            scope.collection[scope.selectedIndex].wasHit = true;
                            scope.onEnter({row: scope.collection[scope.selectedIndex]});
                        }
                        e.preventDefault();
                    }
    
                    scope.$apply();
                });
            }
        };
    }
    
    function cdArrowRow($timeout) {
        return {
            restrict: 'A',
            scope: {
                row: '=cdArrowRow',
                selectedIndex: '=selectedIndex',
                rowIndex: '=rowIndex',
                selectedClass: '=selectedClass',
                enterClass: '=enterClass',
                enterDuration: '=enterDuration'  // milliseconds
            },
            link: function(scope, element, attrs, ctr) {
                // Apply provided CSS class to row for provided duration
                scope.$watch('row.wasHit', function(newValue) {
                    if (newValue === true) {
                        element.addClass(scope.enterClass);
                        $timeout(function() { scope.row.wasHit = false;}, scope.enterDuration);
                    } else {
                        element.removeClass(scope.enterClass);
                    }
                });
    
                // Apply/remove provided CSS class to the row if it is the selected row.
                scope.$watch('selectedIndex', function(newValue, oldValue) {
                    if (newValue === scope.rowIndex) {
                        element.addClass(scope.selectedClass);
                    } else if (oldValue === scope.rowIndex) {
                        element.removeClass(scope.selectedClass);
                    }
                });
    
                // Handles applying/removing selected CSS class when the collection data is filtered.
                scope.$watch('rowIndex', function(newValue, oldValue) {
                    if (newValue === scope.selectedIndex) {
                        element.addClass(scope.selectedClass);
                    } else if (oldValue === scope.selectedIndex) {
                        element.removeClass(scope.selectedClass);
                    }
                });
            }
        }
    }
    

    This directive not only allows you to navigate a table using the arrow keys but it allows you to bind a callback method to the Enter key. So that when the enter key is pressed the row that is currently selected will be included as an argument to the callback method registered with the directive (onEnter).

    As a little bit of an added bonus you can also pass a CSS class and duration to the cdArrowRow directive so that when the enter key is hit on a selected row the CSS class passed in will be applied to the row element then removed after the passed in duration (in milliseconds). This basically allows you to do something like making the row flash a different color when the enter key is hit.

    View Usage:

    <table cd-arrow-table="displayedCollection"
           selected-index="selectedIndex"
           on-enter="addToDB(row)">
        <thead>
            <tr>
                <th>First Name</th>
                <th>Last Name</th>
            </tr>
        </thead>
        <tbody>
            <tr ng-repeat="row in displayedCollection" 
                cd-arrow-row="row" 
                selected-index="selectedIndex" 
                row-index="$index" 
                selected-class="'mySelcetedClass'" 
                enter-class="'myEnterClass'" 
                enter-duration="150"
            >
                <td>{{row.firstName}}</td>
                <td>{{row.lastName}}</td>
            </tr>
        </tbody>
    </table>
    

    Controller:

    angular
        .module('myApp')
        .controller('MyController', myController);
    
        function myController($scope) {
            $scope.selectedIndex = 0;
            $scope.displayedCollection = [
                {firstName:"John", lastName: "Smith"},
                {firstName:"Jane", lastName: "Doe"}
            ];
            $scope.addToDB;
    
            function addToDB(item) {
                // Do stuff with the row data
            }
        }
    
    0 讨论(0)
  • 2020-12-31 15:55

    I had a similar requirement to support UI navigation using arrow keys. What I finally came up with is DOM's keydown event handler encapsulated within an AngularJS directive:

    HTML:

    <ul ng-controller="MainCtrl">
        <li ng-repeat="record in records">
            <div focusable tag="record" on-key="onKeyPressed" class="record">
                {{ record.name }}
            </div>
        </li>
    </ul>
    

    CSS:

    .record {
        color: #000;
        background-color: #fff;
    }
    .record:focus {
        color: #fff;
        background-color: #000;
        outline: none;
    }
    

    JS:

    module.directive('focusable', function () {
        return {
            restrict: 'A',
            link: function (scope, element, attrs) {
                element.attr('tabindex', '-1'); // make it focusable
    
                var tag = attrs.tag ? scope.$eval(attrs.tag) : undefined; // get payload if defined
                var onKeyHandler = attrs.onKey ? scope.$eval(attrs.onKey) : undefined;
    
                element.bind('keydown', function (event) {
                    var target = event.target;
                    var key = event.which;
    
                    if (isArrowKey(key)) {
                        var nextFocused = getNextElement(key); // determine next element that should get focused
                        if (nextFocused) {
                            nextFocused.focus();
                            event.preventDefault();
                            event.stopPropagation();
                        }
                    }
                    else if (onKeyHandler) {
                        var keyHandled = scope.$apply(function () {
                            return onKeyHandler.call(target, key, tag);
                        });
    
                        if (keyHandled) {
                            event.preventDefault();
                            event.stopPropagation();
                        }
                    }
                });
            }
        };
    });
    
    function MainCtrl ($scope, $element) {
        $scope.onKeyPressed = function (key, record) {
            if (isSelectionKey(key)) {
                process(record);
                return true;
            }
            return false;
        };
    
        $element.children[0].focus(); // focus first record
    }
    
    0 讨论(0)
提交回复
热议问题