UNPKG

tokchi

Version:

jQuery plugin that adds Mac OS X style tokens or Android style chips to a text input field

1,000 lines (862 loc) 34.5 kB
/** * Tokchi v 1.1.0 * * Cross-browser input field with MacOS-style "token" / Android-style "chip" * support. * * Copyright (c) 2015, 2016 Thomas Iguchi - https://nobu-games.com/ * Licensed under MIT, see https://github.com/tiguchi/tokchi/LICENSE.md */ (function ($) { var defaultOptions = { allowLineBreaks : false, autoFocus : true, /** * Callback that gets invoked when the Tokchi instance is ready. * * @param tokchi Tokchi instance. */ onReady : function (tokchi) { }, /** * Callback for a change (addition / removal) of tokens in input field. * * @param tokchi Tokchi instance. */ onChange : function (tokchi) { }, /** * Callback for text editing event (key up / key down / key press). * * @param tokchi Tokchi instance. * @param event Key event. * @return If <code>false</code> is returned the event will be * suppressed and the typed character is ignored. */ onEdit : function (tokchi, event) { }, /** * Callback for return button press. * * @param tokchi Tokchi instance. * @return True in order to suppress return press event propagation * to input field. */ onPressReturn : function (tokchi) { return true; }, /** * Keyword search handler. * * Must call `tokchi.setSearchResult(result)` when search result is available. * * @param tokchi Tokchi instance. * @param token Keyword to search. */ onSearchKeyword : function (tokchi, keyword) { tokchi.setSearchResult([{label : keyword}]); }, /** * Adds a label and optional other content to a token that is about to be * added to the input field. * * Override this option if your search result object properties differ from * the default, i.e. the presence of `tokenObj.label` is expected for * the label text. You can also customize the appearance and add more * elements to the `tokenHTMLNode`, such as a small profile picture or * your own custom close button to remove the token. * * @param tokchi Tokchi instance. * @param tokenHTMLNode Token node to add label to. * @param tokenObj Token data object from search result. */ onCreateToken : function (tokchi, tokenHTMLNode, tokenObj) { $(tokenHTMLNode).text(tokenObj.label).append( $('<span>') .text('×') .addClass(tokchi._options.cssClasses['token-close-button']) .click(function () { tokchi.removeToken(tokenHTMLNode); }) ); }, /** * Called when a token has been added to the input field. * * @param tokchi Tokchi instance. * @param tokenObj Token data object from search result. */ onTokenAdded : function (tokchi, tokenObj) { }, /** * Handler that get called when a token is edited and needs to be reverted into * its text representation. This function is responsible for providing the text * representation of the token. * * @param tokchi Tokchi instance. * @param tokenHTMLNode Token node that is about to be unwrapped. * @param tokenObj Token data object from search result. * * @return Unwrapped token text. */ onUnwrapToken : function (tokchi, tokenHTMLNode, tokenObj) { return tokenObj.label; }, /** * Adds a label and other optional content to the dropdown item of each * search result object. * * Override this function to create your own customizations for the * appearance of each dropdown item. * * @param tokchi Tokchi instance. * @param itemHTMLNode Dropdown item node to add label to. * @param resultItem Token search result object. */ onCreateDropdownItem : function (tokchi, itemHTMLNode, resultItem) { $(itemHTMLNode).text(resultItem.label); }, cssClasses : { 'dropdown' : 'tokchi-dropdown', 'dropdown-item' : 'tokchi-dropdown-item', 'dropdown-item-selected' : 'tokchi-dropdown-item-selected', 'token' : 'tokchi-token', 'token-close-button' : 'tokchi-token-close-button' }, /** * `follows` = Dropdown box will open up under current cursor position * `fixed` = Dropdown box will appear under input field */ dropdownStyle : 'follows', /** * Regular expression for finding boundary between search keywords. * * Can be set to `null` in order to ignore word boundaries. In this * case the whole text input at the current cursor position is * taken into account, while only previously inserted tokens act * as boundaries. */ searchKeywordDelimiter : /[\s\u00A0,.:;\?!\)\(\[\]\{\}"`<>+]/ }; var KEY = { ESC : 27, BACKSPACE : 8, DEL : 46, UP : 38, DOWN : 40, LEFT : 37, RIGHT : 39, RETURN : 13, PG_UP : 33, PG_DOWN : 34, END : 35, HOME : 36 }; var selection = { get : function () { if (window.getSelection) return window.getSelection(); return document.getSelection(); }, createRange : function () { return document.createRange(); }, setRange : function (range) { var sel = selection.get(); sel.removeAllRanges(); sel.addRange(range); return sel.getRangeAt(0); }, select : function (node) { var range = selection.createRange(); range.selectNode($(node).get(0)); return selection.setRange(range); }, setRangeAfter : function (obj) { var sel = selection.get(); sel.removeAllRanges(); var range = selection.createRange(); var el = $(obj).get(0); range.setStartAfter(el); range.setEndAfter(el); sel.addRange(range); }, setRangeBefore : function (obj) { var sel = selection.get(); sel.removeAllRanges(); var range = selection.createRange(); var el = $(obj).get(0); range.setStartBefore(el); range.setEndBefore(el); sel.addRange(range); } }; function forEach (array, callback) { for (var i = 0; i < array.length; ++i) { callback(i, array[i]); } } function calculatePosition (el) { console.debug(el); var pos = { top : 0, left : 0 }; do { $el = $(el); pos.top += el.offsetTop + parseInt($el.css('margin-top')) + parseInt($el.css('padding-top')) + parseInt($el.css('border-top-width')); pos.left += el.offsetLeft + parseInt($el.css('margin-left')) + parseInt($el.css('padding-left')) + parseInt($el.css('border-left-width')); } while (el = el.offsetParent); return pos; } var $window = $(window); /** * Creates a new Tokchi UI widget. * * @param {DOMElement | jQuery result set} input Input field to turn into a Tokchi. * @param {object} options Configuration object (see defaultOptions above). Optional. */ function Tokchi(input, options) { var self = this; this._input = $('<div>') .attr('contentEditable', true) .attr('autocomplete', false) .attr('autocorrect', false) .attr('spellcheck', false) .on('cut paste keydown keyup', function (e) { self._onInput(e); }).blur(function () { self._hideDropdown(); self._cleanInputMarkup(); // If there's only whitespace left, clear the input field // so the optional hint text can reappear if (self._input.text().trim().length == 0) { self._input.empty(); } }); $.each(input.attributes, function (i, attr) { self._input.attr(attr.nodeName, attr.nodeValue); }); $(input).replaceWith(this._input); this._inputNode = this._input.get(0); this._options = $.extend({}, defaultOptions, options); this._dropdown = $(this._options.dropdownElement || '<ul/>') .addClass(this._options.cssClasses['dropdown']).hide(); if (!this._options.dropdownElement) this._input.after(this._dropdown); var initVal = this._input.attr('data-value'); if (initVal) { var optionsBackup = this._options.onChange; this._options.onChange = $.noop; this.setValue(JSON.parse(initVal)); this._options.onChange = optionsBackup; } if (window.MutationObserver) { this._mutationObserver = new MutationObserver(function (mutations) { var didChange = false; forEach(mutations, function (index, mutation) { if (!mutation.removedNodes) return; forEach(mutation.addedNodes, function (index, item) { if ($(item).attr('data-token')) { didChange = true; } }); forEach(mutation.removedNodes, function (index, item) { if ($(item).attr('data-token')) { didChange = true; } }); }); if (didChange) { self._options.onChange(self); } }); this._mutationObserver.observe(this._inputNode, { childList : true }); } if (this._options.autoFocus) this._input.focus(); this._options.onReady(this); } /** * Shows the dropdown selection box. */ Tokchi.prototype._showDropdown = function () { if (!this._dropdownShowing) { var offset = this._input.position(); var top = offset.top + this._input.outerHeight(); if (this._options.dropdownStyle == 'follows') { var rect = selection.get().getRangeAt(0).getClientRects()[0]; this._dropdown.css({ position : 'absolute', width : '', top : top, left : rect.left + $window.scrollLeft() }); } else if (this._options.dropdownStyle == 'fixed') { this._dropdown.css({ position : 'absolute', top : top, left : offset.left, width : this._input.width() }); } this._dropdown.show(); this._dropdownShowing = true; } var self = this; if (this._options.dropdownStyle == 'follows') { // Delay to wait for DOM tree layout update window.setTimeout(function () { var inputOffset = self._input.offset(); var inputWidth = self._input.width(); var ddOffset = self._dropdown.offset(); var ddWidth = self._dropdown.width(); if ((ddOffset.left + ddWidth) > (inputOffset.left + inputWidth)) { self._dropdown.css({ left : inputOffset.left + (inputWidth - ddWidth) }); } }, 0); } this._dropdownIndex = 0; this._blurSafeGuard = 0; this._dropdown.scrollTop(0); }; /** * Hides the dropdown. * * @param force (optional) If set to true closing the dropdown is enforced even * when the mouse hovers over an item. */ Tokchi.prototype._hideDropdown = function (force) { if (!force && this._blurSafeGuard > 0) return; this._dropdown.hide(); this._dropdownShowing = false; delete this._currentSearchKey; }; /** * Moves the dropdown selection by a specific amount of positions. * * @param amount Positive value to move down, negative value to move up. */ Tokchi.prototype._moveDropdownSelection = function (amount) { var newIndex = this._dropdownIndex + amount; var max = $(this._dropdown).children().length; if (newIndex >= max) newIndex = max - 1; if (newIndex < 0) newIndex = 0; var self = this; $(this._dropdown).children().each(function (i, child) { var jthis = $(this); if (i == newIndex) { if (jthis.attr('data-disabled')) return; jthis.addClass(self._options.cssClasses["dropdown-item-selected"]); var ddHeight = self._dropdown.outerHeight(false); var ddPaddingTop = parseInt(self._dropdown.css('padding-top')); var ddTop = self._dropdown.scrollTop(); var ddBottom = ddHeight + ddTop; var elTop = jthis.position().top; var elHeight = jthis.outerHeight(true); if (ddBottom < elTop + elHeight) { self._dropdown.scrollTop(elTop - ddHeight + elHeight + ddPaddingTop); } else if (ddTop > elTop) { self._dropdown.scrollTop(elTop - ddPaddingTop); } } else { jthis.removeClass(self._options.cssClasses["dropdown-item-selected"]); } }); this._dropdownIndex = newIndex; }; /** * Selects a dropdown item by its index number. * * @param index Dropdown item index number. */ Tokchi.prototype._setDropdownSelection = function (index) { var child = this._dropdown.children().get(this._dropdownIndex); if (child) $(child).removeClass(this._options.cssClasses["dropdown-item-selected"]); child = $(this._dropdown.children().get(index)); if (!child.attr('data-disabled')) child.addClass(this._options.cssClasses["dropdown-item-selected"]); this._dropdownIndex = index; }; /** * Inserts the currently selected dropdown item as a token * into the input field. */ Tokchi.prototype._pickDropdownItem = function (index) { delete this._blurSafeGuard; var jitem = $(this._dropdown.children().get(index)); if (jitem.attr('data-disabled')) return; var tokenObj = JSON.parse(jitem.attr('data-token')); var chip = this._createToken(tokenObj); if (this._currentSearchNodeStartOffset || this._currentSearchNodeEndOffset) { var toReplace = this._currentSearchNode.splitText(this._currentSearchNodeStartOffset); var offs = this._currentSearchNodeEndOffset - this._currentSearchNodeStartOffset; if (offs && offs < toReplace.nodeValue.length) toReplace.splitText(offs); $(toReplace).replaceWith(chip); } else { $(this._currentSearchNode).replaceWith(chip); } delete this._currentSearchNode; this._padAndSetCursorAfterToken(chip); this._options.onTokenAdded(this, tokenObj); }; /** * Adds whitespace after an inserted token / chip and sets the * input cursor after that if the input has currently focus. * * @param {DOMNodeElement} chip Token / chip that was inserted. */ Tokchi.prototype._padAndSetCursorAfterToken = function (chip) { var space = document.createTextNode('\u00A0'); chip.after(space); if (this._dropdownShowing) { this._input.focus(); selection.setRangeAfter(space); this._hideDropdown(); } this._cleanInputMarkup(); if (!this._mutationObserver) { this._options.onChange(this); } } /** * Restores token click handlers and attributes * in case tokens have been pasted into the input field. */ Tokchi.prototype._repairPastedTokens = function () { var self = this; $('*[data-token]', this._inputNode).each(function (i, token) { var jtoken = $(token); var tokenObj = JSON.parse(jtoken.attr('data-token')); jtoken.replaceWith(self._createToken(tokenObj)); }); this._cleanInputMarkup(); }; /** * Creates a token / chip based on a description object. * * @param tokenObj Token description object (will be automatically * serialized as JSON string and added as `data-token` attribute). * @return Created token DOM object. */ Tokchi.prototype._createToken = function (tokenObj) { var self = this; var chip = $('<div/>') .attr('data-token', JSON.stringify(tokenObj)) .attr('contentEditable', false) .addClass(this._options.cssClasses.token) .each(function (i, token) { self._options.onCreateToken(self, token, tokenObj); }); if (!this._mutationObserver) { chip.bind('DOMNodeRemoved', function () { self._options.onChange(self); }); } return chip; }; /** * Retrieves the closest token relative to the current input cursor position. * * @param ltr Boolean that indicates whether to look from left to right or * the other way. * @return Token DOM object on success or nothing. */ Tokchi.prototype._getClosestToken = function (ltr) { var range = selection.get().getRangeAt(0); var offset = range.startOffset; if (!range.collapsed) return; var editedNode = range.startContainer; if (editedNode == this._inputNode) { editedNode = this._inputNode.childNodes[offset - 1]; offset = 0; } if (!editedNode || (editedNode.nodeType == 3 && editedNode.parentNode == this._inputNode // Allow editing text nodes of sufficient length && (offset > 1 || editedNode.nodeValue.match(/[^\s\u00A0]/)))) return; do { var jeditedNode = $(editedNode); var tokenObj = jeditedNode.attr('data-token'); if (tokenObj) return jeditedNode; editedNode = (ltr ? editedNode.nextSibling : editedNode.previousSibling) || editedNode.parentNode; } while (editedNode && this._inputNode != editedNode && !(editedNode.nodeType == 3 && editedNode.nodeValue)); }; /** * Cleans the input field markup to deal with various cross-browser * quirks and also for removing any special formattings in case * rich content has been pasted. */ Tokchi.prototype._cleanInputMarkup = function () { var self = this; $('> *:not([data-token])', this._input).each(function (i, node) { if (node.nodeType != 3) { var jnode = $(node); jnode.replaceWith(jnode.text() || '\u00A0'); } }); $('*[data-token]', this._input).each(function (i, node) { if (i == 0 && (!node.previousSibling || node.previousSibling.nodeType != 3)) { $(node).before(document.createTextNode('\u00A0')); } if (!node.nextSibling || node.nextSibling.nodeType != 3) { $(node).after(document.createTextNode('\u00A0')); } }); forEach(this._inputNode.childNodes, function (i, node) { if (node.nodeType == 3 && (!node.nodeValue.length || node.nodeValue.match(/^[\s\u00A0]{2,}$/))) { node.nodeValue = '\u00A0'; } }); this._inputNode.normalize(); }; /** * Input / cut / paste handler method. * * @param e Event object. */ Tokchi.prototype._onInput = function (e) { var self = this; if (e.type == 'paste') { setTimeout(function () { self._cleanInputMarkup(); self._repairPastedTokens(); }, 0); return; } switch (e.keyCode) { case KEY.ESC: if (this._dropdownShowing) { e.preventDefault(); this._hideDropdown(); } return; case KEY.HOME: case KEY.END: if (e.type == 'keydown') { this._hideDropdown(true); } return; case KEY.LEFT: if (e.type == 'keydown') { this._hideDropdown(true); var token = this._getClosestToken(false); if (token) { selection.setRangeBefore(token); } } return; case KEY.RIGHT: if (e.type == 'keydown') { this._hideDropdown(true); var token = this._getClosestToken(true); if (token) { selection.setRangeBefore(token); } } return; case KEY.DEL: if (e.type == 'keydown') { this._cleanInputMarkup(); var token = this._getClosestToken(true); if (token) { tokenObj = JSON.parse(token.attr('data-token')); var label = document.createTextNode(this._options.onUnwrapToken(this, token, tokenObj)); token.replaceWith(label); e.preventDefault(); this._hideDropdown(true); } } break; case KEY.BACKSPACE: if (e.type == 'keydown') { this._cleanInputMarkup(); var token = this._getClosestToken(false); if (token) { tokenObj = JSON.parse(token.attr('data-token')); var label = document.createTextNode(this._options.onUnwrapToken(this, token, tokenObj)); var next = token.get(0).nextSibling; token.replaceWith(label); if (next) { next.textContent = next.textContent.trim(); selection.setRangeBefore(next); } else { selection.setRangeAfter(label); } e.preventDefault(); this._hideDropdown(true); } } break; case KEY.PG_DOWN: case KEY.DOWN: if (this._dropdownShowing) { e.preventDefault(); if (e.type == 'keydown') this._moveDropdownSelection(e.keyCode == KEY.DOWN ? 1 : this._dropdownItemsPerPage); return; } break; case KEY.PG_UP: case KEY.UP: if (this._dropdownShowing) { e.preventDefault(); if (e.type == 'keydown') this._moveDropdownSelection(e.keyCode == KEY.UP ? -1 : -this._dropdownItemsPerPage); return; } break; case KEY.RETURN: if (this._dropdownShowing) { e.preventDefault(); if (e.type == 'keyup') this._pickDropdownItem(this._dropdownIndex); return; } else if (this._options.onPressReturn(this)) { e.preventDefault(); return; } break; } if (this._input.text().trim().length == 0) { this._dropdown.empty(); this._hideDropdown(); } else { // User is currently typing something into the input field if (this._options.onEdit(this, e) === false) { e.preventDefault(); return; } var range = selection.get().getRangeAt(0); var editedNode = range.startContainer; if (editedNode.nodeType == 3 && this._inputNode == editedNode.parentNode) { if (e.type == 'keydown') return; this._searchToken(range, editedNode); } else if (this._inputNode != editedNode) { // Avoid that label of token gets edited e.preventDefault(); } } }; /** * Triggers the search for a token based on the specified token text node. * * @param token Token text node to search for. Will be automatically * replaced with a token when user makes a selection from the dropdown list. */ Tokchi.prototype._searchToken = function (range, node) { var searchKey; if (this._options.searchKeywordDelimiter) { this._currentSearchNodeStartOffset = Math.max(range.startOffset - 1, 0); this._currentSearchNodeEndOffset = Math.min(range.endOffset, node.textContent.length); for (; this._currentSearchNodeStartOffset > 0; --this._currentSearchNodeStartOffset) { if (node.textContent.charAt(this._currentSearchNodeStartOffset) .match(this._options.searchKeywordDelimiter)) { break; } } for (; this._currentSearchNodeEndOffset < node.textContent.length; ++this._currentSearchNodeEndOffset) { if (node.textContent.charAt(this._currentSearchNodeEndOffset) .match(this._options.searchKeywordDelimiter)) { break; } } searchKey = node.textContent .substring(this._currentSearchNodeStartOffset, this._currentSearchNodeEndOffset); if (this._options.debug) { console.debug('Start = ' + this._currentSearchNodeStartOffset + ', End = ' + this._currentSearchNodeEndOffset + ', SearchKey = ' + searchKey); } } else { delete this._currentSearchNodeStartOffset; delete this._currentSearchNodeEndOffset; searchKey = node.textContent; } this._currentSearchNode = node; searchKey = searchKey.replace('\u00A0', ' ').trim(); if (this._currentSearchKey == searchKey) return; this._currentSearchKey = searchKey; this._options.onSearchKeyword(this, searchKey); }; /** * Gives focus to the tokchi input field and positions the cursor * at the end. */ Tokchi.prototype.focus = function () { this._input.focus(); var last = this._input.children().last(); selection.setRangeAfter(last); }; /** * Programmatically adds a token to the input field. * * @param {object} tokenObj Token description object. */ Tokchi.prototype.addToken = function (tokenObj) { var chip = this._createToken(tokenObj); this._input.append(chip); this._padAndSetCursorAfterToken(chip); if (!this._mutationObserver) { this._options.onChange(this); } this._options.onTokenAdded(this, tokenObj); }; /** * Removes a token node from the input field. * * @param {DOMElementNode} tokenHTMLNode Token node to remove. */ Tokchi.prototype.removeToken = function (tokenHTMLNode) { $(tokenHTMLNode).remove(); this._cleanInputMarkup(); }; /** * Sets the dropdown style option. * * @param {string} style Either `fixed` or `following` (see defaultOptions). */ Tokchi.prototype.setDropdownStyle = function (style) { this._options.dropdownStyle = style; this._hideDropdown(true); } /** * Callback for `options.onSearchKeyword` handler function when * results are ready for populating the auto-completion dropdown box. * * @param items Array of result objects. */ Tokchi.prototype.setSearchResult = function (items) { var dd = this._dropdown; dd.empty(); if (!items || !items.length) { this._hideDropdown(true); return; } var self = this; var itemHeight; $.each(items, function(i, item) { var ddItem = $('<li/>') .addClass(self._options.cssClasses["dropdown-item"]) .css({ 'white-space' : 'nowrap' }) .mouseover(function () { self._blurSafeGuard++; self._setDropdownSelection(i); }) .mouseout(function () { self._blurSafeGuard--; }) .click(function () { self._pickDropdownItem(i); }).attr('data-token', JSON.stringify(item)); if (item.disabled) ddItem.attr('data-disabled', true); self._options.onCreateDropdownItem(self, ddItem, item); if(!item.disabled && i == 0) ddItem.addClass(self._options.cssClasses["dropdown-item-selected"]); dd.append(ddItem); if (!itemHeight) { itemHeight = ddItem.outerHeight(); } }); this._dropdownItemsPerPage = Math.floor(this._dropdown.outerHeight() / itemHeight); this._showDropdown(); }; /** * Sets the value of the input field. * * @param value Optional string or an array of strings and / or token objects (see getValue). * May also be null to clear the input field. */ Tokchi.prototype.setValue = function (value) { if (typeof value == 'string') { this._input.text(value); return; } this._input.empty(); if (!value) return; var self = this; forEach(value, function (index, item) { if (typeof item == 'string') { self._input.append(document.createTextNode(item)); } else { self.addToken(item); } }); }; /** * Gets a list of all token objects from the input field. * * @return array of token objects. */ Tokchi.prototype.getTokens = function () { var result = []; $('*[data-token]', this._inputNode).each(function (index, token) { result.push(JSON.parse($(token).attr('data-token'))); }); return result; }; /** * Gets the current input text as an `array` of strings and * token `object`s in the order of appearance. * HTML formattings will be ignored. * * @return array of strings and token objects. */ Tokchi.prototype.getValue = function () { this._inputNode.normalize(); var result = []; forEach(this._inputNode.childNodes, function (index, node) { var jnode = $(node); var token = jnode.attr('data-token'); if (token) { result.push(JSON.parse(token)); } else { var text = jnode.text().replace('\u0A00', ' ').trim(); if (text) result.push(text); } }); return result; }; // Register new jQuery function $.fn.tokchify = function (options) { var func, args, result; var first = true; if (typeof options == 'string') { func = Tokchi.prototype[options]; if (options[0] === '_' || typeof func != 'function') throw 'Unknown method ' + options; args = Array.prototype.slice.call(arguments, 1); } this.each(function () { var $this = $(this); var tokchi = $this.data('tokchi'); if (!tokchi) { tokchi = new Tokchi(this, options); // Tokchi replaces the original DOM node, that's why we // need to store the instance there tokchi._input.data('tokchi', tokchi); // In case the tokchify method has been chained with // another tokchify call we also need to store the instance // as data for the original (replaced) DOM elements $this.data('tokchi', tokchi); } if (func) { var tmpResult = func.apply(tokchi, args); if (first) result = tmpResult; first = false; } }); return func ? result : this; }; }(jQuery));