Die Anatomie einer Tablistenkomponente in Vanilla JavaScript im Vergleich zu React

Avatar of Nathan Smith
Nathan Smith am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Wenn man dem Unterstrom der JavaScript-Community folgt, scheint es in letzter Zeit eine Spaltung zu geben. Das reicht über ein Jahrzehnt zurück. Tatsächlich gab es diesen Streit schon immer. Vielleicht liegt es in der menschlichen Natur.

Immer wenn ein beliebtes Framework an Bedeutung gewinnt, sieht man unweigerlich Leute, die es mit Konkurrenten vergleichen. Ich schätze, das ist zu erwarten. Jeder hat seinen persönlichen Favoriten.

In letzter Zeit ist das Framework, das jeder liebt (oder hasst?), React. Man sieht es oft in direkten Blogposts und Feature-Vergleichsmatrizen von Enterprise-Whitepapers gegen andere angetreten. Doch vor ein paar Jahren schien es, als würde jQuery für immer der König bleiben.

Frameworks kommen und gehen. Für mich ist es interessanter, wenn React – oder jedes JS-Framework, was das angeht – gegen die Programmiersprache selbst antritt. Denn natürlich basiert alles auf JS.

Die beiden stehen nicht von Natur aus im Widerspruch. Ich würde sogar so weit gehen zu sagen, dass Sie wahrscheinlich nicht die vollen Vorteile der Verwendung von React nutzen werden, wenn Sie die Grundlagen von JS nicht gut beherrschen. Es kann immer noch hilfreich sein, ähnlich wie die Verwendung eines jQuery-Plugins, ohne seine Interna zu verstehen. Aber ich habe das Gefühl, dass React mehr JS-Vertrautheit voraussetzt.

HTML ist gleichermaßen wichtig. Es gibt eine ganze Menge FUD darum, wie React die Zugänglichkeit beeinflusst. Ich denke, dieses Narrativ ist ungenau. Tatsächlich wird das ESLint JSX a11y Plugin auf mögliche Zugänglichkeitsverletzungen in der Konsole hinweisen.

Console warnings from eslint-jsx-a11y-plugin
ESLint-Warnungen zu leeren <a>-Tags

Kürzlich wurde eine jährliche Studie über die Top 1 Million Websites veröffentlicht. Sie zeigt, dass bei Websites, die JS-Frameworks verwenden, eine erhöhte Wahrscheinlichkeit für Zugänglichkeits probleme besteht. Dies ist Korrelation, keine Kausalität.

Das bedeutet nicht unbedingt, dass die Frameworks diese Fehler verursacht haben, aber es zeigt an, dass Homepages mit diesen Frameworks mehr Fehler als im Durchschnitt aufwiesen.

In gewisser Weise wirken die magischen Formeln von React, unabhängig davon, ob Sie die Worte verstehen. Letztendlich sind Sie immer noch für das Ergebnis verantwortlich.

Philosophische Betrachtungen beiseite, ich bin fest davon überzeugt, das beste Werkzeug für die jeweilige Aufgabe zu wählen. Manchmal bedeutet das, eine Single Page App mit einem Jamstack-Ansatz zu bauen. Oder vielleicht ist ein bestimmtes Projekt besser geeignet, die HTML-Darstellung an den Server auszulagern, wo sie traditionell gehandhabt wurde.

Auf jeden Fall kommt unweigerlich die Notwendigkeit auf, JS zur Verbesserung des Benutzererlebnisses einzusetzen. Bei Reaktiv Studios habe ich versucht, die meisten unserer React-Komponenten mit unserem „Flat-HTML“-Ansatz synchron zu halten. Ich habe auch häufig verwendete Funktionalitäten in Vanilla JS geschrieben. Das hält unsere Optionen offen, sodass unsere Kunden frei wählen können. Außerdem können wir so das gleiche CSS wiederverwenden.

Wenn ich darf, möchte ich teilen, wie ich unsere <Tabs>- und <Accordion>-React-Komponenten erstellt habe. Ich werde auch zeigen, wie ich dieselbe Funktionalität ohne Framework geschrieben habe.

Hoffentlich wird sich diese Lektion wie das Schichten einer Torte anfühlen. Beginnen wir zunächst mit dem Basis-Markup, dann behandeln wir Vanilla JS und schließen mit der Funktionsweise in React ab.

Zur Referenz können Sie mit unseren Live-Beispielen experimentieren

UI-Komponenten von Reaktiv Studios

Flache HTML-Beispiele

Da wir ohnehin JavaScript benötigen, um interaktive Widgets zu erstellen, stellte ich fest, dass der einfachste Ansatz – aus Sicht der serverseitigen Implementierung – darin bestand, nur das absolute Minimum an HTML zu benötigen. Der Rest kann mit JS angereichert werden.

Im Folgenden finden Sie Beispiele für Markup für Tab- und Accordion-Komponenten, die einen Vorher-Nachher-Vergleich zeigen, wie JS das DOM beeinflusst.

Ich habe id="TABS_ID" und id="ACCORDION_ID" zu Demonstrationszwecken hinzugefügt. Dies soll offensichtlicher machen, was passiert. Aber das JS, das ich erklären werde, generiert automatisch eindeutige IDs, wenn im HTML nichts angegeben ist. Es würde in beiden Fällen gut funktionieren, mit oder ohne angegebene id.

<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 (mit 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 (ohne 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 (mit 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-Beispiele

Okay. Nachdem wir die oben genannten HTML-Beispiele gesehen haben, gehen wir nun durch, wie wir von *vorher* zu *nachher* gelangen.

Zuerst möchte ich ein paar Hilfsfunktionen behandeln. Diese werden später mehr Sinn ergeben. Ich denke, es ist am besten, sie zuerst zu dokumentieren, damit wir uns auf den Rest des Codes konzentrieren können, sobald wir tiefer eintauchen.

Datei: getDomFallback.js

Diese Funktion stellt gängige DOM-Eigenschaften und -Methoden als No-Ops bereit, anstatt viele typeof foo.getAttribute-Prüfungen und ähnliches machen zu müssen. Wir könnten solche Bestätigungen ganz vermeiden.

Da Live-HTML-Änderungen eine potenziell volatile Umgebung sein können, fühle ich mich immer etwas sicherer, wenn ich sicherstelle, dass mein JS nicht abstürzt und den Rest der Seite mitnimmt. Hier ist, wie diese Funktion aussieht. Sie gibt einfach ein Objekt mit den DOM-Äquivalenten von falsy-Ergebnissen zurück.

/*
  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 };

Datei: unique.js

Diese Funktion ist ein armer Mann UUID-Äquivalent.

Sie generiert einen eindeutigen String, der verwendet werden kann, um DOM-Elemente miteinander zu verknüpfen. Das ist praktisch, denn dann muss der Autor einer HTML-Seite nicht sicherstellen, dass jede Tab- und Accordion-Komponente eindeutige IDs hat. In den vorherigen HTML-Beispielen würden TABS_ID und ACCORDION_ID hier normalerweise stattdessen die zufällig generierten numerischen Strings enthalten.

// ==========
// 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 };

Bei größeren JavaScript-Projekten würde ich normalerweise npm install uuid verwenden. Aber da wir es hier einfach halten und keine kryptografische Parität benötigen, reicht das Verketten von zwei leicht bearbeiteten Math.random()-Zahlen für unsere Bedürfnisse nach Zeichenketten-Eindeutigkeit aus.

Datei: tablist.js

Diese Datei erledigt den Großteil der Arbeit. Was daran cool ist, wenn ich das so sagen darf, ist, dass es genug Ähnlichkeiten zwischen einer Tab-Komponente und einem Accordion gibt, dass wir beide mit derselben *.js-Datei behandeln können. Scrollen Sie ruhig durch das Ganze, und dann werden wir aufschlüsseln, was jede Funktion einzeln tut.

// 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 };

Funktion: getTabId und getPanelId

Diese beiden Funktionen werden verwendet, um individuell eindeutige IDs für Elemente in einer Schleife zu erstellen, basierend auf einer vorhandenen (oder generierten) Eltern-ID. Dies ist hilfreich, um übereinstimmende Werte für Attribute wie aria-controls="…" und aria-labelledby="…" sicherzustellen. Betrachten Sie diese als die Zugänglichkeitsäquivalente von <label for="…">, die dem Browser mitteilen, welche Elemente miteinander verbunden sind.

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

Funktion: globalClick

Dies ist ein Klick-Handler, der auf document-Ebene angewendet wird. Das bedeutet, dass wir keine Klick-Handler manuell an eine Vielzahl von Elementen anfügen müssen. Stattdessen verwenden wir Event Bubbling, um auf Klicks weiter unten im Dokument zu hören und sie bis zur Spitze propagieren zu lassen.

Praktischerweise ist dies auch die Art und Weise, wie wir Tastaturereignisse behandeln können, wie z. B. das Drücken der Tasten ArrowLeft, ArrowRight, Enter (oder Leertaste). Diese sind notwendig, um eine zugängliche Benutzeroberfläche zu haben.

Im ersten Teil der Funktion destrukturieren wir target und key aus dem eingehenden event. Als nächstes destrukturieren wir parentNode und tagName aus dem target.

Dann versuchen wir, das Wrapper-Element zu erhalten. Dies wäre dasjenige mit entweder class="tabs" oder class="accordion". Da wir möglicherweise auf das oberste Elternelement im DOM-Baum klicken, das existiert, aber möglicherweise nicht die Methode *.closest(...) hat, führen wir eine typeof-Prüfung durch. Wenn diese Funktion existiert, versuchen wir, das Element zu erhalten. Selbst dann könnten wir ohne Treffer enden. Also haben wir sicherheitshalber noch eine getDomFallback.

// 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();
}

Dann speichern wir einen Booleschen Wert darüber, ob das Wrapper-Element aria-multiselectable="true" hat. Darauf werde ich zurückkommen. Ebenso speichern wir, ob der angeklickte Tag eine <li> ist oder nicht. Diese Information benötigen wir später.

Wir stellen auch fest, ob der Klick auf ein relevantes target erfolgte. Denken Sie daran, dass wir Event Bubbling verwenden, sodass der Benutzer tatsächlich auf etwas geklickt haben könnte. Wir befragen auch das Ereignis ein wenig, um festzustellen, ob es durch das Drücken einer Taste durch den Benutzer ausgelöst wurde. Wenn ja, ermitteln wir, ob die Taste relevant ist.

Wir wollen sicherstellen, dass es

  • role="tab" hat
  • Ein Elternelement mit role="tablist" hat

Dann bündeln wir unsere anderen Booleschen Werte in zwei Kategorien: isArrowEvent und isTriggerEvent. Welche wiederum in isValidEvent kombiniert werden.

// 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();
}

Dann treten wir in eine if-Bedingung ein, die prüft, ob entweder die linke oder die rechte Pfeiltaste gedrückt wurde. Wenn ja, wollen wir den Fokus auf den entsprechenden benachbarten Tab ändern. Wenn wir bereits am Anfang unserer Liste sind, springen wir zum Ende. Oder wenn wir bereits am Ende sind, springen wir zum Anfang.

Durch das Auslösen des click-Ereignisses wird diese Funktion erneut ausgeführt. Sie wird dann als Trigger-Ereignis ausgewertet. Dies wird im nächsten Block behandelt.

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();
}

Unter der Annahme, dass das Trigger-event tatsächlich gültig ist, passieren wir unsere nächste if-Prüfung. Jetzt befassen wir uns damit, das Element mit role="tabpanel" und einer id zu erhalten, die mit der aria-controls="…" unseres Tabs übereinstimmt.

Sobald wir es haben, prüfen wir, ob das Panel versteckt ist und ob der Tab ausgewählt ist. Grundsätzlich gehen wir zunächst davon aus, dass wir es mit einem Accordion zu tun haben und drehen die Booleschen Werte um.

Hier kommt auch unser früherer Boolescher Wert isListItem zum Einsatz. Wenn der Benutzer auf eine <li> klickt, wissen wir, dass wir es mit Tabs zu tun haben, nicht mit einem Accordion. In diesem Fall wollen wir unser Panel als sichtbar (über aria-hidden="false") und unseren Tab als ausgewählt (über aria-selected="true") markieren.

Außerdem wollen wir sicherstellen, dass entweder das Wrapper-Element aria-multiselectable="false" hat oder aria-multiselectable vollständig fehlt. Wenn das der Fall ist, durchlaufen wir alle benachbarten role="tab"- und alle role="tabpanel"-Elemente und setzen sie in ihre inaktiven Zustände. Schließlich kommen wir zur Einstellung der zuvor bestimmten Booleschen Werte für die einzelnen Tab- und Panel-Paare.

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);
}

Funktion: addAriaAttributes

Der scharfsinnige Leser mag sich fragen:

Sie sagten vorhin, dass wir mit dem absolut rudimentärsten Markup beginnen, aber die Funktion globalClick nach Attributen suchte, die nicht vorhanden wären. Warum haben Sie gelogen!?

Oder vielleicht auch nicht, denn der scharfsinnige Leser hätte auch die Funktion namens addAriaAttributes bemerkt. Tatsächlich tut diese Funktion genau das, was sie verspricht. Sie erweckt die grundlegende DOM-Struktur zum Leben, indem sie alle erforderlichen aria-*- und role-Attribute hinzufügt.

Dies macht die Benutzeroberfläche nicht nur für assistive Technologien inhärent zugänglicher, sondern stellt auch sicher, dass die Funktionalität tatsächlich funktioniert. Ich ziehe es vor, Vanilla JS-Dinge auf diese Weise zu erstellen, anstatt auf class="…" für Interaktivität zu schwenken, da es mich zwingt, über die Gesamtheit des Benutzererlebnisses nachzudenken, jenseits dessen, was ich visuell sehen kann.

Zuerst holen wir uns alle Elemente auf der Seite, die class="tabs" und/oder class="accordion" haben. Dann prüfen wir, ob wir etwas zum Arbeiten haben. Wenn nicht, würden wir unsere Funktion hier beenden. Angenommen, wir haben eine Liste, durchlaufen wir jedes der umgebenden Elemente und übergeben sie im Gültigkeitsbereich unserer Funktion als wrapper.

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

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

Innerhalb des Gültigkeitsbereichs unserer Schleifenfunktion destrukturieren wir id und classList aus wrapper. Wenn keine ID vorhanden ist, generieren wir eine über unique(). Wir setzen ein Boolesches Flag, um zu identifizieren, ob wir mit einem Accordion arbeiten. Dies wird später verwendet.

Wir holen auch Nachfahren von wrapper, die Tabs und Panels sind, über ihre Klassennamen-Selektoren.

Tabs

  • class="tabs__item" oder
  • class="accordion__item"

Panels

  • class="tabs__panel" oder
  • class="accordion__panel"

Wir setzen dann die id des Wrappers, wenn er noch keine hat.

Wenn wir es mit einem Accordion zu tun haben, dem aria-multiselectable="false" fehlt, setzen wir sein Flag auf true. Der Grund dafür ist, dass, wenn Entwickler nach einem Accordion-UI-Paradigma greifen – und auch Tabs zur Verfügung haben, die von Natur aus gegenseitig ausschließend sind –, die sicherere Annahme ist, dass der Accordion das Erweitern und Zusammenklappen mehrerer Panels unterstützen sollte.

// 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);
}

Als nächstes durchlaufen wir die Tabs. Dabei kümmern wir uns auch um unsere Panels.

Sie fragen sich vielleicht, warum dies eine alte Schule for-Schleife ist, anstatt eine modernere *.forEach. Der Grund ist, dass wir zwei NodeList-Instanzen durchlaufen wollen: Tabs und Panels. Unter der Annahme, dass sie eins zu eins zugeordnet sind, wissen wir, dass beide die gleiche *.length haben. Das erlaubt uns, eine Schleife statt zweier zu haben.

Werfen wir einen Blick in die Schleife. Zuerst erhalten wir eindeutige IDs für jeden Tab und jedes Panel. Diese würden wie eines der beiden folgenden Szenarien aussehen. Diese werden später verwendet, um Tabs mit Panels und umgekehrt zu verknüpfen.

  • tab_WRAPPER_ID_0 oder
    tab_GENERATED_STRING_0
  • tabpanel_WRAPPER_ID_0 oder
    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.
  */
}

Während wir durchlaufen, stellen wir zunächst sicher, dass ein Erweiterungs-/Kollaps-Symbol vorhanden ist. Wir erstellen es bei Bedarf und setzen es auf aria-hidden="true", da es rein dekorativ ist.

Als nächstes prüfen wir die Attribute für den aktuellen Tab. Wenn eine id="…" auf dem Tab nicht existiert, fügen wir sie hinzu. Ebenso, wenn aria-controls="…" nicht existiert, fügen wir diese ebenfalls hinzu und verweisen auf unser neu erstelltes panelId.

Sie werden feststellen, dass hier ein kleiner Dreh- und Angelpunkt ist: Wir prüfen, ob wir aria-selected nicht haben und bestimmen dann weiter, ob wir uns *nicht* im Kontext eines Accordions befinden *und* ob der index 0 ist. In diesem Fall wollen wir unseren ersten Tab als ausgewählt erscheinen lassen. Der Grund dafür ist, dass, obwohl ein Accordion vollständig eingeklappt werden kann, tabellarische Inhalte dies nicht können. Es ist immer mindestens ein Panel sichtbar.

Dann stellen wir sicher, dass role="tab" existiert. Wir speichern den aktuellen index unserer Schleife als data-index="…" für den Fall, dass wir ihn später für die Tastaturavigation benötigen.

Wir fügen auch die richtige tabindex="0" oder möglicherweise tabindex="-1" hinzu, je nachdem, um welche Art von Element es sich handelt. Dies ermöglicht es allen Auslösern eines Accordions, den Tastaturfokus :focus zu erhalten, im Gegensatz zu nur dem aktuell aktiven Auslöser in einem Tabs-Layout.

Schließlich prüfen wir, ob wir uns in der ersten Iteration unserer Schleife befinden, bei der index 0 ist. Wenn ja, gehen wir eine Ebene nach oben zum parentNode. Wenn dieses Element kein role="tablist" hat, fügen wir es hinzu.

Wir tun dies über parentNode anstelle von wrapper, weil im Kontext von Tabs (nicht Accordion) ein <ul>-Element um die Tab-<li> existiert, das role="tablist" benötigt. Im Falle eines Accordions wäre es der äußerste <div>-Vorfahre. Dieser Code berücksichtigt beide.

Wir setzen auch die richtige aria-orientation, abhängig vom UI-Typ. Accordion ist vertical und Tabs sind 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);
    }
  }
}

Weiter innerhalb der früheren for-Schleife fügen wir Attribute für jedes panel hinzu. Wir fügen eine id hinzu, falls erforderlich. Wir setzen auch aria-hidden auf entweder true oder false, abhängig vom Kontext eines Accordions (oder nicht).

Ebenso stellen wir sicher, dass unser Panel über aria-labelledby="…" auf seinen Tab-Trigger zurückverweist und dass role="tabpanel" gesetzt wurde. Wir geben ihm auch tabindex="0", damit es :focus empfangen kann.

// 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);
}

Ganz am Ende der Datei haben wir ein paar Setup- und Teardown-Funktionen. Als Möglichkeit, gut mit anderem JS auf der Seite zu spielen, bieten wir eine unbind-Funktion an, die unsere globalen Event-Listener entfernt. Sie kann für sich selbst aufgerufen werden, über tablist.unbind(), ist aber hauptsächlich dafür da, dass wir unbind() vor dem (Wieder-)Binden aufrufen können. Auf diese Weise verhindern wir Verdopplungen.

Innerhalb unserer init-Funktion rufen wir addAriaAttributes() auf, das das DOM für Zugänglichkeit modifiziert. Dann rufen wir unbind() auf und fügen unsere Event-Listener zum document hinzu.

Schließlich bündeln wir beide Methoden zu einem übergeordneten Objekt und exportieren es unter dem Namen tablist. So können wir beim Einbinden in eine flache HTML-Seite tablist.init() aufrufen, wenn wir bereit sind, unsere Funktionalität anzuwenden.

// =====================
// 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-Beispiele

Es gibt eine Szene in Batman Begins, in der Lucius Fox (gespielt von Morgan Freeman) einem genesenden Bruce Wayne (Christian Bale) die wissenschaftlichen Schritte erklärt, die er unternommen hat, um sein Leben nach einer Vergiftung zu retten.

Lucius Fox: „Ich habe Ihr Blut analysiert, die Rezeptorverbindungen und den proteinbasierten Katalysator isoliert.“

Bruce Wayne: „Soll ich das verstehen?“

Lucius Fox: „Überhaupt nicht, ich wollte nur, dass Sie wissen, wie schwer es war. Fazit: Ich habe ein Gegenmittel synthetisiert.“

Morgan Freeman and Christian Bale, sitting inside the Batmobile
„Wie konfiguriere ich Webpack?“

↑ Wenn ich mit einem Framework arbeite, denke ich in diesen Begriffen.

Jetzt, da wir wissen, wie „schwer“ es ist – nicht wirklich, aber nehmen Sie es mir nicht übel –, rohe DOM-Manipulation und Event-Bindung durchzuführen, können wir die Existenz eines Gegenmittels besser würdigen. React abstrahiert viel von dieser Komplexität und erledigt sie automatisch für uns.

Datei: Tabs.js

Jetzt, da wir uns mit React-Beispielen befassen, beginnen wir mit der <Tabs>-Komponente.

// =============
// 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>

Hier ist der Inhalt unserer Tabs.js-Datei. Beachten Sie, dass es im Sprachgebrauch von React üblich ist, die Datei mit derselben Großschreibung wie ihre export default Komponente zu benennen.

Wir beginnen mit denselben Funktionen getTabId und getPanelId wie in unserem Vanilla JS-Ansatz, da wir immer noch sicherstellen müssen, dass Tabs und Komponenten zugänglich zugeordnet werden. Schauen Sie sich den gesamten Code an, und dann werden wir ihn weiter aufschlüsseln.

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;

Funktion: getIsActive

Da eine <Tabs>-Komponente immer etwas Aktives und Sichtbares hat, enthält diese Funktion einige Logik, um zu bestimmen, ob ein index eines bestimmten Tabs der glückliche Gewinner sein sollte. Im Wesentlichen lautet die Logik in Satzform wie folgt.

Dieser aktuelle Tab ist aktiv, wenn

  • Sein index mit dem activeIndex übereinstimmt, oder
  • Die Tabs-Benutzeroberfläche nur einen Tab hat, oder
  • Es ist der erste Tab, und der activeIndex-Tab existiert nicht.
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;
};

Funktion: getTabsList

Diese Funktion generiert die klickbare <li role="tabs"> UI und gibt sie in ein übergeordnetes <ul role="tablist"> verpackt zurück. Sie weist alle relevanten aria-* und role Attribute zu und kümmert sich um die Bindung der onClick- und onKeyDown-Ereignisse. Wenn ein Ereignis ausgelöst wird, wird setActiveIndex aufgerufen. Dies aktualisiert den internen Zustand der Komponente.

Es ist bemerkenswert, wie der Inhalt des <li> abgeleitet wird. Dieser wird als <div label="…">-Kinder der übergeordneten <Tabs>-Komponente übergeben. Obwohl dies kein echtes Konzept in flachem HTML ist, ist es eine praktische Art, über die Beziehung des Inhalts nachzudenken. Die children dieses <div> werden später zu den Innereien unseres role="tabpanel".

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>
  );
};

Funktion: getPanelsList

Diese Funktion analysiert die eingehenden children der obersten Komponente und extrahiert den Inhalt. Sie nutzt auch getIsActive, um zu bestimmen, ob aria-hidden="true" angewendet werden soll oder nicht. Wie man mittlerweile erwarten könnte, fügt sie auch alle anderen relevanten aria-* und role Attribute hinzu. Sie wendet auch alle zusätzlichen className oder style an, die übergeben wurden.

Sie ist auch "intelligent" genug, um jeden string-Inhalt – alles, was bereits keine umschließende Tag hat – in <p>-Tags für Konsistenz einzuwickeln.

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;
};

Funktion: Tabs

Dies ist die Hauptkomponente. Sie setzt einen internen Zustand für eine id, um effektiv jede generierte uuid() zu cachen, damit sie sich während der Lebensdauer der Komponente nicht ändert. React ist empfindlich, wenn seine key-Attribute (in den vorherigen Schleifen) dynamisch wechseln, daher stellt dies sicher, dass sie nach dem Setzen statisch bleiben.

Wir verwenden auch useState, um den aktuell ausgewählten Tab zu verfolgen, und übergeben eine setActiveIndex-Funktion an jedes <li>, um zu überwachen, wann sie angeklickt werden. Danach ist es ziemlich unkompliziert. Wir rufen getTabsList und getPanelsList auf, um unsere UI zu erstellen, und verpacken dann alles in <div role="tablist">.

Sie akzeptiert jedes className oder style auf Wrapper-Ebene, falls jemand weitere Anpassungen während der Implementierung wünscht. Indem man anderen Entwicklern (als Konsumenten) diese Flexibilität bietet, sinkt die Wahrscheinlichkeit, dass weitere Änderungen an der Kernkomponente vorgenommen werden müssen. In letzter Zeit habe ich dies als "Best Practice" für alle von mir erstellten Komponenten angewendet.

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>
  );
};

Wenn Sie neugierig auf die <Render>-Funktion sind, können Sie hier in diesem Beispiel mehr darüber erfahren.

Datei: 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>

Wie Sie vielleicht schon vermutet haben – aufgrund des Vanilla-JS-Beispiels, das sowohl Tabs *als auch* Akkordeons handhabt – hat diese Datei viele Ähnlichkeiten mit der Arbeitsweise von Tabs.js.

Anstatt den Punkt zu überstrapazieren, werde ich einfach den Inhalt der Datei zur Vollständigkeit angeben und dann über die spezifischen Bereiche sprechen, in denen sich die Logik unterscheidet. Nehmen Sie sich also den Inhalt vor und ich werde erklären, was <Accordion> eigenartig macht.

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;

Funktion: handleClick

Während der Großteil unserer <Accordion>-Logik <Tabs> ähnelt, unterscheidet sie sich in der Art und Weise, wie sie den aktuell aktiven Tab speichert.

Da <Tabs> immer gegenseitig ausschließend sind, benötigen wir eigentlich nur einen einzigen numerischen index. Ganz einfach.

Da ein <Accordion> jedoch gleichzeitig sichtbare Panels haben kann – oder in einer gegenseitig ausschließenden Weise verwendet werden kann –, müssen wir dies useState so darstellen, dass es beides handhaben kann.

Wenn Sie anfingen zu denken…

"Ich würde das in einem Objekt speichern."

…dann herzlichen Glückwunsch. Sie haben Recht!

Diese Funktion prüft kurz, ob isMulti auf true gesetzt wurde. Wenn ja, verwenden wir die Spread-Syntax, um die vorhandenen activeItems auf unser newState-Objekt anzuwenden. Dann setzen wir den aktuellen index auf sein Boolesches Gegenteil.

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);
};

Zur Referenz: So sieht unser activeItems-Objekt aus, wenn nur das erste Akkordeon-Panel aktiv ist und ein Benutzer auf das zweite klickt. Beide Indizes würden auf true gesetzt. Dies ermöglicht die gleichzeitige Anzeige von zwei erweiterten role="tabpanel".

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

{
  0: true,
  1: true,
}

Wenn wir dagegen *nicht* im isMulti-Modus arbeiten würden – wenn der Wrapper aria-multiselectable="false" hat –, dann würde activeItems immer nur ein Schlüssel/Wert-Paar enthalten.

Denn anstatt die aktuellen activeItems zu verteilen, würden wir null verteilen. Das löscht effektiv die Schiefertafel, bevor der aktuell aktive Tab aufgezeichnet wird.

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

{
  1: true,
}

Fazit

Immer noch hier? Großartig.

Hoffentlich fanden Sie diesen Artikel informativ und haben vielleicht sogar etwas mehr über Barrierefreiheit und JS(X) gelernt. Zur Wiederholung, lassen Sie uns noch einmal unser flaches HTML-Beispiel und die React-Verwendung unserer <Tabs>-Komponente betrachten. Hier ist ein Vergleich des Markups, das wir in einem Vanilla-JS-Ansatz schreiben würden, mit dem JSX, das benötigt wird, um dasselbe zu generieren.

Ich sage nicht, dass das eine besser ist als das andere, aber man sieht, wie React es ermöglicht, Dinge in einem mentalen Modell zu destillieren. Wenn man direkt in HTML arbeitet, muss man sich immer jedes Tag bewusst sein.

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>

↑ Eines davon sieht wahrscheinlich besser aus, je nach Ihrem Standpunkt.

Das Schreiben von Code, der näher am Metall ist, bedeutet mehr direkte Kontrolle, aber auch mehr mühsame Arbeit. Die Verwendung eines Frameworks wie React bedeutet, dass man mehr Funktionalität "kostenlos" erhält, aber es kann auch eine Black Box sein.

Das heißt, es sei denn, man versteht die zugrunde liegenden Nuancen bereits. Dann kann man sich fließend in beiden Bereichen bewegen. Denn man kann The Matrix als das sehen, was es wirklich ist: Nur JavaScript™. Kein schlechter Ort, egal wo man sich befindet.