diff --git a/README.md b/README.md index c0a724235..98e3c026f 100644 --- a/README.md +++ b/README.md @@ -278,7 +278,7 @@ String, class name(s). Default: "glyphicon" as defined by [Bootstrap Glyphicons Sets the icon to be used on a tree node with no child nodes. #### enableLinks -Boolean. Default: false +Boolean. Default: false Whether or not to present node text as a hyperlink. The href value of which must be provided in the data structure on a per node basis. @@ -293,10 +293,20 @@ Boolean. Default: true Whether or not to highlight search results. #### highlightSelected -Boolean. Default: true +Boolean. Default: true Whether or not to highlight the selected node. +#### lazyLoad +Boolean. Default: false + +Whether or not to lazily load child nodes (must be used in conjunction with lazyLoadFunction). + +#### lazyLoadFunction +Function. Default: bareBonesLazyLoadFunction (just enough to satisfy the callback loop, will show zero child nodes and remove the ability to attempt to expand that node again) + +The function to be called for retrieving child nodes for the node currently being expanded. This function should accept a node, and a callback function. + #### levels Integer. Default: 2 @@ -317,6 +327,11 @@ String, [any legal color value](http://www.w3schools.com/cssref/css_colors_legal Sets the default background color activated when the users cursor hovers over a node. +#### readOnly +Boolean. Default: false. + +Sets the entire tree to readOnly. This mode is visually similar to disabled, with grey font and "not allowed" cursor styling by default, while still allowing for the tree to be expanded, show search matches, and/or have a node selected upon loading. The tree may continue to be interacted with programatically; however, click events will be ignored. + #### selectedIcon String, class name(s). Default: "glyphicon glyphicon-stop" as defined by [Bootstrap Glyphicons](http://getbootstrap.com/components/#glyphicons) @@ -747,10 +762,23 @@ $('#tree').on('nodeSelected', function(event, data) { `searchCleared (event, results)` - After search results are cleared +## Keyboard Actions + +This fork of the original project now offers keyboard-based accessibility functionality based upon the recommended keyboard interactions defined at http://www.w3.org/TR/2015/WD-wai-aria-practices-1.1-20150514/#TreeView (and as implemented in the reference implementation shown at http://www.oaa-accessibility.org/examplep/treeview1/). + +__NOTE:__ not all recommended keyboard interactions have been implemented within this component. Only those listed below have been implemented: +- __Up arrow:__ Select the previous visible tree item. +- __Down arrow:__ Select next visible tree item. +- __Left arrow:__ Collapse the currently selected parent node if it is expanded. Move to the previous parent node (if possible) when the current parent node is collapsed. +- __Right arrow:__ Expand the currently selected parent node and move to the first child list item. +- __Space/Enter:__ Toggle the expanded or collapsed state of the selected parent node. +- __Home:__ Select the root parent node of the tree. +- __End:__ Select the last visible node of the tree. +- __Tab/Shift-Tab:__ Navigate away from tree. ## Copyright and Licensing -Copyright 2013 Jonathan Miles +Copyright 2013 Jonathan Miles, 2017 Rich Rein Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/src/js/bootstrap-treeview.js b/src/js/bootstrap-treeview.js index 7a82a2eeb..a220796eb 100644 --- a/src/js/bootstrap-treeview.js +++ b/src/js/bootstrap-treeview.js @@ -1,8 +1,8 @@ /* ========================================================= - * bootstrap-treeview.js v1.2.0 + * bootstrap-treeview.js v1.3.0 * ========================================================= - * Copyright 2013 Jonathan Miles - * Project URL : http://www.jondmiles.com/bootstrap-treeview + * Copyright 2017 Rich Rein + * Project URL: https://github.com/reinrl/bootstrap-treeview * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,1231 +19,1403 @@ ;(function ($, window, document, undefined) { - /*global jQuery, console*/ - - 'use strict'; - - var pluginName = 'treeview'; - - var _default = {}; - - _default.settings = { - - injectStyle: true, - - levels: 2, - - expandIcon: 'glyphicon glyphicon-plus', - collapseIcon: 'glyphicon glyphicon-minus', - emptyIcon: 'glyphicon', - nodeIcon: '', - selectedIcon: '', - checkedIcon: 'glyphicon glyphicon-check', - uncheckedIcon: 'glyphicon glyphicon-unchecked', - - color: undefined, // '#000000', - backColor: undefined, // '#FFFFFF', - borderColor: undefined, // '#dddddd', - onhoverColor: '#F5F5F5', - selectedColor: '#FFFFFF', - selectedBackColor: '#428bca', - searchResultColor: '#D9534F', - searchResultBackColor: undefined, //'#FFFFFF', - - enableLinks: false, - highlightSelected: true, - highlightSearchResults: true, - showBorder: true, - showIcon: true, - showCheckbox: false, - showTags: false, - multiSelect: false, - - // Event handlers - onNodeChecked: undefined, - onNodeCollapsed: undefined, - onNodeDisabled: undefined, - onNodeEnabled: undefined, - onNodeExpanded: undefined, - onNodeSelected: undefined, - onNodeUnchecked: undefined, - onNodeUnselected: undefined, - onSearchComplete: undefined, - onSearchCleared: undefined - }; - - _default.options = { - silent: false, - ignoreChildren: false - }; - - _default.searchOptions = { - ignoreCase: true, - exactMatch: false, - revealResults: true - }; - - var Tree = function (element, options) { - - this.$element = $(element); - this.elementId = element.id; - this.styleId = this.elementId + '-style'; - - this.init(options); - - return { - - // Options (public access) - options: this.options, - - // Initialize / destroy methods - init: $.proxy(this.init, this), - remove: $.proxy(this.remove, this), - - // Get methods - getNode: $.proxy(this.getNode, this), - getParent: $.proxy(this.getParent, this), - getSiblings: $.proxy(this.getSiblings, this), - getSelected: $.proxy(this.getSelected, this), - getUnselected: $.proxy(this.getUnselected, this), - getExpanded: $.proxy(this.getExpanded, this), - getCollapsed: $.proxy(this.getCollapsed, this), - getChecked: $.proxy(this.getChecked, this), - getUnchecked: $.proxy(this.getUnchecked, this), - getDisabled: $.proxy(this.getDisabled, this), - getEnabled: $.proxy(this.getEnabled, this), - - // Select methods - selectNode: $.proxy(this.selectNode, this), - unselectNode: $.proxy(this.unselectNode, this), - toggleNodeSelected: $.proxy(this.toggleNodeSelected, this), - - // Expand / collapse methods - collapseAll: $.proxy(this.collapseAll, this), - collapseNode: $.proxy(this.collapseNode, this), - expandAll: $.proxy(this.expandAll, this), - expandNode: $.proxy(this.expandNode, this), - toggleNodeExpanded: $.proxy(this.toggleNodeExpanded, this), - revealNode: $.proxy(this.revealNode, this), - - // Expand / collapse methods - checkAll: $.proxy(this.checkAll, this), - checkNode: $.proxy(this.checkNode, this), - uncheckAll: $.proxy(this.uncheckAll, this), - uncheckNode: $.proxy(this.uncheckNode, this), - toggleNodeChecked: $.proxy(this.toggleNodeChecked, this), - - // Disable / enable methods - disableAll: $.proxy(this.disableAll, this), - disableNode: $.proxy(this.disableNode, this), - enableAll: $.proxy(this.enableAll, this), - enableNode: $.proxy(this.enableNode, this), - toggleNodeDisabled: $.proxy(this.toggleNodeDisabled, this), - - // Search methods - search: $.proxy(this.search, this), - clearSearch: $.proxy(this.clearSearch, this) - }; - }; - - Tree.prototype.init = function (options) { - - this.tree = []; - this.nodes = []; - - if (options.data) { - if (typeof options.data === 'string') { - options.data = $.parseJSON(options.data); - } - this.tree = $.extend(true, [], options.data); - delete options.data; - } - this.options = $.extend({}, _default.settings, options); - - this.destroy(); - this.subscribeEvents(); - this.setInitialStates({ nodes: this.tree }, 0); - this.render(); - }; - - Tree.prototype.remove = function () { - this.destroy(); - $.removeData(this, pluginName); - $('#' + this.styleId).remove(); - }; - - Tree.prototype.destroy = function () { - - if (!this.initialized) return; - - this.$wrapper.remove(); - this.$wrapper = null; - - // Switch off events - this.unsubscribeEvents(); - - // Reset this.initialized flag - this.initialized = false; - }; - - Tree.prototype.unsubscribeEvents = function () { - - this.$element.off('click'); - this.$element.off('nodeChecked'); - this.$element.off('nodeCollapsed'); - this.$element.off('nodeDisabled'); - this.$element.off('nodeEnabled'); - this.$element.off('nodeExpanded'); - this.$element.off('nodeSelected'); - this.$element.off('nodeUnchecked'); - this.$element.off('nodeUnselected'); - this.$element.off('searchComplete'); - this.$element.off('searchCleared'); - }; - - Tree.prototype.subscribeEvents = function () { - - this.unsubscribeEvents(); - - this.$element.on('click', $.proxy(this.clickHandler, this)); - - if (typeof (this.options.onNodeChecked) === 'function') { - this.$element.on('nodeChecked', this.options.onNodeChecked); - } - - if (typeof (this.options.onNodeCollapsed) === 'function') { - this.$element.on('nodeCollapsed', this.options.onNodeCollapsed); - } - - if (typeof (this.options.onNodeDisabled) === 'function') { - this.$element.on('nodeDisabled', this.options.onNodeDisabled); - } - - if (typeof (this.options.onNodeEnabled) === 'function') { - this.$element.on('nodeEnabled', this.options.onNodeEnabled); - } - - if (typeof (this.options.onNodeExpanded) === 'function') { - this.$element.on('nodeExpanded', this.options.onNodeExpanded); - } - - if (typeof (this.options.onNodeSelected) === 'function') { - this.$element.on('nodeSelected', this.options.onNodeSelected); - } - - if (typeof (this.options.onNodeUnchecked) === 'function') { - this.$element.on('nodeUnchecked', this.options.onNodeUnchecked); - } - - if (typeof (this.options.onNodeUnselected) === 'function') { - this.$element.on('nodeUnselected', this.options.onNodeUnselected); - } - - if (typeof (this.options.onSearchComplete) === 'function') { - this.$element.on('searchComplete', this.options.onSearchComplete); - } - - if (typeof (this.options.onSearchCleared) === 'function') { - this.$element.on('searchCleared', this.options.onSearchCleared); - } - }; - - /* - Recurse the tree structure and ensure all nodes have - valid initial states. User defined states will be preserved. - For performance we also take this opportunity to - index nodes in a flattened structure - */ - Tree.prototype.setInitialStates = function (node, level) { - - if (!node.nodes) return; - level += 1; - - var parent = node; - var _this = this; - $.each(node.nodes, function checkStates(index, node) { - - // nodeId : unique, incremental identifier - node.nodeId = _this.nodes.length; - - // parentId : transversing up the tree - node.parentId = parent.nodeId; - - // if not provided set selectable default value - if (!node.hasOwnProperty('selectable')) { - node.selectable = true; - } - - // where provided we should preserve states - node.state = node.state || {}; - - // set checked state; unless set always false - if (!node.state.hasOwnProperty('checked')) { - node.state.checked = false; - } - - // set enabled state; unless set always false - if (!node.state.hasOwnProperty('disabled')) { - node.state.disabled = false; - } - - // set expanded state; if not provided based on levels - if (!node.state.hasOwnProperty('expanded')) { - if (!node.state.disabled && - (level < _this.options.levels) && - (node.nodes && node.nodes.length > 0)) { - node.state.expanded = true; - } - else { - node.state.expanded = false; - } - } - - // set selected state; unless set always false - if (!node.state.hasOwnProperty('selected')) { - node.state.selected = false; - } - - // index nodes in a flattened structure for use later - _this.nodes.push(node); - - // recurse child nodes and transverse the tree - if (node.nodes) { - _this.setInitialStates(node, level); - } - }); - }; - - Tree.prototype.clickHandler = function (event) { - - if (!this.options.enableLinks) event.preventDefault(); - - var target = $(event.target); - var node = this.findNode(target); - if (!node || node.state.disabled) return; - - var classList = target.attr('class') ? target.attr('class').split(' ') : []; - if ((classList.indexOf('expand-icon') !== -1)) { - - this.toggleExpandedState(node, _default.options); - this.render(); - } - else if ((classList.indexOf('check-icon') !== -1)) { - - this.toggleCheckedState(node, _default.options); - this.render(); - } - else { + /*global jQuery, console*/ + + 'use strict'; + + var pluginName = 'treeview'; + + var _default = {}; + + _default.settings = { + + injectStyle: true, + + levels: 2, + + lazyLoad: false, + + readOnly: false, + + expandIcon: 'glyphicon glyphicon-plus', + collapseIcon: 'glyphicon glyphicon-minus', + emptyIcon: 'glyphicon', + nodeIcon: '', + selectedIcon: '', + checkedIcon: 'glyphicon glyphicon-check', + uncheckedIcon: 'glyphicon glyphicon-unchecked', + + color: undefined, // '#000000', + backColor: undefined, // '#FFFFFF', + borderColor: undefined, // '#dddddd', + onhoverColor: '#F5F5F5', + selectedColor: '#FFFFFF', + selectedBackColor: '#428bca', + searchResultColor: '#D9534F', + searchResultBackColor: undefined, //'#FFFFFF', + + enableLinks: false, + highlightSelected: true, + highlightSearchResults: true, + showBorder: true, + showIcon: true, + showCheckbox: false, + showTags: false, + multiSelect: false, + + // Event handlers + onNodeChecked: undefined, + onNodeCollapsed: undefined, + onNodeDisabled: undefined, + onNodeEnabled: undefined, + onNodeExpanded: undefined, + onNodeSelected: undefined, + onNodeUnchecked: undefined, + onNodeUnselected: undefined, + onSearchComplete: undefined, + onSearchCleared: undefined + }; + + _default.options = { + silent: false, + ignoreChildren: false + }; + + _default.searchOptions = { + ignoreCase: true, + exactMatch: false, + revealResults: true + }; + + var Tree = function (element, options) { + this.keys = { + tab: 9, + enter: 13, + space: 32, + pageup: 33, + pagedown: 34, + end: 35, + home: 36, + left: 37, + up: 38, + right: 39, + down: 40 + }; + + this.$visibleItems = null; // holds a jQuery array of the currently visible items in the tree + + this.$element = $(element); + this.elementId = element.id; + this.styleId = this.elementId + '-style'; + + this.init(options); + + return { + + // Options (public access) + options: this.options, + + // Initialize / destroy methods + init: $.proxy(this.init, this), + remove: $.proxy(this.remove, this), + + // Get methods + getNode: $.proxy(this.getNode, this), + getParent: $.proxy(this.getParent, this), + getSiblings: $.proxy(this.getSiblings, this), + getSelected: $.proxy(this.getSelected, this), + getUnselected: $.proxy(this.getUnselected, this), + getExpanded: $.proxy(this.getExpanded, this), + getCollapsed: $.proxy(this.getCollapsed, this), + getChecked: $.proxy(this.getChecked, this), + getUnchecked: $.proxy(this.getUnchecked, this), + getDisabled: $.proxy(this.getDisabled, this), + getEnabled: $.proxy(this.getEnabled, this), + + // Select methods + selectNode: $.proxy(this.selectNode, this), + unselectNode: $.proxy(this.unselectNode, this), + toggleNodeSelected: $.proxy(this.toggleNodeSelected, this), + + // Expand / collapse methods + collapseAll: $.proxy(this.collapseAll, this), + collapseNode: $.proxy(this.collapseNode, this), + expandAll: $.proxy(this.expandAll, this), + expandNode: $.proxy(this.expandNode, this), + toggleNodeExpanded: $.proxy(this.toggleNodeExpanded, this), + revealNode: $.proxy(this.revealNode, this), + + // Expand / collapse methods + checkAll: $.proxy(this.checkAll, this), + checkNode: $.proxy(this.checkNode, this), + uncheckAll: $.proxy(this.uncheckAll, this), + uncheckNode: $.proxy(this.uncheckNode, this), + toggleNodeChecked: $.proxy(this.toggleNodeChecked, this), + + // Disable / enable methods + disableAll: $.proxy(this.disableAll, this), + disableNode: $.proxy(this.disableNode, this), + enableAll: $.proxy(this.enableAll, this), + enableNode: $.proxy(this.enableNode, this), + toggleNodeDisabled: $.proxy(this.toggleNodeDisabled, this), + + // Search methods + search: $.proxy(this.search, this), + clearSearch: $.proxy(this.clearSearch, this) + }; + }; + + Tree.prototype.init = function (options) { + + this.tree = []; + this.nodes = []; + + if (options.data) { + if (typeof options.data === 'string') { + options.data = $.parseJSON(options.data); + } + this.tree = $.extend(true, [], options.data); + delete options.data; + } + this.options = $.extend({}, _default.settings, options); + + this.destroy(); + this.subscribeEvents(); + this.setInitialStates({ nodes: this.tree }, 0); + this.render(); + }; + + Tree.prototype.remove = function () { + this.destroy(); + $.removeData(this, pluginName); + $('#' + this.styleId).remove(); + }; + + Tree.prototype.destroy = function () { + + if (!this.initialized) return; + + this.$wrapper.remove(); + this.$wrapper = null; + + // Switch off events + this.unsubscribeEvents(); + + // Reset this.initialized flag + this.initialized = false; + }; + + Tree.prototype.unsubscribeEvents = function () { + + this.$element.off('click'); + this.$element.off('keydown'); + this.$element.off('nodeChecked'); + this.$element.off('nodeCollapsed'); + this.$element.off('nodeDisabled'); + this.$element.off('nodeEnabled'); + this.$element.off('nodeExpanded'); + this.$element.off('nodeSelected'); + this.$element.off('nodeUnchecked'); + this.$element.off('nodeUnselected'); + this.$element.off('searchComplete'); + this.$element.off('searchCleared'); + }; + + Tree.prototype.subscribeEvents = function () { + var thisObj = this; + + this.unsubscribeEvents(); + + this.$element.on('click', $.proxy(this.clickHandler, this)); + + this.$element.on('keydown', function(e) { + return thisObj.handleKeyDown($(this), e); + }); + + if (typeof (this.options.onNodeChecked) === 'function') { + this.$element.on('nodeChecked', this.options.onNodeChecked); + } + + if (typeof (this.options.onNodeCollapsed) === 'function') { + this.$element.on('nodeCollapsed', this.options.onNodeCollapsed); + } + + if (typeof (this.options.onNodeDisabled) === 'function') { + this.$element.on('nodeDisabled', this.options.onNodeDisabled); + } + + if (typeof (this.options.onNodeEnabled) === 'function') { + this.$element.on('nodeEnabled', this.options.onNodeEnabled); + } + + if (typeof (this.options.onNodeExpanded) === 'function') { + this.$element.on('nodeExpanded', this.options.onNodeExpanded); + } + + if (typeof (this.options.onNodeSelected) === 'function') { + this.$element.on('nodeSelected', this.options.onNodeSelected); + } + + if (typeof (this.options.onNodeUnchecked) === 'function') { + this.$element.on('nodeUnchecked', this.options.onNodeUnchecked); + } + + if (typeof (this.options.onNodeUnselected) === 'function') { + this.$element.on('nodeUnselected', this.options.onNodeUnselected); + } + + if (typeof (this.options.onSearchComplete) === 'function') { + this.$element.on('searchComplete', this.options.onSearchComplete); + } + + if (typeof (this.options.onSearchCleared) === 'function') { + this.$element.on('searchCleared', this.options.onSearchCleared); + } + }; + + /* + Recurse the tree structure and ensure all nodes have + valid initial states. User defined states will be preserved. + For performance we also take this opportunity to + index nodes in a flattened structure + */ + Tree.prototype.setInitialStates = function (node, level) { + + if (!node.nodes) return; + level += 1; + + var parent = node; + var _this = this; + $.each(node.nodes, function checkStates(index, node) { + + // nodeId : unique, incremental identifier + node.nodeId = _this.nodes.length; + + // parentId : transversing up the tree + node.parentId = parent.nodeId; + + // if not provided set selectable default value + if (!node.hasOwnProperty('selectable')) { + node.selectable = true; + } + + // where provided we should preserve states + node.state = node.state || {}; + + // set checked state; unless set always false + if (!node.state.hasOwnProperty('checked')) { + node.state.checked = false; + } + + // set enabled state; unless set always false + if (!node.state.hasOwnProperty('disabled')) { + node.state.disabled = false; + } + + // set expanded state; if not provided based on levels + if (!node.state.hasOwnProperty('expanded')) { + if (!node.state.disabled && + (level < _this.options.levels) && + (node.nodes && node.nodes.length > 0)) { + node.state.expanded = true; + } + else { + node.state.expanded = false; + } + } + + // set selected state; unless set always false + if (!node.state.hasOwnProperty('selected')) { + node.state.selected = false; + } + + // index nodes in a flattened structure for use later + _this.nodes.push(node); + + // recurse child nodes and transverse the tree + if (node.nodes) { + _this.setInitialStates(node, level); + } + }); + }; + + Tree.prototype.handleKeyDown = function(targetDiv, e) { + var $item = $(e.target); + + var selectionOptions = this.options; + selectionOptions.setSelectedFocus = true; + + if ((e.altKey || e.ctrlKey) || (e.shiftKey && e.keyCode != this.keys.tab)) { + // do nothing + return true; + } + + var curNdx = this.$visibleItems.index($item); + + switch (e.keyCode) { + //TODO: not working yet... (shift-tab fails to go back to search box??) + case this.keys.tab: { + return true; + } + case this.keys.home: { // jump to first item in tree + var firstItem = this.$visibleItems.first(); + var firstNode = this.findNode(firstItem); + this.selectNode(firstNode, selectionOptions); + + e.stopPropagation(); + return false; + } + case this.keys.end: { // jump to last visible item + var lastItem = this.$visibleItems.last(); + var lastNode = this.findNode(lastItem); + this.selectNode(lastNode, selectionOptions); - if (node.selectable) { - this.toggleSelectedState(node, _default.options); - } else { - this.toggleExpandedState(node, _default.options); - } - - this.render(); - } - }; - - // Looks up the DOM for the closest parent list item to retrieve the - // data attribute nodeid, which is used to lookup the node in the flattened structure. - Tree.prototype.findNode = function (target) { - - var nodeId = target.closest('li.list-group-item').attr('data-nodeid'); - var node = this.nodes[nodeId]; - - if (!node) { - console.log('Error: node does not exist'); - } - return node; - }; - - Tree.prototype.toggleExpandedState = function (node, options) { - if (!node) return; - this.setExpandedState(node, !node.state.expanded, options); - }; - - Tree.prototype.setExpandedState = function (node, state, options) { - - if (state === node.state.expanded) return; - - if (state && node.nodes) { - - // Expand a node - node.state.expanded = true; - if (!options.silent) { - this.$element.trigger('nodeExpanded', $.extend(true, {}, node)); - } - } - else if (!state) { - - // Collapse a node - node.state.expanded = false; - if (!options.silent) { - this.$element.trigger('nodeCollapsed', $.extend(true, {}, node)); - } - - // Collapse child nodes - if (node.nodes && !options.ignoreChildren) { - $.each(node.nodes, $.proxy(function (index, node) { - this.setExpandedState(node, false, options); - }, this)); - } - } - }; - - Tree.prototype.toggleSelectedState = function (node, options) { - if (!node) return; - this.setSelectedState(node, !node.state.selected, options); - }; - - Tree.prototype.setSelectedState = function (node, state, options) { - - if (state === node.state.selected) return; - - if (state) { - - // If multiSelect false, unselect previously selected - if (!this.options.multiSelect) { - $.each(this.findNodes('true', 'g', 'state.selected'), $.proxy(function (index, node) { - this.setSelectedState(node, false, options); - }, this)); - } - - // Continue selecting node - node.state.selected = true; - if (!options.silent) { - this.$element.trigger('nodeSelected', $.extend(true, {}, node)); - } - } - else { - - // Unselect node - node.state.selected = false; - if (!options.silent) { - this.$element.trigger('nodeUnselected', $.extend(true, {}, node)); - } - } - }; - - Tree.prototype.toggleCheckedState = function (node, options) { - if (!node) return; - this.setCheckedState(node, !node.state.checked, options); - }; - - Tree.prototype.setCheckedState = function (node, state, options) { - - if (state === node.state.checked) return; - - if (state) { - - // Check node - node.state.checked = true; - - if (!options.silent) { - this.$element.trigger('nodeChecked', $.extend(true, {}, node)); - } - } - else { - - // Uncheck node - node.state.checked = false; - if (!options.silent) { - this.$element.trigger('nodeUnchecked', $.extend(true, {}, node)); - } - } - }; - - Tree.prototype.setDisabledState = function (node, state, options) { - - if (state === node.state.disabled) return; - - if (state) { - - // Disable node - node.state.disabled = true; - - // Disable all other states - this.setExpandedState(node, false, options); - this.setSelectedState(node, false, options); - this.setCheckedState(node, false, options); - - if (!options.silent) { - this.$element.trigger('nodeDisabled', $.extend(true, {}, node)); - } - } - else { - - // Enabled node - node.state.disabled = false; - if (!options.silent) { - this.$element.trigger('nodeEnabled', $.extend(true, {}, node)); - } - } - }; - - Tree.prototype.render = function () { - - if (!this.initialized) { - - // Setup first time only components - this.$element.addClass(pluginName); - this.$wrapper = $(this.template.list); - - this.injectStyle(); - - this.initialized = true; - } - - this.$element.empty().append(this.$wrapper.empty()); - - // Build tree - this.buildTree(this.tree, 0); - }; - - // Starting from the root node, and recursing down the - // structure we build the tree one node at a time - Tree.prototype.buildTree = function (nodes, level) { - - if (!nodes) return; - level += 1; - - var _this = this; - $.each(nodes, function addNodes(id, node) { - - var treeItem = $(_this.template.item) - .addClass('node-' + _this.elementId) - .addClass(node.state.checked ? 'node-checked' : '') - .addClass(node.state.disabled ? 'node-disabled': '') - .addClass(node.state.selected ? 'node-selected' : '') - .addClass(node.searchResult ? 'search-result' : '') - .attr('data-nodeid', node.nodeId) - .attr('style', _this.buildStyleOverride(node)); - - // Add indent/spacer to mimic tree structure - for (var i = 0; i < (level - 1); i++) { - treeItem.append(_this.template.indent); - } - - // Add expand, collapse or empty spacer icons - var classList = []; - if (node.nodes) { - classList.push('expand-icon'); - if (node.state.expanded) { - classList.push(_this.options.collapseIcon); - } - else { - classList.push(_this.options.expandIcon); - } - } - else { - classList.push(_this.options.emptyIcon); - } - - treeItem - .append($(_this.template.icon) - .addClass(classList.join(' ')) - ); - - - // Add node icon - if (_this.options.showIcon) { - - var classList = ['node-icon']; - - classList.push(node.icon || _this.options.nodeIcon); - if (node.state.selected) { - classList.pop(); - classList.push(node.selectedIcon || _this.options.selectedIcon || - node.icon || _this.options.nodeIcon); - } - - treeItem - .append($(_this.template.icon) - .addClass(classList.join(' ')) - ); - } - - // Add check / unchecked icon - if (_this.options.showCheckbox) { - - var classList = ['check-icon']; - if (node.state.checked) { - classList.push(_this.options.checkedIcon); - } - else { - classList.push(_this.options.uncheckedIcon); - } - - treeItem - .append($(_this.template.icon) - .addClass(classList.join(' ')) - ); - } - - // Add text - if (_this.options.enableLinks) { - // Add hyperlink - treeItem - .append($(_this.template.link) - .attr('href', node.href) - .append(node.text) - ); - } - else { - // otherwise just text - treeItem - .append(node.text); - } - - // Add tags as badges - if (_this.options.showTags && node.tags) { - $.each(node.tags, function addTag(id, tag) { - treeItem - .append($(_this.template.badge) - .append(tag) - ); - }); - } - - // Add item to the tree - _this.$wrapper.append(treeItem); - - // Recursively add child ndoes - if (node.nodes && node.state.expanded && !node.state.disabled) { - return _this.buildTree(node.nodes, level); - } - }); - }; - - // Define any node level style override for - // 1. selectedNode - // 2. node|data assigned color overrides - Tree.prototype.buildStyleOverride = function (node) { - - if (node.state.disabled) return ''; - - var color = node.color; - var backColor = node.backColor; - - if (this.options.highlightSelected && node.state.selected) { - if (this.options.selectedColor) { - color = this.options.selectedColor; - } - if (this.options.selectedBackColor) { - backColor = this.options.selectedBackColor; - } - } - - if (this.options.highlightSearchResults && node.searchResult && !node.state.disabled) { - if (this.options.searchResultColor) { - color = this.options.searchResultColor; - } - if (this.options.searchResultBackColor) { - backColor = this.options.searchResultBackColor; - } - } - - return 'color:' + color + - ';background-color:' + backColor + ';'; - }; - - // Add inline style into head - Tree.prototype.injectStyle = function () { - - if (this.options.injectStyle && !document.getElementById(this.styleId)) { - $('').appendTo('head'); - } - }; - - // Construct trees style based on user options - Tree.prototype.buildStyle = function () { - - var style = '.node-' + this.elementId + '{'; - - if (this.options.color) { - style += 'color:' + this.options.color + ';'; - } - - if (this.options.backColor) { - style += 'background-color:' + this.options.backColor + ';'; - } - - if (!this.options.showBorder) { - style += 'border:none;'; - } - else if (this.options.borderColor) { - style += 'border:1px solid ' + this.options.borderColor + ';'; - } - style += '}'; - - if (this.options.onhoverColor) { - style += '.node-' + this.elementId + ':not(.node-disabled):hover{' + - 'background-color:' + this.options.onhoverColor + ';' + - '}'; - } - - return this.css + style; - }; - - Tree.prototype.template = { - list: '', - item: '
  • ', - indent: '', - icon: '', - link: '', - badge: '' - }; - - Tree.prototype.css = '.treeview .list-group-item{cursor:pointer}.treeview span.indent{margin-left:10px;margin-right:10px}.treeview span.icon{width:12px;margin-right:5px}.treeview .node-disabled{color:silver;cursor:not-allowed}' - - - /** - Returns a single node object that matches the given node id. - @param {Number} nodeId - A node's unique identifier - @return {Object} node - Matching node - */ - Tree.prototype.getNode = function (nodeId) { - return this.nodes[nodeId]; - }; - - /** - Returns the parent node of a given node, if valid otherwise returns undefined. - @param {Object|Number} identifier - A valid node or node id - @returns {Object} node - The parent node - */ - Tree.prototype.getParent = function (identifier) { - var node = this.identifyNode(identifier); - return this.nodes[node.parentId]; - }; - - /** - Returns an array of sibling nodes for a given node, if valid otherwise returns undefined. - @param {Object|Number} identifier - A valid node or node id - @returns {Array} nodes - Sibling nodes - */ - Tree.prototype.getSiblings = function (identifier) { - var node = this.identifyNode(identifier); - var parent = this.getParent(node); - var nodes = parent ? parent.nodes : this.tree; - return nodes.filter(function (obj) { - return obj.nodeId !== node.nodeId; - }); - }; - - /** - Returns an array of selected nodes. - @returns {Array} nodes - Selected nodes - */ - Tree.prototype.getSelected = function () { - return this.findNodes('true', 'g', 'state.selected'); - }; - - /** - Returns an array of unselected nodes. - @returns {Array} nodes - Unselected nodes - */ - Tree.prototype.getUnselected = function () { - return this.findNodes('false', 'g', 'state.selected'); - }; - - /** - Returns an array of expanded nodes. - @returns {Array} nodes - Expanded nodes - */ - Tree.prototype.getExpanded = function () { - return this.findNodes('true', 'g', 'state.expanded'); - }; - - /** - Returns an array of collapsed nodes. - @returns {Array} nodes - Collapsed nodes - */ - Tree.prototype.getCollapsed = function () { - return this.findNodes('false', 'g', 'state.expanded'); - }; - - /** - Returns an array of checked nodes. - @returns {Array} nodes - Checked nodes - */ - Tree.prototype.getChecked = function () { - return this.findNodes('true', 'g', 'state.checked'); - }; - - /** - Returns an array of unchecked nodes. - @returns {Array} nodes - Unchecked nodes - */ - Tree.prototype.getUnchecked = function () { - return this.findNodes('false', 'g', 'state.checked'); - }; - - /** - Returns an array of disabled nodes. - @returns {Array} nodes - Disabled nodes - */ - Tree.prototype.getDisabled = function () { - return this.findNodes('true', 'g', 'state.disabled'); - }; - - /** - Returns an array of enabled nodes. - @returns {Array} nodes - Enabled nodes - */ - Tree.prototype.getEnabled = function () { - return this.findNodes('false', 'g', 'state.disabled'); - }; - - - /** - Set a node state to selected - @param {Object|Number} identifiers - A valid node, node id or array of node identifiers - @param {optional Object} options - */ - Tree.prototype.selectNode = function (identifiers, options) { - this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { - this.setSelectedState(node, true, options); - }, this)); - - this.render(); - }; - - /** - Set a node state to unselected - @param {Object|Number} identifiers - A valid node, node id or array of node identifiers - @param {optional Object} options - */ - Tree.prototype.unselectNode = function (identifiers, options) { - this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { - this.setSelectedState(node, false, options); - }, this)); - - this.render(); - }; - - /** - Toggles a node selected state; selecting if unselected, unselecting if selected. - @param {Object|Number} identifiers - A valid node, node id or array of node identifiers - @param {optional Object} options - */ - Tree.prototype.toggleNodeSelected = function (identifiers, options) { - this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { - this.toggleSelectedState(node, options); - }, this)); - - this.render(); - }; - - - /** - Collapse all tree nodes - @param {optional Object} options - */ - Tree.prototype.collapseAll = function (options) { - var identifiers = this.findNodes('true', 'g', 'state.expanded'); - this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { - this.setExpandedState(node, false, options); - }, this)); - - this.render(); - }; - - /** - Collapse a given tree node - @param {Object|Number} identifiers - A valid node, node id or array of node identifiers - @param {optional Object} options - */ - Tree.prototype.collapseNode = function (identifiers, options) { - this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { - this.setExpandedState(node, false, options); - }, this)); - - this.render(); - }; - - /** - Expand all tree nodes - @param {optional Object} options - */ - Tree.prototype.expandAll = function (options) { - options = $.extend({}, _default.options, options); - - if (options && options.levels) { - this.expandLevels(this.tree, options.levels, options); - } - else { - var identifiers = this.findNodes('false', 'g', 'state.expanded'); - this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { - this.setExpandedState(node, true, options); - }, this)); - } - - this.render(); - }; - - /** - Expand a given tree node - @param {Object|Number} identifiers - A valid node, node id or array of node identifiers - @param {optional Object} options - */ - Tree.prototype.expandNode = function (identifiers, options) { - this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { - this.setExpandedState(node, true, options); - if (node.nodes && (options && options.levels)) { - this.expandLevels(node.nodes, options.levels-1, options); - } - }, this)); - - this.render(); - }; - - Tree.prototype.expandLevels = function (nodes, level, options) { - options = $.extend({}, _default.options, options); - - $.each(nodes, $.proxy(function (index, node) { - this.setExpandedState(node, (level > 0) ? true : false, options); - if (node.nodes) { - this.expandLevels(node.nodes, level-1, options); - } - }, this)); - }; - - /** - Reveals a given tree node, expanding the tree from node to root. - @param {Object|Number|Array} identifiers - A valid node, node id or array of node identifiers - @param {optional Object} options - */ - Tree.prototype.revealNode = function (identifiers, options) { - this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { - var parentNode = this.getParent(node); - while (parentNode) { - this.setExpandedState(parentNode, true, options); - parentNode = this.getParent(parentNode); - }; - }, this)); - - this.render(); - }; - - /** - Toggles a nodes expanded state; collapsing if expanded, expanding if collapsed. - @param {Object|Number} identifiers - A valid node, node id or array of node identifiers - @param {optional Object} options - */ - Tree.prototype.toggleNodeExpanded = function (identifiers, options) { - this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { - this.toggleExpandedState(node, options); - }, this)); - - this.render(); - }; - - - /** - Check all tree nodes - @param {optional Object} options - */ - Tree.prototype.checkAll = function (options) { - var identifiers = this.findNodes('false', 'g', 'state.checked'); - this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { - this.setCheckedState(node, true, options); - }, this)); - - this.render(); - }; - - /** - Check a given tree node - @param {Object|Number} identifiers - A valid node, node id or array of node identifiers - @param {optional Object} options - */ - Tree.prototype.checkNode = function (identifiers, options) { - this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { - this.setCheckedState(node, true, options); - }, this)); - - this.render(); - }; - - /** - Uncheck all tree nodes - @param {optional Object} options - */ - Tree.prototype.uncheckAll = function (options) { - var identifiers = this.findNodes('true', 'g', 'state.checked'); - this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { - this.setCheckedState(node, false, options); - }, this)); - - this.render(); - }; - - /** - Uncheck a given tree node - @param {Object|Number} identifiers - A valid node, node id or array of node identifiers - @param {optional Object} options - */ - Tree.prototype.uncheckNode = function (identifiers, options) { - this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { - this.setCheckedState(node, false, options); - }, this)); - - this.render(); - }; - - /** - Toggles a nodes checked state; checking if unchecked, unchecking if checked. - @param {Object|Number} identifiers - A valid node, node id or array of node identifiers - @param {optional Object} options - */ - Tree.prototype.toggleNodeChecked = function (identifiers, options) { - this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { - this.toggleCheckedState(node, options); - }, this)); - - this.render(); - }; - - - /** - Disable all tree nodes - @param {optional Object} options - */ - Tree.prototype.disableAll = function (options) { - var identifiers = this.findNodes('false', 'g', 'state.disabled'); - this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { - this.setDisabledState(node, true, options); - }, this)); - - this.render(); - }; - - /** - Disable a given tree node - @param {Object|Number} identifiers - A valid node, node id or array of node identifiers - @param {optional Object} options - */ - Tree.prototype.disableNode = function (identifiers, options) { - this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { - this.setDisabledState(node, true, options); - }, this)); - - this.render(); - }; - - /** - Enable all tree nodes - @param {optional Object} options - */ - Tree.prototype.enableAll = function (options) { - var identifiers = this.findNodes('true', 'g', 'state.disabled'); - this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { - this.setDisabledState(node, false, options); - }, this)); - - this.render(); - }; - - /** - Enable a given tree node - @param {Object|Number} identifiers - A valid node, node id or array of node identifiers - @param {optional Object} options - */ - Tree.prototype.enableNode = function (identifiers, options) { - this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { - this.setDisabledState(node, false, options); - }, this)); - - this.render(); - }; - - /** - Toggles a nodes disabled state; disabling is enabled, enabling if disabled. - @param {Object|Number} identifiers - A valid node, node id or array of node identifiers - @param {optional Object} options - */ - Tree.prototype.toggleNodeDisabled = function (identifiers, options) { - this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { - this.setDisabledState(node, !node.state.disabled, options); - }, this)); - - this.render(); - }; - - - /** - Common code for processing multiple identifiers - */ - Tree.prototype.forEachIdentifier = function (identifiers, options, callback) { - - options = $.extend({}, _default.options, options); - - if (!(identifiers instanceof Array)) { - identifiers = [identifiers]; - } - - $.each(identifiers, $.proxy(function (index, identifier) { - callback(this.identifyNode(identifier), options); - }, this)); - }; - - /* - Identifies a node from either a node id or object - */ - Tree.prototype.identifyNode = function (identifier) { - return ((typeof identifier) === 'number') ? - this.nodes[identifier] : - identifier; - }; - - /** - Searches the tree for nodes (text) that match given criteria - @param {String} pattern - A given string to match against - @param {optional Object} options - Search criteria options - @return {Array} nodes - Matching nodes - */ - Tree.prototype.search = function (pattern, options) { - options = $.extend({}, _default.searchOptions, options); - - this.clearSearch({ render: false }); - - var results = []; - if (pattern && pattern.length > 0) { - - if (options.exactMatch) { - pattern = '^' + pattern + '$'; - } - - var modifier = 'g'; - if (options.ignoreCase) { - modifier += 'i'; - } - - results = this.findNodes(pattern, modifier); - - // Add searchResult property to all matching nodes - // This will be used to apply custom styles - // and when identifying result to be cleared - $.each(results, function (index, node) { - node.searchResult = true; - }) - } - - // If revealResults, then render is triggered from revealNode - // otherwise we just call render. - if (options.revealResults) { - this.revealNode(results); - } - else { - this.render(); - } - - this.$element.trigger('searchComplete', $.extend(true, {}, results)); - - return results; - }; - - /** - Clears previous search results - */ - Tree.prototype.clearSearch = function (options) { - - options = $.extend({}, { render: true }, options); - - var results = $.each(this.findNodes('true', 'g', 'searchResult'), function (index, node) { - node.searchResult = false; - }); - - if (options.render) { - this.render(); - } - - this.$element.trigger('searchCleared', $.extend(true, {}, results)); - }; - - /** - Find nodes that match a given criteria - @param {String} pattern - A given string to match against - @param {optional String} modifier - Valid RegEx modifiers - @param {optional String} attribute - Attribute to compare pattern against - @return {Array} nodes - Nodes that match your criteria - */ - Tree.prototype.findNodes = function (pattern, modifier, attribute) { - - modifier = modifier || 'g'; - attribute = attribute || 'text'; - - var _this = this; - return $.grep(this.nodes, function (node) { - var val = _this.getNodeValue(node, attribute); - if (typeof val === 'string') { - return val.match(new RegExp(pattern, modifier)); - } - }); - }; - - /** - Recursive find for retrieving nested attributes values - All values are return as strings, unless invalid - @param {Object} obj - Typically a node, could be any object - @param {String} attr - Identifies an object property using dot notation - @return {String} value - Matching attributes string representation - */ - Tree.prototype.getNodeValue = function (obj, attr) { - var index = attr.indexOf('.'); - if (index > 0) { - var _obj = obj[attr.substring(0, index)]; - var _attr = attr.substring(index + 1, attr.length); - return this.getNodeValue(_obj, _attr); - } - else { - if (obj.hasOwnProperty(attr)) { - return obj[attr].toString(); - } - else { - return undefined; - } - } - }; - - var logError = function (message) { - if (window.console) { - window.console.error(message); + e.stopPropagation(); + return false; } - }; - - // Prevent against multiple instantiations, - // handle updates and method calls - $.fn[pluginName] = function (options, args) { - - var result; - - this.each(function () { - var _this = $.data(this, pluginName); - if (typeof options === 'string') { - if (!_this) { - logError('Not initialized, can not call method : ' + options); - } - else if (!$.isFunction(_this[options]) || options.charAt(0) === '_') { - logError('No such method : ' + options); - } - else { - if (!(args instanceof Array)) { - args = [ args ]; - } - result = _this[options].apply(_this, args); - } - } - else if (typeof options === 'boolean') { - result = _this; - } - else { - $.data(this, pluginName, new Tree(this, $.extend(true, {}, options))); - } - }); - - return result || this; - }; + case this.keys.enter: + case this.keys.space: { + if (!$item.is('.tree-parent')) { + // do nothing + } else { + var nodeToToggle = this.findNode($item); + this.toggleNode($item.children("." + this.options.emptyIcon), nodeToToggle, {setSelectedFocus: true}); + } + + e.stopPropagation(); + return false; + } + case this.keys.left: { + var currentNode = this.findNode($item); + if ($item.is('.tree-parent') && $item.attr('aria-expanded') == 'true') { + // collapse the group and return + this.toggleNode($item.children("." + this.options.emptyIcon), currentNode, {setSelectedFocus: true}); + } else { + var parentNode = this.getParent(currentNode.nodeId); + this.selectNode(parentNode, selectionOptions); + } + + e.stopPropagation(); + return false; + } + case this.keys.right: { + var node = this.findNode($item); + if (!$item.is('.tree-parent')) { + // do nothing + } else if ($item.attr('aria-expanded') == 'false') { + this.toggleNode($item.children("." + this.options.emptyIcon), node, {setSelectedFocus: true}); + } else { + var childNodes = this.findNodes(node.nodeId.toString(), "g", "parentId"); + this.selectNode(childNodes[0], selectionOptions); + } + + e.stopPropagation(); + return false; + } + case this.keys.up: { + if (curNdx > 0) { + var $prev = this.$visibleItems.eq(curNdx - 1); + var prevNode = this.findNode($prev); + this.selectNode(prevNode, selectionOptions); + } + + e.stopPropagation(); + return false; + } + case this.keys.down: { + if (curNdx < this.$visibleItems.length - 1) { + var $next = this.$visibleItems.eq(curNdx + 1); + var nextNode = this.findNode($next); + this.selectNode(nextNode, selectionOptions); + } + e.stopPropagation(); + return false; + } + } + + return true; + }; + + Tree.prototype.clickHandler = function(event) { + if (!this.options.enableLinks) event.preventDefault(); + + var target = $(event.target); + var node = this.findNode(target); + if (!node || node.state.disabled || this.options.readOnly) { + return; + } + + this.toggleNode(target, node); + }; + + Tree.prototype.toggleNode = function(target, node, options) { + options = options || {}; + var selectedNode = node; + var classList = target.attr('class') ? target.attr('class').split(' ') : []; + if ((classList.indexOf('expand-icon') !== -1)) { + if (this.options.lazyLoad) { + var bareBonesLazyLoadFunction = function(parentNode, callback, options){callback(parentNode, [], options);}; + this.options.lazyLoadFunction = (typeof this.options.lazyLoadFunction === 'function') ? this.options.lazyLoadFunction : bareBonesLazyLoadFunction; + this.forEachIdentifier(node, options, $.proxy(function (node, options) { + // we haven't loaded this node's children before, and it didn't have child nodes from the initial population + if (!node.loaded && !(node.nodes && node.nodes.length)){ + var lazyLoadTreeviewCallback = $.proxy(function(parentNode, childNodes) { + if (childNodes && childNodes.length) { + for (var n=0; n < childNodes.length; n++) { + parentNode.nodes[n] = childNodes[n]; + } + } else { + // there are no children, so let's no longer make this expandable... + delete parentNode.nodes; + } + node.loaded = true; + this.setInitialStates({ nodes: this.tree }, 0); + if (node === selectedNode && node.selectable) { + this.setSelectedState(node, true, _default.options); + } + this.toggleExpandedState(node, _default.options); + this.render(options); + }, this); + + this.options.lazyLoadFunction(node, lazyLoadTreeviewCallback, options); + } else { + if (node === selectedNode && node.selectable) { + this.setSelectedState(node, true, _default.options); + } + this.toggleExpandedState(node, _default.options); + this.render(options); + } + }, this)); + } else { + this.toggleExpandedState(node, _default.options); + this.render(options); + } + } else if ((classList.indexOf('check-icon') !== -1)) { + this.toggleCheckedState(node, _default.options); + this.render(options); + } else { + if (node.selectable) { + this.toggleSelectedState(node, _default.options); + } else { + // only the plus/minus should toggle the expanded state + //this.toggleExpandedState(node, _default.options); + } + + this.render(options); + } + }; + + // Looks up the DOM for the closest parent list item to retrieve the + // data attribute nodeid, which is used to lookup the node in the flattened structure. + Tree.prototype.findNode = function (target) { + + var nodeId = target.closest('li.list-group-item').attr('data-nodeid'); + var node = this.nodes[nodeId]; + + if (!node) { + console.log('Error: node does not exist'); + } + return node; + }; + + Tree.prototype.toggleExpandedState = function (node, options) { + if (!node) return; + this.setExpandedState(node, !node.state.expanded, options); + }; + + Tree.prototype.setExpandedState = function (node, state, options) { + + if (state === node.state.expanded) return; + + if (state && node.nodes) { + + // Expand a node + node.state.expanded = true; + if (!options.silent) { + this.$element.trigger('nodeExpanded', $.extend(true, {}, node)); + } + } + else if (!state) { + + // Collapse a node + node.state.expanded = false; + if (!options.silent) { + this.$element.trigger('nodeCollapsed', $.extend(true, {}, node)); + } + + // Collapse child nodes + if (node.nodes && !options.ignoreChildren) { + $.each(node.nodes, $.proxy(function (index, node) { + this.setExpandedState(node, false, options); + }, this)); + } + } + }; + + Tree.prototype.toggleSelectedState = function (node, options) { + if (!node) return; + this.setSelectedState(node, !node.state.selected, options); + }; + + Tree.prototype.setSelectedState = function (node, state, options) { + + if (state === node.state.selected) return; + + if (state) { + + // If multiSelect false, unselect previously selected + if (!this.options.multiSelect) { + $.each(this.findNodes('true', 'g', 'state.selected'), $.proxy(function (index, node) { + this.setSelectedState(node, false, options); + }, this)); + } + + // Continue selecting node + node.state.selected = true; + if (!options.silent) { + this.$element.trigger('nodeSelected', $.extend(true, {}, node)); + } + } + else { + + // Unselect node + node.state.selected = false; + if (!options.silent) { + this.$element.trigger('nodeUnselected', $.extend(true, {}, node)); + } + } + }; + + Tree.prototype.toggleCheckedState = function (node, options) { + if (!node) return; + this.setCheckedState(node, !node.state.checked, options); + }; + + Tree.prototype.setCheckedState = function (node, state, options) { + + if (state === node.state.checked) return; + + if (state) { + + // Check node + node.state.checked = true; + + if (!options.silent) { + this.$element.trigger('nodeChecked', $.extend(true, {}, node)); + } + } + else { + + // Uncheck node + node.state.checked = false; + if (!options.silent) { + this.$element.trigger('nodeUnchecked', $.extend(true, {}, node)); + } + } + }; + + Tree.prototype.setDisabledState = function (node, state, options) { + + if (state === node.state.disabled) return; + + if (state) { + + // Disable node + node.state.disabled = true; + + // Disable all other states + this.setExpandedState(node, false, options); + this.setSelectedState(node, false, options); + this.setCheckedState(node, false, options); + + if (!options.silent) { + this.$element.trigger('nodeDisabled', $.extend(true, {}, node)); + } + } + else { + + // Enabled node + node.state.disabled = false; + if (!options.silent) { + this.$element.trigger('nodeEnabled', $.extend(true, {}, node)); + } + } + }; + + Tree.prototype.render = function (options) { + options = options || {}; + + if (!this.initialized) { + + // Setup first time only components + this.$element.addClass(pluginName); + this.$wrapper = $(this.template.list); + + this.injectStyle(); + + this.initialized = true; + } + + this.$element.empty().append(this.$wrapper.empty()); + + // Build tree + this.buildTree(this.tree, 0); + // keep track of currently visible items + this.$visibleItems = this.$element.find('li:visible'); + // set focus on selected item if specified + if (options.hasOwnProperty("setSelectedFocus") && options.setSelectedFocus) { + this.$element.find('li.node-selected').focus(); + } + }; + + // Starting from the root node, and recursing down the + // structure we build the tree one node at a time + Tree.prototype.buildTree = function (nodes, level) { + + if (!nodes) return; + level += 1; + + var _this = this; + $.each(nodes, function addNodes(id, node) { + + var treeItem = $(_this.template.item) + .addClass(node.nodes ? 'tree-parent' : '') + .addClass('node-' + _this.elementId) + .addClass(node.state.checked ? 'node-checked' : '') + .addClass(node.state.disabled ? 'node-disabled': '') + .addClass(_this.options.readOnly ? 'node-readonly' : '') + .addClass(node.state.selected ? 'node-selected' : '') + .addClass(node.searchResult ? 'search-result' : '') + .attr('data-nodeid', node.nodeId) + .attr('style', _this.buildStyleOverride(node)) + .attr('aria-expanded', node.state.expanded) + .attr('tabindex', (node.state.selected ? 0 : -1)); + + // Add indent/spacer to mimic tree structure + for (var i = 0; i < (level - 1); i++) { + treeItem.append(_this.template.indent); + } + + // Add expand, collapse or empty spacer icons + var classList = []; + if (node.nodes) { + classList.push('expand-icon'); + if (node.state.expanded) { + classList.push(_this.options.collapseIcon); + } + else { + classList.push(_this.options.expandIcon); + } + } + else { + classList.push(_this.options.emptyIcon); + } + + treeItem + .append($(_this.template.icon) + .addClass(classList.join(' ')) + ); + + + // Add node icon + if (_this.options.showIcon) { + + classList = ['node-icon']; + + classList.push(node.icon || _this.options.nodeIcon); + if (node.state.selected) { + classList.pop(); + classList.push(node.selectedIcon || _this.options.selectedIcon || + node.icon || _this.options.nodeIcon); + } + + treeItem + .append($(_this.template.icon) + .addClass(classList.join(' ')) + ); + } + + // Add check / unchecked icon + if (_this.options.showCheckbox) { + + classList = ['check-icon']; + if (node.state.checked) { + classList.push(_this.options.checkedIcon); + } + else { + classList.push(_this.options.uncheckedIcon); + } + + treeItem + .append($(_this.template.icon) + .addClass(classList.join(' ')) + ); + } + + // Add text + if (_this.options.enableLinks && !_this.options.readOnly) { + // Add hyperlink + treeItem + .append($(_this.template.link) + .attr('href', node.href) + .append(node.text) + ); + } + else { + // otherwise just text + treeItem + .append(node.text); + } + + // Add tags as badges + if (_this.options.showTags && node.tags) { + $.each(node.tags, function addTag(id, tag) { + treeItem + .append($(_this.template.badge) + .append(tag) + ); + }); + } + + // Add item to the tree + _this.$wrapper.append(treeItem); + + // Recursively add child ndoes + if (node.nodes && node.state.expanded && !node.state.disabled) { + return _this.buildTree(node.nodes, level); + } + }); + }; + + // Define any node level style override for + // 1. selectedNode + // 2. node|data assigned color overrides + Tree.prototype.buildStyleOverride = function (node) { + + if (node.state.disabled) return ''; + + var color = node.color; + var backColor = node.backColor; + + if (this.options.highlightSelected && node.state.selected) { + if (this.options.selectedColor) { + color = this.options.selectedColor; + } + if (this.options.selectedBackColor) { + backColor = this.options.selectedBackColor; + } + } + + if (this.options.highlightSearchResults && node.searchResult && !node.state.disabled) { + if (this.options.searchResultColor) { + color = this.options.searchResultColor; + } + if (this.options.searchResultBackColor) { + backColor = this.options.searchResultBackColor; + } + } + + return 'color:' + color + + ';background-color:' + backColor + ';'; + }; + + // Add inline style into head + Tree.prototype.injectStyle = function () { + + if (this.options.injectStyle && !document.getElementById(this.styleId)) { + $('').appendTo('head'); + } + }; + + // Construct trees style based on user options + Tree.prototype.buildStyle = function () { + + var style = '.node-' + this.elementId + '{'; + + if (this.options.color) { + style += 'color:' + this.options.color + ';'; + } + + if (this.options.backColor) { + style += 'background-color:' + this.options.backColor + ';'; + } + + if (!this.options.showBorder) { + style += 'border:none;'; + } + else if (this.options.borderColor) { + style += 'border:1px solid ' + this.options.borderColor + ';'; + } + style += '}'; + + if (this.options.onhoverColor) { + style += '.node-' + this.elementId + ':not(.node-disabled):hover{' + + 'background-color:' + this.options.onhoverColor + ';' + + '}'; + } + + return this.css + style; + }; + + Tree.prototype.template = { + list: '', + item: '
  • ', + indent: '', + icon: '', + link: '', + badge: '' + }; + + Tree.prototype.css = '.treeview .list-group-item{cursor:pointer}.treeview span.indent{margin-left:10px;margin-right:10px}.treeview span.icon{width:12px;margin-right:5px}.treeview .node-disabled{color:silver;cursor:not-allowed}.treeview .node-readonly{color:silver;cursor:not-allowed}'; + + + /** + Returns a single node object that matches the given node id. + @param {Number} nodeId - A node's unique identifier + @return {Object} node - Matching node + */ + Tree.prototype.getNode = function (nodeId) { + return this.nodes[nodeId]; + }; + + /** + Returns the parent node of a given node, if valid otherwise returns undefined. + @param {Object|Number} identifier - A valid node or node id + @returns {Object} node - The parent node + */ + Tree.prototype.getParent = function (identifier) { + var node = this.identifyNode(identifier); + return this.nodes[node.parentId]; + }; + + /** + Returns an array of sibling nodes for a given node, if valid otherwise returns undefined. + @param {Object|Number} identifier - A valid node or node id + @returns {Array} nodes - Sibling nodes + */ + Tree.prototype.getSiblings = function (identifier) { + var node = this.identifyNode(identifier); + var parent = this.getParent(node); + var nodes = parent ? parent.nodes : this.tree; + return nodes.filter(function (obj) { + return obj.nodeId !== node.nodeId; + }); + }; + + /** + Returns an array of selected nodes. + @returns {Array} nodes - Selected nodes + */ + Tree.prototype.getSelected = function () { + return this.findNodes('true', 'g', 'state.selected'); + }; + + /** + Returns an array of unselected nodes. + @returns {Array} nodes - Unselected nodes + */ + Tree.prototype.getUnselected = function () { + return this.findNodes('false', 'g', 'state.selected'); + }; + + /** + Returns an array of expanded nodes. + @returns {Array} nodes - Expanded nodes + */ + Tree.prototype.getExpanded = function () { + return this.findNodes('true', 'g', 'state.expanded'); + }; + + /** + Returns an array of collapsed nodes. + @returns {Array} nodes - Collapsed nodes + */ + Tree.prototype.getCollapsed = function () { + return this.findNodes('false', 'g', 'state.expanded'); + }; + + /** + Returns an array of checked nodes. + @returns {Array} nodes - Checked nodes + */ + Tree.prototype.getChecked = function () { + return this.findNodes('true', 'g', 'state.checked'); + }; + + /** + Returns an array of unchecked nodes. + @returns {Array} nodes - Unchecked nodes + */ + Tree.prototype.getUnchecked = function () { + return this.findNodes('false', 'g', 'state.checked'); + }; + + /** + Returns an array of disabled nodes. + @returns {Array} nodes - Disabled nodes + */ + Tree.prototype.getDisabled = function () { + return this.findNodes('true', 'g', 'state.disabled'); + }; + + /** + Returns an array of enabled nodes. + @returns {Array} nodes - Enabled nodes + */ + Tree.prototype.getEnabled = function () { + return this.findNodes('false', 'g', 'state.disabled'); + }; + + + /** + Set a node state to selected + @param {Object|Number} identifiers - A valid node, node id or array of node identifiers + @param {optional Object} options + */ + Tree.prototype.selectNode = function (identifiers, options) { + this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { + this.setSelectedState(node, true, options); + }, this)); + + this.render(options); + }; + + /** + Set a node state to unselected + @param {Object|Number} identifiers - A valid node, node id or array of node identifiers + @param {optional Object} options + */ + Tree.prototype.unselectNode = function (identifiers, options) { + this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { + this.setSelectedState(node, false, options); + }, this)); + + this.render(options); + }; + + /** + Toggles a node selected state; selecting if unselected, unselecting if selected. + @param {Object|Number} identifiers - A valid node, node id or array of node identifiers + @param {optional Object} options + */ + Tree.prototype.toggleNodeSelected = function (identifiers, options) { + this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { + this.toggleSelectedState(node, options); + }, this)); + + this.render(options); + }; + + + /** + Collapse all tree nodes + @param {optional Object} options + */ + Tree.prototype.collapseAll = function (options) { + var identifiers = this.findNodes('true', 'g', 'state.expanded'); + this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { + this.setExpandedState(node, false, options); + }, this)); + + this.render(options); + }; + + /** + Collapse a given tree node + @param {Object|Number} identifiers - A valid node, node id or array of node identifiers + @param {optional Object} options + */ + Tree.prototype.collapseNode = function (identifiers, options) { + this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { + this.setExpandedState(node, false, options); + }, this)); + + this.render(options); + }; + + /** + Expand all tree nodes + @param {optional Object} options + */ + Tree.prototype.expandAll = function (options) { + options = $.extend({}, _default.options, options); + + if (options && options.levels) { + this.expandLevels(this.tree, options.levels, options); + } + else { + var identifiers = this.findNodes('false', 'g', 'state.expanded'); + this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { + this.setExpandedState(node, true, options); + }, this)); + } + + this.render(options); + }; + + /** + Expand a given tree node + @param {Object|Number} identifiers - A valid node, node id or array of node identifiers + @param {optional Object} options + */ + Tree.prototype.expandNode = function (identifiers, options) { + this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { + this.setExpandedState(node, true, options); + if (node.nodes && (options && options.levels)) { + this.expandLevels(node.nodes, options.levels-1, options); + } + }, this)); + + this.render(options); + }; + + Tree.prototype.expandLevels = function (nodes, level, options) { + options = $.extend({}, _default.options, options); + + $.each(nodes, $.proxy(function (index, node) { + this.setExpandedState(node, (level > 0) ? true : false, options); + if (node.nodes) { + this.expandLevels(node.nodes, level-1, options); + } + }, this)); + }; + + /** + Reveals a given tree node, expanding the tree from node to root. + @param {Object|Number|Array} identifiers - A valid node, node id or array of node identifiers + @param {optional Object} options + */ + Tree.prototype.revealNode = function (identifiers, options) { + this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { + var parentNode = this.getParent(node); + while (parentNode) { + this.setExpandedState(parentNode, true, options); + parentNode = this.getParent(parentNode); + } + }, this)); + + this.render(options); + }; + + /** + Toggles a nodes expanded state; collapsing if expanded, expanding if collapsed. + @param {Object|Number} identifiers - A valid node, node id or array of node identifiers + @param {optional Object} options + */ + Tree.prototype.toggleNodeExpanded = function (identifiers, options) { + this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { + this.toggleExpandedState(node, options); + }, this)); + + this.render(options); + }; + + + /** + Check all tree nodes + @param {optional Object} options + */ + Tree.prototype.checkAll = function (options) { + var identifiers = this.findNodes('false', 'g', 'state.checked'); + this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { + this.setCheckedState(node, true, options); + }, this)); + + this.render(options); + }; + + /** + Check a given tree node + @param {Object|Number} identifiers - A valid node, node id or array of node identifiers + @param {optional Object} options + */ + Tree.prototype.checkNode = function (identifiers, options) { + this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { + this.setCheckedState(node, true, options); + }, this)); + + this.render(options); + }; + + /** + Uncheck all tree nodes + @param {optional Object} options + */ + Tree.prototype.uncheckAll = function (options) { + var identifiers = this.findNodes('true', 'g', 'state.checked'); + this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { + this.setCheckedState(node, false, options); + }, this)); + + this.render(options); + }; + + /** + Uncheck a given tree node + @param {Object|Number} identifiers - A valid node, node id or array of node identifiers + @param {optional Object} options + */ + Tree.prototype.uncheckNode = function (identifiers, options) { + this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { + this.setCheckedState(node, false, options); + }, this)); + + this.render(options); + }; + + /** + Toggles a nodes checked state; checking if unchecked, unchecking if checked. + @param {Object|Number} identifiers - A valid node, node id or array of node identifiers + @param {optional Object} options + */ + Tree.prototype.toggleNodeChecked = function (identifiers, options) { + this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { + this.toggleCheckedState(node, options); + }, this)); + + this.render(options); + }; + + + /** + Disable all tree nodes + @param {optional Object} options + */ + Tree.prototype.disableAll = function (options) { + var identifiers = this.findNodes('false', 'g', 'state.disabled'); + this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { + this.setDisabledState(node, true, options); + }, this)); + + this.render(options); + }; + + /** + Disable a given tree node + @param {Object|Number} identifiers - A valid node, node id or array of node identifiers + @param {optional Object} options + */ + Tree.prototype.disableNode = function (identifiers, options) { + this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { + this.setDisabledState(node, true, options); + }, this)); + + this.render(options); + }; + + /** + Enable all tree nodes + @param {optional Object} options + */ + Tree.prototype.enableAll = function (options) { + var identifiers = this.findNodes('true', 'g', 'state.disabled'); + this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { + this.setDisabledState(node, false, options); + }, this)); + + this.render(options); + }; + + /** + Enable a given tree node + @param {Object|Number} identifiers - A valid node, node id or array of node identifiers + @param {optional Object} options + */ + Tree.prototype.enableNode = function (identifiers, options) { + this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { + this.setDisabledState(node, false, options); + }, this)); + + this.render(options); + }; + + /** + Toggles a nodes disabled state; disabling is enabled, enabling if disabled. + @param {Object|Number} identifiers - A valid node, node id or array of node identifiers + @param {optional Object} options + */ + Tree.prototype.toggleNodeDisabled = function (identifiers, options) { + this.forEachIdentifier(identifiers, options, $.proxy(function (node, options) { + this.setDisabledState(node, !node.state.disabled, options); + }, this)); + + this.render(options); + }; + + + /** + Common code for processing multiple identifiers + */ + Tree.prototype.forEachIdentifier = function (identifiers, options, callback) { + + options = $.extend({}, _default.options, options); + + if (!(identifiers instanceof Array)) { + identifiers = [identifiers]; + } + + $.each(identifiers, $.proxy(function (index, identifier) { + callback(this.identifyNode(identifier), options); + }, this)); + }; + + /* + Identifies a node from either a node id or object + */ + Tree.prototype.identifyNode = function (identifier) { + return ((typeof identifier) === 'number') ? + this.nodes[identifier] : + identifier; + }; + + /** + Searches the tree for nodes (text) that match given criteria + @param {String} pattern - A given string to match against + @param {optional Object} options - Search criteria options + @return {Array} nodes - Matching nodes + */ + Tree.prototype.search = function (pattern, options) { + options = $.extend({}, _default.searchOptions, options); + + this.clearSearch({ render: false }); + + var results = []; + if (pattern && pattern.length > 0) { + + if (options.exactMatch) { + // need to escape special characters potentially in the node value + pattern = '^' + pattern.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&") + '$'; + } + + var modifier = 'g'; + if (options.ignoreCase) { + modifier += 'i'; + } + + results = this.findNodes(pattern, modifier); + + // Add searchResult property to all matching nodes + // This will be used to apply custom styles + // and when identifying result to be cleared + $.each(results, function (index, node) { + node.searchResult = true; + }); + } + + // If revealResults, then render is triggered from revealNode + // otherwise we just call render. + if (options.revealResults) { + this.revealNode(results); + } + else { + this.render(options); + } + + this.$element.trigger('searchComplete', $.extend(true, {}, results)); + + return results; + }; + + /** + Clears previous search results + */ + Tree.prototype.clearSearch = function (options) { + + options = $.extend({}, { render: true }, options); + + var results = $.each(this.findNodes('true', 'g', 'searchResult'), function (index, node) { + node.searchResult = false; + }); + + if (options.render) { + this.render(options); + } + + this.$element.trigger('searchCleared', $.extend(true, {}, results)); + }; + + /** + Find nodes that match a given criteria + @param {String} pattern - A given string to match against + @param {optional String} modifier - Valid RegEx modifiers + @param {optional String} attribute - Attribute to compare pattern against + @return {Array} nodes - Nodes that match your criteria + */ + Tree.prototype.findNodes = function (pattern, modifier, attribute) { + + modifier = modifier || 'g'; + attribute = attribute || 'text'; + + var _this = this; + return $.grep(this.nodes, function (node) { + var val = _this.getNodeValue(node, attribute); + if (typeof val === 'string') { + return val.match(new RegExp(pattern, modifier)); + } + }); + }; + + /** + Recursive find for retrieving nested attributes values + All values are return as strings, unless invalid + @param {Object} obj - Typically a node, could be any object + @param {String} attr - Identifies an object property using dot notation + @return {String} value - Matching attributes string representation + */ + Tree.prototype.getNodeValue = function (obj, attr) { + var index = attr.indexOf('.'); + if (index > 0) { + var _obj = obj[attr.substring(0, index)]; + var _attr = attr.substring(index + 1, attr.length); + return this.getNodeValue(_obj, _attr); + } + else { + if (obj.hasOwnProperty(attr) && obj[attr] !== undefined) { + return obj[attr].toString(); + } + else { + return undefined; + } + } + }; + + var logError = function (message) { + if (window.console) { + window.console.error(message); + } + }; + + // Prevent against multiple instantiations, + // handle updates and method calls + $.fn[pluginName] = function (options, args) { + + var result; + + this.each(function () { + var _this = $.data(this, pluginName); + if (typeof options === 'string') { + if (!_this) { + logError('Not initialized, can not call method : ' + options); + } + else if (!$.isFunction(_this[options]) || options.charAt(0) === '_') { + logError('No such method : ' + options); + } + else { + if (!(args instanceof Array)) { + args = [ args ]; + } + result = _this[options].apply(_this, args); + } + } + else if (typeof options === 'boolean') { + result = _this; + } + else { + $.data(this, pluginName, new Tree(this, $.extend(true, {}, options))); + } + }); + + return result || this; + }; })(jQuery, window, document);