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:
- Tab One
- Tab Two
- Tab Three
Lorem ipsum dolor sit amet, qui meliore deserunt at. Percipitur intellegam appellantur cu vim, mei soluta complectitur id, partem reprimique ullamcorper in vim.
- Tab Two One
- Tab Two Two
- Tab Two Three
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.
- Tab Three One
- Tab Three Two
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
- Description
- JS Source
- CSS Source
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
- Tabcordion JS Module
- JS Functions
- CSS Source
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 callssuper
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.
- HTML
- JS Initialization
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:
Kommentar veröffentlichen