Blog-Archiv

Montag, 29. Februar 2016

JS Table Layout Adjustment: Predefined Widths

Here comes the 4th part of my series about adjusting the layout of nested HTML tables. As this seems to be an endless story, there will be just one more follower, about how to apply the JS modules to a DIV structure with CSS display: table; layout.

Introducing Predefined Sizes

The layout of nested tables has been done by calculating the maximum width of all cells in a column, and then applying that width to all cells. Now I want to provide the opportunity to pre-define certain column widths.

The main problem is to associate columns to dotted numbers (which I call "categories"). You have a column that you know as "lastName", and you would like to assign a width of 40 pixels to it. You need to somehow map that semantic column name to a category like "1.1.3". Then you could associate the pixel width and pass such a map to the JS script.

I think that map will have to look similar to this:

            var predefinedSizes = {};
            predefinedSizes["1.1.3"] = 40; // "lastName" pixels
            predefinedSizes["1.1.4"] = 30; // "firstName" pixels
            ....

Take care that you don't define a column as both elastic and predefined-sized !

Having this map, we can weave the pre-defined widths into the existing modules. The abstractCategorizer module won't be affected. I started implementing in abstractLayoutAdjuster module, and when it worked, I took the functions to an overriding module that uses a facility I had to create in the underlying module. Here is how the that override facility looks.

    "use strict";

    var abstractLayoutAdjuster = function(categorizer)
    {
      ....

      /**
       * To be overridden for pre-processing directly before sizes are
       * calculated and set to the table cells. This implementation does nothing.
       * @param levelArraysMap a map with key = level, and value = array of cells on that level.
       * @param maximumLevel the maximum 0-n level being in levelArraysMap.
       */
      that.beforeSizing = function(levelArraysMap, maximumLevel) {
      };
      
      that.init = function(elementsToLayout, predefinedSizes) {
        ....

        for (var i = 0; i < elementsToLayout.length; i++) {
          ....
          
          that.beforeSizing(levelArraysMap, maximumLevel);

          for (var level = maximumLevel; level >= 0; level--)
            calculateAndSize(levelArraysMap[level]);

          ....
        }
        ....
      };

      return that;

    }; // module end

A new function beforeSizing(levelArraysMap, maximumLevel) has been introduced, and it is doing nothing. Overriders can use it to set pre-defined sizes before the calculation of the maximum is done by calculateAndSize().

Why I did not leave the code where I implemented and tested it:

  • I consider JS modules to be smallest building blocks that encapsulate some piece of complexity. My aim is to tame complexity, so I divide it until it gets simple, and then I encapsulate the parts. That way I got the "ghost in a bottle". Don't let the ghost become overwhelming complex again by weaving in all new aspects.
    In short, I try to keep my JS modules simple and understandable. New features can be done in an OO way by overrides.

Implementing Predefined Sizes

Here is the new module. Mind the additional module parameter predefinedSizes, being the column widths map.

 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
    "use strict";

    /**
     * Abstract implementation of a layout-adjuster that also sets predefined sizes.
     * @param categorizer see abstractLayoutAdjuster.
     * @param predefinedSizes map of key = category and value = size integer.
     */
    var abstractPredefinedSizeAdjuster = function(categorizer, predefinedSizes)
    {
      var predefineSizes = function(categorizedElements, predefinedSizes) {
        for (var i = 0; i < categorizedElements.length; i++) {
          var element = categorizedElements[i];

          if (that.isElementToSize(element)) {
            var category = categorizer.getCategory(element);
            var size = predefinedSizes[category];
            if (size !== undefined)
              that.setSize(element, size);
          }
        }
      };

      var that = abstractLayoutAdjuster(categorizer);
      
      /**
       * Overridden to set predefined sizes before sizes are
       * calculated and set to the table cells.
       * @param levelArraysMap a map with key = level, and value = array of cells on that level.
       * @param maximumLevel the maximum 0-n level being in levelArraysMap.
       */
      that.beforeSizing = function(levelArraysMap, maximumLevel) {
          /* bottom-up loop to calculate and size deepest level first */
          if (predefinedSizes)
            for (var level = maximumLevel; level >= 0; level--)
              predefineSizes(levelArraysMap[level], predefinedSizes);
      };
      
      return that;
    };

This new module contains the implementation for setting predefined widths to categorized elements in the private predefineSizes() function. Mind that it calls setSize(), so the module also could be reused for adjusting row heights instead of column widths, just the implementation of setSize() decides what sizing means!

Extending the abstractLayoutAdjuster module is done by

      var that = abstractLayoutAdjuster(categorizer);

The module then overrides the public beforeSizing() function of its super-module to call predefineSizes() with all categorized elements of all levels, from bottom to top.

Using the New Module

To use the new module, an new initialization sequence is necessary. Here is an example, also including an elastic column.

      var predefinedSizes = {
        "1.1.3": 40,
        "1.1.4": 30
      };

      var categorizer = tableColumnCategorizer();
      var predefinedSizesAdjuster = abstractPredefinedSizeAdjuster(categorizer, predefinedSizes);
      var columnAdjuster = nestedTablesColumnAdjuster(categorizer, predefinedSizesAdjuster);
      columnAdjuster.init(tables);

The attentive reader might have noticed that a module parameter is missing here. To be able to use the new module, I changed the signature of the nestedTablesColumnAdjuster module to introduce a new parameter abstractLayoutAdjuster.

    "use strict";

    var nestedTablesColumnAdjuster = function(categorizer, abstractLayoutAdjuster)
    {
      var that = abstractLayoutAdjuster;

      .....

      return that;
    };

I call this "inheritance at runtime". Such would not be possible in a strictly typed language like Java. I pass the module to extend as construction parameter to nestedTablesColumnAdjuster. An impressive power demonstration of the rubber-language JavaScript :-)

As it can be seen here, code re-usage in JS has its price. There are many different ways how modules, functions and their parameters could be put together. In this case, a construction chain elaborates. You could also call it columnAdjusterBuilder.


All JS described here refers to already implemented modules from previous Blogs. When you want to see complete source code, or try out predefined column widths, go to my homepage. Press Ctrl-U or use your browser menu item "View Page Source" to see its HTML and JS.




Sonntag, 21. Februar 2016

JS Element Dimensions

This Blog is a continuation of my clientWidth Blog. It shows the relations between CSS-properties like width and the according JS read-only constant like clientWidth or offsetWidth, also considering the CSS box-sizing property. For a demonstration of element coordinates see my passed Blog about that.

Here is a demonstration panel that allows to set CSS properties and observe how JS values are changing then. The input fields to the left represent CSS-properties that will be set to the elements below immediately (in case your browser is a modern one and supports change-listeners :-). To change these CSS-properties on different elements, click on the element you want to change, or choose one from the selection-box on top. You can change and observe 4 different elements, 2 block-elements and 2 inline-elements, as indicated by the CSS display property.


width
height
margin
border-width
padding
box-sizing
display
position
top
left
clientWidth
clientHeight
clientTop
clientLeft
offsetWidth
offsetHeight
offsetTop
offsetLeft


DIV 1 SPAN 1
SPAN 2
DIV 2

The DIV and SPAN elements are made up by following HTML:

    <div style="border: 1px solid gray;">
      <div id="block-element" style="width: 200px; height: 25px; background-color: LightGreen; border: 4px solid green;">
        DIV 1
        <span id="inline-element" style="background-color: #FFCC66; border: 3px solid red;">SPAN 1</span>
      </div>
      
      <span id="inline-element-2" style="background-color: pink; border: 2px solid magenta;">SPAN 2</span>
      
      <div id="block-element-2" style="width: 60px; height: 25px; background-color: LightBlue; border: 1px solid blue;">
        DIV 2
      </div>

    </div>


What to Learn Here

  • margins are neither in clientWidth nor in offsetWidth, the only way to read them is by parsing the according CSS property values (margin-left, ...)

  • margins can be negative

  • neither paddings nor borders can be negative

  • borders are not contained in clientWidth, but in offsetWidth

  • paddings are contained in both clientWidth and offsetWidth

  • the box-sizing property changes this; default is content-box, setting border-box will cause CSS width to include both borders and paddings, thus offsetWidth will be the same as width, and clientWidth will be width - borders; the element will be smaller then, because borders and paddings are inside of what was the inner size before (clientWidth - padding)

  • inline-elements do not have width or height, setting them will be ignored by the browser, reading them will yield zero

  • inline-elements might overstrike elements above and below when having border and padding on top or bottom, margins of inline elements affect just left and right side, not top and bottom

    • thus the "element dimension" gets ambiguous here: is it the visible height, or the layout height? This is important when calculating coordinates, like bottom = top + height

  • clientTop and clientLeft should have been named "borderTopHeight" and "borderLeftWidth"

  • offsetTop and offsetLeft are coordinates starting at the closest non-static (relative, absolute, fixed) positioned parent's upper left corner; mostly this will be the document; they directly relate to the CSS top and left properties when not positioned static.

Experiences with Browsers




Sonntag, 14. Februar 2016

JS Table Layout Adjustment: Elastic Column

This is the third part of the Layout-Adjustment for Nested Tables series, episode one. Today I'm gonna show you how ....

Stop it! Sorry, this is an IT-Blog, not a TV-series. It's different every time :-)

What I did not cover until now was having an ....

Elastic Column

To achieve an elastic column in an HTML table, I need to make all cells in that column 100% wide. Additionally I must make sure that all parents of those cells are also 100% wide, up until the top-table. Finally there will be an arbitrary number of columns that contain cells with fixed widths, and one column that shows an elastic behaviour.

Until now the JS code to build a layout-adjuster looked like this:

      var categorizer = tableColumnCategorizer();
      var columnAdjuster = nestedTablesColumnAdjuster(categorizer);
      columnAdjuster.init(tables);

I will now introduce a new module that changes this build-code to the following.

      var categorizer = tableColumnCategorizer();
      var columnAdjuster = nestedTablesColumnAdjuster(categorizer);
      var elasticAdjuster = elasticColumnAdjuster(categorizer, columnAdjuster);
      elasticAdjuster.init(tables, elasticColumnCategory);

Use that in the initLayout() function (see predecessor Blog) for trying out. The tables and elasticColumnCategory (e.g. "2.1") variables must be given as parameters. The elasticColumnAdjuster() function is the new module that makes it possible to define an elastic column. The elastic column will stretch when the table is 100% wide and the browser window widens.

Add Categorizer Functions

I need to identify all cells that are in the elastic column, or overlap it because they are either span-cells or parents of elastic cells. Columns are represented by categories, and cells have categories as attributes.

  1. I need a function that generates the next category for a given category, e.g. "1.2.4" for given "1.2.3". That way I could implement a loop that finds out whether a span-cell overlaps the elastic category. I will call that new function nextCategory(category).

  2. I need a function that tells me whether a category overlaps a given category because it is either the same category, or is a parent of the given category. I will call that new function startsWith(category), because "3.2" is a parent of "3.2.1".

Let me show the source code and then explain further. I will add to the abstractCategorizer module, because the new functions logically belong there.

    var abstractCategorizer = function(CATEGORY_ATTRIBUTE_NAME)
    {
      ....

      var that = {};

      /* public functions */

      /** @return the next after the given dotted number (category). */
      that.nextCategory = function(dottedNumber) {
        var head = "";
        var tail = dottedNumber;
        var lastDotIndex = dottedNumber.lastIndexOf(".");
        if (lastDotIndex > 0) {
          head = dottedNumber.substring(0, lastDotIndex + 1);
          tail = dottedNumber.substring(lastDotIndex + 1);
        }
        return head+(window.parseInt(tail) + 1);
      };

      /** @return true when given criterion starts with startPart, considering dots. */
      that.startsWith = function(criterion, startPart) {
        if (criterion === startPart)
          return true; /* equal */

        if (criterion.length <= startPart.length)
          return false; /* same length but not equal */

        var part = criterion.substring(0, startPart.length);
        if (part === startPart && criterion.charAt(startPart.length) === '.')
          return true; /* excluded "1.22" matching "1.2" */

        return false;
      };

      ....

      return that;
    };

The nextCategory() function tries to find the rightmost dot in given category, fetch the number behind, increment it and return it with what was to the left of it. When it does not find a dot, it simply increments the given number.

The startsWith() function compares the given criterion and the startsWith part being in question. When they are equal, it returns true. If the criterion's length is shorter than or equal to the startsWith's length, it returns false. Finally it is sure that the criterion is longer than the startsWith part. Thus a sub-string of it can be compared to the startsWith part, and when they are equal and the criterion has a dot after, true is returned. Else it might have been criterion = "1.2.33" compared to startsWith = "1.2.3", and that criterion definitely is not parent of "1.2.3", thus false is returned.

I will use these new categorizer-functions in ....

The New Module

It extends a columnAdjuster module, in particular, it gets the module it must extend as parameter. That way it does not know whether it works on a real TABLE or a DIV-table, and will be reusable for both. In case TABLE we will pass a nestedTablesColumnAdjuster module instance to it.

Mind that this is a feature available in script languages only: inheritance at runtime!
In Java, which is a strongly typed compiled language, you could not extend another class at runtime, except when using byte-code-engineering libraries, but these are not part of the language, and the resulting source code is far from being readable.

Here is the new module.

 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
    "use strict";

    /**
     * Concrete implementation of a layout-adjuster that lets define one column as
     * elastic (or "full-width"), which will stretch when the table is resized.
     * This adjuster also stretches any given top-table to 100% of its parent.
     * Usage:
     *     elasticColumnAdjuster().init(arrayOfTables, predefinedSizes);
     *     
     * @param categorizer required, builds and manages dotted numbers (categories).
     * @param columnAdjuster required, the adjuster implementation to extend.
     */
    var elasticColumnAdjuster = function(categorizer, columnAdjuster)
    {
      var overlapsElastic = function(span, element, elasticCategory) {
        var category = categorizer.getCategory(element);
        while (span > 0) {
          if (categorizer.startsWith(elasticCategory, category))
            return true;

          span--;
          if (span > 0)
             category = categorizer.nextCategory(category);
        }
        return false;
      };

      var fixSize = function(element) {
        that.setSize(element, that.getSize(element));
      };

      var fixNonElasticStretchElastic = function(structuredElements, elasticCategory) {
        for (var i = 0; i < structuredElements.length; i++) {
          var levelArraysMap = structuredElements[i].levelArraysMap;
          var maximumLevel = structuredElements[i].maximumLevel;
          
          var elasticNonSpanCells = [];
          var nonElasticSpanCells = [];

          for (var level = 0; level <= maximumLevel; level++) {
            var levelArray = levelArraysMap[level];

            for (var cellIndex = 0; cellIndex < levelArray.length; cellIndex++) {
              var cell = levelArray[cellIndex];
              var span = categorizer.getSpan(cell);
              var isElastic = overlapsElastic(span, cell, elasticCategory);

              if ( ! isElastic && span > 1) /* fix non-elastic span cells */
                nonElasticSpanCells.push(cell);
              else if (isElastic && span <= 1) /* stretch elastic non-span cells */
                elasticNonSpanCells.push(cell);
            }
          }

          /* first fix cells ... */
          for (var i = 0; i < nonElasticSpanCells.length; i++)
            fixSize(nonElasticSpanCells[i]);

          /* ... then stretch cells */
          for (var i = 0; i < elasticNonSpanCells.length; i++)
            that.stretchElement(elasticNonSpanCells[i]);
        }
      };

      var that = columnAdjuster;

      /* public functions */

      var superInit = that.init;

      /**
       * Overridden to do post-processing:
       * fix non-elastic span-cells, stretch elastic non-span cells,
       * stretch all top-tables.
       * @param elasticCategory the identifier, or dotted number (category),
       *     of the column to keep elastic.
       * @param rest see columnAdjuster.
       */
      that.init = function(elementsToLayout, elasticCategory) {
        fixNonElasticStretchElastic(
            superInit(elementsToLayout),
            elasticCategory);

        for (var i = 0; i < elementsToLayout.length; i++)
          that.stretchElement(elementsToLayout[i]);
      };

      return that;

    };

I will explain the short functions first, and do the fixNonElasticStretchElastic() at last, because it is not so easy to understand.

Inheritance is done by assigning the given columnAdjuster module to the local that variable. The override is done by replacing the init() function with a new implementation, after saving the old one to the local variable superInit.

    var elasticColumnAdjuster = function(categorizer, columnAdjuster)
    {
      ....

      var that = columnAdjuster;

      var superInit = that.init;

      that.init = function(elementsToLayout, elasticCategory) {
        ....

          superInit(elementsToLayout),
          ....
      }

      return that;

    }

The new init() implementation calls the fixNonElasticStretchElastic() function with what comes back from the superInit() call, and the category denoting the elastic column. Then it stretches all tables (received as parameter) to 100%.

The overlapsElastic() function finds out whether an element overlaps the elastic column. It also receives the span count of this element, and thus can also cover span-cells. For that purpose the categorizer.nextCategory() function is used.

The fixSize() function is used to fix an element to its current size. This is needed for span-cells that do not overlap the elastic column. Without fixing them, they also would be elastic.

Finally, the fixNonElasticStretchElastic() function does what its name announces a little fuzzy. It receives an array of top-level tables to be formatted. Every such table is represented by an object containing the maximumLevel and the levelArraysMap, where key is the 0-n nesting-level and value is an array of cells being on that level. For every top-table, the function then separates the cells overlapping the elastic column from those that do not. Only span-cells are added to the collection of non-elastic cells, and no span-cells are added to the collection of elastic cells. After that separation, the nonElasticSpanCells are fixed to their current width, and the elasticNonSpanCells are stretched to 100%.

Proof of Concept

Date
ProductPriceNote
2016-02-05
Chocolate for nothing! Was a gift
Bread $ 2.50, or was it $ 3.00 ?
2016-02-06
Vegetables $ 5.30 Bought on market
Fish $ 7.80 I do not eat salmon any more
Free afternoon, went for dinner with friends
2016-02-07
Fruit $ 2.60 Fresh and tasty

Mind that when the table was formatted once, and you change the elastic column and format is once more, the non-elastic columns will keep their sizes. Thus the table will get wider and wider. Do a browser page reload in between when trying out different elastic columns.



That's it, hope it works for you. When not, take a look at my homepage, there you'll find the current state of this project. Use Ctl-U to view source, the JS code is at bottom of the page.




Sonntag, 7. Februar 2016

JS Table Layout Adjustment: Sizing

This is the continuation of my last Blog about adjusting columns of nested tables. After I managed to categorize all cells and thus assign them to a column, I should be able to size them now.

Years ago browsers had quite different techniques to format tables. There was the special table-layout: fixed; CSS property that allowed to size cells. Nowadays browsers accept a fixed cell width even without that property, and when you set the width to both CSS width and min-width, the table layout also survives window resizes. Should you have tables that change their cell values "on the fly", it is also recommendable to set the max-width, and with it overflow-x: hidden; to avoid big contents hanging into other cells.

With colspan cells there is special problem. I decided to not touch such cells at all, and until now this worked in all cases. But a prerequisite for that is that all nested tables are stretched to 100% width, so that the colspan-cells will spread between their sized neighbor cells.

Abstract Layout Adjuster

From the tableColumnCategorizer module's init() call I get back a map of arrays, and every array represents all categorized elements of a certain nesting-level. I find the category of an element in its data-layout-category attribute.

So now I can iterate the map, starting from deepest nesting level, going up to zero. On every level I can calculate the maximum cell width for every category (visible column). Subsequently I can size all cells of a category to their maximum. To cope also with colspan cells, all nested tables are finally stretched to 100% width.

 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
    "use strict";

    /**
     * Usage: layoutAdjuster().init(arrayOfTables);
     * @param categorizer required, puts categories into cells.
     */
    var abstractLayoutAdjuster = function(categorizer)
    {
      var calculateMaximums = function(categorizedElements) {
        var sizeMap = {};

        for (var i = 0; i < categorizedElements.length; i++) {
          var element = categorizedElements[i];

          if (that.isElementToSize(element)) {
            var category = categorizer.getCategory(element);
            var size = that.getSize(element);
            if (size > (sizeMap[category] || 0))
              sizeMap[category] = size;
          }
        }
        return sizeMap;
      };

      var calculateAndSize = function(categorizedElements) {
        var sizeMap = calculateMaximums(categorizedElements);

        for (var i = 0; i < categorizedElements.length; i++) {
          var element = categorizedElements[i];

          if (that.isElementToSize(element)) {
            var category = categorizer.getCategory(element);
            that.setSize(element, sizeMap[category]);
          }
        }
      };

      var stretchNestedContainers = function(nestedContainers, elementToLayout) {
        for (var i = 0; i < nestedContainers.length; i++) {
          var nestedContainer = nestedContainers[i];
          that.stretchElement(nestedContainer);
          
          var parent = nestedContainer.parentElement; /* stretch all parents up until TD */
          while ( ! categorizer.getCategory(parent) && parent !== elementToLayout) {
            that.stretchElement(parent);
            parent = parent.parentElement;
          }
        }
      };

      var that = {};

      /* public functions */

      /**
       * Does the layout and returns a structure for post-processing.
       * @param elementsToLayout required, array of tables to adjust.
       * @returns array of objects, per input table, with properties
       *     "levelArraysMap" (map with key = level and value = cell array of that level,
       *     "maximumLevel" (= length of levelArraysMap - 1).
       */
      that.init = function(elementsToLayout) {
        var structuredElements = [];

        for (var i = 0; i < elementsToLayout.length; i++) {
          var elementToLayout = elementsToLayout[i];
          var levelArraysMap = categorizer.init(elementToLayout);

          /* find out maximum level in map */
          var maximumLevel = -1;
          for (var level in levelArraysMap)
            if (levelArraysMap.hasOwnProperty(level) && level > maximumLevel)
              maximumLevel = level;

          for (var level = maximumLevel; level >= 0; level--)
            calculateAndSize(levelArraysMap[level]);

          /* stretch all nested tables to 100% */
          stretchNestedContainers(that.getNestedContainers(elementToLayout), elementToLayout);

          structuredElements.push({ /* provide return */
            levelArraysMap: levelArraysMap,
            maximumLevel: maximumLevel
          });
        }

        return structuredElements;
      };

      return that;
    };

For optimal encapsulation the module is split into private (var xxx = ...") and public (that.xxx = ...") functions. Mind that functions that do not yet exist, like that.setSize, are called as if they would exist. JS makes this possible, but the function must be there at runtime.

The calculateMaximums() function receives an array of elements. It creates a map and uses the category of every element as key for it. For every category the maximum is calculated. The map is returned for further processing.

The calculateAndSize() function sets the calculated maximum sizes to all elements with the according category.

The stretchNestedContainers() function loops over given nested tables and sets 100% width to them. For each such table it makes sure that all parents up to the next categorized cell are also sized to 100%, else the 100% would make no sense. Note that stretchElement() is not implemented yet, because I do not yet decide to stretch the width, it could be the height too.

The init() function first calls the given categorizer for a map of level arrays. It then counts the levels and loops them bottom-up. After calling calculateAndSize() with all of them it makes sure that all nested tables and their parents are sized to 100% width. It then returns, per given table, an object containing the level-array-map and the maximum level of that table (yes, JS does not provide the size of a map in a property!).

This module contains nothing specific to HTML TABLE, not even the functions to get and set sizes. That way it could be also used for adjusting row heights (mind that I named the function setSize and not setWidth). A specific module deriving this one will decide how the work is to be done, in this case by reading the JS property clientWidth, and writing the CSS property width.

Concrete TABLE Columns Adjuster


 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
    "use strict";

    /**
     * Concrete column-adjuster for HTML TABLEs.
     */
    var nestedTablesColumnAdjuster = function(categorizer)
    {
      var that = abstractLayoutAdjuster(categorizer);

      var getPaddingsLeftRight = function(element) {
        var style = window.getComputedStyle(element);
        return window.parseInt(style["padding-left"]) + window.parseInt(style["padding-right"]);
      };

      /* public functions */

      /** @return true when given element has no colspan. */
      that.isElementToSize = function(element) {
        return categorizer.getSpan(element) <= 1;
      };

      /** @return the clientWidth of given element, which includes paddings. */
      that.getSize = function(element) {
        return element.clientWidth;
      };

      /**
       * Sets given size as CSS width to given element after subtracting paddings.
       * @param size the clientWidth to achieve (includes local paddings).
       */
      that.setSize = function(element, size) {
        size -= getPaddingsLeftRight(element);
        var cssWidth = size+"px";

        element.style["width"] = cssWidth;
        element.style["max-width"] = cssWidth;
        element.style["min-width"] = cssWidth;
        element.style["overflow-x"] = "hidden";
      };

      /** @return all nested TABLE elements below given one. */
      that.getNestedContainers = function(elementToLayout) {
        return elementToLayout.querySelectorAll("TABLE");
      };

      /** Concrete implementation of stretching an element to full size. */
      that.stretchElement = function(element) {
        element.style["max-width"] = "";
        element.style["min-width"] = "";
        element.style["width"] = "100%";
      };

      return that;
    };

This synchronizes the column widths of nested TABLE elements, whereby the given top-element needs not to be a TABLE, it could also be a DIV.

The getPaddingsLeftRight() function calculates the horizontal paddings of an element. This is needed because I set the CSS width from the read-only JS property clientWidth, and this includes paddings, but width does not.

The isElementToSize() implementation makes sure that only elements without colspan are sized.

The getSize() implementation uses the clientWidth of an element. This includes the element's padding.

The setSize() implementation turns the (maximum) clientWidth into a CSS width by subtracting horizontal paddings of the target element. Then it sets all three of width, min-width, max-width to the same pixel value. Additionally it prevents contents to be written into other cells by setting overflow to "hidden".

The getNestedContainers() function uses the querySelectorAll() element function to retrieve all TABLE elements from given parent.

The stretchElement() function resets min-width and max-width, and then sets width to 100%.

Apply Modules

To try this out, mark your test-TABLE with CSS class="layoutNestedTables" and write following initialization-JS into your test page. Of course you also will need all code from predecessor Blog.

  <script type="text/javascript">
    "use strict";

    var initLayout = function() {
      var tables = document.getElementsByClassName("layoutNestedTables");

      for (var i = 0; i < tables.length; i++) /* set tables invisible while layouting them */
        tables[i].style.visibility = "hidden";

      var categorizer = tableColumnCategorizer();
      var columnAdjuster = nestedTablesColumnAdjuster(categorizer);
      columnAdjuster.init(tables);

      for (var i = 0; i < tables.length; i++) /* set tables visible again */
        tables[i].style.visibility = "visible";
    };

    window.addEventListener("load", initLayout);

  </script>

This sets all tables to adjust to invisible. Then it allocates a categorizer and an adjuster and makes them work. Finally all tables are set visible again. This is for avoiding probably slow layout work done before the user's eye.


The remaining topics for layout of nested tables are

  1. creating one elastic column for 100% stretched tables,
  2. pre-defining the widths for certain columns,
  3. doing all that also for DIV-tables.

Hope I will soon find time to document that all.




Samstag, 6. Februar 2016

JS Table Layout Adjustment: Naming

To cope with something means to live together with something unknown and seemingly uncontrollable. We don't need to control everything, but we also don't want to suffer from consequences of incalculable risks.

One thing we can do when we have a problem is giving it a name. Practically that name should make it more comprehensible. When we can understand it, we can cope with it.

This is not a self-help-group-instruction. This is about a web-page layout-problem solved by giving names. Although these are different things, I would consider the naming-activity being more important in this than the layout-solution. Remember:

We can cope with things that got a name.
And it will be fine when it is a meaningful name.

Layout Problem

This Blog is about HTML tables nested into other tables. The nested tables are not related to each other, thus their column widths will be different. Such layout does not look good. The JavaScript to be introduced in the following tries to fix that. (Mind that you won't have this problem when you never embed tables into other tables.)

Here is an example of what we have to cope with:

Date
ProductPriceNote
2016-02-05
Chocolate for nothing! Was a gift
Bread $ 2.50, or was it $ 3.00 ?
2016-02-06
Vegetables $ 5.30 Bought on market
Fish $ 7.80 I do not eat salmon any more
Free afternoon, went for dinner with friends
2016-02-07
Fruit $ 2.60 Fresh and tasty

In this example table, all tables have a green border, header cells a blue, data cells a magenta. Tables of nesting-level 1 are yellow. Table cells that have a colspan attribute are rendered orange.

Sure, all data are there and readable. But we can hardly associate the columns in the different nested tables to each other. For example, try to sum up all prices.

The fix won't be just for the eye, it'll be for avoiding human mistakes. When we talk about shape and content, we should be aware that we need them both and together. As is HTML.

Synopsis

The introduction of the JavaScript solving this layout problem will be divided into several Blogs. This one is the first, and it is about naming cells being logically (but sometimes not visually) below each other.

The second one will be about adjusting these cells to have same widths. The column-width will be the initial width of the widest cell in it.

The third Blog will be about script extensions to achieve an elastic column in a 100% stretched table, and how to do the same for a DIV table.

All JS scripts will be modules, and I always use functional inheritance to reuse JS code in a simple and safe manner. No jQuery is used here, nevertheless the code works even in IE-9.

For a more compound test page and full JS source code you can visit my homepage.

Naming the Unknown

When I want to size all cells of one logical column to the same width, I have to cope with table-cell elements being somewhere in a tree of HTML elements, most likely quite far away from each other. To be able to size them, I give names to them. My naming scheme is the well-known chapter numbering system: 1 for first chapter, 1.1 for first sub-chapter of first chapter, 1.1.2 for second sub-sub-chapter of first sub-chapter, and so on. I will call these names categories, because they won't be unique identifiers for cells, but more a category a cell falls into.

Transferring this numbering system to the cells of a table containing nested tables, I would give 1 to all cells in first column, 2 to all cells in second column, and so on. To all cells in first column of a table nested into a cell 2, I would give 2.1. Would there be another table in that cell, its first cell would have the category 2.1.1.

1
2.1 2.2
2.3.1 2.3.2 2.3.3
2.3.1 2.3.2 2.3.3
2.1 2.2
2.3.1 2.3.2 2.3.3
1
2.1 2.2
2.3.1 2.3.2 2.3.3
2.3.1 2.3.2 2.3.3
2.3.1 2.3.2 2.3.3

As you see, all cells that must have same widths are named by the same category (dotted number). The count of dots in the category reflects the nesting level, and the numbers give the column order index.

You could immediately write some CSS now to set fixed widths to the columns categorized in that way. But, in my opinion, JS solutions are more sophisticated and reusable, so I will do it with JS.

Abstract Categorizer

As the table nesting is not restricted to any depth, the JS implementation should work recursive. Here is a JS module that categorizes a given table. It is called "abstract" because it does not know yet the nature of the HTML tags it should look for. It just traverses the HTML table and names elements. A concrete extension of that module will add functions that decide which HTML elements should be looked for.

  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
    /**
     * Usage: elementCategorizer().init(arrayOfTables);
     * @param CATEGORY_ATTRIBUTE_NAME optional name of the attribute where to put the
     *    category into, default is "data-layout-category".
     */
    var abstractCategorizer = function(CATEGORY_ATTRIBUTE_NAME)
    {
      "use strict";

      CATEGORY_ATTRIBUTE_NAME = CATEGORY_ATTRIBUTE_NAME || "data-layout-category";

      var buildDottedNumber = function(topElement, element, index) {
        var dottedNumber = ""+(index + 1); /* start numbering at 1 */
        var parent = element.parentElement;

        while (parent !== topElement)  { /* search for parent's dotted number */
          var parentCategory = that.getCategory(parent);
          if (parentCategory)
            return parentCategory+"."+dottedNumber;

          parent = parent.parentElement;
        }
        return dottedNumber;
      };

      var categorizeElement = function(topElement, element, index, levelArraysMap) {
        var predecessor = (index > 0) ? element.parentElement.children[index - 1] : undefined;
        var span = that.getSpan(predecessor);
        if (span > 1) /* when predecessor has colspan, increment index */
          index += (span - 1);

        var dottedNumber = buildDottedNumber(topElement, element, index);
        element.setAttribute(CATEGORY_ATTRIBUTE_NAME, dottedNumber);

        var level = dottedNumber.split(".").length - 1;
        if (levelArraysMap[level] === undefined)
          levelArraysMap[level] = [];

        levelArraysMap[level].push(element);
        return index;
      };

      var categorize = function(topElement, element, index, levelArraysMap) {
        /* build categories top-down, to enable parent category retrieval. */
        if (that.isElementToCategorize(element))
          index = categorizeElement(topElement, element, index, levelArraysMap);

        var children = element.children; /* go recursive */
        var childIndex = 0;
        for (var i = 0; i < children.length; i++) {
          if (that.isVisible(children[i])) {
            childIndex = categorize(topElement, children[i], childIndex, levelArraysMap);
            childIndex++;
          }
        }
        return index;
      };

      var that = {};

      /* public functions */

      /** @return the next after the given dotted number (category). */
      that.nextCategory = function(dottedNumber) {
        var head = "";
        var tail = dottedNumber;
        var lastDotIndex = dottedNumber.lastIndexOf(".");
        if (lastDotIndex > 0) {
          head = dottedNumber.substring(0, lastDotIndex + 1);
          tail = dottedNumber.substring(lastDotIndex + 1);
        }
        return head+(window.parseInt(tail) + 1);
      };

      /** @return the category of given element, can be undefined. */
      that.getCategory = function(element) {
        return element.getAttribute(CATEGORY_ATTRIBUTE_NAME);
      };

      /** @return true when the categorized elements can be found below given one. */
      that.containsCategorizedElements = function(element) {
        return element.querySelector("["+CATEGORY_ATTRIBUTE_NAME+"]") !== null;
      };

      /** @return true when given criterion starts with startPart, considering dots. */
      that.startsWith = function(criterion, startPart) {
        if (criterion === startPart)
          return true; /* equal */

        if (criterion.length <= startPart.length)
          return false; /* same length but not equal */

        var part = criterion.substring(0, startPart.length);
        if (part === startPart && criterion.charAt(startPart.length) === '.')
          return true; /* excluded "1.22" matching "1.2" */

        return false;
      };

      /** @return true if given element's display style is different from "none". */
      that.isVisible = function(element) {
        return element && window.getComputedStyle(element).display !== "none";
      };
      
      /**
       * Categorize elements below given topElement.
       * @param topElement required, the element where to categorize below.
       * @return map of arrays of elements found in topElement,
       *    key is level 0-n,
       *    value is array of categorized elements on that level.
       */
      that.init = function(topElement) {
        if (topElement === undefined)
          throw "Need a top-element to categorize!";

        var levelArraysMap = {};
        categorize(topElement, topElement, 0, levelArraysMap);
        return levelArraysMap;
      };

      return that;
    };

Mind that two functions are not yet implemented:

  • that.isElementToCategorize(element)
  • that.getSpan(predecessor)

The isElementToCategorize() function will restrict the module to HTML TABLE elements with TH and TD cells. But I want to apply that module also on tables built from DIV elements with CSS display: table. So I will implement concrete sub-classes of this, one for TABLE elements, one for DIV-table elements.

The colspan attribute is specific to TABLE, a DIV-table doesn't support that. The getSpan() implementation for TABLE will return the number in the colspan attribute, or 1 when not found, and the DIV-table implementation always will return 1.

The CATEGORY_ATTRIBUTE_NAME parameter lets set a name for the element attribute where the name (category) will be written into.

The buildDottedNumber() function receives the index of the element to name, and it ascends until it finds a parent-category. When it does not find one, the index alone will be the category, else it is appended to the parent-category.

The categorizeElement() function recognizes the colspan of a predecessor element, and corrects the index when one exists. It sets the built category into the element attribute. Then it calculates the nesting level from the number of dots in the category, and adds the element to an array in a level-map which is to return.

The categorize() function is the recursive traversal. First it categorizes the element received as parameter, then it calls itself recursively in a loop over all children (whereby the child-index will be part of the the generated category). Keeping that call-order, the children will always find parent-categories when calling buildDottedNumber().

The getCategory() function reads the category from an element. This is here to encapsulate CATEGORY_ATTRIBUTE_NAME. Mind that this is the only public function here, besides init(). No other function will need to be called or overridden from modules extending this one.

The init() function at last returns a map of levels. In each level (0-n, used as map-key) an array of elements on that level is stored. This return-map can be used to layout the table.

Now I can extend this module to implement a concrete table-column categorizer.

Concrete TABLE Categorizer


 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
    /**
     * Concrete categorizer for HTML table columns.
     */
    var tableColumnCategorizer = function(CATEGORY_ATTRIBUTE_NAME)
    {
      "use strict";

      var that = abstractCategorizer(CATEGORY_ATTRIBUTE_NAME);

      /* public functions */

      /**
       * @return true when given element should be categorized.
       *    This implementation returns true when element is
       *    TD (table cell) or TH (table header).
       */
      that.isElementToCategorize = function(element) {
        return element.tagName === "TD" || element.tagName === "TH";
      };

      /** @return the number of following elements the given one spans. */
      that.getSpan = function(element) {
        var colspan = element ? element.getAttribute("colspan") : undefined;
        return colspan ? window.parseInt(colspan) : 1;
      };

      return that;
    };

This adds the not-yet-implemented functions, and thus makes the abstract module concrete. The extension of the abstract module is done by the line

var that = abstractCategorizer(CATEGORY_ATTRIBUTE_NAME); 

This is like extending a class in Java. Just that in JS no classes exist, everything is an object. As a consequence you can extend modules even at runtime, which is quite useful in some situations.

The isElementToCategorize() function determines that TD and TH elements will be named (categorized).

The getSpan() function delivers the number of colums the given element spans. As I did not want to restrict this to colspan I named it getSpan(), because we also have rowspan, and basically the script is also able to categorize cells for adjusting row heights.

That's it! Functional inheritance helps to keep JS modules short and encapsulated. When you start the concrete module over a TABLE, all cells will be categorized.

In my next Blog I will show how this can be used to adjust table columns. Again there will be an abstractColumnAdjuster with concrete sub-modules.