/*
|
* Fuel UX Tree
|
* https://github.com/ExactTarget/fuelux
|
*
|
* Copyright (c) 2014 ExactTarget
|
* Licensed under the BSD New license.
|
*/
|
|
// -- BEGIN UMD WRAPPER PREFACE --
|
|
// For more information on UMD visit:
|
// https://github.com/umdjs/umd/blob/master/jqueryPlugin.js
|
|
(function (factory) {
|
if (typeof define === 'function' && define.amd) {
|
// if AMD loader is available, register as an anonymous module.
|
define(['jquery'], factory);
|
} else if (typeof exports === 'object') {
|
// Node/CommonJS
|
module.exports = factory(require('jquery'));
|
} else {
|
// OR use browser globals if AMD is not present
|
factory(jQuery);
|
}
|
}(function ($) {
|
// -- END UMD WRAPPER PREFACE --
|
|
// -- BEGIN MODULE CODE HERE --
|
|
var old = $.fn.tree;
|
|
// TREE CONSTRUCTOR AND PROTOTYPE
|
|
var Tree = function Tree(element, options) {
|
this.$element = $(element);
|
this.options = $.extend({}, $.fn.tree.defaults, options);
|
|
if (this.options.itemSelect) {
|
this.$element.on('click.fu.tree', '.tree-item', $.proxy(function (ev) {
|
this.selectItem(ev.currentTarget);
|
}, this));
|
}
|
|
//ACE
|
this.$element.on('click.fu.tree', '.tree-branch-header', $.proxy(function (ev) {
|
this.toggleFolder(ev.currentTarget);
|
}, this));
|
|
// folderSelect default is true
|
if (this.options.folderSelect) {
|
this.$element.addClass('tree-folder-select');
|
this.$element.off('click.fu.tree', '.tree-branch-header');
|
this.$element.on('click.fu.tree', '.icon-caret', $.proxy(function (ev) {
|
this.toggleFolder($(ev.currentTarget).next());
|
}, this));
|
this.$element.on('click.fu.tree', '.tree-branch-header', $.proxy(function (ev) {
|
this.selectFolder($(ev.currentTarget));
|
}, this));
|
}
|
|
this.render();
|
};
|
|
Tree.prototype = {
|
constructor: Tree,
|
|
deselectAll: function deselectAll(nodes) {
|
// clear all child tree nodes and style as deselected
|
nodes = nodes || this.$element;
|
var that = this;
|
var $selectedElements = $(nodes).find('.tree-selected');
|
$selectedElements.each(function (index, element) {
|
//styleNodeDeselected( $(element), $(element).find( '.glyphicon' ) );
|
styleNodeDeselected( that, $(element), $(element).find('.'+$.trim(that.options['base-icon']).replace(/(\s+)/g, '.')) );//ACE
|
});
|
return $selectedElements;
|
},
|
|
destroy: function destroy() {
|
// any external bindings [none]
|
// empty elements to return to original markup
|
this.$element.find("li:not([data-template])").remove();
|
|
this.$element.remove();
|
// returns string of markup
|
return this.$element[0].outerHTML;
|
},
|
|
render: function render() {
|
this.populate(this.$element);
|
},
|
|
populate: function populate($el, isBackgroundProcess) {
|
var self = this;
|
var $parent = ($el.hasClass('tree')) ? $el : $el.parent();
|
var loader = $parent.find('.tree-loader:eq(0)');
|
var treeData = $parent.data();
|
isBackgroundProcess = isBackgroundProcess || false; // no user affordance needed (ex.- "loading")
|
|
if (isBackgroundProcess === false) {
|
loader.removeClass('hide hidden'); // hide is deprecated
|
}
|
this.options.dataSource(treeData ? treeData : {}, function (items) {
|
loader.addClass('hidden');
|
|
$.each(items.data, function (index, value) {
|
var $entity;
|
|
if (value.type === 'folder') {
|
$entity = self.$element.find('[data-template=treebranch]:eq(0)').clone().removeClass('hide hidden').removeData('template'); // hide is deprecated
|
$entity.data(value);
|
$entity.find('.tree-branch-name > .tree-label').html(value.text || value.name);
|
|
//ACE
|
var header = $entity.find('.tree-branch-header');
|
|
if('icon-class' in value)
|
header.find('i').addClass(value['icon-class']);
|
|
if('additionalParameters' in value
|
&& 'item-selected' in value.additionalParameters
|
&& value.additionalParameters['item-selected'] == true) {
|
setTimeout(function(){header.trigger('click')}, 0);
|
}
|
} else if (value.type === 'item') {
|
$entity = self.$element.find('[data-template=treeitem]:eq(0)').clone().removeClass('hide hidden').removeData('template'); // hide is deprecated
|
$entity.find('.tree-item-name > .tree-label').html(value.text || value.name);
|
$entity.data(value);
|
|
//ACE
|
if('additionalParameters' in value
|
&& 'item-selected' in value.additionalParameters
|
&& value.additionalParameters['item-selected'] == true) {
|
$entity.addClass ('tree-selected');
|
$entity.find('i').removeClass(self.options['unselected-icon']).addClass(self.options['selected-icon']);
|
//$entity.closest('.tree-folder-content').show();
|
}
|
}
|
|
// Decorate $entity with data or other attributes making the
|
// element easily accessable with libraries like jQuery.
|
//
|
// Values are contained within the object returned
|
// for folders and items as attr:
|
//
|
// {
|
// text: "An Item",
|
// type: 'item',
|
// attr = {
|
// 'classes': 'required-item red-text',
|
// 'data-parent': parentId,
|
// 'guid': guid,
|
// 'id': guid
|
// }
|
// };
|
//
|
// the "name" attribute is also supported but is deprecated for "text".
|
|
// add attributes to tree-branch or tree-item
|
var attr = value.attr || value.dataAttributes || [];
|
$.each(attr, function (key, value) {
|
switch (key) {
|
case 'cssClass':
|
case 'class':
|
case 'className':
|
$entity.addClass(value);
|
break;
|
|
// allow custom icons
|
case 'data-icon':
|
$entity.find('.icon-item').removeClass().addClass('icon-item ' + value);
|
$entity.attr(key, value);
|
break;
|
|
// ARIA support
|
case 'id':
|
$entity.attr(key, value);
|
$entity.attr('aria-labelledby', value + '-label');
|
$entity.find('.tree-branch-name > .tree-label').attr('id', value + '-label');
|
break;
|
|
// style, data-*
|
default:
|
$entity.attr(key, value);
|
break;
|
}
|
});
|
|
// add child nodes
|
if ($el.hasClass('tree-branch-header')) {
|
$parent.find('.tree-branch-children:eq(0)').append($entity);
|
} else {
|
$el.append($entity);
|
}
|
});
|
|
// return newly populated folder
|
self.$element.trigger('loaded.fu.tree', $parent);
|
});
|
},
|
|
selectTreeNode: function selectItem(clickedElement, nodeType) {
|
var clicked = {}; // object for clicked element
|
clicked.$element = $(clickedElement);
|
|
var selected = {}; // object for selected elements
|
selected.$elements = this.$element.find('.tree-selected');
|
selected.dataForEvent = [];
|
|
// determine clicked element and it's icon
|
if (nodeType === 'folder') {
|
// make the clicked.$element the container branch
|
clicked.$element = clicked.$element.closest('.tree-branch');
|
clicked.$icon = clicked.$element.find('.icon-folder');
|
}
|
else {
|
clicked.$icon = clicked.$element.find('.icon-item');
|
}
|
clicked.elementData = clicked.$element.data();
|
|
// the below functions pass objects by copy/reference and use modified object in this function
|
if ( this.options.multiSelect ) {
|
multiSelectSyncNodes(this, clicked, selected);
|
}
|
else {
|
singleSelectSyncNodes(this, clicked, selected);
|
}
|
|
// all done with the DOM, now fire events
|
this.$element.trigger(selected.eventType + '.fu.tree', {
|
target: clicked.elementData,
|
selected: selected.dataForEvent
|
});
|
|
clicked.$element.trigger('updated.fu.tree', {
|
selected: selected.dataForEvent,
|
item: clicked.$element,
|
eventType: selected.eventType
|
});
|
},
|
|
discloseFolder: function discloseFolder(el) {
|
var $el = $(el);
|
|
var $branch = $el.closest('.tree-branch');
|
var $treeFolderContent = $branch.find('.tree-branch-children');
|
var $treeFolderContentFirstChild = $treeFolderContent.eq(0);
|
|
//take care of the styles
|
$branch.addClass('tree-open');
|
$branch.attr('aria-expanded', 'true');
|
$treeFolderContentFirstChild.removeClass('hide hidden'); // hide is deprecated
|
$branch.find('> .tree-branch-header .icon-folder').eq(0)
|
//.removeClass('glyphicon-folder-close')
|
//.addClass('glyphicon-folder-open');
|
.removeClass(this.options['close-icon']).addClass(this.options['open-icon']);//ACE
|
|
$branch.find('> .icon-caret').eq(0)
|
.removeClass(this.options['folder-open-icon']).addClass(this.options['folder-close-icon']);//ACE
|
|
//add the children to the folder
|
if (!$treeFolderContent.children().length) {
|
this.populate($treeFolderContent);
|
}
|
|
this.$element.trigger('disclosedFolder.fu.tree', $branch.data());
|
},
|
|
closeFolder: function closeFolder(el) {
|
var $el = $(el);
|
var $branch = $el.closest('.tree-branch');
|
var $treeFolderContent = $branch.find('.tree-branch-children');
|
var $treeFolderContentFirstChild = $treeFolderContent.eq(0);
|
|
//take care of the styles
|
$branch.removeClass('tree-open');
|
$branch.attr('aria-expanded', 'false');
|
$treeFolderContentFirstChild.addClass('hidden');
|
$branch.find('> .tree-branch-header .icon-folder').eq(0)
|
//.removeClass('glyphicon-folder-open')
|
//.addClass('glyphicon-folder-close');
|
.removeClass(this.options['open-icon']).addClass(this.options['close-icon']);//ACE
|
|
$branch.find('> .icon-caret').eq(0)
|
.removeClass(this.options['folder-close-icon']).addClass(this.options['folder-open-icon']);//ACE
|
|
// remove chidren if no cache
|
if (!this.options.cacheItems) {
|
$treeFolderContentFirstChild.empty();
|
}
|
|
this.$element.trigger('closed.fu.tree', $branch.data());
|
},
|
|
toggleFolder: function toggleFolder(el) {
|
var $el = $(el);
|
|
/**
|
if ($el.find('.glyphicon-folder-close').length) {
|
this.discloseFolder(el);
|
} else if ($el.find('.glyphicon-folder-open').length) {
|
this.closeFolder(el);
|
}
|
*/
|
if ($el.find('.'+$.trim(this.options['close-icon']).replace(/(\s+)/g, '.')).length) {//ACE
|
this.discloseFolder(el);
|
} else if($el.find('.'+$.trim(this.options['open-icon']).replace(/(\s+)/g, '.')).length) {//ACE
|
this.closeFolder(el);
|
}
|
},
|
|
selectFolder: function selectFolder(el) {
|
if (this.options.folderSelect) {
|
this.selectTreeNode(el, 'folder');
|
}
|
},
|
|
selectItem: function selectItem(el) {
|
if (this.options.itemSelect) {
|
this.selectTreeNode(el, 'item');
|
}
|
},
|
|
selectedItems: function selectedItems() {
|
var $sel = this.$element.find('.tree-selected');
|
var data = [];
|
|
$.each($sel, function (index, value) {
|
data.push($(value).data());
|
});
|
return data;
|
},
|
|
// collapses open folders
|
collapse: function collapse() {
|
var self = this;
|
var reportedClosed = [];
|
|
var closedReported = function closedReported(event, closed) {
|
reportedClosed.push(closed);
|
|
// hide is deprecated
|
if (self.$element.find(".tree-branch.tree-open:not('.hidden, .hide')").length === 0) {
|
self.$element.trigger('closedAll.fu.tree', {
|
tree: self.$element,
|
reportedClosed: reportedClosed
|
});
|
self.$element.off('loaded.fu.tree', self.$element, closedReported);
|
}
|
};
|
|
//trigger callback when all folders have reported closed
|
self.$element.on('closed.fu.tree', closedReported);
|
|
self.$element.find(".tree-branch.tree-open:not('.hidden, .hide')").each(function () {
|
self.closeFolder(this);
|
});
|
},
|
|
//disclose visible will only disclose visible tree folders
|
discloseVisible: function discloseVisible() {
|
var self = this;
|
|
var $openableFolders = self.$element.find(".tree-branch:not('.tree-open, .hidden, .hide')");
|
var reportedOpened = [];
|
|
var openReported = function openReported(event, opened) {
|
reportedOpened.push(opened);
|
|
if (reportedOpened.length === $openableFolders.length) {
|
self.$element.trigger('disclosedVisible.fu.tree', {
|
tree: self.$element,
|
reportedOpened: reportedOpened
|
});
|
/*
|
* Unbind the `openReported` event. `discloseAll` may be running and we want to reset this
|
* method for the next iteration.
|
*/
|
self.$element.off('loaded.fu.tree', self.$element, openReported);
|
}
|
};
|
|
//trigger callback when all folders have reported opened
|
self.$element.on('loaded.fu.tree', openReported);
|
|
// open all visible folders
|
self.$element.find(".tree-branch:not('.tree-open, .hidden, .hide')").each(function triggerOpen() {
|
self.discloseFolder($(this).find('.tree-branch-header'));
|
});
|
},
|
|
/**
|
* Disclose all will keep listening for `loaded.fu.tree` and if `$(tree-el).data('ignore-disclosures-limit')`
|
* is `true` (defaults to `true`) it will attempt to disclose any new closed folders than were
|
* loaded in during the last disclosure.
|
*/
|
discloseAll: function discloseAll() {
|
var self = this;
|
|
//first time
|
if (typeof self.$element.data('disclosures') === 'undefined') {
|
self.$element.data('disclosures', 0);
|
}
|
|
var isExceededLimit = (self.options.disclosuresUpperLimit >= 1 && self.$element.data('disclosures') >= self.options.disclosuresUpperLimit);
|
var isAllDisclosed = self.$element.find(".tree-branch:not('.tree-open, .hidden, .hide')").length === 0;
|
|
|
if (!isAllDisclosed) {
|
if (isExceededLimit) {
|
self.$element.trigger('exceededDisclosuresLimit.fu.tree', {
|
tree: self.$element,
|
disclosures: self.$element.data('disclosures')
|
});
|
|
/*
|
* If you've exceeded the limit, the loop will be killed unless you
|
* explicitly ignore the limit and start the loop again:
|
*
|
* $tree.one('exceededDisclosuresLimit.fu.tree', function () {
|
* $tree.data('ignore-disclosures-limit', true);
|
* $tree.tree('discloseAll');
|
* });
|
*/
|
if (!self.$element.data('ignore-disclosures-limit')) {
|
return;
|
}
|
|
}
|
|
self.$element.data('disclosures', self.$element.data('disclosures') + 1);
|
|
/*
|
* A new branch that is closed might be loaded in, make sure those get handled too.
|
* This attachment needs to occur before calling `discloseVisible` to make sure that
|
* if the execution of `discloseVisible` happens _super fast_ (as it does in our QUnit tests
|
* this will still be called. However, make sure this only gets called _once_, because
|
* otherwise, every single time we go through this loop, _another_ event will be bound
|
* and then when the trigger happens, this will fire N times, where N equals the number
|
* of recursive `discloseAll` executions (instead of just one)
|
*/
|
self.$element.one('disclosedVisible.fu.tree', function () {
|
self.discloseAll();
|
});
|
|
/*
|
* If the page is very fast, calling this first will cause `disclosedVisible.fu.tree` to not
|
* be bound in time to be called, so, we need to call this last so that the things bound
|
* and triggered above can have time to take place before the next execution of the
|
* `discloseAll` method.
|
*/
|
self.discloseVisible();
|
} else {
|
self.$element.trigger('disclosedAll.fu.tree', {
|
tree: self.$element,
|
disclosures: self.$element.data('disclosures')
|
});
|
|
//if `cacheItems` is false, and they call closeAll, the data is trashed and therefore
|
//disclosures needs to accurately reflect that
|
if (!self.options.cacheItems) {
|
self.$element.one('closeAll.fu.tree', function () {
|
self.$element.data('disclosures', 0);
|
});
|
}
|
|
}
|
},
|
|
// This refreshes the children of a folder. Please destroy and re-initilize for "root level" refresh.
|
// The data of the refreshed folder is not updated. This control's architecture only allows updating of children.
|
// Folder renames should probably be handled directly on the node.
|
refreshFolder: function refreshFolder($el) {
|
var $treeFolder = $el.closest('.tree-branch');
|
var $treeFolderChildren = $treeFolder.find('.tree-branch-children');
|
$treeFolderChildren.eq(0).empty();
|
|
if ($treeFolder.hasClass('tree-open')) {
|
this.populate($treeFolderChildren, false);
|
}
|
else {
|
this.populate($treeFolderChildren, true);
|
}
|
|
this.$element.trigger('refreshedFolder.fu.tree', $treeFolder.data());
|
}
|
|
};
|
|
// ALIASES
|
|
//alias for collapse for consistency. "Collapse" is an ambiguous term (collapse what? All? One specific branch?)
|
Tree.prototype.closeAll = Tree.prototype.collapse;
|
//alias for backwards compatibility because there's no reason not to.
|
Tree.prototype.openFolder = Tree.prototype.discloseFolder;
|
//For library consistency
|
Tree.prototype.getValue = Tree.prototype.selectedItems;
|
|
// PRIVATE FUNCTIONS
|
|
function styleNodeSelected (self, $element, $icon) {
|
$element.addClass('tree-selected');
|
if ( $element.data('type') === 'item' && $icon.hasClass(self.options['unselected-icon']) ) {
|
//$icon.removeClass('fueluxicon-bullet').addClass('glyphicon-ok'); // make checkmark
|
$icon.removeClass(self.options['unselected-icon']).addClass(self.options['selected-icon']); //ACE
|
}
|
}
|
|
function styleNodeDeselected (self, $element, $icon) {
|
$element.removeClass('tree-selected');
|
//if ( $element.data('type') === 'item' && $icon.hasClass('glyphicon-ok') ) {
|
//$icon.removeClass('glyphicon-ok').addClass('fueluxicon-bullet'); // make bullet
|
//}
|
//ACE
|
if ( $element.data('type') === 'item' && $icon.hasClass(self.options['selected-icon']) ) {
|
$icon.removeClass(self.options['selected-icon']).addClass(self.options['unselected-icon']); // make bullet
|
}
|
}
|
|
function multiSelectSyncNodes (self, clicked, selected) {
|
// search for currently selected and add to selected data list if needed
|
$.each(selected.$elements, function (index, element) {
|
var $element = $(element);
|
if ($element[0] !== clicked.$element[0]) {
|
selected.dataForEvent.push( $($element).data() );
|
}
|
});
|
|
if (clicked.$element.hasClass('tree-selected')) {
|
styleNodeDeselected (self, clicked.$element, clicked.$icon);//ACE
|
// set event data
|
selected.eventType = 'deselected';
|
}
|
else {
|
styleNodeSelected(self, clicked.$element, clicked.$icon);//ACE
|
// set event data
|
selected.eventType = 'selected';
|
selected.dataForEvent.push(clicked.elementData);
|
}
|
}
|
|
function singleSelectSyncNodes(self, clicked, selected) {
|
// element is not currently selected
|
if (selected.$elements[0] !== clicked.$element[0]) {
|
var clearedElements = self.deselectAll(self.$element);
|
styleNodeSelected(self, clicked.$element, clicked.$icon);//ACE
|
// set event data
|
selected.eventType = 'selected';
|
selected.dataForEvent = [clicked.elementData];
|
}
|
else {
|
styleNodeDeselected(self, clicked.$element, clicked.$icon);//ACE
|
// set event data
|
selected.eventType = 'deselected';
|
selected.dataForEvent = [];
|
}
|
}
|
|
|
// TREE PLUGIN DEFINITION
|
|
$.fn.tree = function tree(option) {
|
var args = Array.prototype.slice.call(arguments, 1);
|
var methodReturn;
|
|
var $set = this.each(function () {
|
var $this = $(this);
|
var data = $this.data('fu.tree');
|
var options = typeof option === 'object' && option;
|
|
if (!data) {
|
$this.data('fu.tree', (data = new Tree(this, options)));
|
}
|
|
if (typeof option === 'string') {
|
methodReturn = data[option].apply(data, args);
|
}
|
});
|
|
return (methodReturn === undefined) ? $set : methodReturn;
|
};
|
|
$.fn.tree.defaults = {
|
dataSource: function dataSource(options, callback) {},
|
multiSelect: false,
|
cacheItems: true,
|
folderSelect: true,
|
itemSelect: true,
|
/*
|
* How many times `discloseAll` should be called before a stopping and firing
|
* an `exceededDisclosuresLimit` event. You can force it to continue by
|
* listening for this event, setting `ignore-disclosures-limit` to `true` and
|
* starting `discloseAll` back up again. This lets you make more decisions
|
* about if/when/how/why/how many times `discloseAll` will be started back
|
* up after it exceeds the limit.
|
*
|
* $tree.one('exceededDisclosuresLimit.fu.tree', function () {
|
* $tree.data('ignore-disclosures-limit', true);
|
* $tree.tree('discloseAll');
|
* });
|
*
|
* `disclusuresUpperLimit` defaults to `0`, so by default this trigger
|
* will never fire. The true hard the upper limit is the browser's
|
* ability to load new items (i.e. it will keep loading until the browser
|
* falls over and dies). On the Fuel UX `index.html` page, the point at
|
* which the page became super slow (enough to seem almost unresponsive)
|
* was `4`, meaning 256 folders had been opened, and 1024 were attempting to open.
|
*/
|
disclosuresUpperLimit: 0
|
};
|
|
$.fn.tree.Constructor = Tree;
|
|
$.fn.tree.noConflict = function () {
|
$.fn.tree = old;
|
return this;
|
};
|
|
|
// NO DATA-API DUE TO NEED OF DATA-SOURCE
|
|
// -- BEGIN UMD WRAPPER AFTERWORD --
|
}));
|
// -- END UMD WRAPPER AFTERWORD --
|