Blog-Archiv

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.




Keine Kommentare: