phorge/webroot/rsrc/externals/javelin/lib/control/tokenizer/Tokenizer.js
epriestley 0efce646c9 Improve tokenizer loading behaviors
Summary:
Ref T4420. Fixes T3309. Two major UX issues here:

  - When the user extends a query ("alin" -> "alinc"), we currently hide all the results, then show them again when the new results arrive. This makes the typeahead feel a bit flickery. Instead, show matching results, then add more results when everything arrives.
  - When loading more results from ondemand sources, we currently do not give you any indication that things are loading. Instead:
    - Show a loading GIF (this might need #design help, @chad).
    - Slightly lighten the control border.
    - I didn't want to do anything like actually add "loading" text because it would cause UI flicker in the 'extend a query' case and some other cases, but otherwise this design is totally made up.

Test Plan: Typed into tokenizers and extended queries, got a better-feeling UI.

Reviewers: chad, btrahan

Reviewed By: btrahan

CC: chad, aran

Maniphest Tasks: T3309, T4420

Differential Revision: https://secure.phabricator.com/D8233
2014-02-14 10:24:58 -08:00

448 lines
12 KiB
JavaScript

/**
* @requires javelin-dom
* javelin-util
* javelin-stratcom
* javelin-install
* @provides javelin-tokenizer
* @javelin
*/
/**
* A tokenizer is a UI component similar to a text input, except that it
* allows the user to input a list of items ("tokens"), generally from a fixed
* set of results. A familiar example of this UI is the "To:" field of most
* email clients, where the control autocompletes addresses from the user's
* address book.
*
* @{JX.Tokenizer} is built on top of @{JX.Typeahead}, and primarily adds the
* ability to choose multiple items.
*
* To build a @{JX.Tokenizer}, you need to do four things:
*
* 1. Construct it, padding a DOM node for it to attach to. See the constructor
* for more information.
* 2. Build a {@JX.Typeahead} and configure it with setTypeahead().
* 3. Configure any special options you want.
* 4. Call start().
*
* If you do this correctly, the input should suggest items and enter them as
* tokens as the user types.
*
* When the tokenizer is focused, the CSS class `jx-tokenizer-container-focused`
* is added to the container node.
*
* @group control
*/
JX.install('Tokenizer', {
construct : function(containerNode) {
this._containerNode = containerNode;
this._uiNodes = [];
},
events : [
/**
* Emitted when the value of the tokenizer changes, similar to an 'onchange'
* from a <select />.
*/
'change'],
properties : {
limit : null,
renderTokenCallback : null
},
members : {
_containerNode : null,
_root : null,
_focus : null,
_orig : null,
_typeahead : null,
_tokenid : 0,
_tokens : null,
_tokenMap : null,
_initialValue : null,
_seq : 0,
_lastvalue : null,
_placeholder : null,
_uinodes : null,
start : function() {
if (__DEV__) {
if (!this._typeahead) {
throw new Error(
'JX.Tokenizer.start(): ' +
'No typeahead configured! Use setTypeahead() to provide a ' +
'typeahead.');
}
}
this._orig = JX.DOM.find(this._containerNode, 'input', 'tokenizer-input');
this._tokens = [];
this._tokenMap = {};
var focus = this.buildInput(this._orig.value);
this._focus = focus;
var input_container = JX.DOM.scry(
this._containerNode,
'div',
'tokenizer-input-container'
);
input_container = input_container[0] || this._containerNode;
JX.DOM.listen(
focus,
['click', 'focus', 'blur', 'keydown', 'keypress'],
null,
JX.bind(this, this.handleEvent));
// NOTE: Safari on the iPhone does not normally delegate click events on
// <div /> tags. This causes the event to fire. We want a click (in this
// case, a touch) anywhere in the div to trigger this event so that we
// can focus the input. Without this, you must tap an arbitrary area on
// the left side of the input to focus it.
//
// http://www.quirksmode.org/blog/archives/2010/09/click_event_del.html
input_container.onclick = JX.bag;
JX.DOM.listen(
input_container,
'click',
null,
JX.bind(
this,
function(e) {
if (e.getNode('remove')) {
this._remove(e.getNodeData('token').key, true);
} else if (e.getTarget() == this._root) {
this.focus();
}
}));
var root = JX.$N('div');
root.id = this._orig.id;
JX.DOM.alterClass(root, 'jx-tokenizer', true);
root.style.cursor = 'text';
this._root = root;
root.appendChild(focus);
JX.DOM.appendContent(root, this._uiNodes);
var typeahead = this._typeahead;
typeahead.setInputNode(this._focus);
typeahead.start();
setTimeout(JX.bind(this, function() {
var container = this._orig.parentNode;
JX.DOM.setContent(container, root);
var map = this._initialValue || {};
for (var k in map) {
this.addToken(k, map[k]);
}
JX.DOM.appendContent(
root,
JX.$N('div', {style: {clear: 'both'}})
);
this._redraw();
}), 0);
},
setInitialValue : function(map) {
this._initialValue = map;
return this;
},
setTypeahead : function(typeahead) {
typeahead.setAllowNullSelection(false);
typeahead.removeListener();
typeahead.listen(
'choose',
JX.bind(this, function(result) {
JX.Stratcom.context().prevent();
if (this.addToken(result.rel, result.name)) {
if (this.shouldHideResultsOnChoose()) {
this._typeahead.hide();
}
this._typeahead.clear();
this._redraw();
this.focus();
}
})
);
typeahead.listen(
'query',
JX.bind(
this,
function(query) {
// TODO: We should emit a 'query' event here to allow the caller to
// generate tokens on the fly, e.g. email addresses or other freeform
// or algorithmic tokens.
// Then do this if something handles the event.
// this._focus.value = '';
// this._redraw();
// this.focus();
if (query.length) {
// Prevent this event if there's any text, so that we don't submit
// the form (either we created a token or we failed to create a
// token; in either case we shouldn't submit). If the query is
// empty, allow the event so that the form submission takes place.
JX.Stratcom.context().prevent();
}
}));
this._typeahead = typeahead;
return this;
},
shouldHideResultsOnChoose : function() {
return true;
},
handleEvent : function(e) {
this._typeahead.handleEvent(e);
if (e.getPrevented()) {
return;
}
if (e.getType() == 'click') {
if (e.getTarget() == this._root) {
this.focus();
e.prevent();
return;
}
} else if (e.getType() == 'keydown') {
this._onkeydown(e);
} else if (e.getType() == 'blur') {
this._didblur();
// Explicitly update the placeholder since we just wiped the field
// value.
this._typeahead.updatePlaceholder();
} else if (e.getType() == 'focus') {
this._didfocus();
}
},
refresh : function() {
this._redraw(true);
return this;
},
addUINode : function(node) {
this._uiNodes.push(node);
this._typeahead.addUINode(node);
return this;
},
_redraw : function(force) {
// If there are tokens in the tokenizer, never show a placeholder.
// Otherwise, show one if one is configured.
if (JX.keys(this._tokenMap).length) {
this._typeahead.setPlaceholder(null);
} else {
this._typeahead.setPlaceholder(this._placeholder);
}
var focus = this._focus;
if (focus.value === this._lastvalue && !force) {
return;
}
this._lastvalue = focus.value;
var root = this._root;
var metrics = JX.DOM.textMetrics(
this._focus,
'jx-tokenizer-metrics');
metrics.y = null;
metrics.x += 24;
metrics.setDim(focus);
// NOTE: Once, long ago, we set "focus.value = focus.value;" here to fix
// an issue with copy/paste in Firefox not redrawing correctly. However,
// this breaks input of Japanese glyphs in Chrome, and I can't reproduce
// the original issue in modern Firefox.
//
// If future changes muck around with things here, test that Japanese
// inputs still work. Example:
//
// - Switch to Hiragana mode.
// - Type "ni".
// - This should produce a glyph, not the value "n".
//
// With the assignment, Chrome loses the partial input on the "n" when
// the value is assigned.
},
setPlaceholder : function(string) {
this._placeholder = string;
return this;
},
addToken : function(key, value) {
if (key in this._tokenMap) {
return false;
}
var focus = this._focus;
var root = this._root;
var token = this.buildToken(key, value);
this._tokenMap[key] = {
value : value,
key : key,
node : token
};
this._tokens.push(key);
root.insertBefore(token, focus);
this.invoke('change', this);
return true;
},
removeToken : function(key) {
return this._remove(key, false);
},
buildInput: function(value) {
return JX.$N('input', {
className: 'jx-tokenizer-input',
type: 'text',
autocomplete: 'off',
value: value
});
},
/**
* Generate a token based on a key and value. The "token" and "remove"
* sigils are observed by a listener in start().
*/
buildToken: function(key, value) {
var input = JX.$N('input', {
type: 'hidden',
value: key,
name: this._orig.name + '[' + (this._seq++) + ']'
});
var remove = JX.$N('a', {
className: 'jx-tokenizer-x',
sigil: 'remove'
}, '\u00d7'); // U+00D7 multiplication sign
var display_token = value;
var render_callback = this.getRenderTokenCallback();
if (render_callback) {
display_token = render_callback(value, key);
}
return JX.$N('a', {
className: 'jx-tokenizer-token',
sigil: 'token',
meta: {key: key}
}, [display_token, input, remove]);
},
getTokens : function() {
var result = {};
for (var key in this._tokenMap) {
result[key] = this._tokenMap[key].value;
}
return result;
},
_onkeydown : function(e) {
var focus = this._focus;
var root = this._root;
var raw = e.getRawEvent();
if (raw.ctrlKey || raw.metaKey || raw.altKey) {
return;
}
switch (e.getSpecialKey()) {
case 'tab':
var completed = this._typeahead.submit();
if (!completed) {
this._focus.value = '';
}
break;
case 'delete':
if (!this._focus.value.length) {
var tok;
while ((tok = this._tokens.pop())) {
if (this._remove(tok, true)) {
break;
}
}
}
break;
case 'return':
// Don't subject this to token limits.
break;
default:
if (this.getLimit() &&
JX.keys(this._tokenMap).length == this.getLimit()) {
e.prevent();
}
setTimeout(JX.bind(this, this._redraw), 0);
break;
}
},
_remove : function(index, focus) {
if (!this._tokenMap[index]) {
return false;
}
JX.DOM.remove(this._tokenMap[index].node);
delete this._tokenMap[index];
this._redraw(true);
focus && this.focus();
this.invoke('change', this);
return true;
},
focus : function() {
var focus = this._focus;
JX.DOM.show(focus);
// NOTE: We must fire this focus event immediately (during event
// handling) for the iPhone to bring up the keyboard. Previously this
// focus was wrapped in setTimeout(), but it's unclear why that was
// necessary. If this is adjusted later, make sure tapping the inactive
// area of the tokenizer to focus it on the iPhone still brings up the
// keyboard.
JX.DOM.focus(focus);
},
_didfocus : function() {
JX.DOM.alterClass(
this._containerNode,
'jx-tokenizer-container-focused',
true);
},
_didblur : function() {
JX.DOM.alterClass(
this._containerNode,
'jx-tokenizer-container-focused',
false);
this._focus.value = '';
this._redraw();
}
}
});