Keyboard.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514
  1. define([
  2. "dojo/_base/declare",
  3. "dojo/aspect",
  4. "dojo/on",
  5. "dojo/_base/lang",
  6. "dojo/has",
  7. "put-selector/put",
  8. "dojo/_base/Deferred",
  9. "dojo/_base/sniff"
  10. ], function(declare, aspect, on, lang, has, put, Deferred){
  11. var delegatingInputTypes = {
  12. checkbox: 1,
  13. radio: 1,
  14. button: 1
  15. },
  16. hasGridCellClass = /\bdgrid-cell\b/,
  17. hasGridRowClass = /\bdgrid-row\b/;
  18. has.add("dom-contains", function(global, doc, element){
  19. return !!element.contains; // not supported by FF < 9
  20. });
  21. function contains(parent, node){
  22. // summary:
  23. // Checks to see if an element is contained by another element.
  24. if(has("dom-contains")){
  25. return parent.contains(node);
  26. }else{
  27. return parent.compareDocumentPosition(node) & 8 /* DOCUMENT_POSITION_CONTAINS */;
  28. }
  29. }
  30. var Keyboard = declare(null, {
  31. // summary:
  32. // Adds keyboard navigation capability to a list or grid.
  33. // pageSkip: Number
  34. // Number of rows to jump by when page up or page down is pressed.
  35. pageSkip: 10,
  36. tabIndex: 0,
  37. // keyMap: Object
  38. // Hash which maps key codes to functions to be executed (in the context
  39. // of the instance) for key events within the grid's body.
  40. keyMap: null,
  41. // headerKeyMap: Object
  42. // Hash which maps key codes to functions to be executed (in the context
  43. // of the instance) for key events within the grid's header row.
  44. headerKeyMap: null,
  45. postMixInProperties: function(){
  46. this.inherited(arguments);
  47. if(!this.keyMap){
  48. this.keyMap = lang.mixin({}, Keyboard.defaultKeyMap);
  49. }
  50. if(!this.headerKeyMap){
  51. this.headerKeyMap = lang.mixin({}, Keyboard.defaultHeaderKeyMap);
  52. }
  53. },
  54. postCreate: function(){
  55. this.inherited(arguments);
  56. var grid = this;
  57. function handledEvent(event){
  58. // text boxes and other inputs that can use direction keys should be ignored and not affect cell/row navigation
  59. var target = event.target;
  60. return target.type && (!delegatingInputTypes[target.type] || event.keyCode == 32);
  61. }
  62. function enableNavigation(areaNode){
  63. var cellNavigation = grid.cellNavigation,
  64. isFocusableClass = cellNavigation ? hasGridCellClass : hasGridRowClass,
  65. isHeader = areaNode === grid.headerNode,
  66. initialNode = areaNode;
  67. function initHeader(){
  68. if(grid._focusedHeaderNode){
  69. // Remove the tab index for the node that previously had it.
  70. grid._focusedHeaderNode.tabIndex = -1;
  71. }
  72. if(grid.showHeader){
  73. // Set the tab index only if the header is visible.
  74. grid._focusedHeaderNode = initialNode =
  75. cellNavigation ? grid.headerNode.getElementsByTagName("th")[0] : grid.headerNode;
  76. if(initialNode){ initialNode.tabIndex = grid.tabIndex; }
  77. }
  78. }
  79. if(isHeader){
  80. // Initialize header now (since it's already been rendered),
  81. // and aspect after future renderHeader calls to reset focus.
  82. initHeader();
  83. aspect.after(grid, "renderHeader", initHeader, true);
  84. }else{
  85. aspect.after(grid, "renderArray", function(ret){
  86. // summary:
  87. // Ensures the first element of a grid is always keyboard selectable after data has been
  88. // retrieved if there is not already a valid focused element.
  89. return Deferred.when(ret, function(ret){
  90. var focusedNode = grid._focusedNode || initialNode;
  91. // do not update the focused element if we already have a valid one
  92. if(isFocusableClass.test(focusedNode.className) && contains(areaNode, focusedNode)){
  93. return ret;
  94. }
  95. // ensure that the focused element is actually a grid cell, not a
  96. // dgrid-preload or dgrid-content element, which should not be focusable,
  97. // even when data is loaded asynchronously
  98. for(var i = 0, elements = areaNode.getElementsByTagName("*"), element; (element = elements[i]); ++i){
  99. if(isFocusableClass.test(element.className)){
  100. focusedNode = grid._focusedNode = element;
  101. break;
  102. }
  103. }
  104. focusedNode.tabIndex = grid.tabIndex;
  105. return ret;
  106. });
  107. });
  108. }
  109. grid._listeners.push(on(areaNode, "mousedown", function(event){
  110. if(!handledEvent(event)){
  111. grid._focusOnNode(event.target, isHeader, event);
  112. }
  113. }));
  114. grid._listeners.push(on(areaNode, "keydown", function(event){
  115. // For now, don't squash browser-specific functionalities by letting
  116. // ALT and META function as they would natively
  117. if(event.metaKey || event.altKey) {
  118. return;
  119. }
  120. var handler = grid[isHeader ? "headerKeyMap" : "keyMap"][event.keyCode];
  121. // Text boxes and other inputs that can use direction keys should be ignored and not affect cell/row navigation
  122. if(handler && !handledEvent(event)){
  123. handler.call(grid, event);
  124. }
  125. }));
  126. }
  127. if(this.tabableHeader){
  128. enableNavigation(this.headerNode);
  129. on(this.headerNode, "dgrid-cellfocusin", function(){
  130. grid.scrollTo({ x: this.scrollLeft });
  131. });
  132. }
  133. enableNavigation(this.contentNode);
  134. },
  135. removeRow: function(rowElement){
  136. if(!this._focusedNode){
  137. // Nothing special to do if we have no record of anything focused
  138. return this.inherited(arguments);
  139. }
  140. var self = this,
  141. isActive = document.activeElement === this._focusedNode,
  142. focusedTarget = this[this.cellNavigation ? "cell" : "row"](this._focusedNode),
  143. focusedRow = focusedTarget.row || focusedTarget,
  144. sibling;
  145. rowElement = rowElement.element || rowElement;
  146. // If removed row previously had focus, temporarily store information
  147. // to be handled in an immediately-following insertRow call, or next turn
  148. if(rowElement === focusedRow.element){
  149. sibling = this.down(focusedRow, true);
  150. // Check whether down call returned the same row, or failed to return
  151. // any (e.g. during a partial unrendering)
  152. if (!sibling || sibling.element === rowElement) {
  153. sibling = this.up(focusedRow, true);
  154. }
  155. this._removedFocus = {
  156. active: isActive,
  157. rowId: focusedRow.id,
  158. columnId: focusedTarget.column && focusedTarget.column.id,
  159. siblingId: !sibling || sibling.element === rowElement ? undefined : sibling.id
  160. };
  161. // Call _restoreFocus on next turn, to restore focus to sibling
  162. // if no replacement row was immediately inserted.
  163. // Pass original row's id in case it was re-inserted in a renderArray
  164. // call (and thus was found, but couldn't be focused immediately)
  165. setTimeout(function() {
  166. if(self._removedFocus){
  167. self._restoreFocus(focusedRow.id);
  168. }
  169. }, 0);
  170. // Clear _focusedNode until _restoreFocus is called, to avoid
  171. // needlessly re-running this logic
  172. this._focusedNode = null;
  173. }
  174. this.inherited(arguments);
  175. },
  176. insertRow: function(object){
  177. var rowElement = this.inherited(arguments);
  178. if(this._removedFocus && !this._removedFocus.wait){
  179. this._restoreFocus(rowElement);
  180. }
  181. return rowElement;
  182. },
  183. _restoreFocus: function(row) {
  184. // summary:
  185. // Restores focus to the newly inserted row if it matches the
  186. // previously removed row, or to the nearest sibling otherwise.
  187. var focusInfo = this._removedFocus,
  188. newTarget;
  189. row = row && this.row(row);
  190. newTarget = row && row.element && row.id === focusInfo.rowId ? row :
  191. typeof focusInfo.siblingId !== "undefined" && this.row(focusInfo.siblingId);
  192. if(newTarget && newTarget.element){
  193. if(!newTarget.element.parentNode.parentNode){
  194. // This was called from renderArray, so the row hasn't
  195. // actually been placed in the DOM yet; handle it on the next
  196. // turn (called from removeRow).
  197. focusInfo.wait = true;
  198. return;
  199. }
  200. newTarget = typeof focusInfo.columnId !== "undefined" ?
  201. this.cell(newTarget, focusInfo.columnId) : newTarget;
  202. if(focusInfo.active){
  203. // Row/cell was previously focused, so focus the new one immediately
  204. this.focus(newTarget);
  205. }else{
  206. // Row/cell was not focused, but we still need to update tabIndex
  207. // and the element's class to be consistent with the old one
  208. put(newTarget.element, ".dgrid-focus");
  209. newTarget.element.tabIndex = this.tabIndex;
  210. }
  211. }
  212. delete this._removedFocus;
  213. },
  214. addKeyHandler: function(key, callback, isHeader){
  215. // summary:
  216. // Adds a handler to the keyMap on the instance.
  217. // Supports binding additional handlers to already-mapped keys.
  218. // key: Number
  219. // Key code representing the key to be handled.
  220. // callback: Function
  221. // Callback to be executed (in instance context) when the key is pressed.
  222. // isHeader: Boolean
  223. // Whether the handler is to be added for the grid body (false, default)
  224. // or the header (true).
  225. // Aspects may be about 10% slower than using an array-based appraoch,
  226. // but there is significantly less code involved (here and above).
  227. return aspect.after( // Handle
  228. this[isHeader ? "headerKeyMap" : "keyMap"], key, callback, true);
  229. },
  230. _focusOnNode: function(element, isHeader, event){
  231. var focusedNodeProperty = "_focused" + (isHeader ? "Header" : "") + "Node",
  232. focusedNode = this[focusedNodeProperty],
  233. cellOrRowType = this.cellNavigation ? "cell" : "row",
  234. cell = this[cellOrRowType](element),
  235. inputs,
  236. input,
  237. numInputs,
  238. inputFocused,
  239. i;
  240. element = cell && cell.element;
  241. if(!element){ return; }
  242. if(this.cellNavigation){
  243. inputs = element.getElementsByTagName("input");
  244. for(i = 0, numInputs = inputs.length; i < numInputs; i++){
  245. input = inputs[i];
  246. if((input.tabIndex != -1 || "lastValue" in input) && !input.disabled){
  247. // Employ workaround for focus rectangle in IE < 8
  248. if(has("ie") < 8){ input.style.position = "relative"; }
  249. input.focus();
  250. if(has("ie") < 8){ input.style.position = ""; }
  251. inputFocused = true;
  252. break;
  253. }
  254. }
  255. }
  256. event = lang.mixin({ grid: this }, event);
  257. if(event.type){
  258. event.parentType = event.type;
  259. }
  260. if(!event.bubbles){
  261. // IE doesn't always have a bubbles property already true.
  262. // Opera throws if you try to set it to true if it is already true.
  263. event.bubbles = true;
  264. }
  265. if(focusedNode){
  266. // Clean up previously-focused element
  267. // Remove the class name and the tabIndex attribute
  268. put(focusedNode, "!dgrid-focus[!tabIndex]");
  269. if(has("ie") < 8){
  270. // Clean up after workaround below (for non-input cases)
  271. focusedNode.style.position = "";
  272. }
  273. // Expose object representing focused cell or row losing focus, via
  274. // event.cell or event.row; which is set depends on cellNavigation.
  275. event[cellOrRowType] = this[cellOrRowType](focusedNode);
  276. on.emit(element, "dgrid-cellfocusout", event);
  277. }
  278. focusedNode = this[focusedNodeProperty] = element;
  279. // Expose object representing focused cell or row gaining focus, via
  280. // event.cell or event.row; which is set depends on cellNavigation.
  281. // Note that yes, the same event object is being reused; on.emit
  282. // performs a shallow copy of properties into a new event object.
  283. event[cellOrRowType] = cell;
  284. if(!inputFocused){
  285. if(has("ie") < 8){
  286. // setting the position to relative magically makes the outline
  287. // work properly for focusing later on with old IE.
  288. // (can't be done a priori with CSS or screws up the entire table)
  289. element.style.position = "relative";
  290. }
  291. element.tabIndex = this.tabIndex;
  292. element.focus();
  293. }
  294. put(element, ".dgrid-focus");
  295. on.emit(focusedNode, "dgrid-cellfocusin", event);
  296. },
  297. focusHeader: function(element){
  298. this._focusOnNode(element || this._focusedHeaderNode, true);
  299. },
  300. focus: function(element){
  301. this._focusOnNode(element || this._focusedNode, false);
  302. }
  303. });
  304. // Common functions used in default keyMap (called in instance context)
  305. var moveFocusVertical = Keyboard.moveFocusVertical = function(event, steps){
  306. var cellNavigation = this.cellNavigation,
  307. target = this[cellNavigation ? "cell" : "row"](event),
  308. columnId = cellNavigation && target.column.id,
  309. next = this.down(this._focusedNode, steps, true);
  310. // Navigate within same column if cell navigation is enabled
  311. if(cellNavigation){ next = this.cell(next, columnId); }
  312. this._focusOnNode(next, false, event);
  313. event.preventDefault();
  314. };
  315. var moveFocusUp = Keyboard.moveFocusUp = function(event){
  316. moveFocusVertical.call(this, event, -1);
  317. };
  318. var moveFocusDown = Keyboard.moveFocusDown = function(event){
  319. moveFocusVertical.call(this, event, 1);
  320. };
  321. var moveFocusPageUp = Keyboard.moveFocusPageUp = function(event){
  322. moveFocusVertical.call(this, event, -this.pageSkip);
  323. };
  324. var moveFocusPageDown = Keyboard.moveFocusPageDown = function(event){
  325. moveFocusVertical.call(this, event, this.pageSkip);
  326. };
  327. var moveFocusHorizontal = Keyboard.moveFocusHorizontal = function(event, steps){
  328. if(!this.cellNavigation){ return; }
  329. var isHeader = !this.row(event), // header reports row as undefined
  330. currentNode = this["_focused" + (isHeader ? "Header" : "") + "Node"];
  331. this._focusOnNode(this.right(currentNode, steps), isHeader, event);
  332. event.preventDefault();
  333. };
  334. var moveFocusLeft = Keyboard.moveFocusLeft = function(event){
  335. moveFocusHorizontal.call(this, event, -1);
  336. };
  337. var moveFocusRight = Keyboard.moveFocusRight = function(event){
  338. moveFocusHorizontal.call(this, event, 1);
  339. };
  340. var moveHeaderFocusEnd = Keyboard.moveHeaderFocusEnd = function(event, scrollToBeginning){
  341. // Header case is always simple, since all rows/cells are present
  342. var nodes;
  343. if(this.cellNavigation){
  344. nodes = this.headerNode.getElementsByTagName("th");
  345. this._focusOnNode(nodes[scrollToBeginning ? 0 : nodes.length - 1], true, event);
  346. }
  347. // In row-navigation mode, there's nothing to do - only one row in header
  348. // Prevent browser from scrolling entire page
  349. event.preventDefault();
  350. };
  351. var moveHeaderFocusHome = Keyboard.moveHeaderFocusHome = function(event){
  352. moveHeaderFocusEnd.call(this, event, true);
  353. };
  354. var moveFocusEnd = Keyboard.moveFocusEnd = function(event, scrollToTop){
  355. // summary:
  356. // Handles requests to scroll to the beginning or end of the grid.
  357. // Assume scrolling to top unless event is specifically for End key
  358. var self = this,
  359. cellNavigation = this.cellNavigation,
  360. contentNode = this.contentNode,
  361. contentPos = scrollToTop ? 0 : contentNode.scrollHeight,
  362. scrollPos = contentNode.scrollTop + contentPos,
  363. endChild = contentNode[scrollToTop ? "firstChild" : "lastChild"],
  364. hasPreload = endChild.className.indexOf("dgrid-preload") > -1,
  365. endTarget = hasPreload ? endChild[(scrollToTop ? "next" : "previous") + "Sibling"] : endChild,
  366. endPos = endTarget.offsetTop + (scrollToTop ? 0 : endTarget.offsetHeight),
  367. handle;
  368. if(hasPreload){
  369. // Find the nearest dgrid-row to the relevant end of the grid
  370. while(endTarget && endTarget.className.indexOf("dgrid-row") < 0){
  371. endTarget = endTarget[(scrollToTop ? "next" : "previous") + "Sibling"];
  372. }
  373. // If none is found, there are no rows, and nothing to navigate
  374. if(!endTarget){ return; }
  375. }
  376. // Grid content may be lazy-loaded, so check if content needs to be
  377. // loaded first
  378. if(!hasPreload || endChild.offsetHeight < 1){
  379. // End row is loaded; focus the first/last row/cell now
  380. if(cellNavigation){
  381. // Preserve column that was currently focused
  382. endTarget = this.cell(endTarget, this.cell(event).column.id);
  383. }
  384. this._focusOnNode(endTarget, false, event);
  385. }else{
  386. // In IE < 9, the event member references will become invalid by the time
  387. // _focusOnNode is called, so make a (shallow) copy up-front
  388. if(!has("dom-addeventlistener")){
  389. event = lang.mixin({}, event);
  390. }
  391. // If the topmost/bottommost row rendered doesn't reach the top/bottom of
  392. // the contentNode, we are using OnDemandList and need to wait for more
  393. // data to render, then focus the first/last row in the new content.
  394. handle = aspect.after(this, "renderArray", function(rows){
  395. handle.remove();
  396. return Deferred.when(rows, function(rows){
  397. var target = rows[scrollToTop ? 0 : rows.length - 1];
  398. if(cellNavigation){
  399. // Preserve column that was currently focused
  400. target = self.cell(target, self.cell(event).column.id);
  401. }
  402. self._focusOnNode(target, false, event);
  403. });
  404. });
  405. }
  406. if(scrollPos === endPos){
  407. // Grid body is already scrolled to end; prevent browser from scrolling
  408. // entire page instead
  409. event.preventDefault();
  410. }
  411. };
  412. var moveFocusHome = Keyboard.moveFocusHome = function(event){
  413. moveFocusEnd.call(this, event, true);
  414. };
  415. function preventDefault(event){
  416. event.preventDefault();
  417. }
  418. Keyboard.defaultKeyMap = {
  419. 32: preventDefault, // space
  420. 33: moveFocusPageUp, // page up
  421. 34: moveFocusPageDown, // page down
  422. 35: moveFocusEnd, // end
  423. 36: moveFocusHome, // home
  424. 37: moveFocusLeft, // left
  425. 38: moveFocusUp, // up
  426. 39: moveFocusRight, // right
  427. 40: moveFocusDown // down
  428. };
  429. // Header needs fewer default bindings (no vertical), so bind it separately
  430. Keyboard.defaultHeaderKeyMap = {
  431. 32: preventDefault, // space
  432. 35: moveHeaderFocusEnd, // end
  433. 36: moveHeaderFocusHome, // home
  434. 37: moveFocusLeft, // left
  435. 39: moveFocusRight // right
  436. };
  437. return Keyboard;
  438. });