Path: blob/master/webroot/rsrc/externals/javelin/lib/control/tokenizer/Tokenizer.js
12242 views
/**1* @requires javelin-dom2* javelin-util3* javelin-stratcom4* javelin-install5* @provides javelin-tokenizer6* @javelin7*/89/**10* A tokenizer is a UI component similar to a text input, except that it11* allows the user to input a list of items ("tokens"), generally from a fixed12* set of results. A familiar example of this UI is the "To:" field of most13* email clients, where the control autocompletes addresses from the user's14* address book.15*16* @{JX.Tokenizer} is built on top of @{JX.Typeahead}, and primarily adds the17* ability to choose multiple items.18*19* To build a @{JX.Tokenizer}, you need to do four things:20*21* 1. Construct it, padding a DOM node for it to attach to. See the constructor22* for more information.23* 2. Build a {@JX.Typeahead} and configure it with setTypeahead().24* 3. Configure any special options you want.25* 4. Call start().26*27* If you do this correctly, the input should suggest items and enter them as28* tokens as the user types.29*30* When the tokenizer is focused, the CSS class `jx-tokenizer-container-focused`31* is added to the container node.32*/33JX.install('Tokenizer', {34construct : function(containerNode) {35this._containerNode = containerNode;36},3738events : [39/**40* Emitted when the value of the tokenizer changes, similar to an 'onchange'41* from a <select />.42*/43'change'],4445properties : {46limit : null,47renderTokenCallback : null,48browseURI: null,49disabled: false50},5152members : {53_containerNode : null,54_root : null,55_frame: null,56_focus : null,57_orig : null,58_typeahead : null,59_tokenid : 0,60_tokens : null,61_tokenMap : null,62_initialValue : null,63_seq : 0,64_lastvalue : null,65_placeholder : null,6667start : function() {68if (this.getDisabled()) {69JX.DOM.alterClass(this._containerNode, 'disabled-control', true);70return;71}7273if (__DEV__) {74if (!this._typeahead) {75throw new Error(76'JX.Tokenizer.start(): ' +77'No typeahead configured! Use setTypeahead() to provide a ' +78'typeahead.');79}80}8182this._orig = JX.DOM.find(this._containerNode, 'input', 'tokenizer-input');83this._tokens = [];84this._tokenMap = {};8586try {87this._frame = JX.DOM.findAbove(this._orig, 'div', 'tokenizer-frame');88} catch (e) {89// Ignore, this tokenizer doesn't have a frame.90}9192if (this._frame) {93JX.DOM.alterClass(this._frame, 'has-browse', !!this.getBrowseURI());94JX.DOM.listen(95this._frame,96'click',97'tokenizer-browse',98JX.bind(this, this._onbrowse));99}100101var focus = this.buildInput(this._orig.value);102this._focus = focus;103104var input_container = JX.DOM.scry(105this._containerNode,106'div',107'tokenizer-input-container'108);109input_container = input_container[0] || this._containerNode;110111JX.DOM.listen(112focus,113['click', 'focus', 'blur', 'keydown', 'keypress', 'paste'],114null,115JX.bind(this, this.handleEvent));116117// NOTE: Safari on the iPhone does not normally delegate click events on118// <div /> tags. This causes the event to fire. We want a click (in this119// case, a touch) anywhere in the div to trigger this event so that we120// can focus the input. Without this, you must tap an arbitrary area on121// the left side of the input to focus it.122//123// http://www.quirksmode.org/blog/archives/2010/09/click_event_del.html124input_container.onclick = JX.bag;125126JX.DOM.listen(127input_container,128'click',129null,130JX.bind(131this,132function(e) {133if (e.getNode('remove')) {134this._remove(e.getNodeData('token').key, true);135} else if (e.getTarget() == this._root) {136this.focus();137}138}));139140var root = JX.$N('div');141root.id = this._orig.id;142JX.DOM.alterClass(root, 'jx-tokenizer', true);143root.style.cursor = 'text';144this._root = root;145146root.appendChild(focus);147148var typeahead = this._typeahead;149typeahead.setInputNode(this._focus);150typeahead.start();151152setTimeout(JX.bind(this, function() {153var container = this._orig.parentNode;154JX.DOM.setContent(container, root);155var map = this._initialValue || {};156for (var k in map) {157this.addToken(k, map[k]);158}159JX.DOM.appendContent(160root,161JX.$N('div', {style: {clear: 'both'}})162);163this._redraw();164}), 0);165},166167setInitialValue : function(map) {168this._initialValue = map;169return this;170},171172setTypeahead : function(typeahead) {173174typeahead.setAllowNullSelection(false);175typeahead.removeListener();176177typeahead.listen(178'choose',179JX.bind(this, function(result) {180JX.Stratcom.context().prevent();181if (this.addToken(result.rel, result.name)) {182if (this.shouldHideResultsOnChoose()) {183this._typeahead.hide();184}185this._typeahead.clear();186this._redraw();187this.focus();188}189})190);191192typeahead.listen(193'query',194JX.bind(195this,196function(query) {197198// TODO: We should emit a 'query' event here to allow the caller to199// generate tokens on the fly, e.g. email addresses or other freeform200// or algorithmic tokens.201202// Then do this if something handles the event.203// this._focus.value = '';204// this._redraw();205// this.focus();206207if (query.length) {208// Prevent this event if there's any text, so that we don't submit209// the form (either we created a token or we failed to create a210// token; in either case we shouldn't submit). If the query is211// empty, allow the event so that the form submission takes place.212JX.Stratcom.context().prevent();213}214}));215216this._typeahead = typeahead;217218return this;219},220221shouldHideResultsOnChoose : function() {222return true;223},224225handleEvent : function(e) {226this._typeahead.handleEvent(e);227if (e.getPrevented()) {228return;229}230231if (e.getType() == 'click') {232if (e.getTarget() == this._root) {233this.focus();234e.prevent();235return;236}237} else if (e.getType() == 'keydown') {238this._onkeydown(e);239} else if (e.getType() == 'blur') {240this._didblur();241242// Explicitly update the placeholder since we just wiped the field243// value.244this._typeahead.updatePlaceholder();245} else if (e.getType() == 'focus') {246this._didfocus();247} else if (e.getType() == 'paste') {248setTimeout(JX.bind(this, this._redraw), 0);249}250251},252253refresh : function() {254this._redraw(true);255return this;256},257258_redraw : function(force) {259260// If there are tokens in the tokenizer, never show a placeholder.261// Otherwise, show one if one is configured.262if (JX.keys(this._tokenMap).length) {263this._typeahead.setPlaceholder(null);264} else {265this._typeahead.setPlaceholder(this._placeholder);266}267268var focus = this._focus;269270if (focus.value === this._lastvalue && !force) {271return;272}273this._lastvalue = focus.value;274275var metrics = JX.DOM.textMetrics(276this._focus,277'jx-tokenizer-metrics');278metrics.y = null;279metrics.x += 24;280metrics.setDim(focus);281282// NOTE: Once, long ago, we set "focus.value = focus.value;" here to fix283// an issue with copy/paste in Firefox not redrawing correctly. However,284// this breaks input of Japanese glyphs in Chrome, and I can't reproduce285// the original issue in modern Firefox.286//287// If future changes muck around with things here, test that Japanese288// inputs still work. Example:289//290// - Switch to Hiragana mode.291// - Type "ni".292// - This should produce a glyph, not the value "n".293//294// With the assignment, Chrome loses the partial input on the "n" when295// the value is assigned.296},297298setPlaceholder : function(string) {299this._placeholder = string;300return this;301},302303addToken : function(key, value) {304if (key in this._tokenMap) {305return false;306}307308var focus = this._focus;309var root = this._root;310var token = this.buildToken(key, value);311312this._tokenMap[key] = {313value : value,314key : key,315node : token316};317this._tokens.push(key);318319root.insertBefore(token, focus);320321this._didChangeValue();322323return true;324},325326removeToken : function(key) {327return this._remove(key, false);328},329330buildInput: function(value) {331return JX.$N('input', {332className: 'jx-tokenizer-input',333type: 'text',334autocomplete: 'off',335value: value336});337},338339/**340* Generate a token based on a key and value. The "token" and "remove"341* sigils are observed by a listener in start().342*/343buildToken: function(key, value) {344var input = JX.$N('input', {345type: 'hidden',346value: key,347name: this._orig.name + '[' + (this._seq++) + ']'348});349350var remove = JX.$N('a', {351className: 'jx-tokenizer-x',352sigil: 'remove'353}, '\u00d7'); // U+00D7 multiplication sign354355var display_token = value;356357var attrs = {358className: 'jx-tokenizer-token',359sigil: 'token',360meta: {key: key}361};362var container = JX.$N('a', attrs);363364var render_callback = this.getRenderTokenCallback();365if (render_callback) {366display_token = render_callback(value, key, container);367}368369JX.DOM.setContent(container, [display_token, input, remove]);370371return container;372},373374getTokens : function() {375var result = {};376for (var key in this._tokenMap) {377result[key] = this._tokenMap[key].value;378}379return result;380},381382_onkeydown : function(e) {383var raw = e.getRawEvent();384if (raw.ctrlKey || raw.metaKey || raw.altKey) {385return;386}387388switch (e.getSpecialKey()) {389case 'tab':390var completed = this._typeahead.submit();391if (!completed) {392this._focus.value = '';393}394break;395case 'delete':396if (!this._focus.value.length) {397// In unusual cases, it's possible for us to end up with a token398// that has the empty string ("") as a value. Support removal of399// this unusual token.400401var tok;402while (this._tokens.length) {403tok = this._tokens.pop();404if (this._remove(tok, true)) {405break;406}407}408}409break;410case 'return':411// Don't subject this to token limits.412break;413default:414if (this.getLimit() &&415JX.keys(this._tokenMap).length == this.getLimit()) {416e.prevent();417}418setTimeout(JX.bind(this, this._redraw), 0);419break;420}421},422423_remove : function(index, focus) {424if (!this._tokenMap[index]) {425return false;426}427JX.DOM.remove(this._tokenMap[index].node);428delete this._tokenMap[index];429this._redraw(true);430focus && this.focus();431432this._didChangeValue();433434return true;435},436437_didChangeValue: function() {438439if (this.getBrowseURI()) {440var button = JX.DOM.find(this._frame, 'a', 'tokenizer-browse');441JX.DOM.alterClass(button, 'disabled', !!this._shouldLockBrowse());442}443444this.invoke('change', this);445},446447_shouldLockBrowse: function() {448var limit = this.getLimit();449450if (!limit) {451// If there's no limit, never lock the browse button.452return false;453}454455if (limit == 1) {456// If the limit is 1, we'll replace the current token if the457// user selects a new one, so we never need to lock the button.458return false;459}460461if (limit > JX.keys(this.getTokens()).length) {462return false;463}464465return true;466},467468focus : function() {469var focus = this._focus;470JX.DOM.show(focus);471472// NOTE: We must fire this focus event immediately (during event473// handling) for the iPhone to bring up the keyboard. Previously this474// focus was wrapped in setTimeout(), but it's unclear why that was475// necessary. If this is adjusted later, make sure tapping the inactive476// area of the tokenizer to focus it on the iPhone still brings up the477// keyboard.478479JX.DOM.focus(focus);480},481482_didfocus : function() {483JX.DOM.alterClass(484this._containerNode,485'jx-tokenizer-container-focused',486true);487},488489_didblur : function() {490JX.DOM.alterClass(491this._containerNode,492'jx-tokenizer-container-focused',493false);494this._focus.value = '';495this._redraw();496},497498_onbrowse: function(e) {499e.kill();500501var uri = this.getBrowseURI();502if (!uri) {503return;504}505506if (this._shouldLockBrowse()) {507return;508}509510new JX.Workflow(uri, {exclude: JX.keys(this.getTokens()).join(',')})511.setHandler(512JX.bind(this, function(r) {513var source = this._typeahead.getDatasource();514515source.addResult(r.token);516var result = source.getResult(r.key);517518// If we have a limit of 1 token, replace the current token with519// the new token if we currently have a token.520if (this.getLimit() == 1) {521for (var k in this.getTokens()) {522this.removeToken(k);523}524}525526this.addToken(r.key, result.name);527this.focus();528}))529.start();530}531532}533});534535536