UNPKG

fuelux

Version:

Base Fuel UX styles and controls

782 lines (639 loc) 20.8 kB
/* global jQuery:true utilities:true */ /* * Fuel UX Pillbox * https://github.com/ExactTarget/fuelux * * Copyright (c) 2014 ExactTarget * Licensed under the BSD New license. */ // -- BEGIN UMD WRAPPER PREFACE -- // WARNING: Anything placed inside of the UMD Wrapper may be stripped out by dist task // For more information on UMD visit: // https://github.com/umdjs/umd/blob/master/jqueryPlugin.js (function umdFactory (factory) { if (typeof define === 'function' && define.amd) { // if AMD loader is available, register as an anonymous module. define(['jquery', 'fuelux/utilities', 'fuelux/dropdown-autoflip'], factory); } else if (typeof exports === 'object') { // Node/CommonJS module.exports = factory(require('jquery'), require('./utilities'), require('./dropdown-autoflip')); } else { // OR use browser globals if AMD is not present factory(jQuery); } }(function PillboxWrapper ($) { if (!$.fn.dropdownautoflip) { throw new Error('Fuel UX pillbox control requires dropdown-autoflip.'); } if (!$.fn.utilities) { throw new Error('Fuel UX pillbox control requires FuelUX utilities.'); } // -- END UMD WRAPPER PREFACE -- // -- BEGIN MODULE CODE HERE -- var old = $.fn.pillbox; var utilities = $.fn.utilities; var CONST = $.fn.utilities.CONST; var COMMA_KEYCODE = CONST.COMMA_KEYCODE; var ENTER_KEYCODE = CONST.ENTER_KEYCODE; var isBackspaceKey = utilities.isBackspaceKey; var isDeleteKey = utilities.isDeleteKey; var isTabKey = utilities.isTabKey; var isUpArrow = utilities.isUpArrow; var isDownArrow = utilities.isDownArrow; var cleanInput = utilities.cleanInput; var isShiftHeld = utilities.isShiftHeld; // PILLBOX CONSTRUCTOR AND PROTOTYPE var Pillbox = function Pillbox (element, options) { this.$element = $(element); this.$moreCount = this.$element.find('.pillbox-more-count'); this.$pillGroup = this.$element.find('.pill-group'); this.$addItem = this.$element.find('.pillbox-add-item'); this.$addItemWrap = this.$addItem.parent(); this.$suggest = this.$element.find('.suggest'); this.$pillHTML = '<li class="btn btn-default pill">' + ' <span></span>' + ' <span class="glyphicon glyphicon-close">' + ' <span class="sr-only">Remove</span>' + ' </span>' + '</li>'; this.options = $.extend({}, $.fn.pillbox.defaults, options); if (this.options.readonly === -1) { if (this.$element.attr('data-readonly') !== undefined) { this.readonly(true); } } else if (this.options.readonly) { this.readonly(true); } // EVENTS this.acceptKeyCodes = this._generateObject(this.options.acceptKeyCodes); // Create an object out of the key code array, so we don't have to loop through it on every key stroke this.$element.on('click.fu.pillbox', '.pill-group > .pill', $.proxy(this.itemClicked, this)); this.$element.on('click.fu.pillbox', $.proxy(this.inputFocus, this)); this.$element.on('keydown.fu.pillbox', '.pillbox-add-item', $.proxy(this.inputEvent, this)); if (this.options.onKeyDown) { this.$element.on('mousedown.fu.pillbox', '.suggest > li', $.proxy(this.suggestionClick, this)); } if (this.options.edit) { this.$element.addClass('pills-editable'); this.$element.on('blur.fu.pillbox', '.pillbox-add-item', $.proxy(this.cancelEdit, this)); } this.$element.on('blur.fu.pillbox', '.pillbox-add-item', $.proxy(this.inputEvent, this)); }; Pillbox.prototype = { constructor: Pillbox, destroy: function destroy () { this.$element.remove(); // any external bindings // [none] // empty elements to return to original markup // [none] // returns string of markup return this.$element[0].outerHTML; }, items: function items () { var self = this; return this.$pillGroup.children('.pill').map(function getItemsData () { return self.getItemData($(this)); }).get(); }, itemClicked: function itemClicked (e) { var $target = $(e.target); var $item; e.preventDefault(); e.stopPropagation(); this._closeSuggestions(); if (!$target.hasClass('pill')) { $item = $target.parent(); if (this.$element.attr('data-readonly') === undefined) { if ($target.hasClass('glyphicon-close')) { if (this.options.onRemove) { this.options.onRemove(this.getItemData($item, { el: $item }), $.proxy(this._removeElement, this)); } else { this._removeElement(this.getItemData($item, { el: $item })); } return false; } else if (this.options.edit) { if ($item.find('.pillbox-list-edit').length) { return false; } this.openEdit($item); } } } else { $item = $target; } this.$element.trigger('clicked.fu.pillbox', this.getItemData($item)); return true; }, readonly: function readonly (enable) { if (enable) { this.$element.attr('data-readonly', 'readonly'); } else { this.$element.removeAttr('data-readonly'); } if (this.options.truncate) { this.truncate(enable); } }, suggestionClick: function suggestionClick (e) { var $item = $(e.currentTarget); var item = { text: $item.html(), value: $item.data('value') }; e.preventDefault(); this.$addItem.val(''); if ($item.data('attr')) { item.attr = JSON.parse($item.data('attr')); } item.data = $item.data('data'); this.addItems(item, true); // needs to be after addItems for IE this._closeSuggestions(); }, itemCount: function itemCount () { return this.$pillGroup.children('.pill').length; }, // First parameter is 1 based index (optional, if index is not passed all new items will be appended) // Second parameter can be array of objects [{ ... }, { ... }] or you can pass n additional objects as args // object structure is as follows (attr and value are optional): { text: '', value: '', attr: {}, data: {} } addItems: function addItems () { var self = this; var items; var index; var isInternal; if (isFinite(String(arguments[0])) && !(arguments[0] instanceof Array)) { items = [].slice.call(arguments).slice(1); index = arguments[0]; } else { items = [].slice.call(arguments).slice(0); isInternal = items[1] && !items[1].text; } // If first argument is an array, use that, otherwise they probably passed each thing through as a separate arg, so use items as-is if (items[0] instanceof Array) { items = items[0]; } if (items.length) { $.each(items, function normalizeItemsObject (i, value) { var data = { text: value.text, value: (value.value ? value.value : value.text), el: self.$pillHTML }; if (value.attr) { data.attr = value.attr; } if (value.data) { data.data = value.data; } items[i] = data; }); if (this.options.edit && this.currentEdit) { items[0].el = this.currentEdit.wrap('<div></div>').parent().html(); } if (isInternal) { items.pop(1); } if (self.options.onAdd && isInternal) { if (this.options.edit && this.currentEdit) { self.options.onAdd(items[0], $.proxy(self.saveEdit, this)); } else { self.options.onAdd(items[0], $.proxy(self.placeItems, this)); } } else if (this.options.edit && this.currentEdit) { self.saveEdit(items); } else if (index) { self.placeItems(index, items); } else { self.placeItems(items, isInternal); } } }, // First parameter is the index (1 based) to start removing items // Second parameter is the number of items to be removed removeItems: function removeItems (index, howMany) { var self = this; if (!index) { this.$pillGroup.find('.pill').remove(); this._removePillTrigger({ method: 'removeAll' }); } else { var itemsToRemove = howMany ? howMany : 1; for (var item = 0; item < itemsToRemove; item++) { var $currentItem = self.$pillGroup.find('> .pill:nth-child(' + index + ')'); if ($currentItem) { $currentItem.remove(); } else { break; } } } }, // First parameter is index (optional) // Second parameter is new arguments placeItems: function placeItems () { var items; var index; var $neighbor; var isInternal; if (isFinite(String(arguments[0])) && !(arguments[0] instanceof Array)) { items = [].slice.call(arguments).slice(1); index = arguments[0]; } else { items = [].slice.call(arguments).slice(0); isInternal = items[1] && !items[1].text; } if (items[0] instanceof Array) { items = items[0]; } if (items.length) { var newItems = []; $.each(items, function prepareItemForAdd (i, item) { var $item = $(item.el); $item.attr('data-value', item.value); $item.find('span:first').html(item.text); // DOM attributes if (item.attr) { $.each(item.attr, function handleDOMAttributes (key, value) { if (key === 'cssClass' || key === 'class') { $item.addClass(value); } else { $item.attr(key, value); } }); } if (item.data) { $item.data('data', item.data); } newItems.push($item); }); if (this.$pillGroup.children('.pill').length > 0) { if (index) { $neighbor = this.$pillGroup.find('.pill:nth-child(' + index + ')'); if ($neighbor.length) { $neighbor.before(newItems); } else { this.$pillGroup.children('.pill:last').after(newItems); } } else { this.$pillGroup.children('.pill:last').after(newItems); } } else { this.$pillGroup.prepend(newItems); } if (isInternal) { this.$element.trigger('added.fu.pillbox', { text: items[0].text, value: items[0].value }); } } }, inputEvent: function inputEvent (e) { var self = this; var text = self.options.cleanInput(this.$addItem.val()); var isFocusOutEvent = e.type === 'focusout'; var blurredAfterInput = (isFocusOutEvent && text.length > 0); // If we test for keycode only, it will match for `<` & `,` instead of just `,` // This way users can type `<3` and `1 < 3`, etc... var acceptKeyPressed = (this.acceptKeyCodes[e.keyCode] && !isShiftHeld(e)); if (acceptKeyPressed || blurredAfterInput) { var attr; var value; if (this.options.onKeyDown && this._isSuggestionsOpen()) { var $selection = this.$suggest.find('.pillbox-suggest-sel'); if ($selection.length) { text = self.options.cleanInput($selection.html()); value = self.options.cleanInput($selection.data('value')); attr = $selection.data('attr'); } } // ignore comma and make sure text that has been entered (protects against " ,". https://github.com/ExactTarget/fuelux/issues/593), unless allowEmptyPills is true. if (text.replace(/[ ]*\,[ ]*/, '').match(/\S/) || (this.options.allowEmptyPills && text.length)) { this._closeSuggestions(); this.$addItem.val('').hide(); if (attr) { this.addItems({ text: text, value: value, attr: JSON.parse(attr) }, true); } else { this.addItems({ text: text, value: value }, true); } setTimeout(function clearAddItemInput () { self.$addItem.show().attr({ size: 10 }).focus(); }, 0); } e.preventDefault(); return true; } else if (isBackspaceKey(e) || isDeleteKey(e)) { if (!text.length) { e.preventDefault(); if (this.options.edit && this.currentEdit) { this.cancelEdit(); return true; } this._closeSuggestions(); var $lastItem = this.$pillGroup.children('.pill:last'); if ($lastItem.hasClass('pillbox-highlight')) { this._removeElement(this.getItemData($lastItem, { el: $lastItem })); } else { $lastItem.addClass('pillbox-highlight'); } return true; } } else if (text.length > 10) { if (this.$addItem.width() < (this.$pillGroup.width() - 6)) { this.$addItem.attr({ size: text.length + 3 }); } } this.$pillGroup.find('.pill').removeClass('pillbox-highlight'); if (this.options.onKeyDown && !isFocusOutEvent) { if ( isTabKey(e) || isUpArrow(e) || isDownArrow(e) ) { if (this._isSuggestionsOpen()) { this._keySuggestions(e); } return true; } // only allowing most recent event callback to register this.callbackId = e.timeStamp; this.options.onKeyDown({ event: e, value: text }, function callOpenSuggestions (data) { self._openSuggestions(e, data); }); } return true; }, openEdit: function openEdit (el) { var targetChildIndex = el.index() + 1; var $addItemWrap = this.$addItemWrap.detach().hide(); this.$pillGroup.find('.pill:nth-child(' + targetChildIndex + ')').before($addItemWrap); this.currentEdit = el.detach(); $addItemWrap.addClass('editing'); this.$addItem.val(el.find('span:first').html()); $addItemWrap.show(); this.$addItem.focus().select(); }, cancelEdit: function cancelEdit (e) { var $addItemWrap; if (!this.currentEdit) { return false; } this._closeSuggestions(); if (e) { this.$addItemWrap.before(this.currentEdit); } this.currentEdit = false; $addItemWrap = this.$addItemWrap.detach(); $addItemWrap.removeClass('editing'); this.$addItem.val(''); this.$pillGroup.append($addItemWrap); return true; }, // Must match syntax of placeItem so addItem callback is called when an item is edited // expecting to receive an array back from the callback containing edited items saveEdit: function saveEdit () { var item = arguments[0][0] ? arguments[0][0] : arguments[0]; this.currentEdit = $(item.el); this.currentEdit.data('value', item.value); this.currentEdit.find('span:first').html(item.text); this.$addItemWrap.hide(); this.$addItemWrap.before(this.currentEdit); this.currentEdit = false; this.$addItem.val(''); this.$addItemWrap.removeClass('editing'); this.$pillGroup.append(this.$addItemWrap.detach().show()); this.$element.trigger('edited.fu.pillbox', { value: item.value, text: item.text }); }, removeBySelector: function removeBySelector () { var selectors = [].slice.call(arguments).slice(0); var self = this; $.each(selectors, function doRemove (i, sel) { self.$pillGroup.find(sel).remove(); }); this._removePillTrigger({ method: 'removeBySelector', removedSelectors: selectors }); }, removeByValue: function removeByValue () { var values = [].slice.call(arguments).slice(0); var self = this; $.each(values, function doRemove (i, val) { self.$pillGroup.find('> .pill[data-value="' + val + '"]').remove(); }); this._removePillTrigger({ method: 'removeByValue', removedValues: values }); }, removeByText: function removeByText () { var text = [].slice.call(arguments).slice(0); var self = this; $.each(text, function doRemove (i, matchingText) { self.$pillGroup.find('> .pill:contains("' + matchingText + '")').remove(); }); this._removePillTrigger({ method: 'removeByText', removedText: text }); }, truncate: function truncate (enable) { var self = this; this.$element.removeClass('truncate'); this.$addItemWrap.removeClass('truncated'); this.$pillGroup.find('.pill').removeClass('truncated'); if (enable) { this.$element.addClass('truncate'); var availableWidth = this.$element.width(); var containerFull = false; var processedPills = 0; var totalPills = this.$pillGroup.find('.pill').length; var widthUsed = 0; this.$pillGroup.find('.pill').each(function processPills () { var pill = $(this); if (!containerFull) { processedPills++; self.$moreCount.text(totalPills - processedPills); if ((widthUsed + pill.outerWidth(true) + self.$addItemWrap.outerWidth(true)) <= availableWidth) { widthUsed += pill.outerWidth(true); } else { self.$moreCount.text((totalPills - processedPills) + 1); pill.addClass('truncated'); containerFull = true; } } else { pill.addClass('truncated'); } }); if (processedPills === totalPills) { this.$addItemWrap.addClass('truncated'); } } }, inputFocus: function inputFocus () { this.$element.find('.pillbox-add-item').focus(); }, getItemData: function getItemData (el, data) { return $.extend({ text: el.find('span:first').html() }, el.data(), data); }, _removeElement: function _removeElement (data) { data.el.remove(); delete data.el; this.$element.trigger('removed.fu.pillbox', data); }, _removePillTrigger: function _removePillTrigger (removedBy) { this.$element.trigger('removed.fu.pillbox', removedBy); }, _generateObject: function _generateObject (data) { var obj = {}; $.each(data, function setObjectValue (index, value) { obj[value] = true; }); return obj; }, _openSuggestions: function _openSuggestions (e, data) { var $suggestionList = $('<ul>'); if (this.callbackId !== e.timeStamp) { return false; } if (data.data && data.data.length) { $.each(data.data, function appendSuggestions (index, value) { var val = value.value ? value.value : value.text; // markup concatentation is 10x faster, but does not allow data store var $suggestion = $('<li data-value="' + val + '">' + value.text + '</li>'); if (value.attr) { $suggestion.data('attr', JSON.stringify(value.attr)); } if (value.data) { $suggestion.data('data', value.data); } $suggestionList.append($suggestion); }); // suggestion dropdown this.$suggest.html('').append($suggestionList.children()); $(document).trigger('suggested.fu.pillbox', this.$suggest); } return true; }, _closeSuggestions: function _closeSuggestions () { this.$suggest.html('').parent().removeClass('open'); }, _isSuggestionsOpen: function _isSuggestionsOpen () { return this.$suggest.parent().hasClass('open'); }, _keySuggestions: function _keySuggestions (e) { var $first = this.$suggest.find('li.pillbox-suggest-sel'); var dir = isUpArrow(e); e.preventDefault(); if (!$first.length) { $first = this.$suggest.find('li:first'); $first.addClass('pillbox-suggest-sel'); } else { var $next = dir ? $first.prev() : $first.next(); if (!$next.length) { $next = dir ? this.$suggest.find('li:last') : this.$suggest.find('li:first'); } if ($next) { $next.addClass('pillbox-suggest-sel'); $first.removeClass('pillbox-suggest-sel'); } } } }; Pillbox.prototype.getValue = Pillbox.prototype.items; // PILLBOX PLUGIN DEFINITION $.fn.pillbox = function pillbox (option) { var args = Array.prototype.slice.call(arguments, 1); var methodReturn; var $set = this.each(function set () { var $this = $(this); var data = $this.data('fu.pillbox'); var options = typeof option === 'object' && option; if (!data) { $this.data('fu.pillbox', (data = new Pillbox(this, options))); } if (typeof option === 'string') { methodReturn = data[option].apply(data, args); } }); return (methodReturn === undefined) ? $set : methodReturn; }; $.fn.pillbox.defaults = { edit: false, readonly: -1, // can be true or false. -1 means it will check for data-readonly="readonly" truncate: false, acceptKeyCodes: [ ENTER_KEYCODE, COMMA_KEYCODE ], allowEmptyPills: false, cleanInput: cleanInput // example on remove /* onRemove: function(data,callback){ console.log('onRemove'); callback(data); } */ // example on key down /* onKeyDown: function(event, data, callback ){ callback({data:[ {text: Math.random(),value:'sdfsdfsdf'}, {text: Math.random(),value:'sdfsdfsdf'} ]}); } */ // example onAdd /* onAdd: function( data, callback ){ console.log(data, callback); callback(data); } */ }; $.fn.pillbox.Constructor = Pillbox; $.fn.pillbox.noConflict = function noConflict () { $.fn.pillbox = old; return this; }; // DATA-API $(document).on('mousedown.fu.pillbox.data-api', '[data-initialize=pillbox]', function dataAPI (e) { var $control = $(e.target).closest('.pillbox'); if (!$control.data('fu.pillbox')) { $control.pillbox($control.data()); } }); // Must be domReady for AMD compatibility $(function DOMReady () { $('[data-initialize=pillbox]').each(function init () { var $this = $(this); if ($this.data('fu.pillbox')) return; $this.pillbox($this.data()); }); }); // -- BEGIN UMD WRAPPER AFTERWORD -- })); // -- END UMD WRAPPER AFTERWORD --