Blog-Archiv

Sonntag, 3. April 2016

JS / CSS Tabcordion

This is a follow-up of my Blog about JS / CSS Tabs. It introduces a way how tabs could be displayed in an Accordion manner without changing the structure of HTML. The source code for tabs is re-used, HTML structure is the same, some JS is overridden, some CSS is added.

I called that UI-control Tabcordion. It was inspired by Transformer Tabs on the CSS-Tricks web site. The difference is that Tabcordion (1) is not responsive, more it tries to replace Accordion, and (2) pretends to be ergonomic - the tab-title does not "jump".
Here is an example:

Lorem ipsum dolor sit amet, qui meliore deserunt at. Percipitur intellegam appellantur cu vim, mei soluta complectitur id, partem reprimique ullamcorper in vim.

At atqui choro theophrastus sit, ne erroribus vulputate vis, eam et antiopam scripserit. Et sea altera salutandi iudicabit. Vidisse probatus moderatius cum te, et vis feugiat luptatum consulatu.

Nec an stet decore honestatis, omittam maiestatis ei quo, eripuit facilis recusabo ius cu. Eu oblique detraxit honestatis vim, hinc sonet definitiones has ea.

At atqui choro theophrastus sit, ne erroribus vulputate vis, eam et antiopam scripserit. Et sea altera salutandi iudicabit. Vidisse probatus moderatius cum te, et vis feugiat luptatum consulatu.

Lorem ipsum dolor sit amet, qui meliore deserunt at. Percipitur intellegam appellantur cu vim, mei soluta complectitur id, partem reprimique ullamcorper in vim.

Nec an stet decore honestatis, omittam maiestatis ei quo, eripuit facilis recusabo ius cu. Eu oblique detraxit honestatis vim, hinc sonet definitiones has ea.

The menu-button on the right is visible when all other tab-bars are hidden. When you click the bar, all other tab-bars will show, pushing the currently showing content down. Clicking the bar once more will hide them again, pulling up the content. But when you click one of the other tab-bars, its content will replace the current content. Any tab-bar below the clicked one will disappear then, but none of those above (→ difference to Transformer Tabs).

The disadvantage of Tabcordion is that not all tabs are initially visible. There is a constructor parameter that can change this, but then it looks like tabs that have been arranged vertically.

This might not be a UI-control you want to use, but it shows how well-written JS source could be reused to show completely different results, doing just a few overrides, and adding some CSS.


Tabs Source Base


Click on tab-bar above to navigate to the source code base I'm going to extend now. It is packed in script and style tags, uncommented, ready for copy & paste onto your web page. Mind that style go to head, while script tags should be at end of the body.

For a description of that source go to my last Blog about Tabs.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
  <script type="text/javascript">
    var domUtil = function()
    {
      "use strict";
      
      var that = {};
    
      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;
    };
  </script>

  <script type="text/javascript">
    var keyboard = function()
    {
      "use strict";
      
      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;
    };
  </script>

  <script type="text/javascript">
    var tabBase = function(domUtil, keyboard)
    {
      "use strict";
      
      var that = {};
      
      that.observedKeyEvent = "keydown";
      
      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;
      };
      
      that.findTabPanels = function(tabbedPane) {
        return domUtil.findChildren(
            tabbedPane,
            function(childElement) {
              return childElement.getAttribute("role") === "tabpanel";
            }
        );
      };
      
      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;
      };
      
      that.isSelectedTab = function(tabButton, tabPanel, tabbedPane) {
        return tabButton.getAttribute("aria-selected") === "true";
      };
      
      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);
      };
      
      that.setDisplayed = function(tabPanel, isDisplayed) {
        tabPanel.setAttribute("aria-hidden", isDisplayed ? "false" : "true");
      };
      
      that.setButtonFocus = function(tabButton, isFocused) {
        tabButton.setAttribute("aria-selected", isFocused ? "true" : "false");
        if (isFocused)
          tabButton.focus();
      };
      
      var initTabbedPanes = function(topElement) {
        var tabbedPanes = that.findTabbedPanes(topElement || document.body);
        var allTabPanelsWithButtons = [];
        
        for (var i = tabbedPanes.length - 1; i >= 0; i--) {
          var tabbedPane = tabbedPanes[i];
          var tabPanelsWithButtons = initTabs(tabbedPane, allTabPanelsWithButtons);
          
          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;
      };
      
      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;
      };
      
      that.init = function(topElement) {
        initTabbedPanes(topElement);
      };
      
      return that;     
    };
  </script>
      [aria-hidden = 'true'] {
        display: none;
      }
      
      .tabbed-pane > [role = 'tablist'] > [role = 'tab'] {
        display: inline-block;
        border-radius: 0 1em 0 0;
        margin-right: 0.16em;
      }
      
      [role = 'tablist'] {
        list-style: none;
        margin: 0;
        padding: 0;
      }
      
      [role = 'tab'][aria-selected = 'true'] {
        color: white;
        background-color: gray;
        cursor: initial;
      }

      [role = 'tab'] {
        padding: 0.45em 0.4em 0.2em 0.3em;
        color: black;
        background-color: #F0F0F0;
        cursor: pointer;
        margin: 0;
      }

      [role = 'tab']:focus {
        outline: 0;
        border: 1px solid red;
      }
      
      [role = "tabpanel"] {
        padding: 0.2em;
        background-color: Khaki;
      }

Tabcordion Overrides


Here is the module outline I am going to implement now, in a script tag. It extends the given instantiated tabBase module (parameter) by setting it to the local that object, and then replacing functions on it. (The tabBase was called tabModule in my last Blog.)

  <script type="text/javascript">
    /**
     * Create a tabcordion.
     * @param initiallyExpanded when true, tabcordions will be initially expanded,
     *     else they will be collapsed and the menu icon will show on selected button.
     * @returns the object to call init() upon to find tabbed panes and tabcordions.
     */
    var tabcordion = function(tabBase, keyboard, initiallyExpanded)
    {
      initiallyExpanded = (initiallyExpanded === undefined) ? false : initiallyExpanded;
      
      var that = tabBase;
      
      var initializing = true;
      
      ....

      var superInit = that.init;
      
      /**
       * Builds tabbed panes found in given topElement (or current document).
       * Overrides must be done before calling this.
       * @param topElement optional, default is document.body,
       *     the top-element where to search for tabbed-panes.
       */
      that.init = function(topElement) {
        superInit(topElement);
        initializing = false;
      };
      
      return that;
      
    };

  </script>

The initiallyExpanded parameter defaults to false to make tab-buttons below the focused one initially invisible. Passing true would change that Tabcordion behavior.

The local initializing flag will be used to mark the initialization phase. The init() override sets it to false at end of its execution.

Take this source code to the module outline where the "...." is.

      var superSetButtonFocus = that.setButtonFocus;
      
      /**
       * Called when a tab was activated by the user.
       * This implementation calls super, and then, when isFocused is true,
       * toggles CSS "aria-expanded" on the parent container (role="tablist").
       * @param tabButton the clicked tab-button.
       * @param isFocused true when the tab is going to foreground, false when going to background.
       */
      that.setButtonFocus = function(tabButton, isFocused) {
        var wasFocused = (tabButton.getAttribute("aria-selected") === "true");
            
        superSetButtonFocus(tabButton, isFocused);
        
        if (isFocused) { // only the focused button can collapse or expand the list parent
          var tablist = that.getExpandableParent(tabButton);
          
          if (initializing) // set initial expansion state to the list
            tablist.setAttribute("aria-expanded", initiallyExpanded ? "true" : "false");
          else if ( ! wasFocused ) // when changing selection, again set initial expansion state to list
            tablist.setAttribute("aria-expanded", initiallyExpanded ? "true" : "false");
          else // when keeping selection state, toggle expansion state of list
            toggleExpandable(tablist);
        }
      };
      
      var toggleExpandable = function(tablist) {
        var expanded = (tablist.getAttribute("aria-expanded") === "true");
        tablist.setAttribute("aria-expanded", expanded ? "false" : "true");
      };
      
      /**
       * This implementation assumes that the "aria-expanded" attribute is carried by direct parent.
       * @return the parent element of given button that carries the "aria-expanded" attribute.
       */
      that.getExpandableParent = function(tabButton) {
        return tabButton.parentElement;
      };
      
      var superInstallListeners = that.installListeners;
      
      that.installListeners = function(tabButton, tabPanelsWithButtons, selectedIndex, allTabPanelsWithButtons) {
        superInstallListeners(tabButton, tabPanelsWithButtons, selectedIndex, allTabPanelsWithButtons);
        
        if ( ! tabButton.getAttribute("title") )
          tabButton.setAttribute("title", "Click to show or hide other tabs");

        tabButton.addEventListener(that.observedKeyEvent, function(event) {
          var key = keyboard.getKey(event);
          if (key === "Enter") {
            event.stopPropagation();
            event.preventDefault();
            toggleExpandable(that.getExpandableParent(tabButton));
          }
        });
      };

Mind that no CSS is set by JS, just WAI-ARIA attributes are used to change the UI state. CSS rules that refer to these attributes will do the real work.

Extending the base module is done by saving function-pointers to local variables and then overwriting these functions. I always call the saved pointers superXXX, for a function XXX. This functional-inheritance-technique is needed when you want to call the super-functions from the override.

Just two functions were overridden:

  • setButtonFocus() is called on initialization, and whenever the user clicks tab-button. It calls super to do the normal tabbing work, and then, when the tab was focused, toggles the visibility state of the tab-buttons below the current one by setting "aria-expanded".

  • installListeners() was overwritten to also catch the ENTER key on tab-buttons, which should toggle the visibility state of the tab-buttons below. Besides it also puts a tooltip on the tab-button.

The getExpandableParent() function refers to the list of tab-buttons. It was made public to be overridden for other determination ways than the direct parent element.

This must go to a style tag in head of your page.

      /* tabcordion tab button, with rounded corners and showing always a hand-cursor */
      .tabcordion > [role = 'tablist'] > [role = 'tab'] {
        border-radius: 0.5em 0.5em 0 0;
        cursor: pointer;
        margin-bottom: 0.1em;
      }
      /* show all tabcordion buttons when list is expanded */
      .tabcordion > [role = 'tablist'][aria-expanded = 'true'] > [role = 'tab'] {
        display: block;
      }
      /*
        Hide buttons below selected when list is collapsed.
        This variant does not jump, but items above are always visible.
       */
      .tabcordion > [role = 'tablist'][aria-expanded = 'false'] > [role = 'tab'][aria-selected = 'true'] ~ [role = 'tab'] {
        display: none;
      }
      
      /* tabcordion tablist is parent for absolutely positioned menu-icon */
      .tabcordion > [role = 'tablist'] {
        position: relative;
      }
      /* menu-icon as pseudo-element at the very right, visible only when list collapsed */
      .tabcordion > [role = 'tablist'][aria-expanded = 'false'] > [role = 'tab'][aria-selected = 'true']::after {
        content: '\02630';
        margin-right: 0.8em;
        position: absolute;
        right: 0;
        font-family: monospace;
        font-size: 120%;
      }
      
      /* left indent of a nested tabcordion */
      .tabcordion > [role = "tabpanel"] > .tabcordion {
        padding-left: 1.3em;
      }

Please read the inline comments of these CSS rules. Some of them are just decorative.
Essential is the toggling of tab-button list, done by the two rules

  • .tabcordion > [role = 'tablist'][aria-expanded = 'true'] > [role = 'tab']
  • .tabcordion > [role = 'tablist'][aria-expanded = 'false'] > [role = 'tab'][aria-selected = 'true'] ~ [role = 'tab']

The selector [role = "tab"] denotes tab-buttons.
The CSS ">" operator denotes a direct child, and "~" any following sibling on same level.
CSS rules always point to the rightmost described element.
Thus the second rule says that tab-buttons below the focused one should not be visible when the list of tab-buttons is not aria-expanded.

The .tabcordion > [role = "tabpanel"] > .tabcordion rule indents nested tabcordions. This makes it look a little bit like a tree.


Application

Here is an example Tabcordion.

Following is a Tabcordion containing

  • text content on Tab One,
  • a nested Tabcordion on Tab Two,
  • and a nested tabbed pane on Tab Three.

You can see it in action on top of this page.

The only structural difference between tabbed pane and Tabcordion is the CSS class on the top-level div element. Thus you could easily turn any Tabcordion into a tabbed pane just by changing the CSS class from "tabcordion" to "tabbed-pane".

        <div class="tabcordion">
          
          <ul role="tablist" aria-expanded='true'><li
                id="tab-1" role="tab" aria-controls="tab-panel-1" aria-selected="true">Tab One</li><li
                id="tab-2" role="tab" aria-controls="tab-panel-2" aria-selected="false">Tab Two</li><li
                id="tab-3" role="tab" aria-controls="tab-panel-3" aria-selected="false">Tab Three</li><li>
          </ul>
          
          <div role="tabpanel" id="tab-panel-1" aria-labelledby="tab-1">
            <p>Lorem ipsum dolor sit amet, qui meliore deserunt at.</p>
          </div>
          
          <div role="tabpanel" id="tab-panel-2" aria-labelledby="tab-2">
            
            <div class="tabcordion">
            
              <ul role="tablist"><li
                    id="tab-2-1" role="tab" aria-controls="tab-panel-2-1" aria-selected="true">Tab Two One</li><li
                    id="tab-2-2" role="tab" aria-controls="tab-panel-2-2" aria-selected="false">Tab Two Two</li><li
                    id="tab-2-3" role="tab" aria-controls="tab-panel-2-3" aria-selected="false">Tab Two Three</li>
              </ul>
          
              <div role="tabpanel" id="tab-panel-2-1" aria-labelledby="tab-2-1">
                <p>At atqui choro theophrastus sit, ne erroribus vulputate vis, eam et antiopam scripserit. </p>
              </div>
              
              <div role="tabpanel" id="tab-panel-2-2" aria-labelledby="tab-2-2">
                <p>Nec an stet decore honestatis, omittam maiestatis ei quo, eripuit facilis recusabo ius cu.</p>
              </div>
              
              <div role="tabpanel" id="tab-panel-2-3" aria-labelledby="tab-2-3">
                <p>At atqui choro theophrastus sit, ne erroribus vulputate vis, eam et antiopam scripserit. </p>
              </div>
              
            </div>
            
          </div>
          
          <div role="tabpanel" id="tab-panel-3" aria-labelledby="tab-3">
            
            <div class="tabbed-pane">
              <ul role="tablist"><li
                    id="tab-3-1" role="tab" aria-controls="tab-panel-3-1" aria-selected="true">Tab Three One</li><li
                    id="tab-3-2" role="tab" aria-controls="tab-panel-3-2" aria-selected="false">Tab Three Two</li><li>
              </ul>
          
              <div role="tabpanel" id="tab-panel-3-1" aria-labelledby="tab-3-1">
                <p>Lorem ipsum dolor sit amet, qui meliore deserunt at.</p>
              </div>
              
              <div role="tabpanel" id="tab-panel-3-2" aria-labelledby="tab-3-2">
                <p>Nec an stet decore honestatis, omittam maiestatis ei quo, eripuit facilis recusabo ius cu.</p>
              </div>
              
            </div>
        
          </div>
          
        </div>
        
  </div>

Following script should be at end of the body of your page.

  <script type="text/javascript">
  
    var domFinder= domUtil();
    var keyboardUtil = keyboard();
    var tabs = tabBase(domFinder, keyboardUtil);
    var initiallyExpanded = false;
    
    tabcordion(tabs, keyboardUtil, initiallyExpanded).init();
    
  </script>

All involved JS modules are instantiated and built together here. Finally init() is called to make the tabcordion module instantiation do its work.


Summary

Tabs with WAI-ARIA are a little painful. You always have to provide ids and id-references for aria-controls and aria-labelled-by. Moreover the HTML structure, as proposed by WAI-ARIA, makes it hard to switch between Tabbed-Pane and Accordion without restructuring the HTML, because of the <ul role="tablist"> on top, containing all tab-buttons. For an Accordion, the tab-button would have to be directly above its tab-panel.

In a future Blog I will try to describe how we can organize Tabbed-Panes and Accordions transparently, without having to restructure the HTML. Why don't we want to restructure HTML, for example using JavaScript? Because CSS hardcodes the HTML structure sometimes, and CSS is often written without knowledge about the JS working in the page.




Keine Kommentare: