6
\$\begingroup\$

Background

Unsatisfied with existing implementations of Miller Columns (used to edit hierarchical data), I decided to re-invent the wheel.

Problem

Most of the existing implementations are too complex, offer extraneous functionality, reliance on complex (or unavailable) frameworks, have numerous dependencies, are riddled with severe bugs, or require non-hierarchical data.

Source

The source code has two parts. The JavaScript code performs a breadth-first traversal of an arbitrarily nested set of lists and wraps them into a consecutive, flattened hierarchy surrounded by div tags. The CSS code places the divs in left-floating columns (and applies superficial presentation items).

(function( $ ) {
  $.fn.millerColumns = function() {
    var $list = $(this).first();
    var $columns = $(this);

    // Breadth-first traversal to rearrange list items into
    // consecutively ordered div wrapper elements.
    while( ($list = $list.children()).length ) {
      $list.each( function( index, element ) {
        var $parent = $(element).parent();

        if( $(element).is( "li" ) ) {
          $parent = $($parent).parent();
        }

        // Store the parent id for showing child columns.
        var id = $($parent).attr( "id" );

        if( $(element).is( "ul" ) ) {
          // The parent element shall be marked as 0.
          if( id === undefined ) {
            id = 0;
          }

          var $item = $("li#" + id);
          $item.addClass( "parent" );

          $item.on( "click", function() {
            // Hide everything.
            $("div.column[data-parent!=0]").addClass( "collapsed" );
            $("li").removeClass( "selection" );

            // The "id" for the clicked list item becomes the start of
            // the ancestral chain.
            var $child = $("div.column[data-parent=" + id + "]" );
            $child.removeClass( "collapsed" );

            var $li = $("li.parent[id=" + id + "]");
            var $ancestor = $li.parent().parent();
            var ancestor_id = $ancestor.attr( "data-parent" );

            while( ancestor_id !== undefined ) {
              $li.addClass( "selection" );
              $ancestor.removeClass( "collapsed" );

              $li = $("li.parent[id=" + ancestor_id + "]");
              $ancestor = $li.parent().parent();
              ancestor_id = $ancestor.attr( "data-parent" );
            }

            $li.addClass( "selection" );

            var $breadcrumb = $("div.breadcrumb");
            $breadcrumb.empty();

            // Add the breadcrumb trail.
            $("li.selection").each( function( _, crumb ) {
              $breadcrumb.append( "<span>" + $(crumb).text() + "</span>" );
            });
          });
          
          var $div = $("<div>").attr( "data-parent", id );
          var $wrapped = $(element).wrapAll( $div );

          // Unnest the list items into contiguous div elements.
          $wrapped.parent().detach().appendTo( $columns ).addClass( "column" );
        }
      });
    }

    $columns.prepend( $("<div>").addClass( "breadcrumb" ) );
    $columns.append( $("<div>").addClass( "toolbar" ) );

    // Hide all the columns except the root.
    $("div.column[data-parent!=0]").addClass( "collapsed" );

    return this;
  };

  $("div.columns").millerColumns();
}(jQuery));
div.columns,
div.breadcrumb {
  border-top-left-radius: 0.4em 0.4em;
  border-top-right-radius: 0.4em 0.4em;
}

div.columns,
div.toolbar {
  border-bottom-left-radius: 0.4em 0.4em;
  border-bottom-right-radius: 0.4em 0.4em;
}

div.columns {
  margin-top: 1em;
  margin-bottom: 1em;
  min-height: 150px;
  overflow: auto;
  background: #949494;
}

div.column {
  float: left;
  display: inline-block;
  background: #FFF;
  border-right: 1px solid #666;
  font-size: 0.9em;
  font-family: Arial;
}

div.collapsed {
  display: none;
}

li.parent:hover {
  color: black;
  background-color: #DDE4E8;
}

/* Ensure the selection hierarchy is highlighted. */

li.selection {
  background-color: #08C !important;
  color: white !important;
}

div.breadcrumb,
div.toolbar {
  height: 1.25em;
}

div.breadcrumb,
div.toolbar {
  background: linear-gradient(#f0f0f0, #d8d8d8);
  background: -webkit-linear-gradient(#f0f0f0, #d8d8d8);
  background: -o-linear-gradient(#f0f0f0, #d8d8d8);
  background: -moz-linear-gradient(#f0f0f0, #d8d8d8);
}

div.breadcrumb {
  border-bottom: 1px solid #666;
}

div.breadcrumb > span {
  height: 1.25em;
  line-height: 1.25em;
}

div.toolbar {
  clear: both;
  border-top: 1px solid #666;
}

div.breadcrumb > span {
  font-size: 0.7em;
  font-weight: bold;
  padding-left: 0.25em;
  color: #666;
}

div.breadcrumb > span::after {
  content: " \25B8";
}

div.breadcrumb > span:first-child {
  padding-left: 1em;
}

div.breadcrumb > span:last-child:after {
  content: "";
}

ul {
  padding: 0;
  margin: 0;
  overflow: auto;
}

li {
  list-style: none;
  padding-left: 0.5em;
  padding-right: 0.5em;
  padding-top: 0.25em;
  padding-bottom: 0.25em;
  min-width: 150px;
}

/* Use an arrow to indicate that the parent has child elements. */
li.parent:after {
  content: "\25B8";
  float: right;
}

/* Zebra stripes. */
li:nth-child(odd) {
  background-color: #EEE;
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
<div class="columns">
  <ul>
    <li id="1">Item A</li>
    <li id="2">Item B
      <ul>
        <li id="21">Item BA</li>
        <li id="22">Item BB</li>
      </ul>
    </li>
    <li id="3">Item C
      <ul>
        <li id="31">Item CA</li>
        <li id="32">Item CB</li>
        <li id="33">Item CC 
          <ul>
            <li id="331">Item CCA</li>
            <li id="332">Item CCB</li>
            <li id="333">Item CCC</li>
          </ul>
        </li>
        <li id="34">Item CD</li>
        <li id="35">Item CE</li>
      </ul>
    </li>
  </ul>
</div>

Question

The JavaScript code, with comments, is 80 lines long.

How can the algorithm to produce the drillable columns be simplified?

For example, the following lines are virtually duplicated within the while loop:

var $li = $("li.parent[id=" + id + "]");
var $ancestor = $li.parent().parent();
var ancestor_id = $ancestor.attr( "data-parent" );

Notes

Note that the code, as presented, has a few known issues, including:

  • Horizontal scrolling does not work.
  • Vertical scrolling does not work.
  • Any column that does not fit the width will appear below the previous column.
  • There is no keyboard interface.
\$\endgroup\$
0

2 Answers 2

3
\$\begingroup\$

A few observations :

  • You should be able to write this so that it doesn't rely on id. The code should be able to detect the hierarchy without being told.
  • Several chaining simplifications are possible - eg. var $breadcrumb = $("div.breadcrumb").empty()
  • Vars should be declared up front in their respective functions, in a single var statement.
  • Combined assignment and test is bad practice and makes code hard to read - while( ($list = $list.children()).length ) {.
  • $ancestor.data('parent') is much more efficient than $ancestor.attr('data-parent') The former involves only javascript while the latter involves the DOM.
  • The need to clone jQuery objects is rare - As far as I can see, $($parent) will simplify to $parent without consequence.
\$\endgroup\$
4
  • \$\begingroup\$ @DaveJarvis, I confess that I didn't try to penetrate the algorithm - or more accurately, I tried but failed. I suspect that the algorithm can be improved but that's only a gut feeling. \$\endgroup\$ Nov 26, 2014 at 9:40
  • \$\begingroup\$ I am inclined to think the algorithm can be simplified, too. Do you have a source for the bad practice claim? It's a technique that's quite prevalent in Java (e.g., reading lines from a file). \$\endgroup\$ Nov 26, 2014 at 22:28
  • \$\begingroup\$ Yes, for some reason that bad practice is commonplace in Java and PHP but hardly ever seen in Javascript. I don't have a reference but IIRC, the objection is readability - unless you're concentrating, = can be read for ==. There may also be a difference in operator preference that makes it less attractive in js. \$\endgroup\$ Nov 26, 2014 at 22:36
  • \$\begingroup\$ An entirely different approach was required. The key, which your answer hinted at, was to maintain the original hierarchy using (recursive) DOM node references--instead of the confusing id/parent-id data values. With this algorithmic change in place, the code becomes significantly simpler. \$\endgroup\$ Dec 29, 2014 at 9:58
1
\$\begingroup\$

Algorithmically, there are a number of improvements that can be made, including:

  • Eliminate the id and parent-id dependency using recursive DOM references
  • Use unadorned ul tags (without wrapping them in div tags).
  • Eliminate all duplicated code.
  • Simplify the CSS.
  • Avoid or address the issues raised by this other answer.

This also fixes a few of the issues mentioned in the question, including horizontal scrolling and columns appearing beside each other (never below).

Aside, this also removes all references to "ul", so the code should work with "ol" elements, as well. Further, it will be easier to adapt to hierarchical JSON data.

(function( $ ) {
  $.fn.millerColumns = function() {
    return this.each( function() {
      var $columns = $(this);
      $columns.before( $("<div>").addClass( "breadcrumb" ) );
      $columns.after( $("<div>").addClass( "toolbar" ) );
      unnest( $columns );
      collapse();

      // Expand the requested child node on click.
      $columns.find( "li" ).on( "click", function() {
        collapse();
        $(".selection").removeClass( "selection" );

        var $descendants = $(this).data( "descendants" );

        if( $descendants !== undefined ) {
          $descendants.removeClass( "collapsed" );
          $descendants.children().removeClass( "selection" );
        }

        var $ancestor = $(this);

        // Reveal (uncollapse) all ancestors to the clicked item.
        while( $ancestor !== undefined ) {
          $ancestor.addClass( "selection" ).parent().removeClass( "collapsed" );
          $ancestor = $ancestor.data( "ancestor" );
        }

        breadcrumb();

        // Ensure the viewport shows the entire newly expanded item.
        $columns.animate( { scrollLeft: $(this).offset().left }, 500 );
      });
    });
  }

  /** Determine the breadcrumb path via the selected items. */
  function breadcrumb() {
    var $breadcrumb = $("div.breadcrumb").empty();

    // Add the breadcrumb trail.
    $("li.selection").each( function( _, crumb ) {
      $breadcrumb.append( "<span>" + $(crumb).text() + "</span>" );
    });
  }

  /** Convert nested lists into columns using breadth-first traversal. */
  function unnest( $columns ) {
    var queue = [];

    // Push the root unordered list item into the queue.
    queue.push( $columns.children() );

    while( queue.length ) {
      var $node = queue.shift();

      $node.children().each( function( _, el ) {
        var $descendants = $(this).children();
        var $ancestor = $(this).parent().parent();

        // Retain item hierarchy (because it is lost after flattening).
        if( $ancestor.length && ($(this).data( "ancestor" ) === undefined) ) {
          $(this).siblings().addBack().data( "ancestor", $ancestor );
        }

        if( $descendants.length > 0 ) {
          queue.push( $descendants );
          $(this).data( "descendants", $descendants ).addClass( "parent" );
        }

        // Causes item siblings to have a flattened DOM lineage.
        $(this).parent().detach().appendTo( $columns ).addClass( "column" );
      });
    }
  }

  /** Hide all columns, except the first. */
  function collapse() {
    $(".column:gt(0)").addClass( "collapsed" );
  }
})(jQuery);

$(document).ready( function() { $("div.columns").millerColumns(); });
@charset "utf-8"; 

/**
 *
 * Cascading columns styling.
 *
 */
div.columns {
  float: left;
  width: 100%;
  height: 200px;
  overflow-x: auto;
  overflow-y: hidden;
  white-space: nowrap;

  background-color: #949494;
}

/* Display the lists as columns in blocks. */
ul.column {
  display: inline-block;

  vertical-align: top;
  overflow: hidden;
  margin: 0 auto;

  border-right: 1px solid #666;
  background-color: white;
  white-space: normal;

  font-size: 0.9em;
}

/* Setting a list container class to "collapsed" will hide the column. */
ul.column.collapsed {
  display: none;
}

/* Put some space between the column's list entries. */
ul.column > li {
  list-style: none;
  padding-left: 0.5em;
  padding-right: 0.5em;
  padding-top: 0.25em;
  padding-bottom: 0.25em;

  min-width: 200px;
}

ul.column > li.parent::after {
  content: "▶";
  float: right;
}

/* Zebra stripes, which can be overridden. */
ul.column > li:nth-child(odd) {
  background-color: #EEE;
}

/* Highlight while hovering, without allowing selected items to override. */
ul.column > li:hover {
  color: black;
  background-color: #DDE4E8;
}

/* Ensure all selected nodes in the hierarchy are easily seen. */
ul.column > li.selection {
  background-color: #08C;
  color: white;
}

/**
 *
 * Breadcrumb styling.
 *
 */
div.breadcrumb {
  margin-top: 1em;
  border-top-left-radius: 0.4em 0.4em;
  border-top-right-radius: 0.4em 0.4em;

  border-bottom: 1px solid #666;
}

div.breadcrumb > span {
  height: 1.25em;
  line-height: 1.25em;

  font-size: 0.7em;
  font-weight: bold;
  padding-left: 0.25em;
  color: #666;
}

div.breadcrumb > span::after {
  content: " ▸";
}

div.breadcrumb > span:first-child {
  padding-left: 1em;
}

div.breadcrumb > span:last-child:after {
  content: "";
}

/**
 *
 * Toolbar styling.
 *
 */
div.toolbar {
  margin-bottom: 1em;
  border-bottom-left-radius: 0.4em 0.4em;
  border-bottom-right-radius: 0.4em 0.4em;

  clear: both;
  border-top: 1px solid #666;
}

div.toolbar:after {
  font-size: 0.8em;
  content: "new | edit | delete | flag";
  height: 1.25em;
  line-height: 1.25em;
  float: right;
  padding-right: 1em;
}

/**
 *
 * Breadcrumb and toolbar styling.
 *
 */
div.breadcrumb, div.toolbar {
  height: 1.25em;
  background: linear-gradient(#f0f0f0, #d8d8d8);
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.2/jquery.min.js"></script>
<div class="columns">
  <ul>
    <li>A1</li>
    <li>A2</li>
    <li>A3
      <ul>
        <li>B1</li>
        <li>B2
          <ul>
            <li>C1</li>
            <li>C2</li>
            <li>C3</li>
          </ul>
        <li>B3
          <ul>
            <li>D1</li>
            <li>D2</li>
            <li>D3</li>
          </ul>
      </ul>
  </ul>
</div>

\$\endgroup\$
3
  • 1
    \$\begingroup\$ Looks cool. Maybe just a few things to tidy ... (a) <div> in .before($("<div>").addClass("breadcrumb")) should be <div/>; there's some technical reason why unclosed tags can fail (in some browsers?). (b) $column appears to be used but not defined. (c) .addBack() and .detach() both appear to be unnecessary. (d) I think that .parent().parent() could be .closest("li"), though no big advantage. (e) You can save a line here and there with more method chaining. \$\endgroup\$ Dec 29, 2014 at 22:27
  • \$\begingroup\$ Thank you again! Good catch on the <div/>; the $column variable is $(this).offset().left; .addBack() is needed to clear the selection chain, but detach() was useless; and the code should limit hard-coded HTML tag references for future JSON integration. \$\endgroup\$ Dec 30, 2014 at 1:08
  • \$\begingroup\$ Whoops, some of my comments relate to the code at first posting, at which time I copied it into an editor... . I don't know why I couldn't see the need for .addBack(). Unless you can find a way to avoid the hard-coded li in $columns.find("li"), then as far as I can see, you might as well hard-code elsewhere, or maybe I'm missing the point. Anyways, code is way cooler than it was back in November. \$\endgroup\$ Dec 30, 2014 at 9:23

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.