From 14ed5482ab258ffc011b35ceb8307e925070c10c Mon Sep 17 00:00:00 2001 From: epriestley Date: Tue, 25 Jan 2011 13:48:05 -0800 Subject: [PATCH] Typeaheads --- src/__celerity_resource_map__.php | 43 +- src/__phutil_library_map__.php | 6 + ...AphrontDefaultApplicationConfiguration.php | 5 + .../DifferentialRevisionEditController.php | 20 +- ...abricatorTypeaheadDatasourceController.php | 26 + .../typeahead/controller/base/__init__.php | 12 + ...torTypeaheadCommonDatasourceController.php | 45 + .../typeahead/controller/common/__init__.php | 16 + .../tokenizer/AphrontFormTokenizerControl.php | 106 ++ src/view/form/control/tokenizer/__init__.php | 18 + webroot/rsrc/css/aphront/tokenizer.css | 64 + webroot/rsrc/css/aphront/typeahead.css | 44 + .../js/application/core/behavior-tokenizer.js | 27 + webroot/rsrc/js/javelin/typeahead.dev.js | 1110 +++++++++++++++++ webroot/rsrc/js/javelin/typeahead.min.js | 3 + 15 files changed, 1539 insertions(+), 6 deletions(-) create mode 100644 src/applications/typeahead/controller/base/PhabricatorTypeaheadDatasourceController.php create mode 100644 src/applications/typeahead/controller/base/__init__.php create mode 100644 src/applications/typeahead/controller/common/PhabricatorTypeaheadCommonDatasourceController.php create mode 100644 src/applications/typeahead/controller/common/__init__.php create mode 100755 src/view/form/control/tokenizer/AphrontFormTokenizerControl.php create mode 100644 src/view/form/control/tokenizer/__init__.php create mode 100644 webroot/rsrc/css/aphront/tokenizer.css create mode 100644 webroot/rsrc/css/aphront/typeahead.css create mode 100644 webroot/rsrc/js/application/core/behavior-tokenizer.js create mode 100644 webroot/rsrc/js/javelin/typeahead.dev.js create mode 100644 webroot/rsrc/js/javelin/typeahead.min.js diff --git a/src/__celerity_resource_map__.php b/src/__celerity_resource_map__.php index 07f979a06f..f3f406e8d5 100644 --- a/src/__celerity_resource_map__.php +++ b/src/__celerity_resource_map__.php @@ -17,7 +17,7 @@ celerity_register_resource_map(array( ), 'aphront-form-view-css' => array( - 'path' => '/res/20ebc99b/rsrc/css/aphront/form-view.css', + 'path' => '/res/17285e65/rsrc/css/aphront/form-view.css', 'type' => 'css', 'requires' => array( @@ -47,6 +47,23 @@ celerity_register_resource_map(array( array( ), ), + 'aphront-tokenizer-control-css' => + array( + 'path' => '/res/a3d23074/rsrc/css/aphront/tokenizer.css', + 'type' => 'css', + 'requires' => + array( + 0 => 'aphront-typeahead-control-css', + ), + ), + 'aphront-typeahead-control-css' => + array( + 'path' => '/res/928df9f0/rsrc/css/aphront/typeahead.css', + 'type' => 'css', + 'requires' => + array( + ), + ), 'phabricator-standard-page-view' => array( 'path' => '/res/0eef6905/rsrc/css/application/base/standard-page-view.css', @@ -111,6 +128,14 @@ celerity_register_resource_map(array( array( ), ), + 'javelin-behavior-aphront-basic-tokenizer' => + array( + 'path' => '/res/12de8502/rsrc/js/application/core/behavior-tokenizer.js', + 'type' => 'js', + 'requires' => + array( + ), + ), 'javelin-behavior-differential-populate' => array( 'path' => '/res/b419291a/rsrc/js/application/differential/behavior-populate.js', @@ -151,4 +176,20 @@ celerity_register_resource_map(array( array( ), ), + 'javelin-typeahead-dev' => + array( + 'path' => '/res/c81c0f01/rsrc/js/javelin/typeahead.dev.js', + 'type' => 'js', + 'requires' => + array( + ), + ), + 'javelin-typeahead-prod' => + array( + 'path' => '/res/871c9b0f/rsrc/js/javelin/typeahead.min.js', + 'type' => 'js', + 'requires' => + array( + ), + ), )); diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index 6d9c2ecb28..1d3f88e9d0 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -27,6 +27,7 @@ phutil_register_library_map(array( 'AphrontFormSubmitControl' => 'view/form/control/submit', 'AphrontFormTextAreaControl' => 'view/form/control/textarea', 'AphrontFormTextControl' => 'view/form/control/text', + 'AphrontFormTokenizerControl' => 'view/form/control/tokenizer', 'AphrontFormView' => 'view/form/base', 'AphrontMySQLDatabaseConnection' => 'storage/connection/mysql', 'AphrontNullView' => 'view/null', @@ -122,6 +123,8 @@ phutil_register_library_map(array( 'PhabricatorPeopleListController' => 'applications/people/controller/list', 'PhabricatorPeopleProfileController' => 'applications/people/controller/profile', 'PhabricatorStandardPageView' => 'view/page/standard', + 'PhabricatorTypeaheadCommonDatasourceController' => 'applications/typeahead/controller/common', + 'PhabricatorTypeaheadDatasourceController' => 'applications/typeahead/controller/base', 'PhabricatorUser' => 'applications/people/storage/user', 'PhabricatorUserDAO' => 'applications/people/storage/base', ), @@ -159,6 +162,7 @@ phutil_register_library_map(array( 'AphrontFormSubmitControl' => 'AphrontFormControl', 'AphrontFormTextAreaControl' => 'AphrontFormControl', 'AphrontFormTextControl' => 'AphrontFormControl', + 'AphrontFormTokenizerControl' => 'AphrontFormControl', 'AphrontFormView' => 'AphrontView', 'AphrontMySQLDatabaseConnection' => 'AphrontDatabaseConnection', 'AphrontNullView' => 'AphrontView', @@ -233,6 +237,8 @@ phutil_register_library_map(array( 'PhabricatorPeopleListController' => 'PhabricatorPeopleController', 'PhabricatorPeopleProfileController' => 'PhabricatorPeopleController', 'PhabricatorStandardPageView' => 'AphrontPageView', + 'PhabricatorTypeaheadCommonDatasourceController' => 'PhabricatorTypeaheadDatasourceController', + 'PhabricatorTypeaheadDatasourceController' => 'PhabricatorController', 'PhabricatorUser' => 'PhabricatorUserDAO', 'PhabricatorUserDAO' => 'PhabricatorLiskDAO', ), diff --git a/src/aphront/default/configuration/AphrontDefaultApplicationConfiguration.php b/src/aphront/default/configuration/AphrontDefaultApplicationConfiguration.php index 31d9e89bf6..bab44a6b2e 100644 --- a/src/aphront/default/configuration/AphrontDefaultApplicationConfiguration.php +++ b/src/aphront/default/configuration/AphrontDefaultApplicationConfiguration.php @@ -87,6 +87,11 @@ class AphrontDefaultApplicationConfiguration '(?[a-f0-9]{8})/(?.+\.(?:css|js))$' => 'CelerityResourceController', ), + + '/typeahead/' => array( + 'common/(?\w+)/$' + => 'PhabricatorTypeaheadCommonDatasourceController', + ), ); } diff --git a/src/applications/differential/controller/revisionedit/DifferentialRevisionEditController.php b/src/applications/differential/controller/revisionedit/DifferentialRevisionEditController.php index 2bab3f81c5..1f9a1679dc 100644 --- a/src/applications/differential/controller/revisionedit/DifferentialRevisionEditController.php +++ b/src/applications/differential/controller/revisionedit/DifferentialRevisionEditController.php @@ -63,14 +63,20 @@ class DifferentialRevisionEditController extends DifferentialController { } */ $e_name = true; + $e_testplan = true; $form = new AphrontFormView(); if ($revision->getID()) { - $form->setAction('/differential/revision/edit/'.$category->getID().'/'); + $form->setAction('/differential/revision/edit/'.$revision->getID().'/'); } else { $form->setAction('/differential/revision/edit/'); } + $reviewer_map = array( + 1 => 'A Zebra', + 2 => 'Pie Messenger', + ); + $form ->appendChild( id(new AphrontFormTextAreaControl()) @@ -90,13 +96,17 @@ class DifferentialRevisionEditController extends DifferentialController { ->setValue($revision->getTestPlan()) ->setError($e_testplan)) ->appendChild( - id(new AphrontFormTextAreaControl()) + id(new AphrontFormTokenizerControl()) ->setLabel('Reviewers') - ->setName('reviewers')) + ->setName('reviewers') + ->setDatasource('/typeahead/common/user/') + ->setValue($reviewer_map)) ->appendChild( - id(new AphrontFormTextAreaControl()) + id(new AphrontFormTokenizerControl()) ->setLabel('CC') - ->setName('cc')) + ->setName('cc') + ->setDatasource('/typeahead/common/user/') + ->setValue($reviewer_map)) ->appendChild( id(new AphrontFormTextControl()) ->setLabel('Blame Revision') diff --git a/src/applications/typeahead/controller/base/PhabricatorTypeaheadDatasourceController.php b/src/applications/typeahead/controller/base/PhabricatorTypeaheadDatasourceController.php new file mode 100644 index 0000000000..a58587bb9c --- /dev/null +++ b/src/applications/typeahead/controller/base/PhabricatorTypeaheadDatasourceController.php @@ -0,0 +1,26 @@ +type = $data['type']; + } + + public function processRequest() { + + $data = array(); + + $users = id(new PhabricatorUser())->loadAll(); + + $data = array(); + foreach ($users as $user) { + $data[] = array( + $user->getUsername().' ('.$user->getRealName().')', + '/p/'.$user->getUsername(), + $user->getPHID(), + ); + } + + return id(new AphrontAjaxResponse()) + ->setContent($data); + } + +} diff --git a/src/applications/typeahead/controller/common/__init__.php b/src/applications/typeahead/controller/common/__init__.php new file mode 100644 index 0000000000..34f6ae27c4 --- /dev/null +++ b/src/applications/typeahead/controller/common/__init__.php @@ -0,0 +1,16 @@ +datasource = $datasource; + return $this; + } + + protected function getCustomControlClass() { + return 'aphront-form-control-tokenizer'; + } + + protected function renderInput() { + require_celerity_resource('aphront-tokenizer-control-css'); + require_celerity_resource('javelin-typeahead-dev'); + + $tokens = array(); + $values = nonempty($this->getValue(), array()); + foreach ($values as $key => $value) { + $tokens[] = $this->renderToken($key, $value); + } + + $name = $this->getName(); + + $input = javelin_render_tag( + 'input', + array( + 'mustcapture' => true, + 'name' => $name, + 'class' => 'jx-tokenizer-input', + 'sigil' => 'tokenizer', + 'style' => 'width: 0px;', + 'disabled' => 'disabled', + 'type' => 'text', + )); + + $id = celerity_generate_unique_node_id(); + + Javelin::initBehavior('aphront-basic-tokenizer', array( + 'id' => $id, + 'src' => $this->datasource, + 'value' => $values, + )); + + return phutil_render_tag( + 'div', + array( + 'id' => $id, + 'class' => 'jx-tokenizer-container', + ), + implode('', $tokens). + $input. + '
'); + + return phutil_render_tag( + 'input', + array( + 'type' => 'text', + 'name' => $this->getName(), + 'value' => $this->getValue(), + 'disabled' => $this->getDisabled() ? 'disabled' : null, + )); + } + + private function renderToken($key, $value) { + $input_name = $this->getName(); + if ($input_name) { + $input_name .= '[]'; + } + return phutil_render_tag( + 'a', + array( + 'class' => 'jx-tokenizer-token', + ), + phutil_escape_html($value). + phutil_render_tag( + 'input', + array( + 'type' => 'hidden', + 'name' => $input_name, + 'value' => $key, + )). + ''); + } + + +} diff --git a/src/view/form/control/tokenizer/__init__.php b/src/view/form/control/tokenizer/__init__.php new file mode 100644 index 0000000000..85ffaeed29 --- /dev/null +++ b/src/view/form/control/tokenizer/__init__.php @@ -0,0 +1,18 @@ +## 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.getTarget()); + 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++) { + var data = this._raw[hits[kk]]; + nodes.push(JX.$N( + 'a', + { + href: data.uri, + name: data.name, + rel: data.id, + className: 'jx-result' + }, + data.display)); + } + + this._typeahead.showResults(nodes); + }, + 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 = JX.$N('input', { + className: 'jx-tokenizer-input', + type: 'text', + value: 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.getNodes().remove) { + this._remove(e.getData().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 = JX.$N('a', { + className: 'jx-tokenizer-token' + }, 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' + }, JX.HTML('×')); + + this._tokenMap[key] = { + value : value, + key : key, + node : token + }; + this._tokens.push(key); + + JX.Stratcom.sigilize(token, 'token', {key : key}); + JX.Stratcom.sigilize(remove, 'remove'); + + token.appendChild(input); + token.appendChild(remove); + + root.insertBefore(token, focus); + + return true; + }, + + 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); }); + } + } +}); diff --git a/webroot/rsrc/js/javelin/typeahead.min.js b/webroot/rsrc/js/javelin/typeahead.min.js new file mode 100644 index 0000000000..07ab5a6f22 --- /dev/null +++ b/webroot/rsrc/js/javelin/typeahead.min.js @@ -0,0 +1,3 @@ +/** @provides javelin-typeahead-prod */ + +JX.install('Typeahead',{construct:function(b,a){this._a=b;this._b=a||JX.DOM.find(b,'input');this._c=JX.$N('div',{className:'jx-typeahead-results'});this._d=[];JX.DOM.listen(this._b,['focus','blur','keypress','keydown'],null,JX.bind(this,this.handleEvent));JX.DOM.listen(this._c,['mouseover','mouseout'],null,JX.bind(this,this._e));JX.DOM.listen(this._c,'mousedown','tag:a',JX.bind(this,function(c){this._f(c.getTarget());c.prevent();}));},events:['choose','query','start','change'],properties:{allowNullSelection:true,normalizer:null},members:{_c:null,_b:null,_a:null,_g:null,_h:false,_i:-1,_d:null,start:function(){this.invoke('start');},setDatasource:function(a){a.bindToTypeahead(this);},setInputNode:function(a){this._b=a;return this;},hide:function(){this._j(Number.NEGATIVE_INFINITY);this._d=[];this._k=false;JX.DOM.setContent(this._c,'');JX.DOM.remove(this._c);},showResults:function(b){this._d=b;if(b.length){JX.DOM.setContent(this._c,b);this._j(Number.NEGATIVE_INFINITY);var a=JX.$V.getDim(this._a);a.x=0;a.setPos(this._c);this._a.appendChild(this._c);}else this.hide();},refresh:function(){if(this._h)return;this._g=this._b.value;!this.invoke('change',this._g).getPrevented();},waitForResults:function(){this.hide();},_e:function(event){this._k=(event.getType()=='mouseover');this._l();},_j:function(a){var b=Math.min(Math.max(-1,this._i+a),this._d.length-1);if(!this.getAllowNullSelection())b=Math.max(0,b);if(this._i>=0&&this._i=0&&this._d[this._i]){this._f(this._d[this._i]);return true;}else{result=this.invoke('query',this._b.value);if(result.getPrevented())return true;}return false;},setValue:function(a){this._b.value=a;},getValue:function(){return this._b.value;},_m:function(event){var a=event&&event.getSpecialKey();if(a&&event.getType()=='keydown')switch(a){case 'up':if(this._d.length&&this._j(-1))event.prevent();break;case 'down':if(this._d.length&&this._j(1))event.prevent();break;case 'return':if(this.submit()){event.prevent();return;}break;case 'esc':if(this._d.length&&this.getAllowNullSelection()){this.hide();event.prevent();}break;case 'tab':return;}JX.defer(JX.bind(this,function(){if(this._g==this._b.value)return;this.refresh();}));},handleEvent:function(a){if(this._h||a.getPrevented())return;var b=a.getType();if(b=='blur'){this.hide();}else this._m(a);}}});JX.install('TypeaheadNormalizer',{statics:{normalize:function(a){return (''+a).toLowerCase().replace(/[^a-z0-9 ]/g,'').replace(/ +/g,' ').replace(/^\s*|\s*$/g,'');}}});JX.install('TypeaheadSource',{construct:function(){this._n={};this._o={};this.setNormalizer(JX.TypeaheadNormalizer.normalize);},properties:{normalizer:null,transformer:null,maximumResultCount:5},members:{_n:null,_o:null,_p:null,_q:null,bindToTypeahead:function(a){this._p=a;a.listen('change',JX.bind(this,this.didChange));a.listen('start',JX.bind(this,this.didStart));},didChange:function(a){return;},didStart:function(){return;},addResult:function(b){b=(this.getTransformer()||this._r)(b);if(b.id in this._n)return;this._n[b.id]=b;var c=this.tokenize(b.name);for(var a=0;a