tree.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  1. /*
  2. * Fuel UX Tree
  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'], factory);
  15. } else {
  16. // OR use browser globals if AMD is not present
  17. factory(jQuery);
  18. }
  19. }(function ($) {
  20. // -- END UMD WRAPPER PREFACE --
  21. // -- BEGIN MODULE CODE HERE --
  22. var old = $.fn.tree;
  23. // TREE CONSTRUCTOR AND PROTOTYPE
  24. var Tree = function Tree(element, options) {
  25. this.$element = $(element);
  26. this.options = $.extend({}, $.fn.tree.defaults, options);
  27. if (this.options.itemSelect) {
  28. this.$element.on('click.fu.tree', '.tree-item', $.proxy(function (ev) {
  29. this.selectItem(ev.currentTarget);
  30. }, this));
  31. }
  32. this.$element.on('click.fu.tree', '.tree-branch-name', $.proxy(function (ev) {
  33. this.toggleFolder(ev.currentTarget);
  34. }, this));
  35. if (this.options.folderSelect) {
  36. this.$element.off('click.fu.tree', '.tree-branch-name');
  37. this.$element.on('click.fu.tree', '.icon-caret', $.proxy(function (ev) {
  38. this.toggleFolder($(ev.currentTarget).parent());
  39. }, this));
  40. this.$element.on('click.fu.tree', '.tree-branch-name', $.proxy(function (ev) {
  41. this.selectFolder($(ev.currentTarget));
  42. }, this));
  43. }
  44. this.render();
  45. };
  46. Tree.prototype = {
  47. constructor: Tree,
  48. destroy: function destroy() {
  49. // any external bindings [none]
  50. // empty elements to return to original markup
  51. this.$element.find("li:not([data-template])").remove();
  52. this.$element.remove();
  53. // returns string of markup
  54. return this.$element[0].outerHTML;
  55. },
  56. render: function render() {
  57. this.populate(this.$element);
  58. },
  59. populate: function populate($el) {
  60. var self = this;
  61. var $parent = ($el.hasClass('tree')) ? $el : $el.parent();
  62. var loader = $parent.find('.tree-loader:eq(0)');
  63. var treeData = $parent.data();
  64. loader.removeClass('hide');
  65. this.options.dataSource(treeData ? treeData : {}, function (items) {
  66. loader.addClass('hide');
  67. $.each(items.data, function (index, value) {
  68. var $entity;
  69. if (value.type === 'folder') {
  70. $entity = self.$element.find('[data-template=treebranch]:eq(0)').clone().removeClass('hide').removeData('template');
  71. $entity.data(value);
  72. $entity.find('.tree-branch-name > .tree-label').html(value.text || value.name);
  73. } else if (value.type === 'item') {
  74. $entity = self.$element.find('[data-template=treeitem]:eq(0)').clone().removeClass('hide').removeData('template');
  75. $entity.find('.tree-item-name > .tree-label').html(value.text || value.name);
  76. $entity.data(value);
  77. }
  78. // Decorate $entity with data or other attributes making the
  79. // element easily accessable with libraries like jQuery.
  80. //
  81. // Values are contained within the object returned
  82. // for folders and items as attr:
  83. //
  84. // {
  85. // text: "An Item",
  86. // type: 'item',
  87. // attr = {
  88. // 'classes': 'required-item red-text',
  89. // 'data-parent': parentId,
  90. // 'guid': guid,
  91. // 'id': guid
  92. // }
  93. // };
  94. //
  95. // the "name" attribute is also supported but is deprecated for "text".
  96. // add attributes to tree-branch or tree-item
  97. var attr = value.attr || value.dataAttributes || [];
  98. $.each(attr, function (key, value) {
  99. switch (key) {
  100. case 'cssClass':
  101. case 'class':
  102. case 'className':
  103. $entity.addClass(value);
  104. break;
  105. // allow custom icons
  106. case 'data-icon':
  107. $entity.find('.icon-item').removeClass().addClass('icon-item ' + value);
  108. $entity.attr(key, value);
  109. break;
  110. // ARIA support
  111. case 'id':
  112. $entity.attr(key, value);
  113. $entity.attr('aria-labelledby', value + '-label');
  114. $entity.find('.tree-branch-name > .tree-label').attr('id', value + '-label');
  115. break;
  116. // style, data-*
  117. default:
  118. $entity.attr(key, value);
  119. break;
  120. }
  121. });
  122. // add child nodes
  123. if ($el.hasClass('tree-branch-header')) {
  124. $parent.find('.tree-branch-children:eq(0)').append($entity);
  125. } else {
  126. $el.append($entity);
  127. }
  128. });
  129. // return newly populated folder
  130. self.$element.trigger('loaded.fu.tree', $parent);
  131. });
  132. },
  133. selectItem: function selectItem(el) {
  134. if (!this.options.itemSelect) return;
  135. var $el = $(el);
  136. var selData = $el.data();
  137. var $all = this.$element.find('.tree-selected');
  138. var data = [];
  139. var $icon = $el.find('.icon-item');
  140. if (this.options.multiSelect) {
  141. $.each($all, function (index, value) {
  142. var $val = $(value);
  143. if ($val[0] !== $el[0]) {
  144. data.push($(value).data());
  145. }
  146. });
  147. } else if ($all[0] !== $el[0]) {
  148. $all.removeClass('tree-selected')
  149. .find('.glyphicon').removeClass('glyphicon-ok').addClass('fueluxicon-bullet');
  150. data.push(selData);
  151. }
  152. var eventType = 'selected';
  153. if ($el.hasClass('tree-selected')) {
  154. eventType = 'deselected';
  155. $el.removeClass('tree-selected');
  156. if ($icon.hasClass('glyphicon-ok') || $icon.hasClass('fueluxicon-bullet')) {
  157. $icon.removeClass('glyphicon-ok').addClass('fueluxicon-bullet');
  158. }
  159. } else {
  160. $el.addClass ('tree-selected');
  161. // add tree dot back in
  162. if ($icon.hasClass('glyphicon-ok') || $icon.hasClass('fueluxicon-bullet')) {
  163. $icon.removeClass('fueluxicon-bullet').addClass('glyphicon-ok');
  164. }
  165. if (this.options.multiSelect) {
  166. data.push(selData);
  167. }
  168. }
  169. this.$element.trigger(eventType + '.fu.tree', {
  170. target: selData,
  171. selected: data
  172. });
  173. // Return new list of selected items, the item
  174. // clicked, and the type of event:
  175. $el.trigger('updated.fu.tree', {
  176. selected: data,
  177. item: $el,
  178. eventType: eventType
  179. });
  180. },
  181. openFolder: function openFolder(el, ignoreRedundantOpens) {
  182. var $el = $(el);
  183. //don't break the API :| (make this functionally the same as calling 'toggleFolder')
  184. if (!ignoreRedundantOpens && $el.find('.glyphicon-folder-open').length && !this.options.ignoreRedundantOpens) {
  185. this.closeFolder(el);
  186. }
  187. var $branch = $el.closest('.tree-branch');
  188. var $treeFolderContent = $branch.find('.tree-branch-children');
  189. var $treeFolderContentFirstChild = $treeFolderContent.eq(0);
  190. //take care of the styles
  191. $branch.addClass('tree-open');
  192. $branch.attr('aria-expanded', 'true');
  193. $treeFolderContentFirstChild.removeClass('hide');
  194. $branch.find('> .tree-branch-header .icon-folder').eq(0)
  195. .removeClass('glyphicon-folder-close')
  196. .addClass('glyphicon-folder-open');
  197. //add the children to the folder
  198. if (!$treeFolderContent.children().length) {
  199. this.populate($treeFolderContent);
  200. }
  201. this.$element.trigger('opened.fu.tree', $branch.data());
  202. },
  203. closeFolder: function closeFolder(el) {
  204. var $el = $(el);
  205. var $branch = $el.closest('.tree-branch');
  206. var $treeFolderContent = $branch.find('.tree-branch-children');
  207. var $treeFolderContentFirstChild = $treeFolderContent.eq(0);
  208. //take care of the styles
  209. $branch.removeClass('tree-open');
  210. $branch.attr('aria-expanded', 'false');
  211. $treeFolderContentFirstChild.addClass('hide');
  212. $branch.find('> .tree-branch-header .icon-folder').eq(0)
  213. .removeClass('glyphicon-folder-open')
  214. .addClass('glyphicon-folder-close');
  215. // remove chidren if no cache
  216. if (!this.options.cacheItems) {
  217. $treeFolderContentFirstChild.empty();
  218. }
  219. this.$element.trigger('closed.fu.tree', $branch.data());
  220. },
  221. toggleFolder: function toggleFolder(el) {
  222. var $el = $(el);
  223. if ($el.find('.glyphicon-folder-close').length) {
  224. this.openFolder(el);
  225. } else if ($el.find('.glyphicon-folder-open').length) {
  226. this.closeFolder(el);
  227. }
  228. },
  229. selectFolder: function selectFolder(clickedElement) {
  230. if (!this.options.folderSelect) return;
  231. var $clickedElement = $(clickedElement);
  232. var $clickedBranch = $clickedElement.closest('.tree-branch');
  233. var $selectedBranch = this.$element.find('.tree-branch.tree-selected');
  234. var clickedData = $clickedBranch.data();
  235. var selectedData = [];
  236. var eventType = 'selected';
  237. // select clicked item
  238. if ($clickedBranch.hasClass('tree-selected')) {
  239. eventType = 'deselected';
  240. $clickedBranch.removeClass('tree-selected');
  241. } else {
  242. $clickedBranch.addClass('tree-selected');
  243. }
  244. if (this.options.multiSelect) {
  245. // get currently selected
  246. $selectedBranch = this.$element.find('.tree-branch.tree-selected');
  247. $.each($selectedBranch, function (index, value) {
  248. var $value = $(value);
  249. if ($value[0] !== $clickedElement[0]) {
  250. selectedData.push($(value).data());
  251. }
  252. });
  253. } else if ($selectedBranch[0] !== $clickedElement[0]) {
  254. $selectedBranch.removeClass('tree-selected');
  255. selectedData.push(clickedData);
  256. }
  257. this.$element.trigger(eventType + '.fu.tree', {
  258. target: clickedData,
  259. selected: selectedData
  260. });
  261. // Return new list of selected items, the item
  262. // clicked, and the type of event:
  263. $clickedElement.trigger('updated.fu.tree', {
  264. selected: selectedData,
  265. item: $clickedElement,
  266. eventType: eventType
  267. });
  268. },
  269. selectedItems: function selectedItems() {
  270. var $sel = this.$element.find('.tree-selected');
  271. var data = [];
  272. $.each($sel, function (index, value) {
  273. data.push($(value).data());
  274. });
  275. return data;
  276. },
  277. // collapses open folders
  278. collapse: function collapse() {
  279. var self = this;
  280. var reportedClosed = [];
  281. var closedReported = function closedReported(event, closed) {
  282. reportedClosed.push(closed);
  283. if (self.$element.find(".tree-branch.tree-open:not('.hide')").length === 0) {
  284. self.$element.trigger('closedAll.fu.tree', {
  285. tree: self.$element,
  286. reportedClosed: reportedClosed
  287. });
  288. self.$element.off('loaded.fu.tree', self.$element, closedReported);
  289. }
  290. };
  291. //trigger callback when all folders have reported closed
  292. self.$element.on('closed.fu.tree', closedReported);
  293. self.$element.find(".tree-branch.tree-open:not('.hide')").each(function () {
  294. self.closeFolder(this);
  295. });
  296. },
  297. //disclose visible will only disclose visible tree folders
  298. discloseVisible: function discloseVisible() {
  299. var self = this;
  300. var $openableFolders = self.$element.find(".tree-branch:not('.tree-open, .hide')");
  301. var reportedOpened = [];
  302. var openReported = function openReported(event, opened) {
  303. reportedOpened.push(opened);
  304. if (reportedOpened.length === $openableFolders.length) {
  305. self.$element.trigger('disclosedVisible.fu.tree', {
  306. tree: self.$element,
  307. reportedOpened: reportedOpened
  308. });
  309. /*
  310. * Unbind the `openReported` event. `discloseAll` may be running and we want to reset this
  311. * method for the next iteration.
  312. */
  313. self.$element.off('loaded.fu.tree', self.$element, openReported);
  314. }
  315. };
  316. //trigger callback when all folders have reported opened
  317. self.$element.on('loaded.fu.tree', openReported);
  318. // open all visible folders
  319. self.$element.find(".tree-branch:not('.tree-open, .hide')").each(function triggerOpen() {
  320. self.openFolder($(this).find('.tree-branch-header'), true);
  321. });
  322. },
  323. /**
  324. * Disclose all will keep listening for `loaded.fu.tree` and if `$(tree-el).data('ignore-disclosures-limit')`
  325. * is `true` (defaults to `true`) it will attempt to disclose any new closed folders than were
  326. * loaded in during the last disclosure.
  327. */
  328. discloseAll: function discloseAll() {
  329. var self = this;
  330. //first time
  331. if (typeof self.$element.data('disclosures') === 'undefined') {
  332. self.$element.data('disclosures', 0);
  333. }
  334. var isExceededLimit = (self.options.disclosuresUpperLimit >= 1 && self.$element.data('disclosures') >= self.options.disclosuresUpperLimit);
  335. var isAllDisclosed = self.$element.find(".tree-branch:not('.tree-open, .hide')").length === 0;
  336. if (!isAllDisclosed) {
  337. if (isExceededLimit) {
  338. self.$element.trigger('exceededDisclosuresLimit.fu.tree', {
  339. tree: self.$element,
  340. disclosures: self.$element.data('disclosures')
  341. });
  342. /*
  343. * If you've exceeded the limit, the loop will be killed unless you
  344. * explicitly ignore the limit and start the loop again:
  345. *
  346. * $tree.one('exceededDisclosuresLimit.fu.tree', function () {
  347. * $tree.data('ignore-disclosures-limit', true);
  348. * $tree.tree('discloseAll');
  349. * });
  350. */
  351. if (!self.$element.data('ignore-disclosures-limit')) {
  352. return;
  353. }
  354. }
  355. self.$element.data('disclosures', self.$element.data('disclosures') + 1);
  356. /*
  357. * A new branch that is closed might be loaded in, make sure those get handled too.
  358. * This attachment needs to occur before calling `discloseVisible` to make sure that
  359. * if the execution of `discloseVisible` happens _super fast_ (as it does in our QUnit tests
  360. * this will still be called. However, make sure this only gets called _once_, because
  361. * otherwise, every single time we go through this loop, _another_ event will be bound
  362. * and then when the trigger happens, this will fire N times, where N equals the number
  363. * of recursive `discloseAll` executions (instead of just one)
  364. */
  365. self.$element.one('disclosedVisible.fu.tree', function () {
  366. self.discloseAll();
  367. });
  368. /*
  369. * If the page is very fast, calling this first will cause `disclosedVisible.fu.tree` to not
  370. * be bound in time to be called, so, we need to call this last so that the things bound
  371. * and triggered above can have time to take place before the next execution of the
  372. * `discloseAll` method.
  373. */
  374. self.discloseVisible();
  375. } else {
  376. self.$element.trigger('disclosedAll.fu.tree', {
  377. tree: self.$element,
  378. disclosures: self.$element.data('disclosures')
  379. });
  380. //if `cacheItems` is false, and they call closeAll, the data is trashed and therefore
  381. //disclosures needs to accurately reflect that
  382. if (!self.options.cacheItems) {
  383. self.$element.one('closeAll.fu.tree', function () {
  384. self.$element.data('disclosures', 0);
  385. });
  386. }
  387. }
  388. }
  389. };
  390. //alias for collapse for consistency. "Collapse" is an ambiguous term (collapse what? All? One specific branch?)
  391. Tree.prototype.closeAll = Tree.prototype.collapse;
  392. // TREE PLUGIN DEFINITION
  393. $.fn.tree = function tree(option) {
  394. var args = Array.prototype.slice.call(arguments, 1);
  395. var methodReturn;
  396. var $set = this.each(function () {
  397. var $this = $(this);
  398. var data = $this.data('fu.tree');
  399. var options = typeof option === 'object' && option;
  400. if (!data) {
  401. $this.data('fu.tree', (data = new Tree(this, options)));
  402. }
  403. if (typeof option === 'string') {
  404. methodReturn = data[option].apply(data, args);
  405. }
  406. });
  407. return (methodReturn === undefined) ? $set : methodReturn;
  408. };
  409. $.fn.tree.defaults = {
  410. dataSource: function dataSource(options, callback) {},
  411. multiSelect: false,
  412. cacheItems: true,
  413. folderSelect: true,
  414. itemSelect: true,
  415. /*
  416. * Calling "open" on something, should do that. However, the current API
  417. * instead treats "open" as a "toggle" and will close a folder that is open
  418. * if you call `openFolder` on it. Setting `ignoreRedundantOpens` to `true`
  419. * will make the folder instead ignore the redundant call and stay open.
  420. * This allows you to fix the API until 3.7.x when we can deprecate the switch
  421. * and make `openFolder` behave correctly by default.
  422. */
  423. ignoreRedundantOpens: false,
  424. /*
  425. * How many times `discloseAll` should be called before a stopping and firing
  426. * an `exceededDisclosuresLimit` event. You can force it to continue by
  427. * listening for this event, setting `ignore-disclosures-limit` to `true` and
  428. * starting `discloseAll` back up again. This lets you make more decisions
  429. * about if/when/how/why/how many times `discloseAll` will be started back
  430. * up after it exceeds the limit.
  431. *
  432. * $tree.one('exceededDisclosuresLimit.fu.tree', function () {
  433. * $tree.data('ignore-disclosures-limit', true);
  434. * $tree.tree('discloseAll');
  435. * });
  436. *
  437. * `disclusuresUpperLimit` defaults to `0`, so by default this trigger
  438. * will never fire. The true hard the upper limit is the browser's
  439. * ability to load new items (i.e. it will keep loading until the browser
  440. * falls over and dies). On the Fuel UX `index.html` page, the point at
  441. * which the page became super slow (enough to seem almost unresponsive)
  442. * was `4`, meaning 256 folders had been opened, and 1024 were attempting to open.
  443. */
  444. disclosuresUpperLimit: 0
  445. };
  446. $.fn.tree.Constructor = Tree;
  447. $.fn.tree.noConflict = function () {
  448. $.fn.tree = old;
  449. return this;
  450. };
  451. // NO DATA-API DUE TO NEED OF DATA-SOURCE
  452. // -- BEGIN UMD WRAPPER AFTERWORD --
  453. }));
  454. // -- END UMD WRAPPER AFTERWORD --