Yahoo! UI Library

node-tokeninput  3.3.0pr1

Yahoo! UI Library > node-tokeninput > node-tokeninput.js (source view)
Search:
 
Filters
/**
 * Node plugin that turns a text input field into a tokenized input field
 * similar to Cocoa's NSTokenField control.
 *
 * @module node-tokeninput
 * @since 3.3.0
 * @namespace Plugin
 * @class TokenInput
 * @constructor
 * @param {Object} config Configuration object.
 */

var doc = Y.config.doc,

    Lang   = Y.Lang,
    Node   = Y.Node,
    YArray = Y.Array,

    getClassName = Y.bind(Y.ClassNameManager.getClassName, null, 'tokeninput'),

    // keyCode constants.
    KEY_BACKSPACE = 8,
    KEY_DELETE    = 46,
    KEY_DOWN      = 40,
    KEY_ENTER     = 13,
    KEY_LEFT      = 37,
    KEY_RIGHT     = 39,
    KEY_UP        = 38,

    EMPTY_OBJECT = {},

    // String shorthand.
    DELIMITER = 'delimiter',
    TOKENS    = 'tokens',
    VALUE     = 'value';

function TokenInput() {
    TokenInput.superclass.constructor.apply(this, arguments);
}

Y.extend(TokenInput, Y.Plugin.Base, {
    // -- Prototype Properties -------------------------------------------------
    BOX_TEMPLATE    : '<div/>',
    CONTENT_TEMPLATE: '<div/>',
    INPUT_TEMPLATE  : '<input type="text" autocomplete="off">',
    ITEM_TEMPLATE   : '<li/>',
    LIST_TEMPLATE   : '<ul/>',
    REMOVE_TEMPLATE : '<a href="#" title="Remove"><span role="img">\u00D7</span></a>',

    // -- Lifecycle Methods ----------------------------------------------------
    initializer: function (config) {
        var keys      = {},
            selectors = {},
            initialTokens;

        Y.Object.each(TokenInput.CLASS_NAMES, function (className, name) {
            selectors[name] = '.' + className;
        }, this);

        keys[KEY_BACKSPACE] = this._keyBackspace;
        keys[KEY_DELETE]    = this._keyDelete;
        keys[KEY_DOWN]      = this._keyDown;
        keys[KEY_ENTER]     = this._keyEnter;
        keys[KEY_LEFT]      = this._keyLeft;
        keys[KEY_RIGHT]     = this._keyRight;
        keys[KEY_UP]        = this._keyUp;

        this._host      = this.get('host');
        this._keys      = keys;
        this._selectors = selectors;

        initialTokens = this._tokenizeValue(this._host, null, {
            all     : true,
            updateUI: false
        });

        if (initialTokens) {
            this.set(TOKENS, this.get(TOKENS).concat(initialTokens));
        }

        this._render();
        this._bind();
        this._sync();
    },

    destructor: function () {
        var events = this._events;

        while (events && events.length) {
            events.pop().detach();
        }
    },

    // -- Public Prototype Methods ---------------------------------------------

    /**
     * Adds one or more tokens at the specified index, or at the end of the
     * token list if no index is specified.
     *
     * @method add
     * @param {Array|String} newTokens Token string or array of token strings.
     * @param {Number} index (optional) 0-based index at which to add the token.
     * @chainable
     */
    add: function (newTokens, index) {
        var addTokens = [],
            items     = [],
            tokens    = this.get(TOKENS);

        newTokens = Lang.isArray(newTokens) ? newTokens :
                newTokens.split(this.get(DELIMITER));

        YArray.each(newTokens, function (token, i) {
            token = Lang.trim(token);

            if (token) {
                addTokens.push(token);

                items.push(this._createItem({
                    text : token,
                    token: true
                }));
            }
        }, this);

        if (items.length && addTokens.length) {
            items = Y.all(items).toFrag();

            if ((index || index === 0) && index < tokens.length) {
                tokens = tokens.concat();
                tokens.splice.apply(tokens, [index, 0].concat(addTokens));
                this._tokenNodes.item(index).insert(items, 'before');
            } else {
                tokens = tokens.concat(addTokens);
                this._inputItem.insert(items, 'before');
            }

            this._tokenNodes.refresh();
            this.set(TOKENS, tokens, {atomic: true});
        }

        return this;
    },

    /**
     * Removes all tokens.
     *
     * @method clear
     * @chainable
     */
    clear: function () {
        this._tokenNodes.remove(true);
        this._tokenNodes.refresh();

        return this.set(TOKENS, [], {atomic: true});
    },

    /**
     * Removes the token at the specified index.
     *
     * @method remove
     * @param {Number} index 0-based index of the token to remove.
     * @chainable
     */
    remove: function (index) {
        var tokens = this.get(TOKENS);

        tokens.splice(index, 1);

        this._tokenNodes.item(index).remove(true);
        this._tokenNodes.refresh();

        return this.set(TOKENS, tokens, {atomic: true});
    },

    // -- Protected Prototype Methods ------------------------------------------

    /**
     * Binds token input events.
     *
     * @method _bind
     * @protected
     */
    _bind: function () {
        var list      = this._list,
            selectors = this._selectors;

        if (!this._events) {
            this._events = [];
        }

        this._events.concat([
            this._boundingBox.after({
                blur : this._afterBlur,
                click: this._afterFocus, // for FF4, which is buggy
                focus: this._afterFocus
            }, null, this),

            list.delegate({
                blur     : this._onTokenBlur,
                focus    : this._onTokenFocus,
                mouseover: this._onTokenMouseOver,
                mouseout : this._onTokenMouseOut
            }, selectors.token, this),

            list.delegate(Y.UA.gecko ? 'keypress' : 'keydown', this._onKey,
                    selectors.input + ',' + selectors.token, this),

            list.delegate('click', this._onRemoveClick, selectors.remove, this),

            this.after({
                fauxInputChange   : this._afterFauxInputChange,
                removeButtonChange: this._afterRemoveButtonChange,
                tokensChange      : this._afterTokensChange
            })
        ]);
    },

    /**
     * Removes and purges all items from the list, including the input field.
     *
     * @method _clearItems
     * @protected
     */
    _clearItems: function () {
        this._list.all(this._selectors.item).remove(true);
    },

    /**
     * Creates and returns a new token list item.
     *
     * @method _createItem
     * @param {Object} options (optional) Item options.
     * @return {Node} New item.
     * @protected
     */
    _createItem: function (options) {
        var classNames = TokenInput.CLASS_NAMES,
            item       = Node.create(this.ITEM_TEMPLATE),
            input;

        if (!options) {
            options = EMPTY_OBJECT;
        }

        item.addClass(classNames.item);

        YArray.each(['editable', 'hidden', 'token'], function (option) {
            if (options[option]) {
                item.addClass(classNames[option]);
            }
        });

        if (options.editable) {
            input = Node.create(this.INPUT_TEMPLATE).addClass(classNames.input);

            // Event will be purged when the item is removed.
            input.on('valueChange', this._afterInputValueChange, this);

            item.append(input);
        }

        if (options.token) {
            item.setAttrs({
                tabIndex: 0,
                text    : options.text || ''
            });

            if (this.get('removeButton')) {
                item.addClass(classNames.hasremove).append(
                    Node.create(this.REMOVE_TEMPLATE).addClass(
                        classNames.remove).set('role', 'button')
                );
            }
        }

        return item;
    },

    /**
     * Focuses the token after the specified item node, or the input node if
     * there is no next token.
     *
     * @method _focusNext
     * @param {Node} node
     * @return {Boolean} <code>true</code> if focus was set, <code>false</code>
     *   otherwise.
     * @protected
     */
    _focusNext: function (node) {
        var selectors = this._selectors,
            nextToken;

        node      = node.ancestor(selectors.item, true);
        nextToken = node && node.next(selectors.token);

        if (nextToken) {
            nextToken.focus();
        } else {
            this._inputNode.focus();
        }

        return true;
    },

    /**
     * Focuses the token before the specified item node, if any.
     *
     * @method _focusPrev
     * @param {Node} node
     * @return {Boolean} <code>true</code> if focus was set, <code>false</code>
     *   otherwise.
     * @protected
     */
    _focusPrev: function (node) {
        var selectors = this._selectors,
            prevToken;

        node      = node.ancestor(selectors.item, true);
        prevToken = node && node.previous(selectors.token);

        if (prevToken) {
            prevToken.focus();
        } else {
            return false;
        }

        return true;
    },

    /**
     * Returns an object containing the start and end indexes of selected text
     * within the specified node.
     *
     * @method _getSelection
     * @param {Node} node
     * @return {Object} Object with <code>start</code> and <code>end</code>
     *   properties.
     * @protected
     */
    _getSelection: function (node) {
        // TODO: this should probably be a Node extension named node-selection
        // or something.
        var el        = Node.getDOMNode(node),
            selection = {end: 0, start: 0},
            length, value, range;

        if ('selectionStart' in el) {
            // Good browsers.
            selection.start = el.selectionStart;
            selection.end   = el.selectionEnd;
        } else if (el.createTextRange) {
            // IE.
            value  = el.value;
            length = value.length;
            range  = doc.selection.createRange().duplicate();

            range.moveEnd('character', length);
            selection.start = range.text === '' ? length :
                    value.lastIndexOf(range.text);

            range.moveStart('character', -length);
            selection.end = range.text.length;
        }

        return selection;
    },

    /**
     * Handler for the backspace key.
     *
     * @method _keyBackspace
     * @param {EventFacade} e
     * @return {Boolean} <code>false</code> if the event should not be
     *   prevented.
     * @protected
     */
    _keyBackspace: function (e) {
        var node = e.currentTarget,
            index, selection;

        if (node.hasClass(TokenInput.CLASS_NAMES.input)) {
            selection = this._getSelection(node);

            if (selection.start !== 0 || selection.end !== 0) {
                return false;
            }

            // Focus the previous token.
            return this._focusPrev(node);
        }

        node  = node.ancestor(this._selectors.token, true);
        index = this._tokenNodes.indexOf(node);

        if (!node || index === -1) {
            return false;
        }

        // Delete the current token and focus the preceding token. If there is
        // no preceding token, focus the next token, or the input field if there
        // is no next token.
        (this._focusPrev(node) || this._focusNext(node));
        this.remove(index);

        return true;
    },

    /**
     * Handler for the delete key.
     *
     * @method _keyDelete
     * @param {EventFacade} e
     * @return {Boolean} <code>false</code> if the event should not be
     *   prevented.
     * @protected
     */
    _keyDelete: function (e) {
        var node = e.currentTarget,
            index;

        if (!node.hasClass(TokenInput.CLASS_NAMES.token)) {
            return false;
        }

        index = this._tokenNodes.indexOf(node);

        if (index === -1) {
            return false;
        }

        // Delete the current token and focus the following token (or the input
        // field if there is no following token).
        this._focusNext(node);
        this.remove(index);

        return true;
    },

    /**
     * Handler for the down arrow key.
     *
     * @method _keyDown
     * @param {EventFacade} e
     * @return {Boolean} <code>false</code> if the event should not be
     *   prevented.
     * @protected
     */
    _keyDown: function (e) {
        return this._keyRight(e);
    },

    /**
     * Handler for the enter key.
     *
     * @method _keyEnter
     * @param {EventFacade} e
     * @return {Boolean} <code>false</code> if the event should not be
     *   prevented.
     * @protected
     */
    _keyEnter: function (e) {
        var value = Lang.trim(this._inputNode.get(VALUE));

        if (!this.get('tokenizeOnEnter') || !value) {
            return false;
        }

        this._tokenizeValue(null, null, {all: true});
    },

    /**
     * Handler for the left arrow key.
     *
     * @method _keyLeft
     * @param {EventFacade} e
     * @return {Boolean} <code>false</code> if the event should not be
     *   prevented.
     * @protected
     */
    _keyLeft: function (e) {
        var node = e.currentTarget;

        if (node.hasClass(TokenInput.CLASS_NAMES.input) &&
                this._getSelection(node).start !== 0) {
            return false;
        }

        return this._focusPrev(node);
    },

    /**
     * Handler for the right arrow key.
     *
     * @method _keyRight
     * @param {EventFacade} e
     * @return {Boolean} <code>false</code> if the event should not be
     *   prevented.
     * @protected
     */
    _keyRight: function (e) {
        var node = e.currentTarget;

        if (node.hasClass(TokenInput.CLASS_NAMES.input)) {
            return false;
        }

        return this._focusNext(node);
    },

    /**
     * Handler for the up arrow key.
     *
     * @method _keyUp
     * @param {EventFacade} e
     * @return {Boolean} <code>false</code> if the event should not be
     *   prevented.
     * @protected
     */
    _keyUp: function (e) {
        return this._keyLeft(e);
    },

    /**
     * Refreshes the <code>_tokenNodes</code> NodeList, which is used internally
     * to track token item nodes.
     *
     * @method _refresh
     * @protected
     */
    _refresh: function () {
        if (this._tokenNodes) {
            this._tokenNodes.refresh();
        } else {
            this._tokenNodes = this._list.all(this._selectors.token);
        }
    },

    /**
     * Renders the token input markup.
     *
     * @method _render
     * @protected
     */
    _render: function () {
        var classNames  = TokenInput.CLASS_NAMES,
            boundingBox = Node.create(this.BOX_TEMPLATE),
            contentBox  = Node.create(this.CONTENT_TEMPLATE);

        contentBox.addClass(classNames.content);

        boundingBox.addClass(classNames.box).addClass(classNames.os)
                .set('tabIndex', -1).append(contentBox);

        this._set('boundingBox', boundingBox);
        this._set('contentBox', contentBox);

        this._boundingBox = boundingBox;
        this._contentBox  = contentBox;

        this._renderList();

        this._host.addClass(classNames.host).insert(boundingBox, 'after');
    },

    /**
     * Renders the token list.
     *
     * @method _renderList
     * @protected
     */
    _renderList: function () {
        var list = Node.create(this.LIST_TEMPLATE);

        list.addClass(TokenInput.CLASS_NAMES.list);

        this._list = list;
        this._set('listNode', list);

        this._contentBox.append(list);
    },

    /**
     * Setter for the <code>tokens</code> attribute.
     *
     * @method _setTokens
     * @param {Array} tokens Array of token strings.
     * @return {Array} Array of trimmed token strings, with any empty strings
     *   removed.
     * @protected
     */
    _setTokens: function (tokens) {
        // Filter out empty tokens.
        return YArray.filter(tokens, function (token) {
            return !!Lang.trim(token);
        });
    },

    /**
     * Synchronizes the tokenInput's UI state with the internal state.
     *
     * @method _sync
     * @protected
     */
    _sync: function () {
        var items  = [],
            tokens = this.get(TOKENS);

        this._contentBox[this.get('fauxInput') ? 'addClass' : 'removeClass'](
                TokenInput.CLASS_NAMES.fauxinput);

        YArray.each(tokens, function (token, i) {
            items.push(this._createItem({
                text     : Lang.trim(token),
                token    : true
            }));
        }, this);

        this._inputItem = this._createItem({editable: true});
        this._inputNode = this._inputItem.one(this._selectors.input);

        this._set('inputNode', this._inputNode);

        items.push(this._inputItem);
        items = Y.all(items).toFrag();

        this._clearItems();
        this._list.append(items);
        this._refresh();
        this._syncHost();
    },

    /**
     * Synchronizes the value of the host input field with the current set of
     * tokens in the tokenInput, joined with the configured
     * <code>delimiter</code>.
     *
     * @method _syncHost
     * @protected
     */
    _syncHost: function () {
        this._host.set(VALUE, this.get(TOKENS).join(this.get(DELIMITER)));
    },

    /**
     * Tokenizes the value of the specified node (or the passed value if one is
     * provided) and returns an array of tokens. Optionally also adds the tokens
     * to the tokenInput's UI.
     *
     * @method _tokenizeValue
     * @param {Node} node (optional) Node whose value should be tokenized. If
     *   not provided, the token input node will be used.
     * @param {String} value (optional) Value to be tokenized. If not specified,
     *   the value of the <i>node</i> will be used.
     * @param {Object} options (optional) Options object with zero or more of
     *   the following properties:
     *
     * <dl>
     *   <dt>all (Boolean)</dt>
     *   <dd>
     *      If <code>true</code>, the entire value will be split on the
     *      delimiter string and tokenized. If <code>false</code> (the default),
     *      all but the last token will be tokenized, and the last one will be
     *      left in the value.
     *   </dd>
     *
     *   <dt>updateUI (Boolean)</dt>
     *   <dd>
     *     If <code>true</code> (the default), tokens parsed out of the value
     *     will be added to the tokenInput UI. If <code>false</code>, parsed
     *     tokens will be returned, but the UI and the <code>tokens</code>
     *     attribute will not be updated.
     *   </dd>
     * </dl>
     *
     * @return {Array} Array of parsed tokens.
     * @protected
     */
    _tokenizeValue: function (node, value, options) {
        var tokens;

        options = Y.merge({
            updateUI: true
        }, options || EMPTY_OBJECT);

        if (!node) {
            node = this._inputNode;
        }

        if (!value && value !== '') {
            value = node.get(VALUE);
        }

        tokens = value.split(this.get(DELIMITER));

        if (options.all || tokens.length > 1) {
            if (options.all) {
                value = '';
            } else {
                // New input field value is the last item in the array.
                value = Lang.trim(tokens.pop());
            }

            if (options.updateUI) {
                node.set(VALUE, value);

                if (tokens.length) {
                    // All other items are added as tokens.
                    this.add(tokens);
                }
            }
        }

        if (options.updateUI) {
            // Adjust the width of the input field as necessary to fit its
            // contents.
            node.setStyle('width', Math.max(5, value.length + 3) + 'ex');
        }

        return tokens;
    },

    // -- Protected Event Handlers ---------------------------------------------

    /**
     * Handles blur events on the bounding box.
     *
     * @method _afterBlur
     * @param {EventFacade} e
     * @protected
     */
    _afterBlur: function (e) {
        if (this.get('tokenizeOnBlur')) {
            this._tokenizeValue(null, null, {all: true});
        }
    },

    /**
     * Handles changes to the <code>fauxInput</code> attribute.
     *
     * @method _afterFauxInputChange
     * @param {EventFacade} e
     * @protected
     */
    _afterFauxInputChange: function (e) {
        this._sync();
    },

    /**
     * Handles focus events on the bounding box.
     *
     * @method _afterFocus
     * @param {EventFacade} e
     * @protected
     */
    _afterFocus: function (e) {
        var that = this;

        if (!e.target.ancestor(this._selectors.item, true)) {
            setTimeout(function () {
                // FIXME: this doesn't display the keyboard in iOS.
                that._inputNode.focus();
            }, 1);
        }
    },

    /**
     * Handles <code>valueChange</code> events on the token input node.
     *
     * @method _afterInputValueChange
     * @param {EventFacade} e
     * @protected
     */
    _afterInputValueChange: function (e) {
        this._tokenizeValue(e.currentTarget, e.newVal);
    },

    /**
     * Handles changes to the <code>removeButton</code> attribute.
     *
     * @method _afterRemoveButtonChange
     * @param {EventFacade} e
     * @protected
     */
    _afterRemoveButtonChange: function (e) {
        this._sync();
    },

    /**
     * Handles changes to the <code>tokens</code> attribute.
     *
     * @method _afterTokensChange
     * @param {EventFacade} e
     * @protected
     */
    _afterTokensChange: function (e) {
        // Only do a full sync for non-atomic changes (i.e., changes that are
        // made via some means other than the add()/remove() methods).
        if (e.atomic) {
            this._syncHost();
        } else {
            this._sync();
        }
    },

    /**
     * Handles keydown or keypress events on tokens and the token input field.
     *
     * @method _onKey
     * @param {EventFacade} e
     * @protected
     */
    _onKey: function (e) {
        var handler = this._keys[e.keyCode];

        if (handler) {
            // A handler may return false to indicate that it doesn't wish
            // to prevent the default key behavior.
            if (handler.call(this, e) !== false) {
                e.preventDefault();
            }
        }
    },

    /**
     * Handles click events on token remove buttons.
     *
     * @method _onRemoveClick
     * @param {EventFacade} e
     * @protected
     */
    _onRemoveClick: function (e) {
        var item = e.currentTarget.ancestor(this._selectors.item);
        e.preventDefault();
        this.remove(this._tokenNodes.indexOf(item));
    },

    /**
     * Handles blur events on tokens.
     *
     * @method _onTokenBlur
     * @param {EventFacade} e
     * @protected
     */
    _onTokenBlur: function (e) {
        e.currentTarget.removeClass(TokenInput.CLASS_NAMES.focus);
    },

    /**
     * Handles focus events on tokens.
     *
     * @method _onTokenFocus
     * @param {EventFacade} e
     * @protected
     */
    _onTokenFocus: function (e) {
        e.currentTarget.addClass(TokenInput.CLASS_NAMES.focus);
    },

    /**
     * Handles mouseout events on tokens.
     *
     * @method _onTokenMouseOut
     * @param {EventFacade} e
     * @protected
     */
    _onTokenMouseOut: function (e) {
        e.currentTarget.removeClass(TokenInput.CLASS_NAMES.hover);
    },

    /**
     * Handles mouseover events on tokens.
     *
     * @method _onTokenMouseOver
     * @param {EventFacade} e
     * @protected
     */
    _onTokenMouseOver: function (e) {
        e.currentTarget.addClass(TokenInput.CLASS_NAMES.hover);
    }
}, {
    NAME: 'pluginTokenInput',
    NS  : 'tokenInput',

    ATTRS: {
        /**
         * Reference to the bounding box node.
         *
         * @attribute boundingBox
         * @type Node
         * @readonly
         */
        boundingBox: {
            readOnly: true
        },

        /**
         * Reference to the content box node.
         *
         * @attribute contentBox
         * @type Node
         * @readonly
         */
        contentBox: {
            readOnly: true
        },

        /**
         * Token delimiter string. May be a single character or multiple
         * characters. User input will be split on this string as the user
         * types, and the delimited values will be turned into tokens.
         *
         * @attribute delimiter
         * @type String
         * @default ','
         */
        delimiter: {
            value: ','
        },

        /**
         * <p>
         * If <code>true</code>, the CSS class name
         * <code>yui3-tokeninput-fauxinput</code> will be applied to the
         * bounding box. When using the Sam skin, this will cause the
         * TokenInput's styling to mimic a real input field.
         * </p>
         *
         * <p>
         * Note that this styling may not look entirely faithful to native
         * control styling on all browsers and platforms.
         * </p>
         *
         * @attribute fauxInput
         * @type Boolean
         * @default true
         */
        fauxInput: {
            value: true
        },

        /**
         * Reference to the token input node. This is the visible input node
         * the user can type in to add tokens.
         *
         * @attribute inputNode
         * @type Node
         * @readonly
         */
        inputNode: {
            readOnly: true
        },

        /**
         * Reference to the token list node.
         *
         * @attribute listNode
         * @type Node
         * @readonly
         */
        listNode: {
            readOnly: true
        },

        /**
         * If <code>true</code>, each token will have a remove button (in the
         * form of a small "x") which, when clicked, will remove the token.
         *
         * @attribute removeButton
         * @type Boolean
         * @default <code>true</code> for mobile devices, <code>false</code>
         *   elsewhere.
         */
        removeButton: {
            value: !!Y.UA.mobile
        },

        /**
         * If <code>true</code>, any text the user has entered in the token
         * input field will be tokenized when the input field loses focus.
         *
         * @attribute tokenizeOnBlur
         * @type Boolean
         * @default true
         */
        tokenizeOnBlur: {
            value: true
        },

        /**
         * If <code>true</code>, any text the user has entered in the token
         * input field will be tokenized when the user presses the enter key
         * while the input field has focus.
         *
         * @attribute tokenizeOnEnter
         * @type Boolean
         * @default true
         */
        tokenizeOnEnter: {
            value: true
        },

        /**
         * Current tokens.
         *
         * @attribute tokens
         * @type Array
         * @default []
         */
        tokens: {
            setter: '_setTokens',
            value : []
        }
    },

    CLASS_NAMES: {
        box      : getClassName(),
        content  : getClassName('content'),
        editable : getClassName('editable'),
        fauxinput: getClassName('fauxinput'),
        focus    : getClassName('focus'),
        hasremove: getClassName('hasremove'),
        hidden   : getClassName('hidden'),
        host     : getClassName('host'),
        hover    : getClassName('hover'),
        input    : getClassName('input'),
        item     : getClassName('item'),
        list     : getClassName('list'),
        os       : getClassName(Y.UA.os),
        remove   : getClassName('remove'),
        token    : getClassName('token')
    }
});

Y.Plugin.TokenInput = TokenInput;

Copyright © 2010 Yahoo! Inc. All rights reserved.