diff --git a/src/lib/control/typeahead/Typeahead.js b/src/lib/control/typeahead/Typeahead.js
index a641532..340d272 100644
--- a/src/lib/control/typeahead/Typeahead.js
+++ b/src/lib/control/typeahead/Typeahead.js
@@ -1,412 +1,423 @@
/**
* @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 = [];
this._listener = 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', 'show'],
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,
_listener : null,
_value : null,
_stop : false,
_focus : -1,
_display : null,
+ _datasource : 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');
+ if (__DEV__) {
+ if (!this._datasource) {
+ throw new Error(
+ "JX.Typeahead.start(): " +
+ "No datasource configured. Create a datasource and call " +
+ "setDatasource().");
+ }
+ }
},
/**
* Configure a datasource, which is where the Typeahead gets suggestions
* from. See @{JX.TypeaheadDatasource} for more information. You must
- * provide a datasource.
+ * provide exactly one datasource.
*
* @task datasource
* @param JX.TypeaheadDatasource The datasource which the typeahead will
* draw from.
*/
setDatasource : function(datasource) {
+ if (__DEV__) {
+ if (this._datasource) {
+ throw new Error(
+ "JX.Typeahead.setDatasource(): " +
+ "Typeahead already has a datasource.");
+ }
+ }
+ datasource.listen('waiting', JX.bind(this, this.waitForResults));
+ datasource.listen('resultsready', JX.bind(this, this.showResults));
datasource.bindToTypeahead(this);
+ this._datasource = datasource;
},
-
/**
* 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.
+ * Normally, you don't call this method directly. Usually it gets called
+ * in response to events from the datasource you have configured.
*
* @task control
* @param list List of #### tags to show as suggestions/results.
* @return void
*/
showResults : function(results) {
var obj = {show: results};
var e = this.invoke('show', obj);
this._display = obj.show;
if (this._display.length && !e.getPrevented()) {
JX.DOM.setContent(this._root, this._display);
this._changeFocus(Number.NEGATIVE_INFINITY);
var d = JX.Vector.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().");
- }
- }
+ this.invoke('change', this._value);
},
/**
* 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.
+ // Wait on the datasource 'complete' event for hiding the spinner.
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);
}
},
removeListener : function() {
if (this._listener) {
this._listener.remove();
}
}
}
});
diff --git a/src/lib/control/typeahead/source/TypeaheadCompositeSource.js b/src/lib/control/typeahead/source/TypeaheadCompositeSource.js
new file mode 100644
index 0000000..a026e75
--- /dev/null
+++ b/src/lib/control/typeahead/source/TypeaheadCompositeSource.js
@@ -0,0 +1,75 @@
+/**
+ * @requires javelin-install
+ * javelin-typeahead-source
+ * javelin-util
+ * @provides javelin-typeahead-composite-source
+ * @javelin
+ */
+
+JX.install('TypeaheadCompositeSource', {
+
+ extend : 'TypeaheadSource',
+
+ construct : function(sources) {
+ JX.TypeaheadSource.call(this);
+ this.sources = sources;
+
+ for (var ii = 0; ii < this.sources.length; ++ii) {
+ var child = this.sources[ii];
+ child.listen('waiting', JX.bind(this, this.childWaiting));
+ child.listen('resultsready', JX.bind(this, this.childResultsReady));
+ child.listen('complete', JX.bind(this, this.childComplete));
+ }
+ },
+
+ members : {
+ sources : null,
+ results : null,
+ completeCount : 0,
+
+ didChange : function(value) {
+ this.results = [];
+ this.completeCount = 0;
+ for (var ii = 0; ii < this.sources.length; ++ii) {
+ this.sources[ii].didChange(value);
+ }
+ },
+
+ didStart : function() {
+ for (var ii = 0; ii < this.sources.length; ++ii) {
+ this.sources[ii].didStart();
+ }
+ },
+
+ childWaiting : function() {
+ if (!this.results.length) {
+ this.invoke('waiting');
+ }
+ },
+
+ childResultsReady : function(nodes) {
+ this.results = this.mergeResults(this.results, nodes);
+ this.invoke('resultsready', this.results);
+ },
+
+ childComplete : function() {
+ this.completeCount++;
+ if (this.completeCount == this.sources.length) {
+ this.invoke('complete');
+ }
+ },
+
+ /**
+ * Overrideable strategy for combining results.
+ * By default, appends results as they come in
+ * so that results don't jump around.
+ */
+ mergeResults : function(oldResults, newResults) {
+ for (var ii = 0; ii < newResults.length; ++ii) {
+ oldResults.push(newResults[ii]);
+ }
+ return oldResults;
+ }
+ }
+});
+
diff --git a/src/lib/control/typeahead/source/TypeaheadOnDemandSource.js b/src/lib/control/typeahead/source/TypeaheadOnDemandSource.js
index c5ae497..bf2ad8c 100644
--- a/src/lib/control/typeahead/source/TypeaheadOnDemandSource.js
+++ b/src/lib/control/typeahead/source/TypeaheadOnDemandSource.js
@@ -1,88 +1,83 @@
/**
* @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) {
JX.TypeaheadSource.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) {
if (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);
}
}
});
diff --git a/src/lib/control/typeahead/source/TypeaheadPreloadedSource.js b/src/lib/control/typeahead/source/TypeaheadPreloadedSource.js
index 4177863..126531f 100644
--- a/src/lib/control/typeahead/source/TypeaheadPreloadedSource.js
+++ b/src/lib/control/typeahead/source/TypeaheadPreloadedSource.js
@@ -1,61 +1,60 @@
/**
* @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) {
JX.TypeaheadSource.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;
}
}
});
diff --git a/src/lib/control/typeahead/source/TypeaheadSource.js b/src/lib/control/typeahead/source/TypeaheadSource.js
index 08a4fe1..4ac2795 100644
--- a/src/lib/control/typeahead/source/TypeaheadSource.js
+++ b/src/lib/control/typeahead/source/TypeaheadSource.js
@@ -1,230 +1,232 @@
/**
* @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);
},
+ events : ['waiting', 'resultsready', 'complete'],
+
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;
},
clearCache : function() {
this._raw = {};
this._lookup = {};
},
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();
+ this.invoke('waiting');
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);
}
}
- this._typeahead.showResults(this.renderNodes(value, hits));
+ var nodes = this.renderNodes(value, hits);
+ this.invoke('resultsready', nodes);
+ this.invoke('complete');
},
renderNodes : function(value, hits) {
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]]));
}
return 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]
};
}
}
});