[ Index ] |
PHP Cross Reference of Phabricator |
[Summary view] [Print] [Text view]
1 /** 2 * @requires javelin-dom 3 * javelin-util 4 * javelin-stratcom 5 * javelin-install 6 * @provides javelin-tokenizer 7 * @javelin 8 */ 9 10 /** 11 * A tokenizer is a UI component similar to a text input, except that it 12 * allows the user to input a list of items ("tokens"), generally from a fixed 13 * set of results. A familiar example of this UI is the "To:" field of most 14 * email clients, where the control autocompletes addresses from the user's 15 * address book. 16 * 17 * @{JX.Tokenizer} is built on top of @{JX.Typeahead}, and primarily adds the 18 * ability to choose multiple items. 19 * 20 * To build a @{JX.Tokenizer}, you need to do four things: 21 * 22 * 1. Construct it, padding a DOM node for it to attach to. See the constructor 23 * for more information. 24 * 2. Build a {@JX.Typeahead} and configure it with setTypeahead(). 25 * 3. Configure any special options you want. 26 * 4. Call start(). 27 * 28 * If you do this correctly, the input should suggest items and enter them as 29 * tokens as the user types. 30 * 31 * When the tokenizer is focused, the CSS class `jx-tokenizer-container-focused` 32 * is added to the container node. 33 */ 34 JX.install('Tokenizer', { 35 construct : function(containerNode) { 36 this._containerNode = containerNode; 37 }, 38 39 events : [ 40 /** 41 * Emitted when the value of the tokenizer changes, similar to an 'onchange' 42 * from a <select />. 43 */ 44 'change'], 45 46 properties : { 47 limit : null, 48 renderTokenCallback : null 49 }, 50 51 members : { 52 _containerNode : null, 53 _root : null, 54 _focus : null, 55 _orig : null, 56 _typeahead : null, 57 _tokenid : 0, 58 _tokens : null, 59 _tokenMap : null, 60 _initialValue : null, 61 _seq : 0, 62 _lastvalue : null, 63 _placeholder : null, 64 65 start : function() { 66 if (__DEV__) { 67 if (!this._typeahead) { 68 throw new Error( 69 'JX.Tokenizer.start(): ' + 70 'No typeahead configured! Use setTypeahead() to provide a ' + 71 'typeahead.'); 72 } 73 } 74 75 this._orig = JX.DOM.find(this._containerNode, 'input', 'tokenizer-input'); 76 this._tokens = []; 77 this._tokenMap = {}; 78 79 var focus = this.buildInput(this._orig.value); 80 this._focus = focus; 81 82 var input_container = JX.DOM.scry( 83 this._containerNode, 84 'div', 85 'tokenizer-input-container' 86 ); 87 input_container = input_container[0] || this._containerNode; 88 89 JX.DOM.listen( 90 focus, 91 ['click', 'focus', 'blur', 'keydown', 'keypress', 'paste'], 92 null, 93 JX.bind(this, this.handleEvent)); 94 95 // NOTE: Safari on the iPhone does not normally delegate click events on 96 // <div /> tags. This causes the event to fire. We want a click (in this 97 // case, a touch) anywhere in the div to trigger this event so that we 98 // can focus the input. Without this, you must tap an arbitrary area on 99 // the left side of the input to focus it. 100 // 101 // http://www.quirksmode.org/blog/archives/2010/09/click_event_del.html 102 input_container.onclick = JX.bag; 103 104 JX.DOM.listen( 105 input_container, 106 'click', 107 null, 108 JX.bind( 109 this, 110 function(e) { 111 if (e.getNode('remove')) { 112 this._remove(e.getNodeData('token').key, true); 113 } else if (e.getTarget() == this._root) { 114 this.focus(); 115 } 116 })); 117 118 var root = JX.$N('div'); 119 root.id = this._orig.id; 120 JX.DOM.alterClass(root, 'jx-tokenizer', true); 121 root.style.cursor = 'text'; 122 this._root = root; 123 124 root.appendChild(focus); 125 126 var typeahead = this._typeahead; 127 typeahead.setInputNode(this._focus); 128 typeahead.start(); 129 130 setTimeout(JX.bind(this, function() { 131 var container = this._orig.parentNode; 132 JX.DOM.setContent(container, root); 133 var map = this._initialValue || {}; 134 for (var k in map) { 135 this.addToken(k, map[k]); 136 } 137 JX.DOM.appendContent( 138 root, 139 JX.$N('div', {style: {clear: 'both'}}) 140 ); 141 this._redraw(); 142 }), 0); 143 }, 144 145 setInitialValue : function(map) { 146 this._initialValue = map; 147 return this; 148 }, 149 150 setTypeahead : function(typeahead) { 151 152 typeahead.setAllowNullSelection(false); 153 typeahead.removeListener(); 154 155 typeahead.listen( 156 'choose', 157 JX.bind(this, function(result) { 158 JX.Stratcom.context().prevent(); 159 if (this.addToken(result.rel, result.name)) { 160 if (this.shouldHideResultsOnChoose()) { 161 this._typeahead.hide(); 162 } 163 this._typeahead.clear(); 164 this._redraw(); 165 this.focus(); 166 } 167 }) 168 ); 169 170 typeahead.listen( 171 'query', 172 JX.bind( 173 this, 174 function(query) { 175 176 // TODO: We should emit a 'query' event here to allow the caller to 177 // generate tokens on the fly, e.g. email addresses or other freeform 178 // or algorithmic tokens. 179 180 // Then do this if something handles the event. 181 // this._focus.value = ''; 182 // this._redraw(); 183 // this.focus(); 184 185 if (query.length) { 186 // Prevent this event if there's any text, so that we don't submit 187 // the form (either we created a token or we failed to create a 188 // token; in either case we shouldn't submit). If the query is 189 // empty, allow the event so that the form submission takes place. 190 JX.Stratcom.context().prevent(); 191 } 192 })); 193 194 this._typeahead = typeahead; 195 196 return this; 197 }, 198 199 shouldHideResultsOnChoose : function() { 200 return true; 201 }, 202 203 handleEvent : function(e) { 204 this._typeahead.handleEvent(e); 205 if (e.getPrevented()) { 206 return; 207 } 208 209 if (e.getType() == 'click') { 210 if (e.getTarget() == this._root) { 211 this.focus(); 212 e.prevent(); 213 return; 214 } 215 } else if (e.getType() == 'keydown') { 216 this._onkeydown(e); 217 } else if (e.getType() == 'blur') { 218 this._didblur(); 219 220 // Explicitly update the placeholder since we just wiped the field 221 // value. 222 this._typeahead.updatePlaceholder(); 223 } else if (e.getType() == 'focus') { 224 this._didfocus(); 225 } else if (e.getType() == 'paste') { 226 setTimeout(JX.bind(this, this._redraw), 0); 227 } 228 229 }, 230 231 refresh : function() { 232 this._redraw(true); 233 return this; 234 }, 235 236 _redraw : function(force) { 237 238 // If there are tokens in the tokenizer, never show a placeholder. 239 // Otherwise, show one if one is configured. 240 if (JX.keys(this._tokenMap).length) { 241 this._typeahead.setPlaceholder(null); 242 } else { 243 this._typeahead.setPlaceholder(this._placeholder); 244 } 245 246 var focus = this._focus; 247 248 if (focus.value === this._lastvalue && !force) { 249 return; 250 } 251 this._lastvalue = focus.value; 252 253 var root = this._root; 254 var metrics = JX.DOM.textMetrics( 255 this._focus, 256 'jx-tokenizer-metrics'); 257 metrics.y = null; 258 metrics.x += 24; 259 metrics.setDim(focus); 260 261 // NOTE: Once, long ago, we set "focus.value = focus.value;" here to fix 262 // an issue with copy/paste in Firefox not redrawing correctly. However, 263 // this breaks input of Japanese glyphs in Chrome, and I can't reproduce 264 // the original issue in modern Firefox. 265 // 266 // If future changes muck around with things here, test that Japanese 267 // inputs still work. Example: 268 // 269 // - Switch to Hiragana mode. 270 // - Type "ni". 271 // - This should produce a glyph, not the value "n". 272 // 273 // With the assignment, Chrome loses the partial input on the "n" when 274 // the value is assigned. 275 }, 276 277 setPlaceholder : function(string) { 278 this._placeholder = string; 279 return this; 280 }, 281 282 addToken : function(key, value) { 283 if (key in this._tokenMap) { 284 return false; 285 } 286 287 var focus = this._focus; 288 var root = this._root; 289 var token = this.buildToken(key, value); 290 291 this._tokenMap[key] = { 292 value : value, 293 key : key, 294 node : token 295 }; 296 this._tokens.push(key); 297 298 root.insertBefore(token, focus); 299 300 this.invoke('change', this); 301 302 return true; 303 }, 304 305 removeToken : function(key) { 306 return this._remove(key, false); 307 }, 308 309 buildInput: function(value) { 310 return JX.$N('input', { 311 className: 'jx-tokenizer-input', 312 type: 'text', 313 autocomplete: 'off', 314 value: value 315 }); 316 }, 317 318 /** 319 * Generate a token based on a key and value. The "token" and "remove" 320 * sigils are observed by a listener in start(). 321 */ 322 buildToken: function(key, value) { 323 var input = JX.$N('input', { 324 type: 'hidden', 325 value: key, 326 name: this._orig.name + '[' + (this._seq++) + ']' 327 }); 328 329 var remove = JX.$N('a', { 330 className: 'jx-tokenizer-x', 331 sigil: 'remove' 332 }, '\u00d7'); // U+00D7 multiplication sign 333 334 var display_token = value; 335 var render_callback = this.getRenderTokenCallback(); 336 if (render_callback) { 337 display_token = render_callback(value, key); 338 } 339 340 return JX.$N('a', { 341 className: 'jx-tokenizer-token', 342 sigil: 'token', 343 meta: {key: key} 344 }, [display_token, input, remove]); 345 }, 346 347 getTokens : function() { 348 var result = {}; 349 for (var key in this._tokenMap) { 350 result[key] = this._tokenMap[key].value; 351 } 352 return result; 353 }, 354 355 _onkeydown : function(e) { 356 var focus = this._focus; 357 var root = this._root; 358 359 var raw = e.getRawEvent(); 360 if (raw.ctrlKey || raw.metaKey || raw.altKey) { 361 return; 362 } 363 364 switch (e.getSpecialKey()) { 365 case 'tab': 366 var completed = this._typeahead.submit(); 367 if (!completed) { 368 this._focus.value = ''; 369 } 370 break; 371 case 'delete': 372 if (!this._focus.value.length) { 373 var tok; 374 while ((tok = this._tokens.pop())) { 375 if (this._remove(tok, true)) { 376 break; 377 } 378 } 379 } 380 break; 381 case 'return': 382 // Don't subject this to token limits. 383 break; 384 default: 385 if (this.getLimit() && 386 JX.keys(this._tokenMap).length == this.getLimit()) { 387 e.prevent(); 388 } 389 setTimeout(JX.bind(this, this._redraw), 0); 390 break; 391 } 392 }, 393 394 _remove : function(index, focus) { 395 if (!this._tokenMap[index]) { 396 return false; 397 } 398 JX.DOM.remove(this._tokenMap[index].node); 399 delete this._tokenMap[index]; 400 this._redraw(true); 401 focus && this.focus(); 402 403 this.invoke('change', this); 404 405 return true; 406 }, 407 408 focus : function() { 409 var focus = this._focus; 410 JX.DOM.show(focus); 411 412 // NOTE: We must fire this focus event immediately (during event 413 // handling) for the iPhone to bring up the keyboard. Previously this 414 // focus was wrapped in setTimeout(), but it's unclear why that was 415 // necessary. If this is adjusted later, make sure tapping the inactive 416 // area of the tokenizer to focus it on the iPhone still brings up the 417 // keyboard. 418 419 JX.DOM.focus(focus); 420 }, 421 422 _didfocus : function() { 423 JX.DOM.alterClass( 424 this._containerNode, 425 'jx-tokenizer-container-focused', 426 true); 427 }, 428 429 _didblur : function() { 430 JX.DOM.alterClass( 431 this._containerNode, 432 'jx-tokenizer-container-focused', 433 false); 434 this._focus.value = ''; 435 this._redraw(); 436 } 437 438 } 439 });
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
Generated: Sun Nov 30 09:20:46 2014 | Cross-referenced by PHPXref 0.7.1 |