[ Index ]

PHP Cross Reference of Phabricator

title

Body

[close]

/webroot/rsrc/externals/javelin/lib/control/tokenizer/ -> Tokenizer.js (source)

   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  });


Generated: Sun Nov 30 09:20:46 2014 Cross-referenced by PHPXref 0.7.1