fuelux
Version:
Base Fuel UX styles and controls
782 lines (639 loc) • 20.8 kB
JavaScript
/* 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 --