/** @provides javelin-typeahead-dev */
/**
* @requires javelin-install
* javelin-dom
* javelin-vector
* javelin-util
* @provides javelin-typeahead
* @javelin
*/
/**
* A typeahead is a UI component similar to a text input, except that it
* suggests some set of results (like friends' names, common searches, or
* repository paths) as the user types them. Familiar examples of this UI
* include Google Suggest, the Facebook search box, and OS X's Spotlight
* feature.
*
* To build a @{JX.Typeahead}, you need to do four things:
*
* 1. Construct it, passing some DOM nodes for it to attach to. See the
* constructor for more information.
* 2. Attach a datasource by calling setDatasource() with a valid datasource,
* often a @{JX.TypeaheadPreloadedSource}.
* 3. Configure any special options that you want.
* 4. Call start().
*
* If you do this correctly, a dropdown menu should appear under the input as
* the user types, suggesting matching results.
*
* @task build Building a Typeahead
* @task datasource Configuring a Datasource
* @task config Configuring Options
* @task start Activating a Typeahead
* @task control Controlling Typeaheads from Javascript
* @task internal Internal Methods
*/
JX.install('Typeahead', {
/**
* Construct a new Typeahead on some "hardpoint". At a minimum, the hardpoint
* should be a ##
## with "position: relative;" wrapped around a text
* ####. The typeahead's dropdown suggestions will be appended to the
* hardpoint in the DOM. Basically, this is the bare minimum requirement:
*
* LANG=HTML
*
*
*
*
* Then get a reference to the ##
## and pass it as 'hardpoint', and pass
* the #### as 'control'. This will enhance your boring old
* #### with amazing typeahead powers.
*
* On the Facebook/Tools stack, #### can build
* this for you.
*
* @param Node "Hardpoint", basically an anchorpoint in the document which
* the typeahead can append its suggestion menu to.
* @param Node? Actual #### to use; if not provided, the typeahead
* will just look for a (solitary) input inside the hardpoint.
* @task build
*/
construct : function(hardpoint, control) {
this._hardpoint = hardpoint;
this._control = control || JX.DOM.find(hardpoint, 'input');
this._root = JX.$N(
'div',
{className: 'jx-typeahead-results'});
this._display = [];
JX.DOM.listen(
this._control,
['focus', 'blur', 'keypress', 'keydown'],
null,
JX.bind(this, this.handleEvent));
JX.DOM.listen(
this._root,
['mouseover', 'mouseout'],
null,
JX.bind(this, this._onmouse));
JX.DOM.listen(
this._root,
'mousedown',
'tag:a',
JX.bind(this, function(e) {
this._choose(e.getNode('tag:a'));
e.prevent();
}));
},
events : ['choose', 'query', 'start', 'change'],
properties : {
/**
* Boolean. If true (default), the user is permitted to submit the typeahead
* with a custom or empty selection. This is a good behavior if the
* typeahead is attached to something like a search input, where the user
* might type a freeform query or select from a list of suggestions.
* However, sometimes you require a specific input (e.g., choosing which
* user owns something), in which case you can prevent null selections.
*
* @task config
*/
allowNullSelection : true,
/**
* Function. Allows you to reconfigure the Typeahead's normalizer, which is
* @{JX.TypeaheadNormalizer} by default. The normalizer is used to convert
* user input into strings suitable for matching, e.g. by lowercasing all
* input and removing punctuation. See @{JX.TypeaheadNormalizer} for more
* details. Any replacement function should accept an arbitrary user-input
* string and emit a normalized string suitable for tokenization and
* matching.
*
* @task config
*/
normalizer : null
},
members : {
_root : null,
_control : null,
_hardpoint : null,
_value : null,
_stop : false,
_focus : -1,
_display : null,
/**
* Activate your properly configured typeahead. It won't do anything until
* you call this method!
*
* @task start
* @return void
*/
start : function() {
this.invoke('start');
},
/**
* Configure a datasource, which is where the Typeahead gets suggestions
* from. See @{JX.TypeaheadDatasource} for more information. You must
* provide a datasource.
*
* @task datasource
* @param JX.TypeaheadDatasource The datasource which the typeahead will
* draw from.
*/
setDatasource : function(datasource) {
datasource.bindToTypeahead(this);
},
/**
* Override the selected in the constructor with some other input.
* This is primarily useful when building a control on top of the typeahead,
* like @{JX.Tokenizer}.
*
* @task config
* @param node An node to use as the primary control.
*/
setInputNode : function(input) {
this._control = input;
return this;
},
/**
* Hide the typeahead's dropdown suggestion menu.
*
* @task control
* @return void
*/
hide : function() {
this._changeFocus(Number.NEGATIVE_INFINITY);
this._display = [];
this._moused = false;
JX.DOM.setContent(this._root, '');
JX.DOM.remove(this._root);
},
/**
* Show a given result set in the typeahead's dropdown suggestion menu.
* Normally, you only call this method if you are implementing a datasource.
* Otherwise, the datasource you have configured calls it for you in
* response to the user's actions.
*
* @task control
* @param list List of #### tags to show as suggestions/results.
* @return void
*/
showResults : function(results) {
this._display = results;
if (results.length) {
JX.DOM.setContent(this._root, results);
this._changeFocus(Number.NEGATIVE_INFINITY);
var d = JX.$V.getDim(this._hardpoint);
d.x = 0;
d.setPos(this._root);
this._hardpoint.appendChild(this._root);
} else {
this.hide();
}
},
refresh : function() {
if (this._stop) {
return;
}
this._value = this._control.value;
if (!this.invoke('change', this._value).getPrevented()) {
if (__DEV__) {
throw new Error(
"JX.Typeahead._update(): " +
"No listener responded to Typeahead 'change' event. Create a " +
"datasource and call setDatasource().");
}
}
},
/**
* Show a "waiting for results" UI in place of the typeahead's dropdown
* suggestion menu. NOTE: currently there's no such UI, lolol.
*
* @task control
* @return void
*/
waitForResults : function() {
// TODO: Build some sort of fancy spinner or "..." type UI here to
// visually indicate that we're waiting on the server.
this.hide();
},
/**
* @task internal
*/
_onmouse : function(event) {
this._moused = (event.getType() == 'mouseover');
this._drawFocus();
},
/**
* @task internal
*/
_changeFocus : function(d) {
var n = Math.min(Math.max(-1, this._focus + d), this._display.length - 1);
if (!this.getAllowNullSelection()) {
n = Math.max(0, n);
}
if (this._focus >= 0 && this._focus < this._display.length) {
JX.DOM.alterClass(this._display[this._focus], 'focused', 0);
}
this._focus = n;
this._drawFocus();
return true;
},
/**
* @task internal
*/
_drawFocus : function() {
var f = this._display[this._focus];
if (f) {
JX.DOM.alterClass(f, 'focused', !this._moused);
}
},
/**
* @task internal
*/
_choose : function(target) {
var result = this.invoke('choose', target);
if (result.getPrevented()) {
return;
}
this._control.value = target.name;
this.hide();
},
/**
* @task control
*/
clear : function() {
this._control.value = '';
this.hide();
},
/**
* @task control
*/
disable : function() {
this._control.blur();
this._control.disabled = true;
this._stop = true;
},
/**
* @task control
*/
submit : function() {
if (this._focus >= 0 && this._display[this._focus]) {
this._choose(this._display[this._focus]);
return true;
} else {
result = this.invoke('query', this._control.value);
if (result.getPrevented()) {
return true;
}
}
return false;
},
setValue : function(value) {
this._control.value = value;
},
getValue : function() {
return this._control.value;
},
/**
* @task internal
*/
_update : function(event) {
var k = event && event.getSpecialKey();
if (k && event.getType() == 'keydown') {
switch (k) {
case 'up':
if (this._display.length && this._changeFocus(-1)) {
event.prevent();
}
break;
case 'down':
if (this._display.length && this._changeFocus(1)) {
event.prevent();
}
break;
case 'return':
if (this.submit()) {
event.prevent();
return;
}
break;
case 'esc':
if (this._display.length && this.getAllowNullSelection()) {
this.hide();
event.prevent();
}
break;
case 'tab':
// If the user tabs out of the field, don't refresh.
return;
}
}
// We need to defer because the keystroke won't be present in the input's
// value field yet.
JX.defer(JX.bind(this, function() {
if (this._value == this._control.value) {
// The typeahead value hasn't changed.
return;
}
this.refresh();
}));
},
/**
* This method is pretty much internal but @{JX.Tokenizer} needs access to
* it for delegation. You might also need to delegate events here if you
* build some kind of meta-control.
*
* Reacts to user events in accordance to configuration.
*
* @task internal
* @param JX.Event User event, like a click or keypress.
* @return void
*/
handleEvent : function(e) {
if (this._stop || e.getPrevented()) {
return;
}
var type = e.getType();
if (type == 'blur') {
this.hide();
} else {
this._update(e);
}
}
}
});
/**
* @requires javelin-install
* @provides javelin-typeahead-normalizer
* @javelin
*/
JX.install('TypeaheadNormalizer', {
statics : {
normalize : function(str) {
return ('' + str)
.toLowerCase()
.replace(/[^a-z0-9 ]/g, '')
.replace(/ +/g, ' ')
.replace(/^\s*|\s*$/g, '');
}
}
});
/**
* @requires javelin-install
* javelin-util
* javelin-dom
* javelin-typeahead-normalizer
* @provides javelin-typeahead-source
* @javelin
*/
JX.install('TypeaheadSource', {
construct : function() {
this._raw = {};
this._lookup = {};
this.setNormalizer(JX.TypeaheadNormalizer.normalize);
},
properties : {
/**
* Allows you to specify a function which will be used to normalize strings.
* Strings are normalized before being tokenized, and before being sent to
* the server. The purpose of normalization is to strip out irrelevant data,
* like uppercase/lowercase, extra spaces, or punctuation. By default,
* the @{JX.TypeaheadNormalizer} is used to normalize strings, but you may
* want to provide a different normalizer, particiularly if there are
* special characters with semantic meaning in your object names.
*
* @param function
*/
normalizer : null,
/**
* Transformers convert data from a wire format to a runtime format. The
* transformation mechanism allows you to choose an efficient wire format
* and then expand it on the client side, rather than duplicating data
* over the wire. The transformation is applied to objects passed to
* addResult(). It should accept whatever sort of object you ship over the
* wire, and produce a dictionary with these keys:
*
* - **id**: a unique id for each object.
* - **name**: the string used for matching against user input.
* - **uri**: the URI corresponding with the object (must be present
* but need not be meaningful)
* - **display**: the text or nodes to show in the DOM. Usually just the
* same as ##name##.
*
* The default transformer expects a three element list with elements
* [name, uri, id]. It assigns the first element to both ##name## and
* ##display##.
*
* @param function
*/
transformer : null,
/**
* Configures the maximum number of suggestions shown in the typeahead
* dropdown.
*
* @param int
*/
maximumResultCount : 5
},
members : {
_raw : null,
_lookup : null,
_typeahead : null,
_normalizer : null,
bindToTypeahead : function(typeahead) {
this._typeahead = typeahead;
typeahead.listen('change', JX.bind(this, this.didChange));
typeahead.listen('start', JX.bind(this, this.didStart));
},
didChange : function(value) {
return;
},
didStart : function() {
return;
},
addResult : function(obj) {
obj = (this.getTransformer() || this._defaultTransformer)(obj);
if (obj.id in this._raw) {
// We're already aware of this result. This will happen if someone
// searches for "zeb" and then for "zebra" with a
// TypeaheadRequestSource, for example, or the datasource just doesn't
// dedupe things properly. Whatever the case, just ignore it.
return;
}
if (__DEV__) {
for (var k in {name : 1, id : 1, display : 1, uri : 1}) {
if (!(k in obj)) {
throw new Error(
"JX.TypeaheadSource.addResult(): " +
"result must have properties 'name', 'id', 'uri' and 'display'.");
}
}
}
this._raw[obj.id] = obj;
var t = this.tokenize(obj.name);
for (var jj = 0; jj < t.length; ++jj) {
this._lookup[t[jj]] = this._lookup[t[jj]] || [];
this._lookup[t[jj]].push(obj.id);
}
},
waitForResults : function() {
this._typeahead.waitForResults();
return this;
},
matchResults : function(value) {
// This table keeps track of the number of tokens each potential match
// has actually matched. When we're done, the real matches are those
// which have matched every token (so the value is equal to the token
// list length).
var match_count = {};
// This keeps track of distinct matches. If the user searches for
// something like "Chris C" against "Chris Cox", the "C" will match
// both fragments. We need to make sure we only count distinct matches.
var match_fragments = {};
var matched = {};
var seen = {};
var t = this.tokenize(value);
// Sort tokens by longest-first. We match each name fragment with at
// most one token.
t.sort(function(u, v) { return v.length - u.length; });
for (var ii = 0; ii < t.length; ++ii) {
// Do something reasonable if the user types the same token twice; this
// is sort of stupid so maybe kill it?
if (t[ii] in seen) {
t.splice(ii--, 1);
continue;
}
seen[t[ii]] = true;
var fragment = t[ii];
for (var name_fragment in this._lookup) {
if (name_fragment.substr(0, fragment.length) === fragment) {
if (!(name_fragment in matched)) {
matched[name_fragment] = true;
} else {
continue;
}
var l = this._lookup[name_fragment];
for (var jj = 0; jj < l.length; ++jj) {
var match_id = l[jj];
if (!match_fragments[match_id]) {
match_fragments[match_id] = {};
}
if (!(fragment in match_fragments[match_id])) {
match_fragments[match_id][fragment] = true;
match_count[match_id] = (match_count[match_id] || 0) + 1;
}
}
}
}
}
var hits = [];
for (var k in match_count) {
if (match_count[k] == t.length) {
hits.push(k);
}
}
var n = Math.min(this.getMaximumResultCount(), hits.length);
var nodes = [];
for (var kk = 0; kk < n; kk++) {
nodes.push(this.createNode(this._raw[hits[kk]]));
}
this._typeahead.showResults(nodes);
},
createNode : function(data) {
return JX.$N(
'a',
{
href: data.uri,
name: data.name,
rel: data.id,
className: 'jx-result'
},
data.display
);
},
normalize : function(str) {
return (this.getNormalizer() || JX.bag())(str);
},
tokenize : function(str) {
str = this.normalize(str);
if (!str.length) {
return [];
}
return str.split(/ /g);
},
_defaultTransformer : function(object) {
return {
name : object[0],
display : object[0],
uri : object[1],
id : object[2]
};
}
}
});
/**
* @requires javelin-install
* javelin-util
* javelin-stratcom
* javelin-request
* javelin-typeahead-source
* @provides javelin-typeahead-preloaded-source
* @javelin
*/
/**
* Simple datasource that loads all possible results from a single call to a
* URI. This is appropriate if the total data size is small (up to perhaps a
* few thousand items). If you have more items so you can't ship them down to
* the client in one repsonse, use @{JX.TypeaheadOnDemandSource}.
*/
JX.install('TypeaheadPreloadedSource', {
extend : 'TypeaheadSource',
construct : function(uri) {
this.__super__.call(this);
this.uri = uri;
},
members : {
ready : false,
uri : null,
lastValue : null,
didChange : function(value) {
if (this.ready) {
this.matchResults(value);
} else {
this.lastValue = value;
this.waitForResults();
}
JX.Stratcom.context().kill();
},
didStart : function() {
var r = new JX.Request(this.uri, JX.bind(this, this.ondata));
r.setMethod('GET');
r.send();
},
ondata : function(results) {
for (var ii = 0; ii < results.length; ++ii) {
this.addResult(results[ii]);
}
if (this.lastValue !== null) {
this.matchResults(this.lastValue);
}
this.ready = true;
}
}
});
/**
* @requires javelin-install
* javelin-util
* javelin-stratcom
* javelin-request
* javelin-typeahead-source
* @provides javelin-typeahead-ondemand-source
* @javelin
*/
JX.install('TypeaheadOnDemandSource', {
extend : 'TypeaheadSource',
construct : function(uri) {
this.__super__.call(this);
this.uri = uri;
this.haveData = {
'' : true
};
},
properties : {
/**
* Configures how many milliseconds we wait after the user stops typing to
* send a request to the server. Setting a value of 250 means "wait 250
* milliseconds after the user stops typing to request typeahead data".
* Higher values reduce server load but make the typeahead less responsive.
*/
queryDelay : 125,
/**
* Auxiliary data to pass along when sending the query for server results.
*/
auxiliaryData : {}
},
members : {
uri : null,
lastChange : null,
haveData : null,
didChange : function(value) {
if (JX.Stratcom.pass()) {
return;
}
this.lastChange = new Date().getTime();
value = this.normalize(value);
if (this.haveData[value]) {
this.matchResults(value);
} else {
this.waitForResults();
JX.defer(
JX.bind(this, this.sendRequest, this.lastChange, value),
this.getQueryDelay());
}
JX.Stratcom.context().kill();
},
sendRequest : function(when, value) {
if (when != this.lastChange) {
return;
}
var r = new JX.Request(
this.uri,
JX.bind(this, this.ondata, this.lastChange, value));
r.setMethod('GET');
r.setData(JX.copy(this.getAuxiliaryData(), {q : value}));
r.send();
},
ondata : function(when, value, results) {
for (var ii = 0; ii < results.length; ii++) {
this.addResult(results[ii]);
}
this.haveData[value] = true;
if (when != this.lastChange) {
return;
}
this.matchResults(value);
}
}
});
/**
* @requires javelin-typeahead javelin-dom javelin-util
* javelin-stratcom javelin-vector javelin-install
* javelin-typeahead-preloaded-source
* @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.
*/
JX.install('Tokenizer', {
construct : function(containerNode) {
this._containerNode = containerNode;
},
properties : {
limit : null,
nextInput : null
},
members : {
_containerNode : null,
_root : null,
_focus : null,
_orig : null,
_typeahead : null,
_tokenid : 0,
_tokens : null,
_tokenMap : null,
_initialValue : null,
_seq : 0,
_lastvalue : 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');
this._tokens = [];
this._tokenMap = {};
var focus = this.buildInput(this._orig.value);
this._focus = focus;
JX.DOM.listen(
focus,
['click', 'focus', 'blur', 'keydown'],
null,
JX.bind(this, this.handleEvent));
JX.DOM.listen(
this._containerNode,
'click',
null,
JX.bind(
this,
function(e) {
if (e.getNode('remove')) {
this._remove(e.getNodeData('token').key);
} 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);
var typeahead = this._typeahead;
typeahead.setInputNode(this._focus);
typeahead.start();
JX.defer(
JX.bind(
this,
function() {
JX.DOM.setContent(this._orig.parentNode, root);
var map = this._initialValue || {};
for (var k in map) {
this.addToken(k, map[k]);
}
this._redraw();
}));
},
setInitialValue : function(map) {
this._initialValue = map;
return this;
},
setTypeahead : function(typeahead) {
typeahead.setAllowNullSelection(false);
typeahead.listen(
'choose',
JX.bind(
this,
function(result) {
JX.Stratcom.context().prevent();
if (this.addToken(result.rel, result.name)) {
this._typeahead.hide();
this._focus.value = '';
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;
},
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._redraw();
}
},
refresh : function() {
this._redraw(true);
return this;
},
_redraw : function(force) {
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);
// This is a pretty ugly hack to force a redraw after copy/paste in
// Firefox. If we don't do this, it doesn't redraw the input so pasting
// in an email address doesn't give you a very good behavior.
focus.value = focus.value;
var h = JX.$V(focus).add(JX.$V.getDim(focus)).y - JX.$V(root).y;
root.style.height = h + 'px';
},
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);
return true;
},
buildInput: function(value) {
return JX.$N('input', {
className: 'jx-tokenizer-input',
type: 'text',
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'
}, JX.HTML('×'));
return JX.$N('a', {
className: 'jx-tokenizer-token',
sigil: 'token',
meta: {key: key}
}, [value, 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;
switch (e.getSpecialKey()) {
case 'tab':
var completed = this._typeahead.submit();
if (this.getNextInput()) {
if (!completed) {
this._focus.value = '';
}
JX.defer(JX.bind(this, function() {
this.getNextInput().focus();
}));
}
break;
case 'delete':
if (!this._focus.value.length) {
var tok;
while (tok = this._tokens.pop()) {
if (this._remove(tok)) {
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();
}
JX.defer(JX.bind(this, this._redraw));
break;
}
},
_remove : function(index) {
if (!this._tokenMap[index]) {
return false;
}
JX.DOM.remove(this._tokenMap[index].node);
delete this._tokenMap[index];
this._redraw(true);
this.focus();
return true;
},
focus : function() {
var focus = this._focus;
JX.DOM.show(focus);
JX.defer(function() { JX.DOM.focus(focus); });
}
}
});