Carousel (based on a tabpanel)

Carousel (based on a tabpanel)

This carousel pattern is based on an ARIA tab panel pattern. Only one tab panel (carousel item) is visible at a time. There are buttons to go forward, back, or to play/pause the carousel.



Turn on a screen reader to experience this example in action.

Initial HTML Markup

<div class="deque-tabpanel" id="carousel">
  <ul class="deque-tabpanel-tablist" role="tablist">
    <li class="deque-tabpanel-tab" role="tab" id="tabCitySkyline" aria-controls="panelCitySkyline">
      City Skyline
    </li>
    <li class="deque-tabpanel-tab" role="tab" id="tabSunset" aria-controls="panelSunset">
      Beach Sunset
    </li>
    <li class="deque-tabpanel-tab" role="tab" id="tabRomanBuilding" aria-controls="panelRomanBuilding">
      Roman Statue
    </li>
  </ul>
  <div class="deque-tabpanel-tabpanels">
    <div class="deque-tabpanel-tabpanel" role="tabpanel" id="panelCitySkyline" aria-labelledby="tabCitySkyline">
      <img id="imgCitySkyline" src="assets/js/patterns/images/tempimage01.jpeg"
        alt="City skyline">
    </div>
    <div class="deque-tabpanel-tabpanel" role="tabpanel" id="panelSunset" aria-labelledby="tabSunset">
      <img id="imgSunset" src="assets/js/patterns/images/tempimage02.jpeg"
        alt="Sunset over lake">
    </div>
    <div class="deque-tabpanel-tabpanel" role="tabpanel" id="panelRomanBuilding" aria-labelledby="tabRomanBuilding">
      <img id="imgRomanBuilding" src="assets/js/patterns/images/tempimage03.jpeg"
        alt="Old roman building with statue on top.">
    </div>
  </div>

  <div class="deque-tabpanel-button-bar">
    <button class="deque-button" id="prevButton">
      prev
    </button>
    <button class="deque-button" id="playButton">
      play
    </button>
    <button class="deque-button" id="pauseButton">
      pause
    </button>
    <button class="deque-button" id="nextButton">
      next
    </button>
  </div>

</div>

JavaScript

Required: The complete JavaScript file (for all patterns in the library): deque-patterns.min.js

Key parts of the JavaScript file

Note: The code below is functional only in the context of the complete JavaScript file.

In the @tabpanel section:


      function createTabpanel(widget, config) {

        var widgetTablist = widget.querySelector('[role=tablist]');
        widgetTablist.classList.add('deque-tabpanel-tablist');

        var pause = widget.querySelector('.deque-tabpanel-button-bar');
        if (pause && !pause.classList.contains('hidden')) {
          pause.click();
        }

        function initializeTabs(widget) {
          var tabs = widget.querySelectorAll('[role=tab]');
          for (var i = 0; i < tabs.length; i++) {
            tabs[i].classList.add('deque-tabpanel-tab');
            if (i == 0) {
              tabs[i].setAttribute('aria-selected', 'true');
              tabs[i].setAttribute('tabindex', '0');
            } else {
              tabs[i].setAttribute('aria-selected', 'false');
              tabs[i].setAttribute('tabindex', '-1');
            }
          }
          return tabs;
        }
        var tabs = initializeTabs(widget);

        function initializePanels(widget) {
          var panels = widget.querySelectorAll('[role=tabpanel]');
          for (var i = 0; i < panels.length; i++) {
            panels[i].classList.add('deque-tabpanel-tabpanel');
            if (i != 0) {
              panels[i].classList.add('deque-hidden');
            }
          }
          return panels;
        }
        var panels = initializePanels(widget);

        // create a live region to toss tab-panel-related announcements into
        var region = (0, _containerUtils.createLiveRegion)();
        document.body.appendChild(region);

        var autoplayControls;
        var autoplayConfig;
        if (config.autoplay) {
          autoplayConfig = {
            onPlay: function onPlay() { },
            onPause: function onPause() { },
            onNext: function onNext() {
              var nextTab = getNext(getCurrentTab(tabs));
              selectTab(nextTab, tabs, panels);
              region.notify(nextTab.innerText + 'tab');
            },
            onPrevious: function onPrevious() {
              var previousTab = getPrev(getCurrentTab(tabs));
              selectTab(previousTab, tabs, panels);
              region.notify(previousTab.innerText + 'tab');
            }
          };

          autoplayControls = (0, _carouselControls.activateCarouselControls)(widget, region, autoplayConfig, config.autoplay || 3000);
        }

        function selectTab(selectedTab, tabs, panels) {
          var selectedTabLabeledBy = selectedTab.getAttribute('aria-controls');

          for (var i = 0; i < panels.length; i++) {
            if (panels[i].id === selectedTabLabeledBy) {
              panels[i].classList.remove('deque-hidden');
            } else {
              panels[i].classList.add('deque-hidden');
            }
          }

          for (var j = 0; j < tabs.length; j++) {
            if (tabs[j].getAttribute('aria-controls') === selectedTabLabeledBy) {
              tabs[j].setAttribute('tabindex', '0');
              tabs[j].setAttribute('aria-selected', 'true');
            } else {
              tabs[j].setAttribute('tabindex', '-1');
              tabs[j].setAttribute('aria-selected', 'false');
            }
          }
        }

        function applyNavigationLogic(tabs, panels, _ref) {
          var autoselect = _ref.autoselect,
            vertical = _ref.vertical;


          var tabstopConfig = {
            onSpace: function onSpace(item) {
              var selectedTabLabeledBy = item.getAttribute('aria-controls');
              for (var i = 0; i < panels.length; i++) {
                if (panels[i].id === selectedTabLabeledBy) {
                  panels[i].classList.remove('deque-hidden');
                } else {
                  panels[i].classList.add('deque-hidden');
                }
              }
            },
            onClick: function onClick(item) {
              var selectedTabLabeledBy = item.getAttribute('aria-controls');
              for (var i = 0; i < panels.length; i++) {
                if (panels[i].id === selectedTabLabeledBy) {
                  panels[i].classList.remove('deque-hidden');
                } else {
                  panels[i].classList.add('deque-hidden');
                }
              }
            },
            select: selectTab,
            useAriaSelected: true,
            autoselect: autoselect
          };

          if (vertical) {
            tabstopConfig.onUp = function (item) {
              if (autoplayControls) {
                autoplayControls.pause();
              }

              return getPrev(item);
            };

            tabstopConfig.onDown = function (item) {
              if (autoplayControls) {
                autoplayControls.pause();
              }

              return getNext(item);
            };
          } else {
            tabstopConfig.onLeft = function (item) {
              if (autoplayControls) {
                autoplayControls.pause();
              }

              return getPrev(item);
            };

            tabstopConfig.onRight = function (item) {
              if (autoplayControls) {
                autoplayControls.pause();
              }

              return getNext(item);
            };
          }

          (0, _tabstopUtils.createSingleTabstopStructure)(tabs, panels, tabstopConfig);
        }

        applyNavigationLogic(tabs, panels, config);

        function getCurrentTab(tabs) {
          for (var i = 0; i < tabs.length; i++) {
            if (tabs[i].getAttribute('tabindex') === '0') {
              return tabs[i];
            }
          }
        }

        function getPrev(item) {
          var output = item.previousElementSibling || tabs[tabs.length - 1];
          return output;
        }

        function getNext(item) {
          var output = item.nextElementSibling || tabs[0];
          return output;
        }
      }

      function initializeAllTabpanels() {
        var widgets = document.querySelectorAll('.deque-tabpanel');
        var config;

        for (var i = 0; i < widgets.length; i++) {
          if (widgets[i].id == 'carousel') {
            config = {
              autoselect: true,
              autoplay: 3000
            };
          } else {
            config = {
              autoselect: true,
              autoplay: false
            };
          }
          createTabpanel(widgets[i], config);
        }
      }

      initializeAllTabpanels();
      

In the @tabstopUtils section:


      function createSingleTabstopStructure(tabsNL, panels, config) {
        var tabs = Array.prototype.slice.call(tabsNL);
        tabs.forEach(function (tab) {
          tab.addEventListener('focus', function () {
            if (config.onFocus) {
              config.onFocus(tab);
            }
          });

          if (!config.select) {
            config.select = function () { };
          }

          if (config.onClick) {
            tab.addEventListener('click', function () {
              config.onClick(tab);
              config.select(tab, tabs, panels);
            });
          }

          if (config.onSpace) {
            kb.onElementSpace(tab, function (e) {
              e.preventDefault();
              e.stopPropagation();
              config.onSpace(tab);
              config.select(tab, tabs, panels);
            });
          }

          if (config.onLeft) {
            kb.onElementLeft(tab, function (e) {
              e.preventDefault();
              e.stopPropagation();

              var target = config.onLeft(tab);
              if (target) {
                target.focus();
                if (config.autoselect) {
                  config.select(target, tabs, panels);
                }
              }
            });
          }

          if (config.onRight) {
            kb.onElementRight(tab, function (e) {
              e.preventDefault();
              e.stopPropagation();
              var target = config.onRight(tab);
              if (target) {
                target.focus();
                if (config.autoselect) {
                  config.select(target, tabs, panels);
                }
              }
            });
          }

          if (config.onUp) {
            kb.onElementUp(tab, function (e) {
              e.preventDefault();
              e.stopPropagation();
              var target = config.onUp(tab);
              if (target) {
                target.focus();
                if (config.autoselect) {
                  config.select(target, tabs, panels);
                }
              }
            });
          }

          if (config.onDown) {
            kb.onElementDown(tab, function (e) {
              e.preventDefault();
              e.stopPropagation();
              var target = config.onDown(tab);
              if (target) {
                target.focus();
                if (config.autoselect) {
                  config.select(target, tabs, panels);
                }
              }
            });
          }

          if (config.onHome) {
            kb.onElementHome(tab, function (e) {
              e.preventDefault();
              e.stopPropagation();
              var target = config.onHome(tab);
              if (target) {
                target.focus();
                if (config.autoselect) {
                  config.select(target, tabs, panels);
                }
              }
            });
          }

          if (config.onEnd) {
            kb.onElementEnd(tab, function (e) {
              e.preventDefault();
              e.stopPropagation();
              var target = config.onEnd(tab);
              if (target) {
                target.focus();
                if (config.autoselect) {
                  config.select(target, tabs, panels);
                }
              }
            });
          }

          if (config.onPageUp) {
            kb.onElementPageUp(tab, function (e) {
              e.preventDefault();
              e.stopPropagation();
              var target = config.onPageUp(tab);
              if (target) {
                target.focus();
                if (config.autoselect) {
                  config.select(target, tabs, panels);
                }
              }
            });
          }

          if (config.onPageDown) {
            kb.onElementPageDown(tab, function (e) {
              e.preventDefault();
              e.stopPropagation();
              var target = config.onPageDown(tab);
              if (target) {
                target.focus();
                if (config.autoselect) {
                  config.select(target, tabs, panels);
                }
              }
            });
          }

          if (config.onCharacter) {
            kb.onElementCharacter(tab, function (e) {
              e.preventDefault();
              e.stopPropagation();
              var target = config.onCharacter(tab);
              if (target) {
                target.focus();
                if (config.autoselect) {
                  config.select(target, tabs, panels);
                }
              }
            });
          }
        });
      }
      

In the @tabpanel-carouselControls section:


      function createButton(label) {
        var classes = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : [];

        var output = document.createElement('button');
        output.innerText = label;
        classes.forEach(function (c) {
          return output.classList.add(c);
        });
        return output;
      }

      function buildCarouselControls(_ref) {
        var onPlay = _ref.onPlay,
          onPause = _ref.onPause,
          onNext = _ref.onNext,
          onPrevious = _ref.onPrevious;
        var duration = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 2000;

        var output = document.createElement('div');
        output.className = 'output-wrap';
        var prevButton = createButton('prev', ['prev']);
        var playButton = createButton('play', ['play', 'hidden']);
        var pauseButton = createButton('pause', ['pause']);
        var nextButton = createButton('next', ['next']);

        // create a live region to toss tab-panel-related announcements into
        var region = (0, _containerUtils.createLiveRegion)();
        document.body.appendChild(region);

        var playInterval = void 0,
          called = false;

        var startPlaying = function startPlaying() {
          if (playInterval) {
            stopPlaying();
          }
          playInterval = setInterval(next, duration);
          playButton.classList.add('hidden');
          pauseButton.classList.remove('hidden');
          pauseButton.focus();
          if (onPlay) {
            onPlay();
          }
          region.notify('Carousel playing');
        };

        var stopPlaying = function stopPlaying() {
          clearInterval(playInterval);
          playInterval = null;
          playButton.classList.remove('hidden');
          if (called) {
            playButton.focus();
          }
          pauseButton.classList.add('hidden');
          if (onPause) {
            onPause();
          }
          called = true;
          region.notify('Carousel paused');
        };

        var next = function next() {
          if (onNext) {
            onNext();
          }
        };

        var prev = function prev() {
          if (onPrevious) {
            onPrevious();
          }
        };

        nextButton.addEventListener('click', next);
        prevButton.addEventListener('click', prev);
        playButton.addEventListener('click', startPlaying);
        pauseButton.addEventListener('click', stopPlaying);

        output.appendChild(prevButton);
        output.appendChild(playButton);
        output.appendChild(pauseButton);
        output.appendChild(nextButton);

        startPlaying();

        output.start = startPlaying;
        output.pause = stopPlaying;
        output.prev = prev;
        output.next = next;
        return output;
      }

      function activateCarouselControls(widget, region, _ref2) {
        var onPlay = _ref2.onPlay,
          onPause = _ref2.onPause,
          onNext = _ref2.onNext,
          onPrevious = _ref2.onPrevious;
        var duration = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 2000;

        var output = widget.querySelector('.deque-tabpanel-button-bar');
        var buttons = output.querySelectorAll('.deque-button');
        var prevButton;
        var playButton;
        var pauseButton;
        var nextButton;

        for (var i = 0; i < buttons.length; i++) {
          if (buttons[i].id == 'prevButton') {
            prevButton = buttons[i];
          } else if (buttons[i].id == 'playButton') {
            playButton = buttons[i];
          } else if (buttons[i].id == 'pauseButton') {
            pauseButton = buttons[i];
          } else if (buttons[i].id == 'nextButton') {
            nextButton = buttons[i];
          }
        }

        playButton.classList.add('deque-hidden');
        playButton.classList.remove('deque-button');

        var playInterval = false;

        var startPlaying = function startPlaying() {
          if (playInterval) {
            stopPlaying();
          }
          playInterval = setInterval(next, duration);
          playButton.classList.add('deque-hidden');
          playButton.classList.remove('deque-button');
          pauseButton.classList.remove('deque-hidden');
          pauseButton.classList.add('deque-button');
          pauseButton.focus();
          if (onPlay) {
            onPlay();
          }
          region.notify('Carousel playing');
        };

        var stopPlaying = function stopPlaying() {
          clearInterval(playInterval);
          playInterval = null;
          playButton.classList.remove('deque-hidden');
          playButton.classList.add('deque-button');
          playButton.focus();
          pauseButton.classList.add('deque-hidden');
          pauseButton.classList.remove('deque-button');
          if (onPause) {
            onPause();
          }

          region.notify('Carousel paused');
        };

        var next = function next() {
          if (onNext) {
            onNext();
          }
        };

        var prev = function prev() {
          if (onPrevious) {
            onPrevious();
          }
        };

        nextButton.addEventListener('click', next);
        prevButton.addEventListener('click', prev);
        playButton.addEventListener('click', startPlaying);
        pauseButton.addEventListener('click', stopPlaying);

        startPlaying();

        output.start = startPlaying;
        output.pause = stopPlaying;
        output.prev = prev;
        output.next = next;
        return output;
      }
      

In the @guidUtils section:


      /*
        note - not a true guid. I prepend 'g' because 
        the ID of an element cannot start with a numeral
      */

      function generateGuid() {
        var S4 = function S4() {
          return ((1 + Math.random()) * 0x10000 | 0).toString(16).substring(1);
        };
        return 'g' + (S4() + S4() + '-' + S4() + '-' + S4() + '-' + S4() + '-' + S4() + S4() + S4());
      }
      

In the @containerUtils section:


      function elementIsChildOfElement(child, potentialParent) {
        while (child) {
          if (child === potentialParent) {
            return true;
          }

          child = child.parentNode;
        }

        return false;
      }

      function createFieldset(label) {
        var fieldset = document.createElement('fieldset');
        var legend = document.createElement('legend');
        legend.classList.add('legend'); // for easy lookup regardless of mode
        legend.id = (0, _guidUtils.generateGuid)();
        legend.innerText = label;
        fieldset.appendChild(legend);
        return fieldset;
      }

      function createLiveRegion() {
        var level = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 'polite';
        var classes = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : [];

        var output = document.createElement('span');
        classes.forEach(function (c) {
          return output.classList.add(c);
        });
        output.id = (0, _guidUtils.generateGuid)();
        output.setAttribute('aria-live', level);
        output.classList.add('deque-visuallyhidden');
        output.innerText = '';
        output.notify = function (text) {
          // TODO: Clean this up...no need to extend the element prototype
          while (output.firstChild) {
            output.removeChild(output.firstChild);
          }
          var msg = document.createElement('div');
          msg.innerHTML = text;
          output.appendChild(msg);
        };

        return output;
      }
      

In the @keyboardUtils section:


      var KEYS = exports.KEYS = {
        BACKSPACE: 8,
        TAB: 9,
        ENTER: 13,
        SHIFT: 16,
        CTRL: 17,
        ALT: 18,
        ESCAPE: 27,
        SPACE: 32,
        LEFT: 37,
        RIGHT: 39,
        UP: 38,
        DOWN: 40,
        F10: 121,
        HOME: 36,
        END: 35,
        PAGE_UP: 33,
        PAGE_DOWN: 34
      };

      function bindElementToEventValue(element, eventName, testValue, handler) {
        function localHandler(e) {
          if (e.which === testValue) {
            handler(e);
          }
        }

        element.addEventListener(eventName, localHandler);
        return function () {
          element.removeEventListener(eventName, localHandler);
        };
      }

      function bindElementToKeypressValue(element, testValue, handler) {
        return bindElementToEventValue(element, 'keypress', testValue, handler);
      }

      function bindElementToKeydownValue(element, testValue, handler) {
        return bindElementToEventValue(element, 'keydown', testValue, handler);
      }

      function onElementEnter(element, handler) {
        return bindElementToKeydownValue(element, KEYS.ENTER, handler);
      }

      function onElementEscape(element, handler) {
        return bindElementToKeydownValue(element, KEYS.ESCAPE, handler);
      }

      function onElementSpace(element, handler) {
        return bindElementToKeypressValue(element, KEYS.SPACE, handler);
      }

      function onElementLeft(element, handler) {
        return bindElementToKeydownValue(element, KEYS.LEFT, handler);
      }

      function onElementRight(element, handler) {
        return bindElementToKeydownValue(element, KEYS.RIGHT, handler);
      }

      function onElementUp(element, handler) {
        return bindElementToKeydownValue(element, KEYS.UP, handler);
      }

      function onElementDown(element, handler) {
        return bindElementToKeydownValue(element, KEYS.DOWN, handler);
      }

      function onElementHome(element, handler) {
        return bindElementToKeydownValue(element, KEYS.HOME, handler);
      }

      function onElementEnd(element, handler) {
        return bindElementToKeydownValue(element, KEYS.END, handler);
      }

      function onElementPageUp(element, handler) {
        return bindElementToKeydownValue(element, KEYS.PAGE_UP, handler);
      }

      function onElementPageDown(element, handler) {
        return bindElementToKeydownValue(element, KEYS.PAGE_DOWN, handler);
      }

      function onElementF10(element, handler) {
        return bindElementToKeydownValue(element, KEYS.F10, handler);
      }

      function isAlphaNumeric(charCode) {
        return charCode >= 48 && charCode <= 90 /* numbers, uppercase letters */
          || charCode >= 97 && charCode <= 122 /* lowercase letters */;
      }

      function onElementCharacter(element, handler) {
        function localHandler(e) {
          var charCode = e.which;
          if (isAlphaNumeric(charCode)) {
            handler(e);
          }
        }

        element.addEventListener('keypress', localHandler);
        return function () {
          element.removeEventListener('keypress', localHandler);
        };
      }

      function trapEnter(element) {
        onElementEnter(element, function (e) {
          e.stopPropagation();
          e.preventDefault();
        });
      }
      

Note: No additional JavaScript initialization code is necessary for this pattern. All elements with class="deque-tabpanel" will be initialized automatically by the external JavaScript file.

CSS

Required: The complete CSS file (for all patterns in the library): deque-patterns.min.css

Key styles within the CSS file (other styles may also be necessary):


.deque-tabpanel [role='tablist'] {
  list-style-type: none;
  padding: 0;
  display: flex;
  margin: 0 0 20px 0;
  justify-content: center;
}
.deque-tabpanel img {
  max-width: 100%;
}
.deque-tabpanel ul.deque-tabpanel-tablist[role='tablist'] li {
  font-size: 20px;
  display: inline-block;
  flex-shrink: 0;
  font-weight: 200;
  white-space: nowrap;
  padding: 5px 12px;
  cursor: pointer;
}
.deque-tabpanel ul.deque-tabpanel-tablist[role='tablist'] li:focus {
  outline: 1px dashed #000000;
}
.deque-tabpanel li.deque-tabpanel-tab[role='tab'][aria-selected='true'] {
  color: #000000;
  border-bottom: 2px solid;
  border-top: 2px solid;
}
.deque-tabpanel .deque-tabpanel-tabpanels {
  clear: both;
  padding: 0;
  margin: 0;
}
.deque-tabpanel .deque-tabpanel-tabpanel[role='tabpanel'] {
  width: 100%;
}
.deque-tabpanel .deque-tabpanel-button-bar {
  text-align: center;
}
.deque-tabpanel .deque-tabpanel-button-bar .pause,
.deque-tabpanel .deque-tabpanel-button-bar .play {
  margin: 0 15px;
}
@media screen and (max-width: 320px) {
  .deque-tabpanel-tablist {
    display: block !important;
  }
  .deque-tabpanel-tab {
    width: 100% !important;
    box-sizing: border-box;
  }
}

Implementation Instructions

Implement the Tabpanel pattern when you want to provide multiple tabpanels of information (for example, either Landscape, Portrait, or Square) that are focusable via the Tab key and navigable via the arrow keys.

Step 1: Add Dependencies

Add deque-patterns.min.css in the <head> of the document.

<link rel="stylesheet" type="text/css" href="deque-patterns.min.css">

Add a script link to deque-patterns.min.js to the bottom of the page.

<script type="text/javascript" src="deque-patterns.min.js"></script>

Step 2: Add HTML

  • Create a <div> or <span> container with class="deque-tabpanel". If you would like the tabs to automatically cycle through add id="carousel".
    • Make a <ul> container with class="deque-tabpanel-tablist" and role="tablist".
      • List the tabs you want, each within a <li> container. Have them include class="deque-tabpanel-tab", role="tab", a unique id, and a unique aria-controls attribute. The inner text will be the title of the tab.
    • Create a <div> or <span> container with class="deque-tabpanel-tabpanels".
      • List the panels that correspond with the tabs with a <div> or <span> container. These panels are the content that will be displayed when the tab is selected. Each needs to have class="deque-tabpanel-tabpanel" and role="tabpanel". The panel's id is the same as aria-controls of the corresponding tab and there should be an aria-labelledby attribute that is the same as the corresponding tab's id. Within this <div> is the content. That could include text or an <img> container.
    • If your tabpanel is a carousel you can add buttons to control it. Initially, it's in the 'play' state, and the control contains three buttons: "next", "pause" and "previous". If you click pause, it enters the "pause" state and the buttons are "next", "play" and "previous".
    • To create the buttons make a <div> or <span> container with class="deque-tabpanel-button-bar".
      • Each button needs a <button> container. Add class="deque-button" for styling purposes and an id ("prevButton", "playButton", "pauseButton", or "nextButton"). The inner text is the words that appear on the button itself.