Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
freebsd
GitHub Repository: freebsd/phabricator
Path: blob/master/webroot/rsrc/externals/javelin/lib/control/tokenizer/Tokenizer.js
12242 views
1
/**
2
* @requires javelin-dom
3
* javelin-util
4
* javelin-stratcom
5
* javelin-install
6
* @provides javelin-tokenizer
7
* @javelin
8
*/
9
10
/**
11
* A tokenizer is a UI component similar to a text input, except that it
12
* allows the user to input a list of items ("tokens"), generally from a fixed
13
* set of results. A familiar example of this UI is the "To:" field of most
14
* email clients, where the control autocompletes addresses from the user's
15
* address book.
16
*
17
* @{JX.Tokenizer} is built on top of @{JX.Typeahead}, and primarily adds the
18
* ability to choose multiple items.
19
*
20
* To build a @{JX.Tokenizer}, you need to do four things:
21
*
22
* 1. Construct it, padding a DOM node for it to attach to. See the constructor
23
* for more information.
24
* 2. Build a {@JX.Typeahead} and configure it with setTypeahead().
25
* 3. Configure any special options you want.
26
* 4. Call start().
27
*
28
* If you do this correctly, the input should suggest items and enter them as
29
* tokens as the user types.
30
*
31
* When the tokenizer is focused, the CSS class `jx-tokenizer-container-focused`
32
* is added to the container node.
33
*/
34
JX.install('Tokenizer', {
35
construct : function(containerNode) {
36
this._containerNode = containerNode;
37
},
38
39
events : [
40
/**
41
* Emitted when the value of the tokenizer changes, similar to an 'onchange'
42
* from a <select />.
43
*/
44
'change'],
45
46
properties : {
47
limit : null,
48
renderTokenCallback : null,
49
browseURI: null,
50
disabled: false
51
},
52
53
members : {
54
_containerNode : null,
55
_root : null,
56
_frame: null,
57
_focus : null,
58
_orig : null,
59
_typeahead : null,
60
_tokenid : 0,
61
_tokens : null,
62
_tokenMap : null,
63
_initialValue : null,
64
_seq : 0,
65
_lastvalue : null,
66
_placeholder : null,
67
68
start : function() {
69
if (this.getDisabled()) {
70
JX.DOM.alterClass(this._containerNode, 'disabled-control', true);
71
return;
72
}
73
74
if (__DEV__) {
75
if (!this._typeahead) {
76
throw new Error(
77
'JX.Tokenizer.start(): ' +
78
'No typeahead configured! Use setTypeahead() to provide a ' +
79
'typeahead.');
80
}
81
}
82
83
this._orig = JX.DOM.find(this._containerNode, 'input', 'tokenizer-input');
84
this._tokens = [];
85
this._tokenMap = {};
86
87
try {
88
this._frame = JX.DOM.findAbove(this._orig, 'div', 'tokenizer-frame');
89
} catch (e) {
90
// Ignore, this tokenizer doesn't have a frame.
91
}
92
93
if (this._frame) {
94
JX.DOM.alterClass(this._frame, 'has-browse', !!this.getBrowseURI());
95
JX.DOM.listen(
96
this._frame,
97
'click',
98
'tokenizer-browse',
99
JX.bind(this, this._onbrowse));
100
}
101
102
var focus = this.buildInput(this._orig.value);
103
this._focus = focus;
104
105
var input_container = JX.DOM.scry(
106
this._containerNode,
107
'div',
108
'tokenizer-input-container'
109
);
110
input_container = input_container[0] || this._containerNode;
111
112
JX.DOM.listen(
113
focus,
114
['click', 'focus', 'blur', 'keydown', 'keypress', 'paste'],
115
null,
116
JX.bind(this, this.handleEvent));
117
118
// NOTE: Safari on the iPhone does not normally delegate click events on
119
// <div /> tags. This causes the event to fire. We want a click (in this
120
// case, a touch) anywhere in the div to trigger this event so that we
121
// can focus the input. Without this, you must tap an arbitrary area on
122
// the left side of the input to focus it.
123
//
124
// http://www.quirksmode.org/blog/archives/2010/09/click_event_del.html
125
input_container.onclick = JX.bag;
126
127
JX.DOM.listen(
128
input_container,
129
'click',
130
null,
131
JX.bind(
132
this,
133
function(e) {
134
if (e.getNode('remove')) {
135
this._remove(e.getNodeData('token').key, true);
136
} else if (e.getTarget() == this._root) {
137
this.focus();
138
}
139
}));
140
141
var root = JX.$N('div');
142
root.id = this._orig.id;
143
JX.DOM.alterClass(root, 'jx-tokenizer', true);
144
root.style.cursor = 'text';
145
this._root = root;
146
147
root.appendChild(focus);
148
149
var typeahead = this._typeahead;
150
typeahead.setInputNode(this._focus);
151
typeahead.start();
152
153
setTimeout(JX.bind(this, function() {
154
var container = this._orig.parentNode;
155
JX.DOM.setContent(container, root);
156
var map = this._initialValue || {};
157
for (var k in map) {
158
this.addToken(k, map[k]);
159
}
160
JX.DOM.appendContent(
161
root,
162
JX.$N('div', {style: {clear: 'both'}})
163
);
164
this._redraw();
165
}), 0);
166
},
167
168
setInitialValue : function(map) {
169
this._initialValue = map;
170
return this;
171
},
172
173
setTypeahead : function(typeahead) {
174
175
typeahead.setAllowNullSelection(false);
176
typeahead.removeListener();
177
178
typeahead.listen(
179
'choose',
180
JX.bind(this, function(result) {
181
JX.Stratcom.context().prevent();
182
if (this.addToken(result.rel, result.name)) {
183
if (this.shouldHideResultsOnChoose()) {
184
this._typeahead.hide();
185
}
186
this._typeahead.clear();
187
this._redraw();
188
this.focus();
189
}
190
})
191
);
192
193
typeahead.listen(
194
'query',
195
JX.bind(
196
this,
197
function(query) {
198
199
// TODO: We should emit a 'query' event here to allow the caller to
200
// generate tokens on the fly, e.g. email addresses or other freeform
201
// or algorithmic tokens.
202
203
// Then do this if something handles the event.
204
// this._focus.value = '';
205
// this._redraw();
206
// this.focus();
207
208
if (query.length) {
209
// Prevent this event if there's any text, so that we don't submit
210
// the form (either we created a token or we failed to create a
211
// token; in either case we shouldn't submit). If the query is
212
// empty, allow the event so that the form submission takes place.
213
JX.Stratcom.context().prevent();
214
}
215
}));
216
217
this._typeahead = typeahead;
218
219
return this;
220
},
221
222
shouldHideResultsOnChoose : function() {
223
return true;
224
},
225
226
handleEvent : function(e) {
227
this._typeahead.handleEvent(e);
228
if (e.getPrevented()) {
229
return;
230
}
231
232
if (e.getType() == 'click') {
233
if (e.getTarget() == this._root) {
234
this.focus();
235
e.prevent();
236
return;
237
}
238
} else if (e.getType() == 'keydown') {
239
this._onkeydown(e);
240
} else if (e.getType() == 'blur') {
241
this._didblur();
242
243
// Explicitly update the placeholder since we just wiped the field
244
// value.
245
this._typeahead.updatePlaceholder();
246
} else if (e.getType() == 'focus') {
247
this._didfocus();
248
} else if (e.getType() == 'paste') {
249
setTimeout(JX.bind(this, this._redraw), 0);
250
}
251
252
},
253
254
refresh : function() {
255
this._redraw(true);
256
return this;
257
},
258
259
_redraw : function(force) {
260
261
// If there are tokens in the tokenizer, never show a placeholder.
262
// Otherwise, show one if one is configured.
263
if (JX.keys(this._tokenMap).length) {
264
this._typeahead.setPlaceholder(null);
265
} else {
266
this._typeahead.setPlaceholder(this._placeholder);
267
}
268
269
var focus = this._focus;
270
271
if (focus.value === this._lastvalue && !force) {
272
return;
273
}
274
this._lastvalue = focus.value;
275
276
var metrics = JX.DOM.textMetrics(
277
this._focus,
278
'jx-tokenizer-metrics');
279
metrics.y = null;
280
metrics.x += 24;
281
metrics.setDim(focus);
282
283
// NOTE: Once, long ago, we set "focus.value = focus.value;" here to fix
284
// an issue with copy/paste in Firefox not redrawing correctly. However,
285
// this breaks input of Japanese glyphs in Chrome, and I can't reproduce
286
// the original issue in modern Firefox.
287
//
288
// If future changes muck around with things here, test that Japanese
289
// inputs still work. Example:
290
//
291
// - Switch to Hiragana mode.
292
// - Type "ni".
293
// - This should produce a glyph, not the value "n".
294
//
295
// With the assignment, Chrome loses the partial input on the "n" when
296
// the value is assigned.
297
},
298
299
setPlaceholder : function(string) {
300
this._placeholder = string;
301
return this;
302
},
303
304
addToken : function(key, value) {
305
if (key in this._tokenMap) {
306
return false;
307
}
308
309
var focus = this._focus;
310
var root = this._root;
311
var token = this.buildToken(key, value);
312
313
this._tokenMap[key] = {
314
value : value,
315
key : key,
316
node : token
317
};
318
this._tokens.push(key);
319
320
root.insertBefore(token, focus);
321
322
this._didChangeValue();
323
324
return true;
325
},
326
327
removeToken : function(key) {
328
return this._remove(key, false);
329
},
330
331
buildInput: function(value) {
332
return JX.$N('input', {
333
className: 'jx-tokenizer-input',
334
type: 'text',
335
autocomplete: 'off',
336
value: value
337
});
338
},
339
340
/**
341
* Generate a token based on a key and value. The "token" and "remove"
342
* sigils are observed by a listener in start().
343
*/
344
buildToken: function(key, value) {
345
var input = JX.$N('input', {
346
type: 'hidden',
347
value: key,
348
name: this._orig.name + '[' + (this._seq++) + ']'
349
});
350
351
var remove = JX.$N('a', {
352
className: 'jx-tokenizer-x',
353
sigil: 'remove'
354
}, '\u00d7'); // U+00D7 multiplication sign
355
356
var display_token = value;
357
358
var attrs = {
359
className: 'jx-tokenizer-token',
360
sigil: 'token',
361
meta: {key: key}
362
};
363
var container = JX.$N('a', attrs);
364
365
var render_callback = this.getRenderTokenCallback();
366
if (render_callback) {
367
display_token = render_callback(value, key, container);
368
}
369
370
JX.DOM.setContent(container, [display_token, input, remove]);
371
372
return container;
373
},
374
375
getTokens : function() {
376
var result = {};
377
for (var key in this._tokenMap) {
378
result[key] = this._tokenMap[key].value;
379
}
380
return result;
381
},
382
383
_onkeydown : function(e) {
384
var raw = e.getRawEvent();
385
if (raw.ctrlKey || raw.metaKey || raw.altKey) {
386
return;
387
}
388
389
switch (e.getSpecialKey()) {
390
case 'tab':
391
var completed = this._typeahead.submit();
392
if (!completed) {
393
this._focus.value = '';
394
}
395
break;
396
case 'delete':
397
if (!this._focus.value.length) {
398
// In unusual cases, it's possible for us to end up with a token
399
// that has the empty string ("") as a value. Support removal of
400
// this unusual token.
401
402
var tok;
403
while (this._tokens.length) {
404
tok = this._tokens.pop();
405
if (this._remove(tok, true)) {
406
break;
407
}
408
}
409
}
410
break;
411
case 'return':
412
// Don't subject this to token limits.
413
break;
414
default:
415
if (this.getLimit() &&
416
JX.keys(this._tokenMap).length == this.getLimit()) {
417
e.prevent();
418
}
419
setTimeout(JX.bind(this, this._redraw), 0);
420
break;
421
}
422
},
423
424
_remove : function(index, focus) {
425
if (!this._tokenMap[index]) {
426
return false;
427
}
428
JX.DOM.remove(this._tokenMap[index].node);
429
delete this._tokenMap[index];
430
this._redraw(true);
431
focus && this.focus();
432
433
this._didChangeValue();
434
435
return true;
436
},
437
438
_didChangeValue: function() {
439
440
if (this.getBrowseURI()) {
441
var button = JX.DOM.find(this._frame, 'a', 'tokenizer-browse');
442
JX.DOM.alterClass(button, 'disabled', !!this._shouldLockBrowse());
443
}
444
445
this.invoke('change', this);
446
},
447
448
_shouldLockBrowse: function() {
449
var limit = this.getLimit();
450
451
if (!limit) {
452
// If there's no limit, never lock the browse button.
453
return false;
454
}
455
456
if (limit == 1) {
457
// If the limit is 1, we'll replace the current token if the
458
// user selects a new one, so we never need to lock the button.
459
return false;
460
}
461
462
if (limit > JX.keys(this.getTokens()).length) {
463
return false;
464
}
465
466
return true;
467
},
468
469
focus : function() {
470
var focus = this._focus;
471
JX.DOM.show(focus);
472
473
// NOTE: We must fire this focus event immediately (during event
474
// handling) for the iPhone to bring up the keyboard. Previously this
475
// focus was wrapped in setTimeout(), but it's unclear why that was
476
// necessary. If this is adjusted later, make sure tapping the inactive
477
// area of the tokenizer to focus it on the iPhone still brings up the
478
// keyboard.
479
480
JX.DOM.focus(focus);
481
},
482
483
_didfocus : function() {
484
JX.DOM.alterClass(
485
this._containerNode,
486
'jx-tokenizer-container-focused',
487
true);
488
},
489
490
_didblur : function() {
491
JX.DOM.alterClass(
492
this._containerNode,
493
'jx-tokenizer-container-focused',
494
false);
495
this._focus.value = '';
496
this._redraw();
497
},
498
499
_onbrowse: function(e) {
500
e.kill();
501
502
var uri = this.getBrowseURI();
503
if (!uri) {
504
return;
505
}
506
507
if (this._shouldLockBrowse()) {
508
return;
509
}
510
511
new JX.Workflow(uri, {exclude: JX.keys(this.getTokens()).join(',')})
512
.setHandler(
513
JX.bind(this, function(r) {
514
var source = this._typeahead.getDatasource();
515
516
source.addResult(r.token);
517
var result = source.getResult(r.key);
518
519
// If we have a limit of 1 token, replace the current token with
520
// the new token if we currently have a token.
521
if (this.getLimit() == 1) {
522
for (var k in this.getTokens()) {
523
this.removeToken(k);
524
}
525
}
526
527
this.addToken(r.key, result.name);
528
this.focus();
529
}))
530
.start();
531
}
532
533
}
534
});
535
536