The Anatomy of a Tablist Component in Vanilla JavaScript Versus React

Avatar of Nathan Smith
Nathan Smith on (Updated on )

If you follow the undercurrent of the JavaScript community, there seems to be a divide as of late. It goes back over a decade. Really, this sort of strife has always been. Perhaps it is human nature.

Whenever a popular framework gains traction, you inevitably see people comparing it to rivals. I suppose that is to be expected. Everyone has a particular favorite.

Lately, the framework everyone loves (to hate?) is React. You often see it pitted against others in head-to-head blog posts and feature comparison matrices of enterprise whitepapers. Yet a few years ago, it seemed like jQuery would forever be king of the hill.

Frameworks come and go. To me, what is more interesting is when React — or any JS framework for that matter — gets pitted against the programming language itself. Because of course, under the hood, it is all built atop JS.

The two are not inherently at odds. I would even go so far as to say that if you do not have a good handle on JS fundamentals, you probably are not going to reap the full benefits of using React. It can still be helpful, similar to using a jQuery plugin without understanding its internals. But I feel like React presupposes more JS familiarity.

HTML is equally important. There exists a fair bit of FUD around how React affects accessibility. I think this narrative is inaccurate. In fact, the ESLint JSX a11y plugin will warn of possible accessibility violations in the console.

Console warnings from eslint-jsx-a11y-plugin
ESLint warnings about empty <a> tags

Recently, an annual study of the top 1 million sites was released. It shows that for sites using JS frameworks, there is an increased likelihood of accessibility problems. This is correlation, not causation.

This does not necessarily mean that the frameworks caused these errors, but it does indicate that home pages with these frameworks had more errors than on average.

In a manner of speaking, React’s magic incantations work regardless of whether you recognize the words. Ultimately, you are still responsible for the outcome.

Philosophical musings aside, I am a firm believer in choosing the best tool for the job. Sometimes, that means building a single page app with a Jamstack approach. Or maybe a particular project is better suited to offloading HTML rendering to the server, where it has historically been handled.

Either way, there inevitably comes the need for JS to augment the user experience. At Reaktiv Studios, to that end I have been attempting to keep most of our React components in sync with our “flat HTML” approach. I have been writing commonly used functionality in vanilla JS as well. This keeps our options open, so that our clients are free to choose. It also allows us to reuse the same CSS.

If I may, I would like to share how I built our <Tabs> and <Accordion> React components. I will also demonstrate how I wrote the same functionality without using a framework.

Hopefully, this lesson will feel like we are making a layered cake. Let us first start with the base markup, then cover the vanilla JS, and finish with how it works in React.

For reference, you can tinker with our live examples:

Reaktiv Studios UI components

Flat HTML examples

Since we need JavaScript to make interactive widgets either way, I figured the easiest approach — from a server side implementation standpoint — would be to require only the bare minimum HTML. The rest can be augmented with JS.

The following are examples of markup for tabs and accordion components, showing a before/after comparison of how JS affects the DOM.

I have added id="TABS_ID" and id="ACCORDION_ID" for demonstrative purposes. This is to make it more obvious what is happening. But the JS that I will be explaining automatically generates unique IDs if nothing is supplied in the HTML. It would work fine either way, with or without an id specified.

<div class="tabs" id="TABS_ID">
  <ul class="tabs__list">
    <li class="tabs__item">
      Tab 1
    </li>
    <!-- .tabs__item -->

    <li class="tabs__item">
      Tab 2
    </li>
    <!-- .tabs__item -->
  </ul>
  <!-- .tabs__list -->

  <div class="tabs__panel">
    <p>
      Tab 1 content
    </p>
  </div>
  <!-- .tabs__panel -->

  <div class="tabs__panel">
    <p>
      Tab 2 content
    </p>
  </div>
  <!-- .tabs__panel -->
</div>
<!-- .tabs -->

Tabs (with ARIA)

<div class="tabs" id="TABS_ID">
  <ul class="tabs__list" role="tablist">
    <li
      aria-controls="tabpanel_TABS_ID_0"
      aria-selected="false"
      class="tabs__item"
      id="tab_TABS_ID_0"
      role="tab"
      tabindex="0"
    >
      Tab 1
    </li>
    <!-- .tabs__item -->

    <li
      aria-controls="tabpanel_TABS_ID_1"
      aria-selected="true"
      class="tabs__item"
      id="tab_TABS_ID_1"
      role="tab"
      tabindex="0"
    >
      Tab 2
    </li>
    <!-- .tabs__item -->
  </ul>
  <!-- .tabs__list -->

  <div
    aria-hidden="true"
    aria-labelledby="tab_TABS_ID_0"
    class="tabs__panel"
    id="tabpanel_TABS_ID_0"
    role="tabpanel"
  >
    <p>
      Tab 1 content
    </p>
  </div>
  <!-- .tabs__panel -->

  <div
    aria-hidden="false"
    aria-labelledby="tab_TABS_ID_1"
    class="tabs__panel"
    id="tabpanel_TABS_ID_1"
    role="tabpanel"
  >
    <p>
      Tab 2 content
    </p>
  </div>
  <!-- .tabs__panel -->
</div>
<!-- .tabs -->

Accordion (without ARIA)

<div class="accordion" id="ACCORDION_ID">
  <div class="accordion__item">
    Tab 1
  </div>
  <!-- .accordion__item -->

  <div class="accordion__panel">
    <p>
      Tab 1 content
    </p>
  </div>
  <!-- .accordion__panel -->

  <div class="accordion__item">
    Tab 2
  </div>
  <!-- .accordion__item -->

  <div class="accordion__panel">
    <p>
      Tab 2 content
    </p>
  </div>
  <!-- .accordion__panel -->
</div>
<!-- .accordion -->

Accordion (with ARIA)

<div
  aria-multiselectable="true"
  class="accordion"
  id="ACCORDION_ID"
  role="tablist"
>
  <div
    aria-controls="tabpanel_ACCORDION_ID_0"
    aria-selected="true"
    class="accordion__item"
    id="tab_ACCORDION_ID_0"
    role="tab"
    tabindex="0"
  >
    <i aria-hidden="true" class="accordion__item__icon"></i>
    Tab 1
  </div>
  <!-- .accordion__item -->

  <div
    aria-hidden="false"
    aria-labelledby="tab_ACCORDION_ID_0"
    class="accordion__panel"
    id="tabpanel_ACCORDION_ID_0"
    role="tabpanel"
  >
    <p>
      Tab 1 content
    </p>
  </div>
  <!-- .accordion__panel -->

  <div
    aria-controls="tabpanel_ACCORDION_ID_1"
    aria-selected="false"
    class="accordion__item"
    id="tab_ACCORDION_ID_1"
    role="tab"
    tabindex="0"
  >
    <i aria-hidden="true" class="accordion__item__icon"></i>
    Tab 2
  </div>
  <!-- .accordion__item -->

  <div
    aria-hidden="true"
    aria-labelledby="tab_ACCORDION_ID_1"
    class="accordion__panel"
    id="tabpanel_ACCORDION_ID_1"
    role="tabpanel"
  >
    <p>
      Tab 2 content
    </p>
  </div>
  <!-- .accordion__panel -->
</div>
<!-- .accordion -->

Vanilla JavaScript examples

Okay. Now that we have seen the aforementioned HTML examples, let us walk through how we get from before to after.

First, I want to cover a few helper functions. These will make more sense in a bit. I figure it is best to get them documented first, so we can stay focused on the rest of the code once we dive in further.

File: getDomFallback.js

This function provides common DOM properties and methods as no-op, rather than having to make lots of typeof foo.getAttribute checks and whatnot. We could forego those types of confirmations altogether.

Since live HTML changes can be a potentially volatile environment, I always feel a bit safer making sure my JS is not bombing out and taking the rest of the page with it. Here is what that function looks like. It simply returns an object with the DOM equivalents of falsy results.

/*
  Helper to mock DOM methods, for
  when an element might not exist.
*/
const getDomFallback = () => {
  return {
    // Props.
    children: [],
    className: '',
    classList: {
      contains: () => false,
    },
    id: '',
    innerHTML: '',
    name: '',
    nextSibling: null,
    previousSibling: null,
    outerHTML: '',
    tagName: '',
    textContent: '',

    // Methods.
    appendChild: () => Object.create(null),
    blur: () => undefined,
    click: () => undefined,
    cloneNode: () => Object.create(null),
    closest: () => null,
    createElement: () => Object.create(null),
    focus: () => undefined,
    getAttribute: () => null,
    hasAttribute: () => false,
    insertAdjacentElement: () => Object.create(null),
    insertBefore: () => Object.create(null),
    querySelector: () => null,
    querySelectorAll: () => [],
    removeAttribute: () => undefined,
    removeChild: () => Object.create(null),
    replaceChild: () => Object.create(null),
    setAttribute: () => undefined,
  };
};

// Export.
export { getDomFallback };

File: unique.js

This function is a poor man’s UUID equivalent.

It generates a unique string that can be used to associate DOM elements with one another. It is handy, because then the author of an HTML page does not have to ensure that every tabs and accordion component have unique IDs. In the previous HTML examples, this is where TABS_ID and ACCORDION_ID would typically contain the randomly generated numeric strings instead.

// ==========
// Constants.
// ==========

const BEFORE = '0.';
const AFTER = '';

// ==================
// Get unique string.
// ==================

const unique = () => {
  // Get prefix.
  let prefix = Math.random();
  prefix = String(prefix);
  prefix = prefix.replace(BEFORE, AFTER);

  // Get suffix.
  let suffix = Math.random();
  suffix = String(suffix);
  suffix = suffix.replace(BEFORE, AFTER);

  // Expose string.
  return `${prefix}_${suffix}`;
};

// Export.
export { unique };

On larger JavaScript projects, I would typically use npm install uuid. But since we are keeping this simple and do not require cryptographic parity, concatenating two lightly edited Math.random() numbers will suffice for our string uniqueness needs.

File: tablist.js

This file does the bulk of the work. What is cool about it, if I do say so myself, is that there are enough similarities between a tabs component and an accordion that we can handle both with the same *.js file. Go ahead and scroll through the entirety, and then we will break down what each function does individually.

// Helpers.
import { getDomFallback } from './getDomFallback';
import { unique } from './unique';

// ==========
// Constants.
// ==========

// Boolean strings.
const TRUE = 'true';
const FALSE = 'false';

// ARIA strings.
const ARIA_CONTROLS = 'aria-controls';
const ARIA_LABELLEDBY = 'aria-labelledby';
const ARIA_HIDDEN = 'aria-hidden';
const ARIA_MULTISELECTABLE = 'aria-multiselectable';
const ARIA_ORIENTATION = 'aria-orientation';
const ARIA_SELECTED = 'aria-selected';

// Attribute strings.
const DATA_INDEX = 'data-index';
const HORIZONTAL = 'horizontal';
const ID = 'id';
const ROLE = 'role';
const TABINDEX = 'tabindex';
const TABLIST = 'tablist';
const VERTICAL = 'vertical';

// Event strings.
const AFTER_BEGIN = 'afterbegin';
const ARROW_LEFT = 'arrowleft';
const ARROW_RIGHT = 'arrowright';
const CLICK = 'click';
const KEYDOWN = 'keydown';

// Key strings.
const ENTER = 'enter';
const FUNCTION = 'function';
const SPACE = ' ';

// Tag strings.
const I = 'i';
const LI = 'li';

// Selector strings.
const ACCORDION_ITEM_ICON = 'accordion__item__icon';
const ACCORDION_ITEM_ICON_SELECTOR = `.${ACCORDION_ITEM_ICON}`;

const TAB = 'tab';
const TAB_SELECTOR = `[${ROLE}=${TAB}]`;

const TABPANEL = 'tabpanel';
const TABPANEL_SELECTOR = `[${ROLE}=${TABPANEL}]`;

const ACCORDION = 'accordion';
const TABLIST_CLASS_SELECTOR = '.accordion, .tabs';
const TAB_CLASS_SELECTOR = '.accordion__item, .tabs__item';
const TABPANEL_CLASS_SELECTOR = '.accordion__panel, .tabs__panel';

// ===========
// Get tab ID.
// ===========

const getTabId = (id = '', index = 0) => {
  return `${TAB}_${id}_${index}`;
};

// =============
// Get panel ID.
// =============

const getPanelId = (id = '', index = 0) => {
  return `${TABPANEL}_${id}_${index}`;
};

// ==============
// Click handler.
// ==============

const globalClick = (event = {}) => {
  // Get target.
  const { target = getDomFallback() } = event;

  // Get key.
  let { key = '' } = event;
  key = key.toLowerCase();

  // Key events.
  const isArrowLeft = key === ARROW_LEFT;
  const isArrowRight = key === ARROW_RIGHT;
  const isArrowKey = isArrowLeft || isArrowRight;
  const isTriggerKey = key === ENTER || key === SPACE;

  // Get parent.
  const { parentNode = getDomFallback(), tagName = '' } = target;

  // Set later.
  let wrapper = getDomFallback();

  /*
    =====
    NOTE:
    =====

    We test for this, because the method does
    not exist on `document.documentElement`.
  */
  if (typeof target.closest === FUNCTION) {
    // Get wrapper.
    wrapper = target.closest(TABLIST_CLASS_SELECTOR) || getDomFallback();
  }

  // Is multi?
  const isMulti = wrapper.getAttribute(ARIA_MULTISELECTABLE) === TRUE;

  // Valid target?
  const isValidTarget =
    target.getAttribute(ROLE) === TAB && parentNode.getAttribute(ROLE) === TABLIST;

  // Is `<li>`?
  const isListItem = isValidTarget && tagName.toLowerCase() === LI;

  // Valid event?
  const isArrowEvent = isListItem && isArrowKey;
  const isTriggerEvent = isValidTarget && (!key || isTriggerKey);
  const isValidEvent = isArrowEvent || isTriggerEvent;

  // Prevent default.
  if (isValidEvent) {
    event.preventDefault();
  }

  // ============
  // Arrow event?
  // ============

  if (isArrowEvent) {
    // Get index.
    let index = target.getAttribute(DATA_INDEX);
    index = parseFloat(index);

    // Get list.
    const list = wrapper.querySelectorAll(TAB_SELECTOR);

    // Set later.
    let newIndex = null;
    let nextItem = null;

    // Arrow left?
    if (isArrowLeft) {
      newIndex = index - 1;
      nextItem = list[newIndex];

      if (!nextItem) {
        newIndex = list.length - 1;
        nextItem = list[newIndex];
      }
    }

    // Arrow right?
    if (isArrowRight) {
      newIndex = index + 1;
      nextItem = list[newIndex];

      if (!nextItem) {
        newIndex = 0;
        nextItem = list[newIndex];
      }
    }

    // Fallback?
    nextItem = nextItem || getDomFallback();

    // Focus new item.
    nextItem.click();
    nextItem.focus();
  }

  // ==============
  // Trigger event?
  // ==============

  if (isTriggerEvent) {
    // Get panel.
    const panelId = target.getAttribute(ARIA_CONTROLS);
    const panel = wrapper.querySelector(`#${panelId}`) || getDomFallback();

    // Get booleans.
    let boolPanel = panel.getAttribute(ARIA_HIDDEN) !== TRUE;
    let boolTab = target.getAttribute(ARIA_SELECTED) !== TRUE;

    // List item?
    if (isListItem) {
      boolPanel = FALSE;
      boolTab = TRUE;
    }

    // [aria-multiselectable="false"]
    if (!isMulti) {
      // Get tabs & panels.
      const childTabs = wrapper.querySelectorAll(TAB_SELECTOR);
      const childPanels = wrapper.querySelectorAll(TABPANEL_SELECTOR);

      // Loop through tabs.
      childTabs.forEach((tab = getDomFallback()) => {
        tab.setAttribute(ARIA_SELECTED, FALSE);

        // li[tabindex="-1"]
        if (isListItem) {
          tab.setAttribute(TABINDEX, -1);
        }
      });

      // Loop through panels.
      childPanels.forEach((panel = getDomFallback()) => {
        panel.setAttribute(ARIA_HIDDEN, TRUE);
      });
    }

    // Set individual tab.
    target.setAttribute(ARIA_SELECTED, boolTab);

    // li[tabindex="0"]
    if (isListItem) {
      target.setAttribute(TABINDEX, 0);
    }

    // Set individual panel.
    panel.setAttribute(ARIA_HIDDEN, boolPanel);
  }
};

// ====================
// Add ARIA attributes.
// ====================

const addAriaAttributes = () => {
  // Get elements.
  const allWrappers = document.querySelectorAll(TABLIST_CLASS_SELECTOR);

  // Loop through.
  allWrappers.forEach((wrapper = getDomFallback()) => {
    // Get attributes.
    const { id = '', classList } = wrapper;
    const parentId = id || unique();

    // Is accordion?
    const isAccordion = classList.contains(ACCORDION);

    // Get tabs & panels.
    const childTabs = wrapper.querySelectorAll(TAB_CLASS_SELECTOR);
    const childPanels = wrapper.querySelectorAll(TABPANEL_CLASS_SELECTOR);

    // Add ID?
    if (!wrapper.getAttribute(ID)) {
      wrapper.setAttribute(ID, parentId);
    }

    // [aria-multiselectable="true"]
    if (isAccordion && wrapper.getAttribute(ARIA_MULTISELECTABLE) !== FALSE) {
      wrapper.setAttribute(ARIA_MULTISELECTABLE, TRUE);
    }

    // ===========================
    // Loop through tabs & panels.
    // ===========================

    for (let index = 0; index < childTabs.length; index++) {
      // Get elements.
      const tab = childTabs[index] || getDomFallback();
      const panel = childPanels[index] || getDomFallback();

      // Get IDs.
      const tabId = getTabId(parentId, index);
      const panelId = getPanelId(parentId, index);

      // ===================
      // Add tab attributes.
      // ===================

      // Tab: add icon?
      if (isAccordion) {
        // Get icon.
        let icon = tab.querySelector(ACCORDION_ITEM_ICON_SELECTOR);

        // Create icon?
        if (!icon) {
          icon = document.createElement(I);
          icon.className = ACCORDION_ITEM_ICON;
          tab.insertAdjacentElement(AFTER_BEGIN, icon);
        }

        // [aria-hidden="true"]
        icon.setAttribute(ARIA_HIDDEN, TRUE);
      }

      // Tab: add id?
      if (!tab.getAttribute(ID)) {
        tab.setAttribute(ID, tabId);
      }

      // Tab: add controls?
      if (!tab.getAttribute(ARIA_CONTROLS)) {
        tab.setAttribute(ARIA_CONTROLS, panelId);
      }

      // Tab: add selected?
      if (!tab.getAttribute(ARIA_SELECTED)) {
        const bool = !isAccordion && index === 0;

        tab.setAttribute(ARIA_SELECTED, bool);
      }

      // Tab: add role?
      if (tab.getAttribute(ROLE) !== TAB) {
        tab.setAttribute(ROLE, TAB);
      }

      // Tab: add data index?
      if (!tab.getAttribute(DATA_INDEX)) {
        tab.setAttribute(DATA_INDEX, index);
      }

      // Tab: add tabindex?
      if (!tab.getAttribute(TABINDEX)) {
        if (isAccordion) {
          tab.setAttribute(TABINDEX, 0);
        } else {
          tab.setAttribute(TABINDEX, index === 0 ? 0 : -1);
        }
      }

      // Tab: first item?
      if (index === 0) {
        // Get parent.
        const { parentNode = getDomFallback() } = tab;

        /*
          We do this here, instead of outside the loop.

          The top level item isn't always the `tablist`.

          The accordion UI only has `<div>`, whereas
          the tabs UI has both `<div>` and `<ul>`.
        */
        if (parentNode.getAttribute(ROLE) !== TABLIST) {
          parentNode.setAttribute(ROLE, TABLIST);
        }

        // Accordion?
        if (isAccordion) {
          // [aria-orientation="vertical"]
          if (parentNode.getAttribute(ARIA_ORIENTATION) !== VERTICAL) {
            parentNode.setAttribute(ARIA_ORIENTATION, VERTICAL);
          }

          // Tabs?
        } else {
          // [aria-orientation="horizontal"]
          if (parentNode.getAttribute(ARIA_ORIENTATION) !== HORIZONTAL) {
            parentNode.setAttribute(ARIA_ORIENTATION, HORIZONTAL);
          }
        }
      }

      // =====================
      // Add panel attributes.
      // =====================

      // Panel: add ID?
      if (!panel.getAttribute(ID)) {
        panel.setAttribute(ID, panelId);
      }

      // Panel: add hidden?
      if (!panel.getAttribute(ARIA_HIDDEN)) {
        const bool = isAccordion || index !== 0;

        panel.setAttribute(ARIA_HIDDEN, bool);
      }

      // Panel: add labelled?
      if (!panel.getAttribute(ARIA_LABELLEDBY)) {
        panel.setAttribute(ARIA_LABELLEDBY, tabId);
      }

      // Panel: add role?
      if (panel.getAttribute(ROLE) !== TABPANEL) {
        panel.setAttribute(ROLE, TABPANEL);
      }

      // Panel: add tabindex?
      if (!panel.getAttribute(TABINDEX)) {
        panel.setAttribute(TABINDEX, 0);
      }
    }
  });
};

// =====================
// Remove global events.
// =====================

const unbind = () => {
  document.removeEventListener(CLICK, globalClick);
  document.removeEventListener(KEYDOWN, globalClick);
};

// ==================
// Add global events.
// ==================

const init = () => {
  // Add attributes.
  addAriaAttributes();

  // Prevent doubles.
  unbind();

  document.addEventListener(CLICK, globalClick);
  document.addEventListener(KEYDOWN, globalClick);
};

// ==============
// Bundle object.
// ==============

const tablist = {
  init,
  unbind,
};

// =======
// Export.
// =======

export { tablist };

Function: getTabId and getPanelId

These two functions are used to create individually unique IDs for elements in a loop, based on an existing (or generated) parent ID. This is helpful to ensure matching values for attributes like aria-controls="…" and aria-labelledby="…". Think of those as the accessibility equivalents of <label for="…">, telling the browser which elements are related to one another.

const getTabId = (id = '', index = 0) => {
  return `${TAB}_${id}_${index}`;
};
const getPanelId = (id = '', index = 0) => {
  return `${TABPANEL}_${id}_${index}`;
};

Function: globalClick

This is a click handler that is applied at the document level. That means we are not having to manually add click handlers to a number of elements. Instead, we use event bubbling to listen for clicks further down in the document, and allow them to propagate up to the top.

Conveniently, this is also how we can handle keyboard events such as the ArrowLeft, ArrowRight, Enter (or spacebar) keys being pressed. These are necessary to have an accessible UI.

In the first part of the function, we destructure target and key from the incoming event. Next, we destructure the parentNode and tagName from the target.

Then, we attempt to get the wrapper element. This would be the one with either class="tabs" or class="accordion". Because we might actually be clicking on the ancestor element highest in the DOM tree — which exists but possibly does not have the *.closest(…) method — we do a typeof check. If that function exists, we attempt to get the element. Even still, we might come up without a match. So we have one more getDomFallback to be safe.

// Get target.
const { target = getDomFallback() } = event;

// Get key.
let { key = '' } = event;
key = key.toLowerCase();

// Key events.
const isArrowLeft = key === ARROW_LEFT;
const isArrowRight = key === ARROW_RIGHT;
const isArrowKey = isArrowLeft || isArrowRight;
const isTriggerKey = key === ENTER || key === SPACE;

// Get parent.
const { parentNode = getDomFallback(), tagName = '' } = target;

// Set later.
let wrapper = getDomFallback();

/*
  =====
  NOTE:
  =====

  We test for this, because the method does
  not exist on `document.documentElement`.
*/
if (typeof target.closest === FUNCTION) {
  // Get wrapper.
  wrapper = target.closest(TABLIST_CLASS_SELECTOR) || getDomFallback();
}

Then, we store a boolean about whether the wrapper element has aria-multiselectable="true". I will get back to that. Likewise, we store whether or not the tag that was clicked is a <li>. We need this info later on.

We also determine if the click happened on a relevant target. Remember, we are using event bubbling so really the user could have clicked anything. We also interrogate the event a bit, to determine if it was triggered by the user pressing a key. If so, then we determine if the key is relevant.

We want to make sure it:

  • Has role="tab"
  • Has a parent element with role="tablist"

Then we bundle up our other booleans into two categories, isArrowEvent and isTriggerEvent. Which in turn are further combined into isValidEvent.

// Is multi?
const isMulti = wrapper.getAttribute(ARIA_MULTISELECTABLE) === TRUE;

// Valid target?
const isValidTarget =
  target.getAttribute(ROLE) === TAB && parentNode.getAttribute(ROLE) === TABLIST;

// Is `<li>`?
const isListItem = isValidTarget && tagName.toLowerCase() === LI;

// Valid event?
const isArrowEvent = isListItem && isArrowKey;
const isTriggerEvent = isValidTarget && (!key || isTriggerKey);
const isValidEvent = isArrowEvent || isTriggerEvent;

// Prevent default.
if (isValidEvent) {
  event.preventDefault();
}

We then enter an if conditional that checks if either the left or right arrow keys were pressed. If so, then we want to change the focus to the corresponding adjacent tab. If we are already at the beginning of our list, we will jump to the end. Or if we area already at the end, we will jump to the beginning.

By triggering the click event, that causes this same function to be executed again. It is then evaluated as being a trigger event. This is covered in the next block.

if (isArrowEvent) {
  // Get index.
  let index = target.getAttribute(DATA_INDEX);
  index = parseFloat(index);

  // Get list.
  const list = wrapper.querySelectorAll(TAB_SELECTOR);

  // Set later.
  let newIndex = null;
  let nextItem = null;

  // Arrow left?
  if (isArrowLeft) {
    newIndex = index - 1;
    nextItem = list[newIndex];

    if (!nextItem) {
      newIndex = list.length - 1;
      nextItem = list[newIndex];
    }
  }

  // Arrow right?
  if (isArrowRight) {
    newIndex = index + 1;
    nextItem = list[newIndex];

    if (!nextItem) {
      newIndex = 0;
      nextItem = list[newIndex];
    }
  }

  // Fallback?
  nextItem = nextItem || getDomFallback();

  // Focus new item.
  nextItem.click();
  nextItem.focus();
}

Assuming the trigger event is indeed valid, we make it past our next if check. Now, we are concerned with getting the role="tabpanel" element with an id that matches our tab’s aria-controls="…".

Once we have got it, we check whether the panel is hidden, and if the tab is selected. Basically, we first presuppose that we are dealing with an accordion and flip the booleans to their opposites.

This is also where our earlier isListItem boolean comes into play. If the user is clicking an <li> then we know we are dealing with tabs, not an accordion. In which case, we want to flag our panel as being visible (via aria-hiddden="false") and our tab as being selected (via aria-selected="true").

Also, we want to ensure that either the wrapper has aria-multiselectable="false" or is completely missing aria-multiselectable. If that is the case, then we loop through all neighboring role="tab" and all role="tabpanel" elements and set them to their inactive states. Finally, we arrive at setting the previously determined booleans for the individual tab and panel pairing.

if (isTriggerEvent) {
  // Get panel.
  const panelId = target.getAttribute(ARIA_CONTROLS);
  const panel = wrapper.querySelector(`#${panelId}`) || getDomFallback();

  // Get booleans.
  let boolPanel = panel.getAttribute(ARIA_HIDDEN) !== TRUE;
  let boolTab = target.getAttribute(ARIA_SELECTED) !== TRUE;

  // List item?
  if (isListItem) {
    boolPanel = FALSE;
    boolTab = TRUE;
  }

  // [aria-multiselectable="false"]
  if (!isMulti) {
    // Get tabs & panels.
    const childTabs = wrapper.querySelectorAll(TAB_SELECTOR);
    const childPanels = wrapper.querySelectorAll(TABPANEL_SELECTOR);

    // Loop through tabs.
    childTabs.forEach((tab = getDomFallback()) => {
      tab.setAttribute(ARIA_SELECTED, FALSE);

      // li[tabindex="-1"]
      if (isListItem) {
        tab.setAttribute(TABINDEX, -1);
      }
    });

    // Loop through panels.
    childPanels.forEach((panel = getDomFallback()) => {
      panel.setAttribute(ARIA_HIDDEN, TRUE);
    });
  }

  // Set individual tab.
  target.setAttribute(ARIA_SELECTED, boolTab);

  // li[tabindex="0"]
  if (isListItem) {
    target.setAttribute(TABINDEX, 0);
  }

  // Set individual panel.
  panel.setAttribute(ARIA_HIDDEN, boolPanel);
}

Function: addAriaAttributes

The astute reader might be thinking:

You said earlier that we start with the most bare possible markup, yet the globalClick function was looking for attributes that would not be there. Why would you lie!?

Or perhaps not, for the astute reader would have also noticed the function named addAriaAttributes. Indeed, this function does exactly what it says on the tin. It breathes life into the base DOM structure, by adding all the requisite aria-* and role attributes.

This not only makes the UI inherently more accessible to assistive technologies, but it also ensures the functionality actually works. I prefer to build vanilla JS things this way, rather than pivoting on class="…" for interactivity, because it forces me to think about the entirety of the user experience, beyond what I can see visually.

First off, we get all elements on the page that have class="tabs" and/or class="accordion". Then we check if we have something to work with. If not, then we would exit our function here. Assuming we do have a list, we loop through each of the wrapping elements and pass them into the scope of our function as wrapper.

// Get elements.
const allWrappers = document.querySelectorAll(TABLIST_CLASS_SELECTOR);

// Loop through.
allWrappers.forEach((wrapper = getDomFallback()) => {
  /*
    NOTE: Cut, for brevity.
  */
});

Inside the scope of our looping function, we destructure id and classList from wrapper. If there is no ID, then we generate one via unique(). We set a boolean flag, to identify if we are working with an accordion. This is used later.

We also get decendants of wrapper that are tabs and panels, via their class name selectors.

Tabs:

  • class="tabs__item" or
  • class="accordion__item"

Panels:

  • class="tabs__panel" or
  • class="accordion__panel"

We then set the wrapper’s id if it does not already have one.

If we are dealing with an accordion that lacks aria-multiselectable="false", we set its flag to true. Reason being, if developers are reaching for an accordion UI paradigm — and also have tabs available to them, which are inherently mutually exclusive — then the safer assumption is that the accordion should support expanding and collapsing of several panels.

// Get attributes.
const { id = '', classList } = wrapper;
const parentId = id || unique();

// Is accordion?
const isAccordion = classList.contains(ACCORDION);

// Get tabs & panels.
const childTabs = wrapper.querySelectorAll(TAB_CLASS_SELECTOR);
const childPanels = wrapper.querySelectorAll(TABPANEL_CLASS_SELECTOR);

// Add ID?
if (!wrapper.getAttribute(ID)) {
  wrapper.setAttribute(ID, parentId);
}

// [aria-multiselectable="true"]
if (isAccordion && wrapper.getAttribute(ARIA_MULTISELECTABLE) !== FALSE) {
  wrapper.setAttribute(ARIA_MULTISELECTABLE, TRUE);
}

Next, we loop through tabs. Wherein, we also handle our panels.

You may be wondering why this is an old school for loop, instead of a more modern *.forEach. The reason is that we want to loop through two NodeList instances: tabs and panels. Assuming they each map 1-to-1 we know they both have the same *.length. This allows us to have one loop instead of two.

Let us peer inside of the loop. First, we get unique IDs for each tab and panel. These would look like one of the two following scenarios. These are used later on, to associate tabs with panels and vice versa.

  • tab_WRAPPER_ID_0 or
    tab_GENERATED_STRING_0
  • tabpanel_WRAPPER_ID_0 or
    tabpanel_GENERATED_STRING_0
for (let index = 0; index < childTabs.length; index++) {
  // Get elements.
  const tab = childTabs[index] || getDomFallback();
  const panel = childPanels[index] || getDomFallback();

  // Get IDs.
  const tabId = getTabId(parentId, index);
  const panelId = getPanelId(parentId, index);

  /*
    NOTE: Cut, for brevity.
  */
}

As we loop through, we first ensure that an expand/collapse icon exists. We create it if necessary, and set it to aria-hidden="true" since it is purely decorative.

Next, we check on attributes for the current tab. If an id="…" does not exist on the tab, we add it. Likewise, if aria-controls="…" does not exist we add that as well, pointing to our newly created panelId.

You will notice there is a little pivot here, checking if we do not have aria-selected and then further determining if we are not in the context of an accordion and if the index is 0. In that case, we want to make our first tab look selected. The reason is that though an accordion can be fully collapsed, tabbed content cannot. There is always at least one panel visible.

Then we ensure that role="tab" exists. We store the current index of our loop as data-index="…" in case we need it later for keyboard navigation.

We also add the correct tabindex="0" or possibly tabindex="-1" depending on if what time of item it is. This allows all triggers of an accordion to receive keyboard :focus, versus just the currently active trigger in a tabs layout.

Lastly, we check if we are on the first iteration of our loop where index is 0. If so, we go up one level to the parentNode. If that element does not have role="tablist", then we add it.

We do this via parentNode instead of wrapper because in the context of tabs (not accordion) there is a <ul>element around the tab <li> that needs role="tablist". In the case of an accordion, it would be the outermost <div> ancestor. This code accounts for both.

We also set the correct aria-orientation, depending on the UI type. Accordion is vertical and tabs are horizontal.

// Tab: add icon?
if (isAccordion) {
  // Get icon.
  let icon = tab.querySelector(ACCORDION_ITEM_ICON_SELECTOR);

  // Create icon?
  if (!icon) {
    icon = document.createElement(I);
    icon.className = ACCORDION_ITEM_ICON;
    tab.insertAdjacentElement(AFTER_BEGIN, icon);
  }

  // [aria-hidden="true"]
  icon.setAttribute(ARIA_HIDDEN, TRUE);
}

// Tab: add id?
if (!tab.getAttribute(ID)) {
  tab.setAttribute(ID, tabId);
}

// Tab: add controls?
if (!tab.getAttribute(ARIA_CONTROLS)) {
  tab.setAttribute(ARIA_CONTROLS, panelId);
}

// Tab: add selected?
if (!tab.getAttribute(ARIA_SELECTED)) {
  const bool = !isAccordion && index === 0;

  tab.setAttribute(ARIA_SELECTED, bool);
}

// Tab: add role?
if (tab.getAttribute(ROLE) !== TAB) {
  tab.setAttribute(ROLE, TAB);
}

// Tab: add data index?
if (!tab.getAttribute(DATA_INDEX)) {
  tab.setAttribute(DATA_INDEX, index);
}

// Tab: add tabindex?
if (!tab.getAttribute(TABINDEX)) {
  if (isAccordion) {
    tab.setAttribute(TABINDEX, 0);
  } else {
    tab.setAttribute(TABINDEX, index === 0 ? 0 : -1);
  }
}

// Tab: first item?
if (index === 0) {
  // Get parent.
  const { parentNode = getDomFallback() } = tab;

  /*
    We do this here, instead of outside the loop.

    The top level item isn't always the `tablist`.

    The accordion UI only has `<div>`, whereas
    the tabs UI has both `<div>` and `<ul>`.
  */
  if (parentNode.getAttribute(ROLE) !== TABLIST) {
    parentNode.setAttribute(ROLE, TABLIST);
  }

  // Accordion?
  if (isAccordion) {
    // [aria-orientation="vertical"]
    if (parentNode.getAttribute(ARIA_ORIENTATION) !== VERTICAL) {
      parentNode.setAttribute(ARIA_ORIENTATION, VERTICAL);
    }

    // Tabs?
  } else {
    // [aria-orientation="horizontal"]
    if (parentNode.getAttribute(ARIA_ORIENTATION) !== HORIZONTAL) {
      parentNode.setAttribute(ARIA_ORIENTATION, HORIZONTAL);
    }
  }
}

Continuing within the earlier for loop, we add attributes for each panel. We add an id if needed. We also set aria-hidden to either true or false depending on the context of being an accordion (or not).

Likewise, we ensure that our panel points back to its tab trigger via aria-labelledby="…", and that role="tabpanel" has been set. We also give it tabindex="0" so it can receive :focus.

// Panel: add ID?
if (!panel.getAttribute(ID)) {
  panel.setAttribute(ID, panelId);
}

// Panel: add hidden?
if (!panel.getAttribute(ARIA_HIDDEN)) {
  const bool = isAccordion || index !== 0;

  panel.setAttribute(ARIA_HIDDEN, bool);
}

// Panel: add labelled?
if (!panel.getAttribute(ARIA_LABELLEDBY)) {
  panel.setAttribute(ARIA_LABELLEDBY, tabId);
}

// Panel: add role?
if (panel.getAttribute(ROLE) !== TABPANEL) {
  panel.setAttribute(ROLE, TABPANEL);
}

// Panel: add tabindex?
if (!panel.getAttribute(TABINDEX)) {
  panel.setAttribute(TABINDEX, 0);
}

At the very end of the file, we have a few setup and teardown functions. As a way to play nicely with other JS that might be in the page, we provide an unbind function that removes our global event listeners. It can be called by itself, via tablist.unbind() but is mostly there so that we can unbind() before (re-)binding. That way we prevent doubling up.

Inside our init function, we call addAriaAttributes() which modifies the DOM to be accessible. We then call unbind() and then add our event listeners to the document.

Finally, we bundle both methods into a parent object and export it under the name tablist. That way, when dropping it into a flat HTML page, we can call tablist.init() when we are ready to apply our functionality.

// =====================
// Remove global events.
// =====================

const unbind = () => {
  document.removeEventListener(CLICK, globalClick);
  document.removeEventListener(KEYDOWN, globalClick);
};

// ==================
// Add global events.
// ==================

const init = () => {
  // Add attributes.
  addAriaAttributes();

  // Prevent doubles.
  unbind();

  document.addEventListener(CLICK, globalClick);
  document.addEventListener(KEYDOWN, globalClick);
};

// ==============
// Bundle object.
// ==============

const tablist = {
  init,
  unbind,
};

// =======
// Export.
// =======

export { tablist };

React examples

There is a scene in Batman Begins where Lucius Fox (played by Morgan Freeman) explains to a recovering Bruce Wayne (Christian Bale) the scientific steps he took to save his life after being poisoned.

Lucius Fox: “I analyzed your blood, isolating the receptor compounds and the protein-based catalyst.”

Bruce Wayne: “Am I meant to understand any of that?”

Lucius Fox: “Not at all, I just wanted you to know how hard it was. Bottom line, I synthesized an antidote.”

Morgan Freeman and Christian Bale, sitting inside the Batmobile
“How do I configure Webpack?”

↑ When working with a framework, I think in those terms.

Now that we know “hard” it is — not really, but humor me — to do raw DOM manipulation and event binding, we can better appreciate the existence of an antidote. React abstracts a lot of that complexity away, and handles it for us automatically.

File: Tabs.js

Now that we are diving into React examples, we will start with the <Tabs> component.

// =============
// Used like so…
// =============

<Tabs>
  <div label="Tab 1">
    <p>
      Tab 1 content
    </p>
  </div>
  <div label="Tab 2">
    <p>
      Tab 2 content
    </p>
  </div>
</Tabs>

Here is the content from our Tabs.js file. Note that in React parlance, it is standard practice to name the file with the same capitalization as its export default component.

We start out with the same getTabId and getPanelId functions as in our vanilla JS approach, because we still need to make sure to accessibly map tabs to components. Take a look at the entirey of the code, and then we will continue to break it down.

import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { v4 as uuid } from 'uuid';
import cx from 'classnames';

// Helpers.
import { getDomFallback } from '../utils';

// UI.
import Render from './Render';

// ==========
// Constants.
// ==========

const ARROW_LEFT = 'arrowleft';
const ARROW_RIGHT = 'arrowright';
const ENTER = 'enter';
const HORIZONTAL = 'horizontal';
const SPACE = ' ';
const STRING = 'string';

// Selector strings.
const TAB = 'tab';
const TAB_SELECTOR = `[role="${TAB}"]`;

const TABLIST = 'tablist';
const TABLIST_SELECTOR = `[role="${TABLIST}"]`;

const TABPANEL = 'tabpanel';

// ===========
// Get tab ID.
// ===========

const getTabId = (id = '', index = 0) => {
  return `${TAB}_${id}_${index}`;
};

// =============
// Get panel ID.
// =============

const getPanelId = (id = '', index = 0) => {
  return `${TABPANEL}_${id}_${index}`;
};

// ==========
// Is active?
// ==========

const getIsActive = ({ activeIndex = null, index = null, list = [] }) => {
  // Index matches?
  const isMatch = index === parseFloat(activeIndex);

  // Is first item?
  const isFirst = index === 0;

  // Only first item exists?
  const onlyFirstItem = list.length === 1;

  // Item doesn't exist?
  const badActiveItem = !list[activeIndex];

  // Flag as active?
  const isActive = isMatch || onlyFirstItem || (isFirst && badActiveItem);

  // Expose boolean.
  return !!isActive;
};

getIsActive.propTypes = {
  activeIndex: PropTypes.number,
  index: PropTypes.number,
  list: PropTypes.array,
};

// ===============
// Focus new item.
// ===============

const focusNewItem = (target = getDomFallback(), newIndex = 0) => {
  // Get tablist.
  const tablist = target.closest(TABLIST_SELECTOR) || getDomFallback();

  // Get list items.
  const listItems = tablist.querySelectorAll(TAB_SELECTOR);

  // Get new item.
  const newItem = listItems[newIndex] || getDomFallback();

  // Focus new item.
  newItem.focus();
};

// ================
// Get `<ul>` list.
// ================

const getTabsList = ({ activeIndex = null, id = '', list = [], setActiveIndex = () => {} }) => {
  // Build new list.
  const newList = list.map((item = {}, index) => {
    // =========
    // Get data.
    // =========

    const { props: itemProps = {} } = item;
    const { label = '' } = itemProps;
    const idPanel = getPanelId(id, index);
    const idTab = getTabId(id, index);
    const isActive = getIsActive({ activeIndex, index, list });

    // =======
    // Events.
    // =======

    const handleClick = () => {
      // Set active item.
      setActiveIndex(index);
    };

    const handleKeyDown = (event = {}) => {
      // Get target.
      const { target } = event;

      // Get key.
      let { key = '' } = event;
      key = key.toLowerCase();

      // Key events.
      const isArrowLeft = key === ARROW_LEFT;
      const isArrowRight = key === ARROW_RIGHT;
      const isArrowKey = isArrowLeft || isArrowRight;
      const isTriggerKey = key === ENTER || key === SPACE;

      // Valid event?
      const isValidEvent = isArrowKey || isTriggerKey;

      // Prevent default.
      if (isValidEvent) {
        event.preventDefault();
      }

      // ============
      // Arrow event?
      // ============

      if (isArrowKey) {
        // Set later.
        let newIndex = null;
        let nextItem = null;

        // Arrow left?
        if (isArrowLeft) {
          newIndex = index - 1;
          nextItem = list[newIndex];

          if (!nextItem) {
            newIndex = list.length - 1;
            nextItem = list[newIndex];
          }
        }

        // Arrow right?
        if (isArrowRight) {
          newIndex = index + 1;
          nextItem = list[newIndex];

          if (!nextItem) {
            newIndex = 0;
            nextItem = list[newIndex];
          }
        }

        // Item exists?
        if (nextItem) {
          // Focus new item.
          focusNewItem(target, newIndex);

          // Set active item.
          setActiveIndex(newIndex);
        }
      }

      // ==============
      // Trigger event?
      // ==============

      if (isTriggerKey) {
        // Set active item.
        setActiveIndex(index);
      }
    };

    // ============
    // Add to list.
    // ============

    return (
      <li
        aria-controls={idPanel}
        aria-selected={isActive}
        className="tabs__item"
        id={idTab}
        key={idTab}
        role={TAB}
        tabIndex={isActive ? 0 : -1}
        // Events.
        onClick={handleClick}
        onKeyDown={handleKeyDown}
      >
        {label || `${index + 1}`}
      </li>
    );
  });

  // ==========
  // Expose UI.
  // ==========

  return (
    <Render if={newList.length}>
      <ul aria-orientation={HORIZONTAL} className="tabs__list" role={TABLIST}>
        {newList}
      </ul>
    </Render>
  );
};

getTabsList.propTypes = {
  activeIndex: PropTypes.number,
  id: PropTypes.string,
  list: PropTypes.array,
  setActiveIndex: PropTypes.func,
};

// =================
// Get `<div>` list.
// =================

const getPanelsList = ({ activeIndex = null, id = '', list = [] }) => {
  // Build new list.
  const newList = list.map((item = {}, index) => {
    // =========
    // Get data.
    // =========

    const { props: itemProps = {} } = item;
    const { children = '', className = null, style = null } = itemProps;
    const idPanel = getPanelId(id, index);
    const idTab = getTabId(id, index);
    const isActive = getIsActive({ activeIndex, index, list });

    // =============
    // Get children.
    // =============

    let content = children || item;

    if (typeof content === STRING) {
      content = <p>{content}</p>;
    }

    // =================
    // Build class list.
    // =================

    const classList = cx({
      tabs__panel: true,
      [String(className)]: className,
    });

    // ==========
    // Expose UI.
    // ==========

    return (
      <div
        aria-hidden={!isActive}
        aria-labelledby={idTab}
        className={classList}
        id={idPanel}
        key={idPanel}
        role={TABPANEL}
        style={style}
        tabIndex={0}
      >
        {content}
      </div>
    );
  });

  // ==========
  // Expose UI.
  // ==========

  return newList;
};

getPanelsList.propTypes = {
  activeIndex: PropTypes.number,
  id: PropTypes.string,
  list: PropTypes.array,
};

// ==========
// Component.
// ==========

const Tabs = ({
  children = '',
  className = null,
  selected = 0,
  style = null,
  id: propsId = uuid(),
}) => {
  // ===============
  // Internal state.
  // ===============

  const [id] = useState(propsId);
  const [activeIndex, setActiveIndex] = useState(selected);

  // =================
  // Build class list.
  // =================

  const classList = cx({
    tabs: true,
    [String(className)]: className,
  });

  // ===============
  // Build UI lists.
  // ===============

  const list = Array.isArray(children) ? children : [children];

  const tabsList = getTabsList({
    activeIndex,
    id,
    list,
    setActiveIndex,
  });

  const panelsList = getPanelsList({
    activeIndex,
    id,
    list,
  });

  // ==========
  // Expose UI.
  // ==========

  return (
    <Render if={list[0]}>
      <div className={classList} id={id} style={style}>
        {tabsList}
        {panelsList}
      </div>
    </Render>
  );
};

Tabs.propTypes = {
  children: PropTypes.node,
  className: PropTypes.string,
  id: PropTypes.string,
  selected: PropTypes.number,
  style: PropTypes.object,
};

export default Tabs;

Function: getIsActive

Due to a <Tabs> component always having something active and visible, this function contains some logic to determine whether an index of a given tab should be the lucky winner. Essentially, in sentence form the logic goes like this.

This current tab is active if:

  • Its index matches the activeIndex, or
  • The tabs UI has only one tab, or
  • It is the first tab, and the activeIndex tab does not exist.
const getIsActive = ({ activeIndex = null, index = null, list = [] }) => {
  // Index matches?
  const isMatch = index === parseFloat(activeIndex);

  // Is first item?
  const isFirst = index === 0;

  // Only first item exists?
  const onlyFirstItem = list.length === 1;

  // Item doesn't exist?
  const badActiveItem = !list[activeIndex];

  // Flag as active?
  const isActive = isMatch || onlyFirstItem || (isFirst && badActiveItem);

  // Expose boolean.
  return !!isActive;
};

Function: getTabsList

This function generates the clickable <li role="tabs"> UI, and returns it wrapped in a parent <ul role="tablist">. It assigns all the relevant aria-* and role attributes, and handles binding the onClickand onKeyDown events. When an event is triggered, setActiveIndex is called. This updates the component’s internal state.

It is noteworthy how the content of the <li> is derived. That is passed in as <div label="…"> children of the parent <Tabs> component. Though this is not a real concept in flat HTML, it is a handy way to think about the relationship of the content. The children of that <div> become the the innards of our role="tabpanel" later.

const getTabsList = ({ activeIndex = null, id = '', list = [], setActiveIndex = () => {} }) => {
  // Build new list.
  const newList = list.map((item = {}, index) => {
    // =========
    // Get data.
    // =========

    const { props: itemProps = {} } = item;
    const { label = '' } = itemProps;
    const idPanel = getPanelId(id, index);
    const idTab = getTabId(id, index);
    const isActive = getIsActive({ activeIndex, index, list });

    // =======
    // Events.
    // =======

    const handleClick = () => {
      // Set active item.
      setActiveIndex(index);
    };

    const handleKeyDown = (event = {}) => {
      // Get target.
      const { target } = event;

      // Get key.
      let { key = '' } = event;
      key = key.toLowerCase();

      // Key events.
      const isArrowLeft = key === ARROW_LEFT;
      const isArrowRight = key === ARROW_RIGHT;
      const isArrowKey = isArrowLeft || isArrowRight;
      const isTriggerKey = key === ENTER || key === SPACE;

      // Valid event?
      const isValidEvent = isArrowKey || isTriggerKey;

      // Prevent default.
      if (isValidEvent) {
        event.preventDefault();
      }

      // ============
      // Arrow event?
      // ============

      if (isArrowKey) {
        // Set later.
        let newIndex = null;
        let nextItem = null;

        // Arrow left?
        if (isArrowLeft) {
          newIndex = index - 1;
          nextItem = list[newIndex];

          if (!nextItem) {
            newIndex = list.length - 1;
            nextItem = list[newIndex];
          }
        }

        // Arrow right?
        if (isArrowRight) {
          newIndex = index + 1;
          nextItem = list[newIndex];

          if (!nextItem) {
            newIndex = 0;
            nextItem = list[newIndex];
          }
        }

        // Item exists?
        if (nextItem) {
          // Focus new item.
          focusNewItem(target, newIndex);

          // Set active item.
          setActiveIndex(newIndex);
        }
      }

      // ==============
      // Trigger event?
      // ==============

      if (isTriggerKey) {
        // Set active item.
        setActiveIndex(index);
      }
    };

    // ============
    // Add to list.
    // ============

    return (
      <li
        aria-controls={idPanel}
        aria-selected={isActive}
        className="tabs__item"
        id={idTab}
        key={idTab}
        role={TAB}
        tabIndex={isActive ? 0 : -1}
        // Events.
        onClick={handleClick}
        onKeyDown={handleKeyDown}
      >
        {label || `${index + 1}`}
      </li>
    );
  });

  // ==========
  // Expose UI.
  // ==========

  return (
    <Render if={newList.length}>
      <ul aria-orientation={HORIZONTAL} className="tabs__list" role={TABLIST}>
        {newList}
      </ul>
    </Render>
  );
};

Function: getPanelsList

This function parses the incoming children of the top level component and extracts the content. It also makes use of getIsActive to determine whether (or not) to apply aria-hidden="true". As one might expect by now, it adds all the other relevant aria-* and role attributes too. It also applies any extra className or style that was passed in.

It also is “smart” enough to wrap any string content — anything lacking a wrapping tag already — in <p> tags for consistency.

const getPanelsList = ({ activeIndex = null, id = '', list = [] }) => {
  // Build new list.
  const newList = list.map((item = {}, index) => {
    // =========
    // Get data.
    // =========

    const { props: itemProps = {} } = item;
    const { children = '', className = null, style = null } = itemProps;
    const idPanel = getPanelId(id, index);
    const idTab = getTabId(id, index);
    const isActive = getIsActive({ activeIndex, index, list });

    // =============
    // Get children.
    // =============

    let content = children || item;

    if (typeof content === STRING) {
      content = <p>{content}</p>;
    }

    // =================
    // Build class list.
    // =================

    const classList = cx({
      tabs__panel: true,
      [String(className)]: className,
    });

    // ==========
    // Expose UI.
    // ==========

    return (
      <div
        aria-hidden={!isActive}
        aria-labelledby={idTab}
        className={classList}
        id={idPanel}
        key={idPanel}
        role={TABPANEL}
        style={style}
        tabIndex={0}
      >
        {content}
      </div>
    );
  });

  // ==========
  // Expose UI.
  // ==========

  return newList;
};

Function: Tabs

This is the main component. It sets an internal state for an id, to essentially cache any generated uuid() so that it does not change during the lifecycle of the component. React is finicky about its key attributes (in the previous loops) changing dynamically, so this ensures they remain static once set.

We also employ useState to track the currently selected tab, and pass down a setActiveIndex function to each <li> to monitor when they are clicked. After that, it is pretty straightfowrard. We call getTabsList and getPanelsList to build our UI, and then wrap it all up in <div role="tablist">.

It accepts any wrapper level className or style, in case anyone wants further tweaks during implementation. Providing other developers (as consumers) this flexibility means that the likelihood of needing to make further edits to the core component is lower. Lately, I have been doing this as a “best practice” for all components I create.

const Tabs = ({
  children = '',
  className = null,
  selected = 0,
  style = null,
  id: propsId = uuid(),
}) => {
  // ===============
  // Internal state.
  // ===============

  const [id] = useState(propsId);
  const [activeIndex, setActiveIndex] = useState(selected);

  // =================
  // Build class list.
  // =================

  const classList = cx({
    tabs: true,
    [String(className)]: className,
  });

  // ===============
  // Build UI lists.
  // ===============

  const list = Array.isArray(children) ? children : [children];

  const tabsList = getTabsList({
    activeIndex,
    id,
    list,
    setActiveIndex,
  });

  const panelsList = getPanelsList({
    activeIndex,
    id,
    list,
  });

  // ==========
  // Expose UI.
  // ==========

  return (
    <Render if={list[0]}>
      <div className={classList} id={id} style={style}>
        {tabsList}
        {panelsList}
      </div>
    </Render>
  );
};

If you are curious about the <Render> function, you can read more about that in this example.

File: Accordion.js

// =============
// Used like so…
// =============

<Accordion>
  <div label="Tab 1">
    <p>
      Tab 1 content
    </p>
  </div>
  <div label="Tab 2">
    <p>
      Tab 2 content
    </p>
  </div>
</Accordion>

As you may have deduced — due to the vanilla JS example handling both tabs and accordion — this file has quite a few similarities to how Tabs.js works.

Rather than belabor the point, I will simply provide the file’s contents for completeness and then speak about the specific areas in which the logic differs. So, take a gander at the contents and I will explain what makes <Accordion> quirky.

import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { v4 as uuid } from 'uuid';
import cx from 'classnames';

// UI.
import Render from './Render';

// ==========
// Constants.
// ==========

const ENTER = 'enter';
const SPACE = ' ';
const STRING = 'string';
const VERTICAL = 'vertical';

// ===========
// Get tab ID.
// ===========

const getTabId = (id = '', index = 0) => {
  return `tab_${id}_${index}`;
};

// =============
// Get panel ID.
// =============

const getPanelId = (id = '', index = 0) => {
  return `tabpanel_${id}_${index}`;
};

// ==============================
// Get `tab` and `tabpanel` list.
// ==============================

const getTabsAndPanelsList = ({
  activeItems = {},
  id = '',
  isMulti = true,
  list = [],
  setActiveItems = () => {},
}) => {
  // Build new list.
  const newList = [];

  // Loop through.
  list.forEach((item = {}, index) => {
    // =========
    // Get data.
    // =========

    const { props: itemProps = {} } = item;

    const { children = '', className = null, label = '', style = null } = itemProps;

    const idPanel = getPanelId(id, index);
    const idTab = getTabId(id, index);
    const isActive = !!activeItems[index];

    // =======
    // Events.
    // =======

    const handleClick = (event = {}) => {
      let { key = '' } = event;
      key = key.toLowerCase();

      // Trigger key?
      const isTriggerKey = key === ENTER || key === SPACE;

      // Early exit.
      if (key && !isTriggerKey) {
        return;
      }

      // Keep active items?
      const state = isMulti ? activeItems : null;

      // Update active item.
      const newState = {
        ...state,
        [index]: !activeItems[index],
      };

      // Prevent key press.
      event.preventDefault();

      // Set active item.
      setActiveItems(newState);
    };

    // =============
    // Get children.
    // =============

    let content = children || item;

    if (typeof content === STRING) {
      content = <p>{content}</p>;
    }

    // =================
    // Build class list.
    // =================

    const classList = cx({
      accordion__panel: true,
      [String(className)]: className,
    });

    // ========
    // Add tab.
    // ========

    newList.push(
      <div
        aria-controls={idPanel}
        aria-selected={isActive}
        className="accordion__item"
        id={idTab}
        key={idTab}
        role="tab"
        tabIndex={0}
        // Events.
        onClick={handleClick}
        onKeyDown={handleClick}
      >
        <i aria-hidden="true" className="accordion__item__icon" />
        {label || `${index + 1}`}
      </div>
    );

    // ==========
    // Add panel.
    // ==========

    newList.push(
      <div
        aria-hidden={!isActive}
        aria-labelledby={idTab}
        className={classList}
        id={idPanel}
        key={idPanel}
        role="tabpanel"
        style={style}
        tabIndex={0}
      >
        {content}
      </div>
    );
  });

  // ==========
  // Expose UI.
  // ==========

  return newList;
};

getTabsAndPanelsList.propTypes = {
  activeItems: PropTypes.object,
  id: PropTypes.string,
  isMulti: PropTypes.bool,
  list: PropTypes.array,
  setActiveItems: PropTypes.func,
};

// ==========
// Component.
// ==========

const Accordion = ({
  children = '',
  className = null,
  isMulti = true,
  selected = {},
  style = null,
  id: propsId = uuid(),
}) => {
  // ===============
  // Internal state.
  // ===============

  const [id] = useState(propsId);
  const [activeItems, setActiveItems] = useState(selected);

  // =================
  // Build class list.
  // =================

  const classList = cx({
    accordion: true,
    [String(className)]: className,
  });

  // ===============
  // Build UI lists.
  // ===============

  const list = Array.isArray(children) ? children : [children];

  const tabsAndPanelsList = getTabsAndPanelsList({
    activeItems,
    id,
    isMulti,
    list,
    setActiveItems,
  });

  // ==========
  // Expose UI.
  // ==========

  return (
    <Render if={list[0]}>
      <div
        aria-multiselectable={isMulti}
        aria-orientation={VERTICAL}
        className={classList}
        id={id}
        role="tablist"
        style={style}
      >
        {tabsAndPanelsList}
      </div>
    </Render>
  );
};

Accordion.propTypes = {
  children: PropTypes.node,
  className: PropTypes.string,
  id: PropTypes.string,
  isMulti: PropTypes.bool,
  selected: PropTypes.object,
  style: PropTypes.object,
};

export default Accordion;

Function: handleClick

While most of our <Accordion> logic is similar to <Tabs>, it differs in how it stores the currently active tab.

Since <Tabs> are always mutually exclusive, we only really need a single numeric index. Easy peasy.

However, because an <Accordion> can have concurrently visible panels — or be used in a mutually exclusive manner — we need to represent that to useState in a way that could handle both.

If you were beginning to think…

“I would store that in an object.”

…then congrats. You are right!

This function does a quick check to see if isMulti has been set to true. If so, we use the spread syntax to apply the existing activeItems to our newState object. We then set the current index to its boolean opposite.

const handleClick = (event = {}) => {
  let { key = '' } = event;
  key = key.toLowerCase();

  // Trigger key?
  const isTriggerKey = key === ENTER || key === SPACE;

  // Early exit.
  if (key && !isTriggerKey) {
    return;
  }

  // Keep active items?
  const state = isMulti ? activeItems : null;

  // Update active item.
  const newState = {
    ...state,
    [index]: !activeItems[index],
  };

  // Prevent key press.
  event.preventDefault();

  // Set active item.
  setActiveItems(newState);
};

For reference, here is how our activeItems object looks if only the first accordion panel is active and a user clicks the second. Both indexes would be set to true. This allows for viewing two expanded role="tabpanel" simultaneously.

/*
  Internal representation
  of `activeItems` state.
*/

{
  0: true,
  1: true,
}

Whereas if we were not operating in isMulti mode — when the wrapper has aria-multiselectable="false" — then activeItems would only ever contain one key/value pair.

Because rather than spreading the current activeItems, we would be spreading null. That effectively wipes the slate clean, before recording the currently active tab.

/*
  Internal representation
  of `activeItems` state.
*/

{
  1: true,
}

Conclusion

Still here? Awesome.

Hopefully you found this article informative, and maybe even learned a bit more about accessibility and JS(X) along the way. For review, let us look one more time at our flat HTML example and and the React usage of our <Tabs>component. Here is a comparison of the markup we would write in a vanilla JS approach, versus the JSX it takes to generate the same thing.

I am not saying that one is better than the other, but you can see how React makes it possible to distill things down into a mental model. Working directly in HTML, you always have to be aware of every tag.

HTML

<div class="tabs">
  <ul class="tabs__list">
    <li class="tabs__item">
      Tab 1
    </li>
    <li class="tabs__item">
      Tab 2
    </li>
  </ul>
  <div class="tabs__panel">
    <p>
      Tab 1 content
    </p>
  </div>
  <div class="tabs__panel">
    <p>
      Tab 2 content
    </p>
  </div>
</div>

JSX

<Tabs>
  <div label="Tab 1">
    Tab 1 content
  </div>
  <div label="Tab 2">
    Tab 2 content
  </div>
</Tabs>

↑ One of these probably looks preferrable, depending on your point of view.

Writing code closer to the metal means more direct control, but also more tedium. Using a framework like React means you get more functionality “for free,” but also it can be a black box.

That is, unless you understand the underlying nuances already. Then you can fluidly operate in either realm. Because you can see The Matrix for what it really is: Just JavaScript™. Not a bad place to be, no matter where you find yourself.