forked from ibm-js/delite
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathKeyNav.js
481 lines (435 loc) · 15.9 KB
/
KeyNav.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
/** @module delite/KeyNav */
define([
"dcl/dcl",
"delite/keys", // keys.END keys.HOME, keys.LEFT_ARROW etc.
"./features",
"./Widget",
"./focus" // causes _onBlur() to be called when focus removed from KeyNav and logical descendants
], function (dcl, keys, has, Widget) {
/**
* Return true if node is an `<input>` or similar that responds to keyboard input.
* @param {Element} node
* @returns {boolean}
*/
function takesInput(node) {
var tag = node.nodeName.toLowerCase();
return !node.readOnly && (tag === "textarea" || (tag === "input" &&
/^(color|email|number|password|search|tel|text|url|range)$/.test(node.type)));
}
/**
* A mixin to allow arrow key and letter key navigation of child Elements.
* It can be used by delite/Container based widgets with a flat list of children,
* or more complex widgets like a Tree.
*
* To use this mixin, the subclass must:
*
* - Implement `_onLeftArrow()`, `_onRightArrow()``_onDownArrow()`, `_onUpArrow()` methods to handle
* left/right/up/down keystrokes.
* - Set all navigable descendants' initial tabIndex to "-1"; both initial descendants and any
* descendants added later, by for example `addChild()`.
* - Define `childSelector` as a function or string that identifies focusable child Elements.
*
* Note the word "child" in this class is used loosely, to refer to any descendant Element.
* If the child elements contain text though, they should have a label attribute. KeyNav uses the label
* attribute for letter key navigation.
*
* @mixin module:delite/KeyNav
* @augments module:delite/Widget
*/
return dcl(Widget, /** @lends module:delite/KeyNav# */ {
// TODO: due to apparent bugs in jsdoc3, these aren't getting shown.
/**
* The currently focused descendant, or null if there isn't one
* @member {Element}
* @readonly
* @protected
*/
focusedChild: null,
/**
* Hash mapping key code (arrow keys and home/end key) to functions to handle those keys.
* Usually not used directly, as subclasses can instead override _onLeftArrow() etc.
* Must be set before postCreate().
* @member {Object}
* @protected
*/
_keyNavCodes: null,
/**
* Selector to identify which descendants Elements are navigable via arrow keys or
* keyboard search. Note that for subclasses like a Tree, one navigable node could be a descendant of another.
*
* By default, the direct DOM children of this widget are considered as the children.
*
* Must be set in the prototype rather than on the instance.
*
* @member {string|Function}
* @protected
* @constant
*/
childSelector: null,
/**
* Figure out effective target of this event, either a navigable node (a.k.a. a child),
* or this widget itself.
* The meaning of "child" here is complicated because this could be a Tree with nested children.
* @param evt
* @private
*/
_getTargetElement: function (evt) {
for (var child = evt.target; child !== this; child = child.parentNode) {
if (this._selectorFunc(child)) {
return child;
}
}
return this;
},
postCreate: function () {
// If the user hasn't specified a tabindex declaratively, then set to default value.
if (!this.hasAttribute("tabindex")) {
this.tabIndex = "0";
}
var self = this;
// Setup function to check which child nodes are navigable.
if (typeof this.childSelector === "string") {
var matchesFuncName = has("dom-matches");
this._selectorFunc = function (elem) {
return elem[matchesFuncName](this.childSelector);
};
} else if (this.childSelector) {
this._selectorFunc = this.childSelector;
} else {
this._selectorFunc = function (child) { return child.parentNode === self.containerNode; };
}
if (!this._keyNavCodes) {
var keyCodes = this._keyNavCodes = {};
keyCodes[keys.HOME] = function () {
self.focusFirstChild();
};
keyCodes[keys.END] = function () {
self.focusLastChild();
};
keyCodes[this.isLeftToRight() ? keys.LEFT_ARROW : keys.RIGHT_ARROW] = this._onLeftArrow.bind(this);
keyCodes[this.isLeftToRight() ? keys.RIGHT_ARROW : keys.LEFT_ARROW] = this._onRightArrow.bind(this);
keyCodes[keys.UP_ARROW] = this._onUpArrow.bind(this);
keyCodes[keys.DOWN_ARROW] = this._onDownArrow.bind(this);
}
this.on("keypress", this._onContainerKeypress.bind(this)),
this.on("keydown", this._onContainerKeydown.bind(this)),
this.on("focusin", function (evt) {
var target = self._getTargetElement(evt);
if (target === self) {
self._onContainerFocus(evt);
} else {
self._onChildFocus(target, evt);
}
});
},
/**
* Called on left arrow key, or right arrow key if widget is in RTL mode.
* Should go back to the previous child in horizontal container widgets like Toolbar.
* @protected
* @abstract
*/
_onLeftArrow: function () {
},
/**
* Called on right arrow key, or left arrow key if widget is in RTL mode.
* Should go to the next child in horizontal container widgets like Toolbar.
* @protected
* @abstract
*/
_onRightArrow: function () {
},
/**
* Called on up arrow key. Should go to the previous child in vertical container widgets like Menu.
* @protected
* @abstract
*/
_onUpArrow: function () {
},
/**
* Called on down arrow key. Should go to the next child in vertical container widgets like Menu.
* @protected
* @abstract
*/
_onDownArrow: function () {
},
/**
* Default focus() implementation: focus the first child.
*/
focus: function () {
this.focusFirstChild();
},
/**
* Focus the first focusable child in the container.
* @protected
*/
focusFirstChild: function () {
this.focusChild(this._getNext(this, 1));
},
/**
* Focus the last focusable child in the container.
* @protected
*/
focusLastChild: function () {
this.focusChild(this._getNext(this, -1));
},
/**
* Focus specified child Element.
* @param {Element} child - Reference to container's child.
* @param {boolean} last - If true and if child has multiple focusable nodes, focus the
* last one instead of the first one.
* @protected
*/
focusChild: function (child, last) {
// For IE focus outline to appear, must set tabIndex before focus.
// If this._savedTabIndex is set, use it instead of this.tabIndex, because it means
// the container's tabIndex has already been changed to -1.
child.tabIndex = "_savedTabIndex" in this ? this._savedTabIndex : this.tabIndex;
child.focus(last ? "end" : "start");
// Don't set focusedChild here, because the focus event should trigger a call to _onChildFocus(), which will
// set it. More importantly, _onChildFocus(), which may be executed asynchronously (after this function
// returns) needs to know the old focusedChild to set its tabIndex to -1.
},
/**
* Handler for when the container itself gets focus.
*
* Initially the container itself has a tabIndex, but when it gets focus, switch focus to first child.
*
* @param {Event} evt
* @private
*/
_onContainerFocus: function () {
// Note that we can't use _onFocus() because switching focus from the
// _onFocus() handler confuses the focus.js code
// (because it causes _onFocusNode() to be called recursively).
// Also, _onFocus() would fire when focus went directly to a child widget due to mouse click.
// Ignore spurious focus event:
// On IE, clicking the scrollbar of a select dropdown moves focus from the focused child item to me
if (this.focusedChild) {
return;
}
// When the container gets focus by being tabbed into, or a descendant gets focus by being clicked,
// remove the container's tabIndex so that tab or shift-tab
// will go to the fields after/before the container, rather than the container itself
this._savedTabIndex = this.tabIndex;
this.removeAttribute("tabindex");
this.focus();
},
_onBlur: dcl.after(function () {
// When focus is moved away the container, and its descendant (popup) widgets,
// then restore the container's tabIndex so that user can tab to it again.
// Note that using _onBlur() so that this doesn't happen when focus is shifted
// to one of my child widgets (typically a popup)
// TODO: for 2.0 consider changing this to blur whenever the container blurs, to be truthful that there is
// no focused child at that time.
this.setAttribute("tabindex", this._savedTabIndex);
delete this._savedTabIndex;
if (this.focusedChild) {
this.focusedChild.tabIndex = "-1";
this.focusedChild = null;
}
}),
/**
* Called when a child gets focus, either by user clicking it, or programatically by arrow key handling code.
* It marks that the current node is the selected one, and the previously selected node no longer is.
* @param {Element} child
* @private
*/
_onChildFocus: function (child) {
if (child && child !== this.focusedChild) {
if (this.focusedChild && !this.focusedChild._destroyed) {
// mark that the previously focusable node is no longer focusable
this.focusedChild.tabIndex = "-1";
}
// If container still has tabIndex setting then remove it; instead we'll set tabIndex on child
if (!("_savedTabIndex" in this)) {
this._savedTabIndex = this.tabIndex;
this.removeAttribute("tabindex");
}
// mark that the new node is the currently selected one
child.tabIndex = this._savedTabIndex;
this.focusedChild = child;
}
},
_searchString: "",
/**
* If multiple characters are typed where each keystroke happens within
* multiCharSearchDuration of the previous keystroke,
* search for nodes matching all the keystrokes.
*
* For example, typing "ab" will search for entries starting with
* "ab" unless the delay between "a" and "b" is greater than `multiCharSearchDuration`.
*
* @member {number} KeyNav#multiCharSearchDuration
* @default 1000
*/
multiCharSearchDuration: 1000,
/**
* When a key is pressed that matches a child item,
* this method is called so that a widget can take appropriate action is necessary.
*
* @param {Element} item
* @param {Event} evt
* @param {string} searchString
* @param {number} numMatches
* @private
*/
onKeyboardSearch: function (item, /*jshint unused: vars */ evt, searchString, numMatches) {
if (item) {
this.focusChild(item);
}
},
/**
* Compares the searchString to the Element's text label, returning:
*
* - -1: a high priority match and stop searching
* - 0: not a match
* - 1: a match but keep looking for a higher priority match
*
* @param {Element} item
* @param {string} searchString
* @returns {number}
* @private
*/
_keyboardSearchCompare: function (item, searchString) {
var element = item,
text = item.label || (element.focusNode ? element.focusNode.label : "") || element.textContent || "",
currentString = text.replace(/^\s+/, "").substr(0, searchString.length).toLowerCase();
// stop searching after first match by default
return (!!searchString.length && currentString === searchString) ? -1 : 0;
},
/**
* When a key is pressed, if it's an arrow key etc. then it's handled here.
* @param {Event} evt
* @private
*/
_onContainerKeydown: function (evt) {
// Ignore left, right, home, and end on <input> controls
if (takesInput(evt.target) &&
(evt.keyCode === keys.LEFT_ARROW || evt.keyCode === keys.RIGHT_ARROW ||
evt.keyCode === keys.HOME || evt.keyCode === keys.END)) {
return;
}
var func = this._keyNavCodes[evt.keyCode];
if (func) {
func(evt, this.focusedChild);
evt.stopPropagation();
evt.preventDefault();
this._searchString = ""; // so a DOWN_ARROW b doesn't search for ab
} else if (evt.keyCode === keys.SPACE && this._searchTimer && !(evt.ctrlKey || evt.altKey || evt.metaKey)) {
// Stop a11yclick from interpreting key as a click event.
// Also stop IE from scrolling, and most browsers (except FF) from sending keypress.
evt.preventDefault();
this._keyboardSearch(evt, " ");
}
},
/**
* When a printable key is pressed, it's handled here, searching by letter.
* @param {Event} evt
* @private
*/
_onContainerKeypress: function (evt) {
// Ignore:
// - keystrokes on <input> and <textarea>
// - duplicate events on firefox (ex: arrow key that will be handled by keydown handler)
// - control sequences like CMD-Q.
// - the SPACE key (only occurs on FF)
//
// Note: if there's no search in progress, then SPACE should be ignored. If there is a search
// in progress, then SPACE is handled in _onContainerKeyDown.
if (takesInput(evt.target) || evt.charCode <= keys.SPACE || evt.ctrlKey || evt.altKey || evt.metaKey) {
return;
}
if (/^(checkbox|radio)$/.test(evt.target.type) &&
(evt.charCode === keys.SPACE || evt.charCode === keys.ENTER)) {
// Ignore keyboard clicks on checkbox controls
return;
}
evt.preventDefault();
evt.stopPropagation();
this._keyboardSearch(evt, String.fromCharCode(evt.charCode).toLowerCase());
},
/**
* Perform a search of the widget's options based on the user's keyboard activity.
*
* Called on keypress (and sometimes keydown), searches through this widget's children
* looking for items that match the user's typed search string. Multiple characters
* typed within `multiCharSearchDuration` of each other are combined for multi-character searching.
* @param {Event} evt
* @param {string} keyChar
* @private
*/
_keyboardSearch: function (evt, keyChar) {
var
matchedItem = null,
searchString,
numMatches = 0;
if (this._searchTimer) {
this._searchTimer.remove();
}
this._searchString += keyChar;
var allSameLetter = /^(.)\1*$/.test(this._searchString);
var searchLen = allSameLetter ? 1 : this._searchString.length;
searchString = this._searchString.substr(0, searchLen);
this._searchTimer = this.defer(function () { // this is the "success" timeout
this._searchTimer = null;
this._searchString = "";
}, this.multiCharSearchDuration);
var currentItem = this.focusedChild || null;
if (searchLen === 1 || !currentItem) {
currentItem = this._getNext(currentItem, 1); // skip current
if (!currentItem) {
return;
} // no items
}
var stop = currentItem;
do {
var rc = this._keyboardSearchCompare(currentItem, searchString);
if (!!rc && numMatches++ === 0) {
matchedItem = currentItem;
}
if (rc === -1) { // priority match
numMatches = -1;
break;
}
currentItem = this._getNext(currentItem, 1);
} while (currentItem !== stop);
this.onKeyboardSearch(matchedItem, evt, searchString, numMatches);
},
/**
* Returns the next or previous navigable child, relative to "child".
* If "child" is this, then it returns the first focusable child (when dir === 1)
* or last focusable child (when dir === -1).
* @param {Element} child - The current child Element.
* @param {number} dir - 1 = after, -1 = before
* @returns {Element}
* @protected
*/
_getNext: function (child, dir) {
var root = this, origChild = child;
function dfsNext(node) {
if (node.firstElementChild) { return node.firstElementChild; }
while (node !== root) {
if (node.nextElementSibling) { return node.nextElementSibling; }
node = node.parentNode;
}
return root; // loop around, plus corner case when no children
}
function dfsLast(node) {
while (node.lastElementChild) { node = node.lastElementChild; }
return node;
}
function dfsPrev(node) {
return node === root ? dfsLast(root) : // loop around, plus corner case when no children
(node.previousElementSibling && dfsLast(node.previousElementSibling)) || node.parentNode;
}
while (true) {
child = dir > 0 ? dfsNext(child) : dfsPrev(child);
if (child === origChild) {
return null; // looped back to original child
} else if (this._selectorFunc(child)) {
return child; // this child matches
}
}
}
});
});