Blog-Archiv

Montag, 28. März 2016

JS Responsive Breakpoint Notifications

Responsive web-sites use so-called "CSS media queries" to adapt dynamically to the screen dimension they are rendered on. These "media queries" define conditions about the screen, with subsequent blocks of CSS-rules to be applied when the condition matches. Conditions sound like "When the device's width is smaller than 768 pixels". In this condition, 768 pixels would be called a "breakpoint" (has nothing to do with a debugger-breakpoint!).

Such a layout adaption is not always an easy task. In many cases also JavaScript code must be executed. In this Blog I will introduce a short and simple solution for JS "breakpoint" notifications.

Listening for Resize

This browser window is currently

pixels wide.

Try to resize your browser window, the above pixel amount will update each time you do it. Here is the way how to listen for browser resizes:

  <i><p>
  This browser window is currently
  </p>
  <blockquote>
    <span id="width-output"></span>
  </blockquote>
  <p>
  pixels wide.
  </p></i>

  <script type="text/javascript">
    var outputWidth = function() {
      var element = document.getElementById("width-output");
      element.innerHTML = ''+window.innerWidth;
    };
    
    outputWidth();
    
    window.addEventListener("resize", outputWidth);
  </script>

This won't work on older exotic browsers, but essentially window.addEventListener("resize", listenerFunction) is all you need on modern HTML-5 browsers.

Provide the API

How do I want to register and receive breakpoint notifications? I want to pass an arbitrary pixel amount to the API, and a function to execute when the browser is resized across that breakpoint. Something like function addBreakpointListener(breakpoint, listenerFunction) { ... }; should do it.

The second thing the API must provide is the resize-listener function to install to the browser window. This could also be done internally, but leaving the resize-listener installation to the caller allows it also to de-install it temporarily, or install it at a certain time.

Here is the outline of the module to implement:

    /**
     * JS screen-width breakpoint manager that notifies
     * when a breakpoint is hit.
     */
    var screenWidthEventNotifier = function()
    {
      var that = {};
 
      /** Install this to receive browser-resize events. */
      that.resizeListener = function() {
        ....
      };
 
      /** Call this to add listeners to certain breakpoints. */
      that.addBreakpointListener = function(breakpoint, listenerFunction) {
        ....
      };
 
      return that;
    };

This API can then be used like the following:

    // create a breakpoint-notifier
    var breakpointNotifier = screenWidthEventNotifier();
 
    // install breakpoint-notifier as resize-listener
    window.addEventListener("resize", breakpointNotifier.resizeListener);
 
 
    // example breakpoint-listener implementation
    var breakpoint1024Listener = function(breakpoint, currentBrowserWidth) {
      var output = document.getElementById("output");
      output.innerHTML +=
          "<p>Received breakpoint event "+breakpoint+
          " at browser width "+currentBrowserWidth+", "+new Date()+"</p>";
    };
 
    // add example implementation as listener
    breakpointNotifier.addBreakpointListener(1024, breakpoint1024Listener);

This implementation anticipates that somewhere in the HTML page there is an element with id = "output". Every breakpoint-event would append a paragraph to it.

Detecting and Notifying Breakpoints

I will need a map that contains breakpoints as keys, and lists of listener-functions as value. That way I can check for all breakpoints, at any resize-event, whether the related listeners have to be called.

For detecting that a breakpoint is trespassed I will need a field that holds the previous screen width. Using this I can check whether the breakpoint is between current and previous width.

For determining the current browser width I will use window.innerWidth. This works on all modern browsers.

So here is my API implementation:

 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
    var screenWidthEventNotifier = function()
    {
      var breakpoint2ListenerLists = {};
      
      var browserWindowWidth = function() {
        return window.innerWidth;
      }
 
      var fireEvent = function(breakpoint, currentWidth) {
        var listeners = breakpoint2ListenerLists[breakpoint];
        for (var i = 0; i < listeners.length; i++) {
          var listener = listeners[i];
          listener(breakpoint, currentWidth);
        }
      };
 
      var previousWidth = browserWindowWidth();
 
      var that = {};
 
      /** Install this to receive browser-resize events. */
      that.resizeListener = function() {
        var currentWidth = browserWindowWidth();
 
        for (var breakpoint in breakpoint2ListenerLists) {
          if (breakpoint2ListenerLists.hasOwnProperty(breakpoint)) {
            // JS converts every map key to a string, so convert it back to integer
            var intBreakpoint = window.parseInt(breakpoint);

            if (intBreakpoint != currentWidth &&
                   (previousWidth <= intBreakpoint && intBreakpoint < currentWidth ||
                    previousWidth >= intBreakpoint && intBreakpoint > currentWidth))
            {
              fireEvent(intBreakpoint, currentWidth);
            }
          }
        }

        previousWidth = currentWidth;
      };
 
      /** Call this to add listeners to certain breakpoints. */
      that.addBreakpointListener = function(breakpoint, listenerFunction) {
        var listenersForBreakpoint = breakpoint2ListenerLists[breakpoint];
        if ( ! listenersForBreakpoint ) {
          listenersForBreakpoint = [];
          breakpoint2ListenerLists[breakpoint] = listenersForBreakpoint;
        }
        listenersForBreakpoint.push(listenerFunction);
      };
 
      return that;
    };

The line var breakpoint2ListenerLists = {}; sets up my breakpoint-to-listener map. As you might know, in JS there is no difference between a map and an object.

For determining the browser's window width I use window.innerWidth. This is encapsulated to be used once-and-only-once on (1) initialize and (2) every resize-event.

The fireEvent() function is private, it is not visible outside the module. I do not want any code to call this function except the internal resize-listener. The implementation receives the breakpoint that was hit, and the current browser width, as parameters. It then gets the listener array for the breakpoint out of the map, and loops its listeners, passing them the breakpoint and the current width. That way a listener can decide whether it wants to work below or above the breakpoint, or on both sides.

The private previousWidth field always will hold the width of the previous resize-event. Initially I assign the current browser width to it.

After the private part, I allocate a return-object called that where I will put my public functions into.

The most complex part is the resize-event-dispatch in resizeListener(). Essentially this functions loops all breakpoints on every resize-event, and checks whether the breakpoint is hit by the current change.
Do not pay attention to breakpoint2ListenerLists.hasOwnProperty(breakpoint), this is "boiler-plate code" to circumvent JavaScript for-in-loop problems with maps.
Assuming that my breakpoint is at 1024, my previous width at 1023, and my current width at 1024, I will NOT fire an event. When my previous width is at 1024 then, and my current width at 1025 or more, I will fire an event. That way I avoid that the listener receives a currentWidth being the same as the browser width, and thus can not decide whether the browser is now bigger or smaller than the breakpoint.

The public addBreakpointListener() implementation uses the breakpoint as key, and puts an empty array as value when there is no value yet. It adds the given listener to the array under that breakpoint.

Example Breakpoint Listener

Here is the Live-Example. Try it out by resizing your browser window across 1024 pixels in both directions. The JS listener working here is the one from "Provide the API" chapter.

Currently your browser is pixels wide.

Example listener output:




Sonntag, 13. März 2016

JS / CSS Tabs

"Tab" is a very vague term. It could mean anything from a trailer to a bottle, a tag, a bill, a stripe ...
Even the computer-world gave it several meanings (press TAB to navigate tabs below by keyboard:-)

There is the TAB key on your keyboard that produces kind of space when used in a text editor, or navigates the user interface by setting the input focus to buttons, choices or text-fields (this is called "tabbing").
There may be tab-spaces in the text you are reading, these are ASCII character number 9.
The user interface of a computer application could use tabs to hide content that is of just subsequent importance.

This Blog is about how you can get tabs on your web page. It uses tabs itself to give you the feeling of what expects you. You should browse through all tabs in their order when reading this document.

All JS source code shown here is written in pure JavaScript, no jQuery is used.

Term Definitions


TermSemanticWAI-ARIA role
tab-panelthe possibly initially invisible rectangle holding all contents of a tabtabpanel
tab-buttonthe UI-control where you click upon to get a tab-panel to foregroundtab
tabbed panethe top-level container, holding all tab-buttons and tabs-
tab-listthe list of tab-buttons on top of every tabbed panetablist
tabsconvenience term designating a tabbed pane-

Preliminary Considerations


Implementing tabs by pure CSS is not recommendable. See what CSS-Tricks tells about this. The biggest problems may be that

  1. HTML and CSS code have to be very tightly coupled, and
  2. tab-buttons are hyperlinks, so you can not prevent the browser from scrolling down to the link's target, which is not ergonomic (⇾ avoid jumping views).

Pure JavaScript tabs are also not recommendable. Everybody will want to style tabs differently. Be it rounded corners just on one side, or on both, or none at all, custom focus colors, ....

So tabs should be implemented using both CSS and JS.

Of course tabs must be able to contain nested tabs. This can get quite confusing when using lots of levels. It might be recommendable to display (at least the last level of) nested tabs as accordions then. But doing so you may uncover a source maintenance problem:

  • accordions and tabs are structurally different!

While accordions embed a tab-panel directly below its tab-button, tabs have their tab-buttons all on top, and tab-panels below. That structure is present in HTML, and you have to rewrite it when changing from tab to accordion.

Considering that, you may decide to implement that HTML restructuring in JS. But do not forget that CSS is tightly coupled with the structure of HTML, CSS selectors may not match any more when JS rebuilds it.

For the implementation introduced in this Blog I decided to use a custom-type of accordion to bridge this problem. So I do not rebuild HTML by JS, but the accordion will not look and feel as usual. I call this Tabcordion, and it was inspired by Transformer-Tabs. Its implementation will be subject to my next Blog.

The WAI-ARIA organization (like shown below) may be a little clumsy.
I do not want to define a button for every tab-panel, and then reference their ids in two directions, moreover I would like to tag a tab-panel with an attribute holding the title of the tab. This title then could be used by JS to generate a tab-button for it.
Further I wouldn't like to name the role of the HTML elements. Tagging the tabbed pane with a CSS class should be enough. Any block-element below it would count as tab-panel.

This would be much less authoring effort. I will keep my JS overridable for such alternatives.


Implementations

The remainder of this Blog introduces HTML, JS and CSS source-code for tabs.

HTML

Here is an example HTML structure for tabs, as suggested by WAI-ARIA (Web Accessibility Initiative – Accessible Rich Internet Applications). All tab-buttons are on top.

<div class="tabbed-pane" style="padding: 1em; border: 1px solid gray;">
    
    <ul role="tablist">
        <li id="tab-1" role="tab" aria-controls="tab-panel-1" aria-selected="true">Keyboard Key</li><!--
     --><li id="tab-2" role="tab" aria-controls="tab-panel-2" aria-selected="false">Text Space</li><!--
     --><li id="tab-3" role="tab" aria-controls="tab-panel-3" aria-selected="false">User Interface Element</li>
    </ul>
      
    <p role="tabpanel" id="tab-panel-1" aria-labelledby="tab-1">
        There is the TAB key on your keyboard ....
    </p>

    <p role="tabpanel" id="tab-panel-2" aria-labelledby="tab-2">
        There may be tab-spaces in the text ....
    </p>

    <p role="tabpanel" id="tab-panel-3" aria-labelledby="tab-3">
        The user interface could use tabs ....
    </p>
</div>

The tabs are wrapped into a block-element wearing the CSS class tabbed-pane. That way JS can find all tabbed panes it has to manage. Inside, on top, is the list of tab-buttons with their labels. This list will be styled by CSS to be horizontal.

If you wonder why there are commented-out newlines, you may want to read my Blog about spaces between inline-block elements.

Below the list of tab-buttons are the tabs. All of them need to have an id.

WAI-ARIA standardizes user interfaces by recommending attribute names for states like being visible or invisible (aria-hidden), a tab-button pointing to a certain tab and its back-reference (aria-controls, aria-labelledby), a tab being in foreground (aria-selected), or an accordion-fold being open (aria-expanded). The role attribute specifies with which kind of UI-element the HTML stands for. That way it is easy and safe to use attribute names in both JS and CSS, because their semantic is specified.

In other words, when the user clicks a tab-button,

  • JS reads the aria-controls attribute value of that button and changes the element with that id to aria-hidden = "false", while changing all other tabs to aria-hidden = "true"

  • CSS has a selector [aria-hidden = true] that rules matching elements to be display: none;

Thus JS communicates with CSS by setting attribute values in the DOM.

HTML responsibilities are to ....

  • tag tabbed panes (top-level containers) by a CSS class, so that JS and CSS have contact points
  • define the role of HTML elements taking part in the tabbed pane's organization
  • connect tab-buttons with tab-panels by aria-references,

HTML could also ....

  • pre-select a tab by setting aria-selected = "true" on its trigger-button.

Mind that, in WAI-ARIA, the attribute-values aria-hidden on a tab-panel and aria-selected on a tab-button could contradict each other, they are redundant. The tab-panel of a tab-button with aria-selected = "true" could never be aria-hidden = "false".

JS

Tab-Module Outline

Put this script tag on bottom of your HTML page body. Copy all subsequent functions (except the utility modules) to where the "...." is.

<script type="text/javascript">

    var tabModule = function(domUtil, keyboard)
    {
      var that = {};
      
      that.observedKeyEvent = "keydown";
      
      ....

      that.init = function(topElement) {
        initTabbedPanes(topElement);
      };
      
      return that;     
    };

    tabModule(domUtil(), keyboard()).init();

</script>

The trailing line tabModule(domUtil(), keyboard()).init(); installs the tab functionality.

Tab-Module Functions

All of the following go to the Tab-Module script to where the "...." is.

The finder functions outline how the module works:

  • first it finds all tabbed panes, for each tabbed pane
    • it finds all tab-panels, for each tab-panel
      • it finds the tab-button
      • every button together with its panel is packed into a JS object
    • all of these objects are collected into an array
    • that array describes exactly one tabbed pane, and it is the return of function initTabs(), which is among the initializers.
      /**
       * Assumes that the direct parent of an element
       * with role "tablist" is a tabbed pane.
       * @return a list of all tabbed panes below given topElement.
       */
      that.findTabbedPanes = function(topElement) {
        var tabLists = topElement.querySelectorAll("[role = 'tablist']");
        var result = [];
        for (var i = 0; i < tabLists.length; i++)
          result.push(tabLists[i].parentElement);
        return result;
      };
      
      /**
       * Uses domUtil to find all tab-panels within a tabbed-pane,
       * not including tab-panels of nested tabbed panes.
       * @return a list of tab-panels nested directly into given tabbed pane.
       */
      that.findTabPanels = function(tabbedPane) {
        return domUtil.findChildren(
            tabbedPane,
            function(childElement) {
              return childElement.getAttribute("role") === "tabpanel";
            }
        );
      };
      
      /**
       * @return the tab-button for given tab-panel below given tabbed pane,
       *    considering all WAI-ARIA variants of describing it.
       */
      that.findTabButton = function(tabPanel, tabbedPane) {
        var tabButtonId = tabPanel.getAttribute("aria-labelledby");
        var result = document.getElementById(tabButtonId);
        
        if ( ! result )
          result = tabbedPane.querySelector("[aria-controls = '"+tabPanel.id+"']");
        
        if ( ! result )
          throw "Could not find button for tab id="+tabPanel.id;
        
        return result;
      };
      
      /*
       * @return true when given tab is currently selected,
       *     i.e. its "aria-selected" attribute is "true".
       */
      that.isSelectedTab = function(tabButton, tabPanel, tabbedPane) {
        return tabButton.getAttribute("aria-selected") === "true";
      };

This is the heart of tab-building, and to protect it from overriding these functions are private (not visible outside).

The function initTabbedPanes() loops all tabbed panes found below given top-element (default would be document.body). Every tabbed pane is then passed to initTabs(), which builds the array of { button, panel } objects, sets hidden tabs invisible, and installs listeners on buttons.

The adjustKeyboardSupport() function receives the array of tabbed panes, each containing an array of { button, panel } objects.

      var initTabbedPanes = function(topElement) {
        var tabbedPanes = that.findTabbedPanes(topElement || document.body);
        var allTabPanelsWithButtons = [];
        
        /* loop bottom-up to FIRST build nested tabbed-panes */
        for (var i = tabbedPanes.length - 1; i >= 0; i--) {
          var tabbedPane = tabbedPanes[i];
          var tabPanelsWithButtons = initTabs(tabbedPane, allTabPanelsWithButtons);
          
          /* insert the tabs at head, this loop is bottom-up */
          allTabPanelsWithButtons.splice(0, 0, tabPanelsWithButtons);
        }
        
        adjustKeyboardSupport(allTabPanelsWithButtons);
      };

      var initTabs = function(tabbedPane, allTabPanelsWithButtons) {
        var tabPanels = that.findTabPanels(tabbedPane);
        var tabPanelsWithButtons = [];
        var selectedIndex = 0;
        
        for (var i = 0; i < tabPanels.length; i++) {
          var tabPanel = tabPanels[i];
          var tabButton = that.findTabButton(tabPanel, tabbedPane);
          tabPanelsWithButtons.push({
              panel: tabPanel,
              button: tabButton
          });
          
          if (that.isSelectedTab(tabButton, tabPanel, tabbedPane))
            selectedIndex = i;
        }
        
        for (var i = 0; i < tabPanels.length; i++) {
          var tabPanel = tabPanels[i];
          var tabButton = tabPanelsWithButtons[i].button;
          display(tabPanel, tabButton, selectedIndex === i);
          that.installListeners(tabButton, tabPanelsWithButtons, i, allTabPanelsWithButtons);
        }
        
        return tabPanelsWithButtons;
      };

Here come the listener functions. A click-listener selects the tab belonging to given button. A key-listener selects either the previous or the next tab when CURSOR-keys are pressed. TAB keys are not managed here, but HOME and END would jump to either the first or last tab.

The nested changeTab() function uses the closure-parameters of its parent function to ensure that on every call just one tab is set to visible. The display() function ensures that always the correct button is focused when a tab is taken to foreground.

The setDisplayed() and setButtonFocus() functions refer to the WAI-ARIA attributes and set their values to make CSS work. These functions are public for future overriding. Mind that without CSS nothing would happen here!

      /**
       * Installs mouse- and keyboard-listeners to focus tab-panels when a tab-button is clicked.
       * @param tabButton the tab-button on which to install listeners.
       * @param tabPanelsWithButtons array with all tab-panels and -buttons of this tabbed pane.
       * @param selectedIndex the index of the tab that should be in foreground in this tabbed pane.
       * @param allTabPanelsWithButtons for later use in callbacks, list is not yet complete.
       */
      that.installListeners = function(
          tabButton,
          tabPanelsWithButtons,
          selectedIndex,
          allTabPanelsWithButtons)
      {
        var changeTab = function(gotoIndex) {
          for (var i = 0; i < tabPanelsWithButtons.length; i++)
            display(
                tabPanelsWithButtons[i].panel,
                tabPanelsWithButtons[i].button,
                i === gotoIndex);
                
          adjustKeyboardSupport(allTabPanelsWithButtons);
        };
        
        tabButton.addEventListener("click", function() {
          changeTab(selectedIndex);
        });
        
        tabButton.addEventListener(that.observedKeyEvent, function(event) {
          var key = keyboard.getKey(event);
          var tabIndexToGo;
          if (key === "ArrowRight" || key === "ArrowDown")
            tabIndexToGo = (selectedIndex + 1) % tabPanelsWithButtons.length;
          else if (key === "ArrowLeft" || key === "ArrowUp")
            tabIndexToGo = (selectedIndex === 0) ? tabPanelsWithButtons.length - 1 : selectedIndex - 1;
          else if (key === "Home")
            tabIndexToGo = 0;
          else if (key === "End")
            tabIndexToGo = tabPanelsWithButtons.length - 1;
          
          if (tabIndexToGo !== undefined) {
            event.stopPropagation();
            event.preventDefault();
            changeTab(tabIndexToGo);
          }
        });
      };
      
      var display = function(tab, tabButton, isDisplayed) {
        that.setDisplayed(tab, isDisplayed);
        that.setButtonFocus(tabButton, isDisplayed);
      };
      
      /**
       * Sets given tab-panel displayed when isDisplayed is true,
       * else hides it.
       */
      that.setDisplayed = function(tabPanel, isDisplayed) {
        tabPanel.setAttribute("aria-hidden", isDisplayed ? "false" : "true");
      };
      
      /**
       * Sets given tab-button aria-selected when isFocused is true,
       * else de-selects it.
       */
      that.setButtonFocus = function(tabButton, isFocused) {
        tabButton.setAttribute("aria-selected", isFocused ? "true" : "false");
        if (isFocused)
          tabButton.focus();
      };

The keyboard support is managed by the functions below. They are executed any time the user chooses another tab, be it by keyboard or mouse.

For the meaning of the HTML tabindex attribute you could read my passed Blog about this. In short: tabindex -1 makes the element unfocusable by keyboard but focusable by mouse, every tabindex >= 0 makes it focusable in order of the tabindex value, when tabindex is absent it is focusable just when the HTML specification allows.

For the topmost tabbed pane, the tabindex of the currently selected tab-button is set to 1. For all tabbed panes below it, being currently visible, the tabindex of its currently selected button is set to the next number. All other tab-buttons on all other tabbed panes are set to -1.

      var adjustKeyboardSupport = function(allTabPanelsWithButtons) {
        var tabIndex = 1;
        
        for (var i = 0; i < allTabPanelsWithButtons.length; i++) {
          var tabPanelsWithButtons = allTabPanelsWithButtons[i];
          
          for (var j = 0; j < tabPanelsWithButtons.length; j++) {
            var tabPanelWithButton = tabPanelsWithButtons[j];
            
            var tabIndexValue = -1;
            if ( ! isAriaHidden(tabPanelWithButton.panel) && 
                 tabPanelWithButton.button.getAttribute("aria-selected") === "true")
            {
              tabIndexValue = tabIndex;
              tabIndex++;
            }
            tabPanelWithButton.button.setAttribute("tabindex", tabIndexValue);
          }
        }
      };
      
      var isAriaHidden = function(tabPanel) {
        var parent = tabPanel;
        while (parent && parent !== document.body) {
          if (parent.getAttribute("aria-hidden") === "true")
            return true;
          parent = parent.parentElement;
        }
        return false;
      };

Utility Modules

Copy these into a <script type="text/javascript"> tag before the Tab-Module script.

Following DOM module enables me to find child elements, recursively, that comply with a criterion given by a JS function. When a child matches, its children are not iterated. That way I do not find a tab-panel nested in a tab-panel when looking for tab-panels.

    var domUtil = function()
    {
      var that = {};
    
      /**
       * Returns children found by given matchFunction, recursively,
       * but not going into elements where matchFunction is successful.
       * @param topElement required, the element where to start recursive search.
       * @param matchFunction required, elements for which this function returns true will be returned.
       * @return the array of found elements (not nodes).
       */
      that.findChildren = function(topElement, matchFunction, found) {
        found = found || [];

        for (var i = 0; i < topElement.children.length; i++) {
          var child = topElement.children[i];

          if (matchFunction(child))
            found.push(child);
          else
            that.findChildren(child, matchFunction, found);
        }
        return found;
      };

      return that;
    };

Here is a keyboard-interpreter that is necessary as long as not all browsers implement the new W3C keyboard event specification.

  var keyboard = function()
  {
    var that = {};
    
    that.getKey = function(event) {
      if (event.key !== undefined && event.code !== undefined)
        return event.key;
        
      var key = event.which || event.keyCode;
      
      if (key == 8) return "Backspace";
      if (key == 9) return "Tab";
      if (key == 16) return "Shift";
      if (key == 17) return "Control";
      if (key == 18) return "Alt";
      if (key == 20) return "CapsLock";
      if (key == 27) return "Escape";
      if (key == 13) return "Enter";
      if (key == 33) return "PageUp";
      if (key == 34) return "PageDown";
      if (key == 35) return "End";
      if (key == 36) return "Home";
      if (key == 37) return "ArrowLeft";
      if (key == 38) return "ArrowUp";
      if (key == 39) return "ArrowRight";
      if (key == 40) return "ArrowDown";
      if (key == 42) return "PrintScreen"; // keyup only
      if (key == 45) return "Insert";
      if (key == 46) return "Delete";
      if (key == 91) return "OS"; // Windows key
      if (key >= 112 && key <= 123) return "F"+(key - 111);
      if (key == 225) return "AltGraph";
      if (key == 144) return "NumLock";
      
      return String.fromCharCode(key);
    };
    
    return that;
  };

CSS

Put following CSS styles into a <style type="text/css"> tag in head of your HTML.

The tabbed pane styles are essential, you should not leave them out. They arrange the list of buttons to be horizontal. But of course you can customize the corner rounding.

      /* do not display hidden tab panels */
      [aria-hidden = 'true'] {
        display: none;
      }
      
      /* a tab-button below a tabbed pane (not below an accordion) */
      .tabbed-pane > [role = 'tablist'] > [role = 'tab'] {
        display: inline-block; /* horizontally arranged */
        border-radius: 0 1em 0 0; /* rounded top-right corner */
        margin-right: 0.16em; /* a little space to next button */
      }
      
      /* tab button bar */
      [role = 'tablist'] {
        list-style: none; /* removes bullets */
        margin: 0; /* removes indentation */
        padding: 0;
      }

Some styles here are just decorative. I would keep the cursor anyway, because it shows that a tab can be clicked. And there must be some visual difference between a selected and an unselected tab-button.

      /* a selected tab-button */
      [role = 'tab'][aria-selected = 'true'] {
        color: white; /* text color */
        background-color: gray;
        cursor: initial; /* no hyperlink cursor on selected tab-button */
      }

      /* common tab-button styles */
      [role = 'tab'] {
        color: black; /* text color */
        background-color: #F0F0F0; /* very light gray */
        cursor: pointer; /* hyperlink cursor */
        margin: 0;
        padding: 0.45em 0.4em 0.2em 0.3em; /* correction: center text */
      }

      /* remove the browser's focus border from tab-buttons */
      [role = 'tab']:focus {
        outline: 0;
        border: 1px solid red;
      }

Set the styles here to whatever you like, they are just decorative.

      /* tab panel */
      [role = "tabpanel"] {
        padding: 0.2em;
        background-color: Khaki;
      }

Quite a lot of source code to achieve tabs!
Visit the newest state of this project on my homepage.




Sonntag, 6. März 2016

JS Table Layout Adjustment: DIV Tables

This is the last of my series about adjusting columns of nested tables. It answers the question


Tables can be built not only by HTML TABLE elements, but also by DIV elements when using specific CSS display styles. Here is an example:

 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
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">

  <style type="text/css">
    .table {
      display: table;
    }
    .thead {
      display: table-header-group;
    }
    .tbody {
      display: table-row-group;
    }
    .tr {
      display: table-row;
    }
    .td, .th {
      display: table-cell;
    }
  </style>
  
</head>

<body>

  <div class="table">

    <div class="thead">

      <div class="tr">
        <div class="th">Column 1</div>
        <div class="th">Column 2</div>
      </div>

    </div>

    <div class="tbody">

      <div class="tr">
        <div class="td">Content One</div>
        <div class="td">Content Two</div>
      </div>

      <div class="tr">
        <div class="td">Content Three</div>
        <div class="td">Content Four</div>
      </div>

    </div>

  </div>

</body>
</html>

This displays like a normal HTML TABLE. The only difference is that DIV tables do not support certain features like e.g. the colspan attribute, and do not have browser-defaults for padding, margin etc.


How can I reuse the JS code for TABLE elements to do the same for DIV tables?

By implementing new categorizer and adjuster modules, both extending their already provided abstractions.
As you may remember, I implemented modules with different responsibilities:

  • identifying column cells as abstractCategorizer
    • concrete implementation as tableColumnCategorizer
  • measuring and sizing columns as abstractLayoutAdjuster
    • concrete implementation as nestedTablesColumnAdjuster

They were finally built together to create the adjuster fitting to the requirements.

Now here is the new categorizer module implementation for DIV tables:

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

    /**
     * Concrete categorizer for HTML div table columns.
     */
    var divTableColumnCategorizer = function(CATEGORY_ATTRIBUTE_NAME)
    {
      var that = abstractCategorizer(CATEGORY_ATTRIBUTE_NAME);

      /** @return always 1 because DIVs do not support the colspan attribute. */
      that.getSpan = function(element) {
        return 1;
      };

      /**
       * @return true when given element should be categorized.
       *    This implementation returns true when element is
       *    DIV and its "display" CSS property is "table-cell".
       */
      that.isElementToCategorize = function(element) {
        if ( ! element.tagName === "DIV" )
          return false;

        var style = window.getComputedStyle(element);
        return style["display"] === "table-cell"; 
      };

      return that;

    };

This module extends abstractCategorizer. It then implements getSpan() to return 1 (does not support colspan), and isElementToCategorize() to identify table-cells of DIV tables.


Now I need to write an adjuster that can find nested DIV tables.

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

    /**
     * Concrete implementation of a layout-adjuster for HTML DIV-tables.
     * @param abstractLayoutAdjuster the layouter to extend.
     */
    var nestedDivTablesColumnAdjuster = function(categorizer, abstractLayoutAdjuster)
    {
      var findNestedDivTables = function(element, nestedDivTables) {
        nestedDivTables = (nestedDivTables !== undefined) ? nestedDivTables : [];

        var children = element.children;
        for (var i = 0; i < children.length; i++) {
          var child = children[i];
          if (child.tagName === "DIV") {
            var style = window.getComputedStyle(child);
            if (style["display"] === "table")
              nestedDivTables.push(child);
          }
          findNestedDivTables(child, nestedDivTables);
        }
        return nestedDivTables;
      };

      var that = nestedTablesColumnAdjuster(categorizer, abstractLayoutAdjuster);

      /** @return all nested DIV table elements below given one. */
      that.getNestedContainers = function(elementToLayout) {
        return findNestedDivTables(elementToLayout);
      };

      return that;

    };

This module extends its predecessor module nestedTablesColumnAdjuster. It overwrites the getNestedContainers() function to call the private findNestedDivTables() implementation. Unfortunately the JS built-in querySelector() function can not be used here, because there is no CSS selector that can target CSS styles like display: table. So we need to loop through children and read their styles.


Here comes the code to integrate the two new modules:

      var categorizer = divTableColumnCategorizer();
      var abstractAdjuster = abstractLayoutAdjuster(categorizer);
      var columnAdjuster = nestedDivTablesColumnAdjuster(categorizer, abstractAdjuster);
      columnAdjuster.init(tables);

Remember that I provided an abstract module that provides pre-defined columns widths. To be able to integrate that module into the build-chain, I needed to specify the abstractAdjuster parameter separately.

Of course also both the elastic-column and predefined-column-widths modules can be reused for DIV tables. Just the build chain changes:

      var categorizer = divTableColumnCategorizer();
      
      var abstractAdjuster = predefinedSizes ?
          abstractPredefinedSizeAdjuster(categorizer, predefinedSizes) :
          abstractLayoutAdjuster(categorizer);
  
      var adjuster = nestedDivTablesColumnAdjuster(categorizer, abstractAdjuster);
      
      var columnAdjuster = elasticColumn ?
          elasticColumnAdjuster(categorizer, adjuster, elasticColumn) :
          adjuster;
  
      columnAdjuster.init(tables);

That's all! You can go to my homepage to see this working.


The beauty of code reuse by inheritance is that you are mostly done by overwriting only a few things. All object-oriented languages provide inheritance. Although JS is not object-oriented, similar can be provided by using functional inheritance.

Generally JS provides several kinds of inheritance (would we have needed so much?). There are prototypal, classical, .... I wrote about this in one of my past Blogs.
I recommend functional inheritance, because it is easy to use and understand, and avoids the risks of the prototype chain and the this pointer, and does not require understanding the new operator. And private instance variables work fine.

Keep it simple, and you will win!