Blog-Archiv

Sonntag, 20. August 2017

Expand-All Control for JS Trees

Knowledge is a very long line of text that has been written since thousands of years. To make it available, it has been split into books that provide titles, summarizing the contained knowledge. When you open a book, you find pages, and you need to turn them to find the knowledge you are looking for. Nowadays the Internet has redefined the term "page", and you can find entire books on web pages. Here you can navigate using a search-field, and instead of turning pages you scroll.

Availability increases when knowledge gets structured. That means short books, more accurate and descriptive titles, having tables of contents and indexes, and libraries that provide well-organized catalogues. The Internet is a big library that has no catalogues, but there are search engines and hyperlinks. For web pages, structuring means that you don't want to face full complexity at first sight. In other words, the details should be initially hidden. That's what folding is for.


In this Blog I want to present an idea for tree expand-controls that should make life easier in certain cases. Instead of having just one expand-control there are two, and the new one expands or collapses the whole sub-tree on just one click.

The Idea

Click on the bigger ▶ expand-control to expand just the current folder ("traditional").
Use the new smaller ▸ "expand-all" control to expand the tree fully.

  • root
    • item 1
      • item 1.1
      • item 1.2
    • item 2
    • item 3
      • item 3.1
        • item 3.1.1
        • item 3.1.2
        • item 3.1.3
      • item 3.2
      • item 3.3
        • item 3.3.1
        • item 3.3.2
        • item 3.3.3
          • item 3.3.3.1
          • item 3.3.3.2
      • item 3.4
    • item 4
      • item 4.1
      • item 4.2
      • item 4.3

Mind that the small control nicely indicates the presence of sub-folders!

Of course such a solution is not suitable for all kinds of trees. A dynamically expanding tree that fetches server data any time the user expands a folder may not be able to provide the second expand-control, because it sees just one level down the tree. But for trees that are fully available at page-load-time this could be a nice enhancement.

Design Decisions

A number of things have to be thought over for the second expand control.

  • Should the new "expand-all" control be left or right of the main expand control?
  • Should it be bigger than, smaller than, or same size as the main?

I believe the new control should be smaller, because users will expect the main control to be dominant.

Concerning the question "left or right?", here is a tree where the "expand-all" control is right (and initially fully expanded):

  • item 1
    • item 1.1
    • item 1.2
    • item 1.3
      • item 1.3.1
      • item 1.3.2
      • item 1.3.3
    • item 1.4
  • item 2
    • item 2.1
    • item 2.2
  • item 3

Doesn't look so good, too much space between the main control and the tree label. I think having the new control left is also a good indication about where the sub-trees are. Hence this is the implemented default location, but the following JavaScript lets configure this.

Source Code

The JS code consists of two modules, where the second extends the first. I use functional inheritance. The first module is foldingUtil, this can be used for any kind of expandable disclosure. The second is doubleFoldingUtil, this adds the second expand-control and its functionality. Finally some page-specific adapter code uses these modules to achieve the trees shown on this page.

Put all the JS source shown below inside a <script> tag on bottom of your HTML page's <body>, and then write some adapter code below to catch the trees you want to fold.

I won't explain the following JS source code line by line. I tried to comment everything, so that it should be readable and understandable. When you are completely new to JS programming, step through the code with the browser debugger, this is one of the best ways to get familiar with a piece of software. Set breakpoints in doubleFoldingUtil.initialize(), and doubleFoldingUtil.clickAllListener().

CSS

You will need this CSS in your page's <head> to get rid of <UL> list bullets and paddings.

  <style type="text/css">
    ul {
      list-style-type: none;
      padding-left: 1em;
    }
  </style>

JS Folding Util

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
    /**
     * Basic folding utility, suitable for both simple disclosures and complex trees.
     * @param {string} symbolExpand required, the symbol to set on a collapsed folder.
     * @param {string} symbolCollapse required, the symbol to set on an expanded folder.
     * @returns a folding utility to connect() a title with its content.
     */
    var foldingUtil = function(symbolExpand, symbolCollapse)
    {
      "use strict";
    
      var that = {};
      
      /**
       * Connects a title element with a content element for folding.
       * @param titleElement the DOM container that will get an expand-control inserted.
       * @param content optional, the sub-list DOM element to be folded.
       * @param {boolean} initiallyFolded optional, default false, the tree will be collapsed initially when true.
       * @returns the DOM expand-control element that was generated for this fold.
       */
      that.connect = function(titleElement, content, initiallyFolded) {
        var expandControl = that.prependExpandControlContainer(titleElement);
        if (content) {
          expandControl.innerHTML = (initiallyFolded ? symbolExpand : symbolCollapse);
          expandControl.addEventListener("click", function() {
            clickListener(expandControl, content);
          });
          setDisplayed(content, ! initiallyFolded); /* set initial state */
        }
        return expandControl;
      };

      /** This is for calls by sub-classes, do not call from outside! */
      that.prependExpandControlContainer = function(titleElement) {
        var expandControl = document.createElement("div");
        expandControl.style.display = "inline-block";
        expandControl.style.cursor = "pointer";
        expandControl.style["text-align"] = "center";
        
        var width = that.getExpandControlWidth();
        expandControl.style.width = (width !== undefined) ? width : "1em"; /* MUST have a width, placeholder */
        
        var marginRight = that.getExpandControlRightMargin();
        if (marginRight)
          expandControl.style["margin-right"] = marginRight;
        
        titleElement.insertBefore(expandControl, titleElement.childNodes[0]);
        return expandControl;
      };
      
      /** Returns the width value to set onto an expand-control. */
      that.getExpandControlWidth = function() {
        return "1em";
      };
      
      /** Returns the margin-right value to set onto an expand-control. */
      that.getExpandControlRightMargin = function() {
        return "0.5em";
      };
      
      /** Returns true when given expand-control contains an collapse-symbol. */
      that.isControlExpanded = function(expandControl) {
        return expandControl.innerHTML === symbolCollapse;
      };
      
      var clickListener = function(expandControl, content) {
        var displayed = that.isControlExpanded(expandControl);
        setDisplayed(content, displayed === false); /* revert state */
        expandControl.innerHTML = displayed ? symbolExpand : symbolCollapse;
      };
      
      var setDisplayed = function(content, displayed) {
        content.style.display = displayed ? "" : "none";
      };

      return that;
    };

JS Double Folding

The concept here is to mark "single-expand" controls (main) by a CSS class, and then click() programmatically onto all of them when the "expand-all" control is clicked.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
    /**
     * Folding utility for trees, lets expand and collapse
     * the whole tree with just one click by providing a second expand control.
     * @param foldingUtil required, the folding utility to inherit from, super-object.
     * @param {string} symbolAllExpand required, the expand-all symbol to set on a collapsed folder.
     * @param {string} symbolAllCollapse required, the expand-all symbol to set on an expanded folder.
     * @param {boolean} expandAllSymbolAtRight optional, default false, when true, the expand-all control is second.
     * @returns a folding utility to initialize() a tree structure with double expand-controls.
     */
    var doubleFoldingUtil = function(foldingUtil, symbolAllExpand, symbolAllCollapse, expandAllSymbolAtRight)
    {
      "use strict";
      
      var that = foldingUtil;
      
      /**
       * Call this to install double-folding.
       * This is here to ensure that the treeNodesTopDown list is iterated bottom-up.
       * @param treeNodesTopDown required, array of title-elements to connect with contained sub-lists, in top-down order.
       * @param recognizeSubListFunction required, returns true when given tree-node child is a sub-list.
       * @param {boolean} initiallyFolded optional, default false, the tree will be collapsed initially when true.
       */
      that.initialize = function(treeNodesTopDown, recognizeSubListFunction, initiallyFolded) {
        /* loop bottom-up, so that findSingleExpandControlsBelow() will work correctly */
        for (var i = treeNodesTopDown.length - 1; i >= 0; i--) {
          var treeNode = treeNodesTopDown[i];
          
          var subList = undefined; /* need to initialize this due to variable hoisting */
          var children = treeNode.children;
          for (var c = 0; ! subList && c < children.length; c++)
            if (recognizeSubListFunction(children[c]))
              subList = children[c];

          that.connect(treeNode, subList, initiallyFolded);
        }
      };
      
      
      var singleExpandControlClassName = "singleExpandControl";
      
      
      var superConnect = that.connect; /* will be overridden */
      
      /** Overridden to install a second expand-control. */
      that.connect = function(titleElement, content, initiallyFolded) {
        var singleExpandControl;
        
        if ( ! expandAllSymbolAtRight ) /* insert normal expand-control when being at right */
          singleExpandControl = superConnect(titleElement, content, initiallyFolded);
        
        var expandAllControl = connectAll(titleElement, content, initiallyFolded); /* prepend expand-all-control */
        
        if ( expandAllSymbolAtRight ) /* prepend normal expand-control when being at left */
          singleExpandControl = superConnect(titleElement, content, initiallyFolded);
        
        rightMarginOnExpandControls(singleExpandControl, expandAllControl);
        
        if (content) {
          singleExpandControl.classList.add(singleExpandControlClassName); /* need to find expand-controls */
          
          if (expandAllControl.innerHTML) { /* is not just an indent placeholder */
            singleExpandControl.addEventListener("click", function() { /* synchronize expand-all-control */
              var expanded = that.isControlExpanded(singleExpandControl);
              expandAllControl.innerHTML =
                  (expanded && ! existsCollapsedBelow(content)) ? symbolAllCollapse : symbolAllExpand;
            });
          }
        }
        
        return singleExpandControl;
      };


      var superGetExpandControlRightMargin = that.getExpandControlRightMargin;
      
      /** Overridden to avoid right margin on super's expand-control. */
      that.getExpandControlRightMargin = function() {
        return undefined;
      };
      
      
      var rightMarginOnExpandControls = function(singleExpandControl, expandAllControl) {
        var marginTarget = expandAllSymbolAtRight ? expandAllControl : singleExpandControl;
        marginTarget.style["margin-right"] = superGetExpandControlRightMargin();
      };
      
      var connectAll = function(titleElement, content, initiallyFolded) {
        var expandAllControl = that.prependExpandControlContainer(titleElement);
        /* just nodes that have folder-nodes below them need an expand-all symbol */
        if (findSingleExpandControlsBelow(content).length > 0) {
          expandAllControl.innerHTML = initiallyFolded ? symbolAllExpand : symbolAllCollapse;
          expandAllControl.addEventListener("click", function() {
            clickAllListener(expandAllControl, content);
          });
        }
        return expandAllControl;
      };

      var clickAllListener = function(expandAllControl, content) {
        var singleExpandControl = findSingleExpandControlsBelow(expandAllControl.parentElement)[0];
        var singleExpanded = that.isControlExpanded(singleExpandControl);
        var allExpanded = (expandAllControl.innerHTML === symbolAllCollapse);
        
        if (singleExpanded === allExpanded) /* when both controls have same state, */
          singleExpandControl.click(); /* click the single-expand control */
        
        expandAllControl.innerHTML = allExpanded ? symbolAllExpand : symbolAllCollapse;
        
        clickSingleExpandControlsBelow(content, allExpanded);
      };
      
      var clickSingleExpandControlsBelow = function(content, collapsing) {
        var singleExpandControls = findSingleExpandControlsBelow(content);
        /* loop bottom-up, so that existsCollapsedBelow() will work correctly */
        for (var i = singleExpandControls.length - 1; i >= 0; i--) {
          var singleExpandControl= singleExpandControls[i];
          var collapsed = ! that.isControlExpanded(singleExpandControl);
          if (collapsing !== collapsed) /* needs state change */
            singleExpandControl.click();
        }
      };
      
      var existsCollapsedBelow = function(content) {
        var singleExpandControls = findSingleExpandControlsBelow(content);
        for (var i = 0; i < singleExpandControls.length; i++)
          if ( ! that.isControlExpanded(singleExpandControls[i]) )
            return true;
        return false;
      };
      
      var findSingleExpandControlsBelow = function(content) {
        return content ? content.querySelectorAll("."+singleExpandControlClassName) : [];
      };
      
      return that;
    };

JS Usage Example

Following example assumes that the tree consists of nested <UL> elements, and the root element has an id = "expandable-tree":

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
    (function() {
      
      /* different expand- and collapse-symbols */
      var blackTriangleRight = "\u25B6";
      var blackTriangleDown = "\u25BC";
      var blackTriangleRightSmall = "\u25B8";
      var blackTriangleDownSmall = "\u25BE";

      /* parameter function for doubleFoldingUtil.initialize(). */
      var recognizeSubListFunction = function(child) {
        return child.tagName === "UL";
      };
      
      /* build for tree */
      var treeFoldingUtil = doubleFoldingUtil(
          foldingUtil(
              blackTriangleRight,
              blackTriangleDown),
          blackTriangleRightSmall,
          blackTriangleDownSmall);

      treeFoldingUtil.initialize(
          document.getElementById("expandable-tree").getElementsByTagName("LI"),
          recognizeSubListFunction,
          true /* initially folded */
      );
      
    })();



Keine Kommentare: