pillbox.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779
  1. /*
  2. * Fuel UX Pillbox
  3. * https://github.com/ExactTarget/fuelux
  4. *
  5. * Copyright (c) 2014 ExactTarget
  6. * Licensed under the BSD New license.
  7. */
  8. // -- BEGIN UMD WRAPPER PREFACE --
  9. // For more information on UMD visit:
  10. // https://github.com/umdjs/umd/blob/master/jqueryPlugin.js
  11. (function (factory) {
  12. if (typeof define === 'function' && define.amd) {
  13. // if AMD loader is available, register as an anonymous module.
  14. define(['jquery', 'fuelux/dropdown-autoflip'], factory);
  15. } else {
  16. // OR use browser globals if AMD is not present
  17. factory(jQuery);
  18. }
  19. }(function ($) {
  20. if (!$.fn.dropdownautoflip) {
  21. throw new Error('Fuel UX pillbox control requires dropdown-autoflip.');
  22. }
  23. // -- END UMD WRAPPER PREFACE --
  24. // -- BEGIN MODULE CODE HERE --
  25. var old = $.fn.pillbox;
  26. // PILLBOX CONSTRUCTOR AND PROTOTYPE
  27. var Pillbox = function (element, options) {
  28. this.$element = $(element);
  29. this.$moreCount = this.$element.find('.pillbox-more-count');
  30. this.$pillGroup = this.$element.find('.pill-group');
  31. this.$addItem = this.$element.find('.pillbox-add-item');
  32. this.$addItemWrap = this.$addItem.parent();
  33. this.$suggest = this.$element.find('.suggest');
  34. this.$pillHTML = '<li class="btn btn-default pill">' +
  35. ' <span></span>' +
  36. ' <span class="glyphicon glyphicon-close">' +
  37. ' <span class="sr-only">Remove</span>' +
  38. ' </span>' +
  39. '</li>';
  40. this.options = $.extend({}, $.fn.pillbox.defaults, options);
  41. if (this.options.readonly === -1) {
  42. if (this.$element.attr('data-readonly') !== undefined) {
  43. this.readonly(true);
  44. }
  45. } else if (this.options.readonly) {
  46. this.readonly(true);
  47. }
  48. // EVENTS
  49. this.acceptKeyCodes = this._generateObject(this.options.acceptKeyCodes);
  50. // Creatie an object out of the key code array, so we dont have to loop through it on every key stroke
  51. this.$element.on('click.fu.pillbox', '.pill-group > .pill', $.proxy(this.itemClicked, this));
  52. this.$element.on('click.fu.pillbox', $.proxy(this.inputFocus, this));
  53. this.$element.on('keydown.fu.pillbox', '.pillbox-add-item', $.proxy(this.inputEvent, this));
  54. if (this.options.onKeyDown) {
  55. this.$element.on('mousedown.fu.pillbox', '.suggest > li', $.proxy(this.suggestionClick, this));
  56. }
  57. if (this.options.edit) {
  58. this.$element.addClass('pills-editable');
  59. this.$element.on('blur.fu.pillbox', '.pillbox-add-item', $.proxy(this.cancelEdit, this));
  60. }
  61. };
  62. Pillbox.prototype = {
  63. constructor: Pillbox,
  64. destroy: function () {
  65. this.$element.remove();
  66. // any external bindings
  67. // [none]
  68. // empty elements to return to original markup
  69. // [none]
  70. // returns string of markup
  71. return this.$element[0].outerHTML;
  72. },
  73. items: function () {
  74. var self = this;
  75. return this.$pillGroup.children('.pill').map(function () {
  76. return self.getItemData($(this));
  77. }).get();
  78. },
  79. itemClicked: function (e) {
  80. var self = this;
  81. var $target = $(e.target);
  82. var $item;
  83. e.preventDefault();
  84. e.stopPropagation();
  85. this._closeSuggestions();
  86. if (!$target.hasClass('pill')) {
  87. $item = $target.parent();
  88. if (this.$element.attr('data-readonly') === undefined) {
  89. if ($target.hasClass('glyphicon-close')) {
  90. if (this.options.onRemove) {
  91. this.options.onRemove(this.getItemData($item, {
  92. el: $item
  93. }), $.proxy(this._removeElement, this));
  94. } else {
  95. this._removeElement(this.getItemData($item, {
  96. el: $item
  97. }));
  98. }
  99. return false;
  100. } else if (this.options.edit) {
  101. if ($item.find('.pillbox-list-edit').length) {
  102. return false;
  103. }
  104. this.openEdit($item);
  105. }
  106. }
  107. } else {
  108. $item = $target;
  109. }
  110. this.$element.trigger('clicked.fu.pillbox', this.getItemData($item));
  111. },
  112. readonly: function (enable) {
  113. if (enable) {
  114. this.$element.attr('data-readonly', 'readonly');
  115. } else {
  116. this.$element.removeAttr('data-readonly');
  117. }
  118. if (this.options.truncate) {
  119. this.truncate(enable);
  120. }
  121. },
  122. suggestionClick: function (e) {
  123. var $item = $(e.currentTarget);
  124. var item = {
  125. text: $item.html(),
  126. value: $item.data('value')
  127. };
  128. e.preventDefault();
  129. this.$addItem.val('');
  130. if ($item.data('attr')) {
  131. item.attr = JSON.parse($item.data('attr'));
  132. }
  133. item.data = $item.data('data');
  134. this.addItems(item, true);
  135. // needs to be after addItems for IE
  136. this._closeSuggestions();
  137. },
  138. itemCount: function () {
  139. return this.$pillGroup.children('.pill').length;
  140. },
  141. // First parameter is 1 based index (optional, if index is not passed all new items will be appended)
  142. // Second parameter can be array of objects [{ ... }, { ... }] or you can pass n additional objects as args
  143. // object structure is as follows (attr and value are optional): { text: '', value: '', attr: {}, data: {} }
  144. addItems: function () {
  145. var self = this;
  146. var items, index, isInternal;
  147. if (isFinite(String(arguments[0])) && !(arguments[0] instanceof Array)) {
  148. items = [].slice.call(arguments).slice(1);
  149. index = arguments[0];
  150. } else {
  151. items = [].slice.call(arguments).slice(0);
  152. isInternal = items[1] && !items[1].text;
  153. }
  154. //If first argument is an array, use that, otherwise they probably passed each thing through as a separate arg, so use items as-is
  155. if (items[0] instanceof Array) {
  156. items = items[0];
  157. }
  158. if (items.length) {
  159. $.each(items, function (i, value) {
  160. var data = {
  161. text: value.text,
  162. value: (value.value ? value.value : value.text),
  163. el: self.$pillHTML
  164. };
  165. if (value.attr) {
  166. data.attr = value.attr;
  167. }
  168. if (value.data) {
  169. data.data = value.data;
  170. }
  171. items[i] = data;
  172. });
  173. if (this.options.edit && this.currentEdit) {
  174. items[0].el = this.currentEdit.wrap('<div></div>').parent().html();
  175. }
  176. if (isInternal) {
  177. items.pop(1);
  178. }
  179. if (self.options.onAdd && isInternal) {
  180. if (this.options.edit && this.currentEdit) {
  181. self.options.onAdd(items[0], $.proxy(self.saveEdit, this));
  182. } else {
  183. self.options.onAdd(items[0], $.proxy(self.placeItems, this));
  184. }
  185. } else {
  186. if (this.options.edit && this.currentEdit) {
  187. self.saveEdit(items);
  188. } else {
  189. if (index) {
  190. self.placeItems(index, items);
  191. } else {
  192. self.placeItems(items, isInternal);
  193. }
  194. }
  195. }
  196. }
  197. },
  198. //First parameter is the index (1 based) to start removing items
  199. //Second parameter is the number of items to be removed
  200. removeItems: function (index, howMany) {
  201. var self = this;
  202. var count;
  203. var $currentItem;
  204. if (!index) {
  205. this.$pillGroup.find('.pill').remove();
  206. this._removePillTrigger({
  207. method: 'removeAll'
  208. });
  209. } else {
  210. howMany = howMany ? howMany : 1;
  211. for (count = 0; count < howMany; count++) {
  212. $currentItem = self.$pillGroup.find('> .pill:nth-child(' + index + ')');
  213. if ($currentItem) {
  214. $currentItem.remove();
  215. } else {
  216. break;
  217. }
  218. }
  219. }
  220. },
  221. //First parameter is index (optional)
  222. //Second parameter is new arguments
  223. placeItems: function () {
  224. var $newHtml = [];
  225. var items;
  226. var index;
  227. var $neighbor;
  228. var isInternal;
  229. if (isFinite(String(arguments[0])) && !(arguments[0] instanceof Array)) {
  230. items = [].slice.call(arguments).slice(1);
  231. index = arguments[0];
  232. } else {
  233. items = [].slice.call(arguments).slice(0);
  234. isInternal = items[1] && !items[1].text;
  235. }
  236. if (items[0] instanceof Array) {
  237. items = items[0];
  238. }
  239. if (items.length) {
  240. $.each(items, function (i, item) {
  241. var $item = $(item.el);
  242. var $neighbor;
  243. $item.attr('data-value', item.value);
  244. $item.find('span:first').html(item.text);
  245. // DOM attributes
  246. if (item.attr) {
  247. $.each(item.attr, function (key, value) {
  248. if (key === 'cssClass' || key === 'class') {
  249. $item.addClass(value);
  250. } else {
  251. $item.attr(key, value);
  252. }
  253. });
  254. }
  255. if (item.data) {
  256. $item.data('data', item.data);
  257. }
  258. $newHtml.push($item);
  259. });
  260. if (this.$pillGroup.children('.pill').length > 0) {
  261. if (index) {
  262. $neighbor = this.$pillGroup.find('.pill:nth-child(' + index + ')');
  263. if ($neighbor.length) {
  264. $neighbor.before($newHtml);
  265. } else {
  266. this.$pillGroup.children('.pill:last').after($newHtml);
  267. }
  268. } else {
  269. this.$pillGroup.children('.pill:last').after($newHtml);
  270. }
  271. } else {
  272. this.$pillGroup.prepend($newHtml);
  273. }
  274. if (isInternal) {
  275. this.$element.trigger('added.fu.pillbox', {
  276. text: items[0].text,
  277. value: items[0].value
  278. });
  279. }
  280. }
  281. },
  282. inputEvent: function (e) {
  283. var self = this;
  284. var text = this.$addItem.val();
  285. var value;
  286. var attr;
  287. var $lastItem;
  288. var $selection;
  289. if (this.acceptKeyCodes[e.keyCode]) {
  290. if (this.options.onKeyDown && this._isSuggestionsOpen()) {
  291. $selection = this.$suggest.find('.pillbox-suggest-sel');
  292. if ($selection.length) {
  293. text = $selection.html();
  294. value = $selection.data('value');
  295. attr = $selection.data('attr');
  296. }
  297. }
  298. //ignore comma and make sure text that has been entered (protects against " ,". https://github.com/ExactTarget/fuelux/issues/593), unless allowEmptyPills is true.
  299. if (text.replace(/[ ]*\,[ ]*/, '').match(/\S/) || (this.options.allowEmptyPills && text.length)) {
  300. this._closeSuggestions();
  301. this.$addItem.hide();
  302. if (attr) {
  303. this.addItems({
  304. text: text,
  305. value: value,
  306. attr: JSON.parse(attr)
  307. }, true);
  308. } else {
  309. this.addItems({
  310. text: text,
  311. value: value
  312. }, true);
  313. }
  314. setTimeout(function () {
  315. self.$addItem.show().val('').attr({
  316. size: 10
  317. });
  318. }, 0);
  319. }
  320. e.preventDefault();
  321. return true;
  322. } else if (e.keyCode === 8 || e.keyCode === 46) {
  323. // backspace: 8
  324. // delete: 46
  325. if (!text.length) {
  326. e.preventDefault();
  327. if (this.options.edit && this.currentEdit) {
  328. this.cancelEdit();
  329. return true;
  330. }
  331. this._closeSuggestions();
  332. $lastItem = this.$pillGroup.children('.pill:last');
  333. if ($lastItem.hasClass('pillbox-highlight')) {
  334. this._removeElement(this.getItemData($lastItem, {
  335. el: $lastItem
  336. }));
  337. } else {
  338. $lastItem.addClass('pillbox-highlight');
  339. }
  340. return true;
  341. }
  342. } else if (text.length > 10) {
  343. if (this.$addItem.width() < (this.$pillGroup.width() - 6)) {
  344. this.$addItem.attr({
  345. size: text.length + 3
  346. });
  347. }
  348. }
  349. this.$pillGroup.find('.pill').removeClass('pillbox-highlight');
  350. if (this.options.onKeyDown) {
  351. if (e.keyCode === 9 || e.keyCode === 38 || e.keyCode === 40) {
  352. // tab: 9
  353. // up arrow: 38
  354. // down arrow: 40
  355. if (this._isSuggestionsOpen()) {
  356. this._keySuggestions(e);
  357. }
  358. return true;
  359. }
  360. //only allowing most recent event callback to register
  361. this.callbackId = e.timeStamp;
  362. this.options.onKeyDown({
  363. event: e,
  364. value: text
  365. }, function (data) {
  366. self._openSuggestions(e, data);
  367. });
  368. }
  369. },
  370. openEdit: function (el) {
  371. var index = el.index() + 1;
  372. var $addItemWrap = this.$addItemWrap.detach().hide();
  373. this.$pillGroup.find('.pill:nth-child(' + index + ')').before($addItemWrap);
  374. this.currentEdit = el.detach();
  375. $addItemWrap.addClass('editing');
  376. this.$addItem.val(el.find('span:first').html());
  377. $addItemWrap.show();
  378. this.$addItem.focus().select();
  379. },
  380. cancelEdit: function (e) {
  381. var $addItemWrap;
  382. if (!this.currentEdit) {
  383. return false;
  384. }
  385. this._closeSuggestions();
  386. if (e) {
  387. this.$addItemWrap.before(this.currentEdit);
  388. }
  389. this.currentEdit = false;
  390. $addItemWrap = this.$addItemWrap.detach();
  391. $addItemWrap.removeClass('editing');
  392. this.$addItem.val('');
  393. this.$pillGroup.append($addItemWrap);
  394. },
  395. //Must match syntax of placeItem so addItem callback is called when an item is edited
  396. //expecting to receive an array back from the callback containing edited items
  397. saveEdit: function () {
  398. var item = arguments[0][0] ? arguments[0][0] : arguments[0];
  399. this.currentEdit = $(item.el);
  400. this.currentEdit.data('value', item.value);
  401. this.currentEdit.find('span:first').html(item.text);
  402. this.$addItemWrap.hide();
  403. this.$addItemWrap.before(this.currentEdit);
  404. this.currentEdit = false;
  405. this.$addItem.val('');
  406. this.$addItemWrap.removeClass('editing');
  407. this.$pillGroup.append(this.$addItemWrap.detach().show());
  408. this.$element.trigger('edited.fu.pillbox', {
  409. value: item.value,
  410. text: item.text
  411. });
  412. },
  413. removeBySelector: function () {
  414. var selectors = [].slice.call(arguments).slice(0);
  415. var self = this;
  416. $.each(selectors, function (i, sel) {
  417. self.$pillGroup.find(sel).remove();
  418. });
  419. this._removePillTrigger({
  420. method: 'removeBySelector',
  421. removedSelectors: selectors
  422. });
  423. },
  424. removeByValue: function () {
  425. var values = [].slice.call(arguments).slice(0);
  426. var self = this;
  427. $.each(values, function (i, val) {
  428. self.$pillGroup.find('> .pill[data-value="' + val + '"]').remove();
  429. });
  430. this._removePillTrigger({
  431. method: 'removeByValue',
  432. removedValues: values
  433. });
  434. },
  435. removeByText: function () {
  436. var text = [].slice.call(arguments).slice(0);
  437. var self = this;
  438. $.each(text, function (i, text) {
  439. self.$pillGroup.find('> .pill:contains("' + text + '")').remove();
  440. });
  441. this._removePillTrigger({
  442. method: 'removeByText',
  443. removedText: text
  444. });
  445. },
  446. truncate: function (enable) {
  447. var self = this;
  448. var available, full, i, pills, used;
  449. this.$element.removeClass('truncate');
  450. this.$addItemWrap.removeClass('truncated');
  451. this.$pillGroup.find('.pill').removeClass('truncated');
  452. if (enable) {
  453. this.$element.addClass('truncate');
  454. available = this.$element.width();
  455. full = false;
  456. i = 0;
  457. pills = this.$pillGroup.find('.pill').length;
  458. used = 0;
  459. this.$pillGroup.find('.pill').each(function () {
  460. var pill = $(this);
  461. if (!full) {
  462. i++;
  463. self.$moreCount.text(pills - i);
  464. if ((used + pill.outerWidth(true) + self.$addItemWrap.outerWidth(true)) <= available) {
  465. used += pill.outerWidth(true);
  466. } else {
  467. self.$moreCount.text((pills - i) + 1);
  468. pill.addClass('truncated');
  469. full = true;
  470. }
  471. } else {
  472. pill.addClass('truncated');
  473. }
  474. });
  475. if (i === pills) {
  476. this.$addItemWrap.addClass('truncated');
  477. }
  478. }
  479. },
  480. inputFocus: function (e) {
  481. this.$element.find('.pillbox-add-item').focus();
  482. },
  483. getItemData: function (el, data) {
  484. return $.extend({
  485. text: el.find('span:first').html()
  486. }, el.data(), data);
  487. },
  488. _removeElement: function (data) {
  489. data.el.remove();
  490. delete data.el;
  491. this.$element.trigger('removed.fu.pillbox', data);
  492. },
  493. _removePillTrigger: function (removedBy) {
  494. this.$element.trigger('removed.fu.pillbox', removedBy);
  495. },
  496. _generateObject: function (data) {
  497. var obj = {};
  498. $.each(data, function (index, value) {
  499. obj[value] = true;
  500. });
  501. return obj;
  502. },
  503. _openSuggestions: function (e, data) {
  504. var markup = '';
  505. var $suggestionList = $('<ul>');
  506. if (this.callbackId !== e.timeStamp) {
  507. return false;
  508. }
  509. if (data.data && data.data.length) {
  510. $.each(data.data, function (index, value) {
  511. var val = value.value ? value.value : value.text;
  512. // markup concatentation is 10x faster, but does not allow data store
  513. var $suggestion = $('<li data-value="' + val + '">' + value.text + '</li>');
  514. if (value.attr) {
  515. $suggestion.data('attr', JSON.stringify(value.attr));
  516. }
  517. if (value.data) {
  518. $suggestion.data('data', value.data);
  519. }
  520. $suggestionList.append($suggestion);
  521. });
  522. // suggestion dropdown
  523. this.$suggest.html('').append($suggestionList.children());
  524. $(document.body).trigger('suggested.fu.pillbox', this.$suggest);
  525. }
  526. },
  527. _closeSuggestions: function () {
  528. this.$suggest.html('').parent().removeClass('open');
  529. },
  530. _isSuggestionsOpen: function () {
  531. return this.$suggest.parent().hasClass('open');
  532. },
  533. _keySuggestions: function (e) {
  534. var $first = this.$suggest.find('li.pillbox-suggest-sel');
  535. var dir = e.keyCode === 38;// up arrow
  536. var $next, val;
  537. e.preventDefault();
  538. if (!$first.length) {
  539. $first = this.$suggest.find('li:first');
  540. $first.addClass('pillbox-suggest-sel');
  541. } else {
  542. $next = dir ? $first.prev() : $first.next();
  543. if (!$next.length) {
  544. $next = dir ? this.$suggest.find('li:last') : this.$suggest.find('li:first');
  545. }
  546. if ($next) {
  547. $next.addClass('pillbox-suggest-sel');
  548. $first.removeClass('pillbox-suggest-sel');
  549. }
  550. }
  551. }
  552. };
  553. // PILLBOX PLUGIN DEFINITION
  554. $.fn.pillbox = function (option) {
  555. var args = Array.prototype.slice.call(arguments, 1);
  556. var methodReturn;
  557. var $set = this.each(function () {
  558. var $this = $(this);
  559. var data = $this.data('fu.pillbox');
  560. var options = typeof option === 'object' && option;
  561. if (!data) {
  562. $this.data('fu.pillbox', (data = new Pillbox(this, options)));
  563. }
  564. if (typeof option === 'string') {
  565. methodReturn = data[option].apply(data, args);
  566. }
  567. });
  568. return (methodReturn === undefined) ? $set : methodReturn;
  569. };
  570. $.fn.pillbox.defaults = {
  571. onAdd: undefined,
  572. onRemove: undefined,
  573. onKeyDown: undefined,
  574. edit: false,
  575. readonly: -1,//can be true or false. -1 means it will check for data-readonly="readonly"
  576. truncate: false,
  577. acceptKeyCodes: [
  578. 13,//Enter
  579. 188//Comma
  580. ],
  581. allowEmptyPills: false
  582. //example on remove
  583. /*onRemove: function(data,callback){
  584. console.log('onRemove');
  585. callback(data);
  586. }*/
  587. //example on key down
  588. /*onKeyDown: function(event, data, callback ){
  589. callback({data:[
  590. {text: Math.random(),value:'sdfsdfsdf'},
  591. {text: Math.random(),value:'sdfsdfsdf'}
  592. ]});
  593. }
  594. */
  595. //example onAdd
  596. /*onAdd: function( data, callback ){
  597. console.log(data, callback);
  598. callback(data);
  599. }*/
  600. };
  601. $.fn.pillbox.Constructor = Pillbox;
  602. $.fn.pillbox.noConflict = function () {
  603. $.fn.pillbox = old;
  604. return this;
  605. };
  606. // DATA-API
  607. $(document).on('mousedown.fu.pillbox.data-api', '[data-initialize=pillbox]', function (e) {
  608. var $control = $(e.target).closest('.pillbox');
  609. if (!$control.data('fu.pillbox')) {
  610. $control.pillbox($control.data());
  611. }
  612. });
  613. // Must be domReady for AMD compatibility
  614. $(function () {
  615. $('[data-initialize=pillbox]').each(function () {
  616. var $this = $(this);
  617. if ($this.data('fu.pillbox')) return;
  618. $this.pillbox($this.data());
  619. });
  620. });
  621. // -- BEGIN UMD WRAPPER AFTERWORD --
  622. }));
  623. // -- END UMD WRAPPER AFTERWORD --