diff --git a/src/features/cellnav/js/cellnav.js b/src/features/cellnav/js/cellnav.js index dc9c52d32d..ef2f803eaa 100644 --- a/src/features/cellnav/js/cellnav.js +++ b/src/features/cellnav/js/cellnav.js @@ -15,11 +15,6 @@ */ var module = angular.module('ui.grid.cellNav', ['ui.grid']); - function RowCol(row, col) { - this.row = row; - this.col = col; - } - /** * @ngdoc object * @name ui.grid.cellNav.constant:uiGridCellNavConstants @@ -37,9 +32,86 @@ } }); + /** + * @ngdoc object + * @name ui.grid.cellNav.object:RowCol + * @param {GridRow} row The row for this pair + * @param {GridColumn} column The column for this pair + * @description A row and column pair that represents the intersection of these two entities. + */ + module.factory('RowColFactory', ['$parse', '$filter', + function($parse, $filter){ + var RowCol = function RowCol(row, col) { + /** + * @ngdoc object + * @name row + * @propertyOf ui.grid.cellNav.object:RowCol + * @description {@link ui.grid.class:GridRow } + */ + this.row = row; + /** + * @ngdoc object + * @name col + * @propertyOf ui.grid.cellNav.object:RowCol + * @description {@link ui.grid.class:GridColumn } + */ + this.col = col; + }; + + /** + * @ngdoc function + * @name getIntersectionValueRaw + * @methodOf ui.grid.cellNav.object:RowCol + * @description Gets the intersection of where the row and column meet. + * @returns {String|Number|Object} The value from the grid data that this RowCol points too. + * If the column has a cellFilter this will NOT return the filtered value. + */ + RowCol.prototype.getIntersectionValueRaw = function(){ + var getter = $parse(this.col.field); + var context = this.row.entity; + return getter(context); + }; + /** + * @ngdoc function + * @name getIntersectionValueFiltered + * @methodOf ui.grid.cellNav.object:RowCol + * @description Gets the intersection of where the row and column meet. + * @returns {String|Number|Object} The value from the grid data that this RowCol points too. + * If the column has a cellFilter this will also apply the filter to it and return the value that the filter displays. + */ + RowCol.prototype.getIntersectionValueFiltered = function(){ + var value = this.getIntersectionValueRaw(); + if (this.col.cellFilter && this.col.cellFilter !== ''){ + var getFilterIfExists = function(filterName){ + try { + return $filter(filterName); + } catch (e){ + return null; + } + }; + var filter = getFilterIfExists(this.col.cellFilter); + if (filter) { // Check if this is filter name or a filter string + value = filter(value); + } else { // We have the template version of a filter so we need to parse it apart + // Get the filter params out using a regex + // Test out this regex here https://regex101.com/r/rC5eR5/2 + var re = /([^:]*):([^:]*):?([\s\S]+)?/; + var matches; + if ((matches = re.exec(this.col.cellFilter)) !== null) { + // View your result using the matches-variable. + // eg matches[0] etc. + value = $filter(matches[1])(value, matches[2], matches[3]); + } + } + } + return value; + }; + return RowCol; + }]); + - module.factory('uiGridCellNavFactory', ['gridUtil', 'uiGridConstants', 'uiGridCellNavConstants', '$q', - function (gridUtil, uiGridConstants, uiGridCellNavConstants, $q) { + module.factory('uiGridCellNavFactory', ['gridUtil', 'uiGridConstants', 'uiGridCellNavConstants', 'RowColFactory', '$q', + function (gridUtil, uiGridConstants, uiGridCellNavConstants, RowCol, $q) { /** * @ngdoc object * @name ui.grid.cellNav.object:CellNav @@ -270,8 +342,8 @@ * @description Services for cell navigation features. If you don't like the key maps we use, * or the direction cells navigation, override with a service decorator (see angular docs) */ - module.service('uiGridCellNavService', ['gridUtil', 'uiGridConstants', 'uiGridCellNavConstants', '$q', 'uiGridCellNavFactory', 'ScrollEvent', - function (gridUtil, uiGridConstants, uiGridCellNavConstants, $q, UiGridCellNav, ScrollEvent) { + module.service('uiGridCellNavService', ['gridUtil', 'uiGridConstants', 'uiGridCellNavConstants', '$q', 'uiGridCellNavFactory', 'RowColFactory', 'ScrollEvent', + function (gridUtil, uiGridConstants, uiGridCellNavConstants, $q, UiGridCellNav, RowCol, ScrollEvent) { var service = { @@ -624,8 +696,8 @@ */ - module.directive('uiGridCellnav', ['gridUtil', 'uiGridCellNavService', 'uiGridCellNavConstants', 'uiGridConstants', '$timeout', - function (gridUtil, uiGridCellNavService, uiGridCellNavConstants, uiGridConstants, $timeout) { + module.directive('uiGridCellnav', ['gridUtil', 'uiGridCellNavService', 'uiGridCellNavConstants', 'uiGridConstants', 'RowColFactory', '$timeout', '$compile', + function (gridUtil, uiGridCellNavService, uiGridCellNavConstants, uiGridConstants, RowCol, $timeout, $compile) { return { replace: true, priority: -150, @@ -642,6 +714,14 @@ uiGridCtrl.cellNav = {}; + //Ensure that the object has all of the methods we expect it to + uiGridCtrl.cellNav.makeRowCol = function (obj) { + if (!(obj instanceof RowCol)) { + obj = new RowCol(obj.row, obj.col); + } + return obj; + }; + uiGridCtrl.cellNav.getActiveCell = function () { var elms = $elm[0].getElementsByClassName('ui-grid-cell-focus'); if (elms.length > 0){ @@ -651,19 +731,25 @@ return undefined; }; - uiGridCtrl.cellNav.broadcastCellNav = grid.cellNav.broadcastCellNav = function (newRowCol, modifierDown) { + uiGridCtrl.cellNav.broadcastCellNav = grid.cellNav.broadcastCellNav = function (newRowCol, modifierDown, originEvt) { modifierDown = !(modifierDown === undefined || !modifierDown); - uiGridCtrl.cellNav.broadcastFocus(newRowCol, modifierDown); - _scope.$broadcast(uiGridCellNavConstants.CELL_NAV_EVENT, newRowCol, modifierDown); + + newRowCol = uiGridCtrl.cellNav.makeRowCol(newRowCol); + + uiGridCtrl.cellNav.broadcastFocus(newRowCol, modifierDown, originEvt); + _scope.$broadcast(uiGridCellNavConstants.CELL_NAV_EVENT, newRowCol, modifierDown, originEvt); }; uiGridCtrl.cellNav.clearFocus = grid.cellNav.clearFocus = function () { - _scope.$broadcast(uiGridCellNavConstants.CELL_NAV_EVENT, { eventType: uiGridCellNavConstants.EVENT_TYPE.CLEAR }); + grid.cellNav.focusedCells = []; + _scope.$broadcast(uiGridCellNavConstants.CELL_NAV_EVENT); }; - uiGridCtrl.cellNav.broadcastFocus = function (rowCol, modifierDown) { + uiGridCtrl.cellNav.broadcastFocus = function (rowCol, modifierDown, originEvt) { modifierDown = !(modifierDown === undefined || !modifierDown); + rowCol = uiGridCtrl.cellNav.makeRowCol(rowCol); + var row = rowCol.row, col = rowCol.col; @@ -748,6 +834,70 @@ }; }, post: function ($scope, $elm, $attrs, uiGridCtrl) { + var _scope = $scope; + var grid = uiGridCtrl.grid; + + function addAriaLiveRegion(){ + // Thanks to google docs for the inspiration behind how to do this + // XXX: Why is this entire mess nessasary? + // Because browsers take a lot of coercing to get them to read out live regions + //http://www.paciellogroup.com/blog/2012/06/html5-accessibility-chops-aria-rolealert-browser-support/ + var ariaNotifierDomElt = '
' + + ' ' + + '
'; + + var ariaNotifier = $compile(ariaNotifierDomElt)($scope); + $elm.prepend(ariaNotifier); + $scope.$on(uiGridCellNavConstants.CELL_NAV_EVENT, function (evt, rowCol, modifierDown, originEvt) { + /* + * If the cell nav event was because of a focus event then we don't want to + * change the notifier text. + * Reasoning: Voice Over fires a focus events when moving arround the grid. + * If the screen reader is handing the grid nav properly then we don't need to + * use the alert to notify the user of the movement. + * In all other cases we do want a notification event. + */ + if (originEvt && originEvt.type === 'focus'){return;} + + function setNotifyText(text){ + if (text === ariaNotifier.text()){return;} + ariaNotifier[0].style.clip = 'rect(0px,0px,0px,0px)'; + /* + * This is how google docs handles clearing the div. Seems to work better than setting the text of the div to '' + */ + ariaNotifier[0].innerHTML = ""; + ariaNotifier[0].style.visibility = 'hidden'; + ariaNotifier[0].style.visibility = 'visible'; + if (text !== ''){ + ariaNotifier[0].style.clip = 'auto'; + /* + * The space after the text is something that google docs does. + */ + ariaNotifier[0].appendChild(document.createTextNode(text + " ")); + ariaNotifier[0].style.visibility = 'hidden'; + ariaNotifier[0].style.visibility = 'visible'; + } + } + + var values = []; + var currentSelection = grid.api.cellNav.getCurrentSelection(); + for (var i = 0; i < currentSelection.length; i++) { + values.push(currentSelection[i].getIntersectionValueFiltered()); + } + var cellText = values.toString(); + setNotifyText(cellText); + + }); + } + addAriaLiveRegion(); } }; } @@ -765,7 +915,8 @@ return { post: function ($scope, $elm, $attrs, controllers) { var uiGridCtrl = controllers[0], - renderContainerCtrl = controllers[1]; + renderContainerCtrl = controllers[1], + uiGridCellnavCtrl = controllers[2]; // Skip attaching cell-nav specific logic if the directive is not attached above us if (!uiGridCtrl.grid.api.cellNav) { return; } @@ -782,23 +933,41 @@ // Needs to run last after all renderContainers are built uiGridCellNavService.decorateRenderContainers(grid); + if (uiGridCtrl.grid.options.modifierKeysToMultiSelectCells){ + $elm.attr('aria-multiselectable', true); + } else { + $elm.attr('aria-multiselectable', false); + } + //add an element with no dimensions that can be used to set focus and capture keystrokes - var focuser = $compile('
')($scope); + var focuser = $compile('
')($scope); $elm.append(focuser); + focuser.on('focus', function (evt) { + evt.uiGridTargetRenderContainerId = containerId; + var rowCol = uiGridCtrl.grid.api.cellNav.getFocusedCell(); + if (rowCol === null) { + rowCol = uiGridCtrl.grid.renderContainers[containerId].cellNav.getNextRowCol(uiGridCellNavConstants.direction.DOWN, null, null); + if (rowCol.row && rowCol.col) { + uiGridCtrl.cellNav.broadcastCellNav(rowCol); + } + } + }); + + uiGridCellnavCtrl.setAriaActivedescendant = function(id){ + $elm.attr('aria-activedescendant', id); + }; + + uiGridCellnavCtrl.removeAriaActivedescendant = function(id){ + if ($elm.attr('aria-activedescendant') === id){ + $elm.attr('aria-activedescendant', ''); + } + }; + + uiGridCtrl.focus = function () { - focuser[0].focus(); + gridUtil.focus.byElement(focuser[0]); //allow for first time grid focus - focuser.on('focus', function (evt) { - evt.uiGridTargetRenderContainerId = containerId; - var rowCol = uiGridCtrl.grid.api.cellNav.getFocusedCell(); - if (rowCol === null) { - rowCol = uiGridCtrl.grid.renderContainers[containerId].cellNav.getNextRowCol(uiGridCellNavConstants.direction.DOWN, null, null); - if (rowCol.row && rowCol.col) { - uiGridCtrl.cellNav.broadcastCellNav(rowCol); - } - } - }); }; var viewPortKeyDownWasRaisedForRowCol = null; @@ -827,6 +996,11 @@ } }); + $scope.$on('$destroy', function(){ + //Remove all event handlers associated with this focuser. + focuser.off(); + }); + } }; } @@ -859,17 +1033,11 @@ var grid = uiGridCtrl.grid; - - - uiGridCtrl.focus(); - - - grid.api.core.on.scrollBegin($scope, function (args) { // Skip if there's no currently-focused cell var lastRowCol = uiGridCtrl.grid.api.cellNav.getFocusedCell(); - if (lastRowCol == null) { + if (lastRowCol === null) { return; } @@ -879,17 +1047,14 @@ return; } - //clear dom of focused cell - - var elements = $elm[0].getElementsByClassName('ui-grid-cell-focus'); - Array.prototype.forEach.call(elements,function(e){angular.element(e).removeClass('ui-grid-cell-focus');}); + uiGridCtrl.cellNav.clearFocus(); }); grid.api.core.on.scrollEnd($scope, function (args) { // Skip if there's no currently-focused cell var lastRowCol = uiGridCtrl.grid.api.cellNav.getFocusedCell(); - if (lastRowCol == null) { + if (lastRowCol === null) { return; } @@ -921,14 +1086,16 @@ * @restrict A * @description Stacks on top of ui.grid.uiGridCell to provide cell navigation */ - module.directive('uiGridCell', ['$timeout', '$document', 'uiGridCellNavService', 'gridUtil', 'uiGridCellNavConstants', 'uiGridConstants', - function ($timeout, $document, uiGridCellNavService, gridUtil, uiGridCellNavConstants, uiGridConstants) { + module.directive('uiGridCell', ['$timeout', '$document', 'uiGridCellNavService', 'gridUtil', 'uiGridCellNavConstants', 'uiGridConstants', 'RowColFactory', + function ($timeout, $document, uiGridCellNavService, gridUtil, uiGridCellNavConstants, uiGridConstants, RowCol) { return { priority: -150, // run after default uiGridCell directive and ui.grid.edit uiGridCell restrict: 'A', - require: '^uiGrid', + require: ['^uiGrid', '?^uiGridCellnav'], scope: false, - link: function ($scope, $elm, $attrs, uiGridCtrl) { + link: function ($scope, $elm, $attrs, controllers) { + var uiGridCtrl = controllers[0], + uiGridCellnavCtrl = controllers[1]; // Skip attaching cell-nav specific logic if the directive is not attached above us if (!uiGridCtrl.grid.api.cellNav) { return; } @@ -936,56 +1103,77 @@ return; } + //Convinience local variables + var grid = uiGridCtrl.grid; + $scope.focused = false; + + // Make this cell focusable but only with javascript/a mouse click + $elm.attr('tabindex', -1); + // When a cell is clicked, broadcast a cellNav event saying that this row+col combo is now focused $elm.find('div').on('click', function (evt) { - uiGridCtrl.cellNav.broadcastCellNav(new RowCol($scope.row, $scope.col), evt.ctrlKey || evt.metaKey); + uiGridCtrl.cellNav.broadcastCellNav(new RowCol($scope.row, $scope.col), evt.ctrlKey || evt.metaKey, evt); evt.stopPropagation(); $scope.$apply(); }); - $elm.find('div').on('focus', function (evt) { - uiGridCtrl.cellNav.broadcastCellNav(new RowCol($scope.row, $scope.col), evt.ctrlKey || evt.metaKey); + + /* + * XXX Hack for screen readers. + * This allows the grid to focus using only the screen reader cursor. + * Since the focus event doesn't include key press information we can't use it + * as our primary source of the event. + */ + $elm.on('mousedown', function(evt) { + //Prevents the foucus event from firing if the click event is already going to fire. + //If both events fire it will cause bouncing behavior. + evt.preventDefault(); + }); + + //You can only focus on elements with a tabindex value + $elm.on('focus', function (evt) { + uiGridCtrl.cellNav.broadcastCellNav(new RowCol($scope.row, $scope.col), false, evt); + evt.stopPropagation(); + $scope.$apply(); }); // This event is fired for all cells. If the cell matches, then focus is set $scope.$on(uiGridCellNavConstants.CELL_NAV_EVENT, function (evt, rowCol, modifierDown) { - if (evt.eventType === uiGridCellNavConstants.EVENT_TYPE.CLEAR) { - clearFocus(); - return; - } - - if (rowCol.row === $scope.row && - rowCol.col === $scope.col) { - if (uiGridCtrl.grid.options.modifierKeysToMultiSelectCells && modifierDown && - uiGridCtrl.grid.api.cellNav.rowColSelectIndex(rowCol) === -1) { - clearFocus(); - } else { - setFocused(); - } - - // // This cellNav event came from a keydown event so we can safely refocus - // if (rowCol.hasOwnProperty('eventType') && rowCol.eventType === uiGridCellNavConstants.EVENT_TYPE.KEYDOWN) { - //// $elm.find('div')[0].focus(); - // } - } - else if (!(uiGridCtrl.grid.options.modifierKeysToMultiSelectCells && modifierDown)) { + var isFocused = grid.cellNav.focusedCells.some(function(focusedRowCol, index){ + return (focusedRowCol.row === $scope.row && focusedRowCol.col === $scope.col); + }); + if (isFocused){ + setFocused(); + } else { clearFocus(); } }); function setFocused() { - var div = $elm.find('div'); - div.addClass('ui-grid-cell-focus'); + if (!$scope.focused){ + var div = $elm.find('div'); + div.addClass('ui-grid-cell-focus'); + $elm.attr('aria-selected', true); + uiGridCellnavCtrl.setAriaActivedescendant($elm.attr('id')); + $scope.focused = true; + } } function clearFocus() { - var div = $elm.find('div'); - div.removeClass('ui-grid-cell-focus'); + if ($scope.focused){ + var div = $elm.find('div'); + div.removeClass('ui-grid-cell-focus'); + $elm.attr('aria-selected', false); + uiGridCellnavCtrl.removeAriaActivedescendant($elm.attr('id')); + $scope.focused = false; + } } $scope.$on('$destroy', function () { - $elm.find('div').off('click'); + //.off withouth paramaters removes all handlers + $elm.find('div').off(); + $elm.off(); }); } }; diff --git a/src/features/cellnav/less/cellNav.less b/src/features/cellnav/less/cellNav.less index ce4da36786..b24eae2284 100644 --- a/src/features/cellnav/less/cellNav.less +++ b/src/features/cellnav/less/cellNav.less @@ -1,4 +1,5 @@ @import '../../../less/variables'; +@import (reference) '../../../less/bootstrap/bootstrap'; // .ui-grid-cell-contents:focus { // outline: 0; @@ -11,6 +12,19 @@ } .ui-grid-focuser { - width:0px; - height:0px; + position: absolute; + left: 0px; + top: 0px; + z-index: -1; + width:100%; + height:100%; + #ui-grid-twbs > .form-control-focus(); +} + +.ui-grid-offscreen{ + display: block; + position: absolute; + left: -10000px; + top: -10000px; + clip:rect(0px,0px,0px,0px); }