使用原生 JavaScript 和 React 构建 Tablist 组件的解剖

Avatar of Nathan Smith
Nathan Smith

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 $200 免费积分!

如果您关注 JavaScript 社区的暗流,最近似乎存在分歧。这可以追溯到十多年前。实际上,这种争端一直存在。也许这是人性。

每当一个流行的框架获得关注时,您总会看到人们将其与竞争对手进行比较。我认为这是可以预料的。每个人都有自己的最爱。

最近,每个人都喜欢(或讨厌?)的框架是 React。您经常会在企业白皮书的正面交锋博客文章和功能比较矩阵中看到它与其他框架进行比较。然而,几年前,似乎 jQuery 将永远是王者。

框架来了又去。对我来说,更有趣的是 React(或任何 JS 框架)与编程语言本身的比较。因为当然,在幕后,一切都是建立在 JS 之上的。

两者本身并不矛盾。我甚至会说,如果您对 JS 基础知识没有很好的掌握,您可能无法充分利用 React。它仍然可以提供帮助,类似于使用 jQuery 插件而不了解其内部机制。但我感觉 React 预设了更多 JS 熟悉度。

HTML 同样重要。关于 React 如何影响可访问性的说法有很多 FUD。我认为这种说法是不准确的。实际上,ESLint JSX a11y 插件 会在 console 中警告可能的可访问性违规。

Console warnings from eslint-jsx-a11y-plugin
ESLint 关于空 <a> 标签的警告

最近,一项 对排名前 100 万个网站的年度研究 发布了。它表明,对于使用 JS 框架的网站,可访问性问题发生的可能性更高。这是相关性,而不是因果关系。

这并不一定意味着这些框架导致了这些错误,但它确实表明具有这些框架的首页的错误数量高于平均水平。

从某种意义上说,无论您是否认识这些词,React 的神奇咒语都会起作用。最终,您仍然对结果负责。

抛开哲学思考,我坚信选择最适合工作的工具。有时,这意味着使用 Jamstack 方法构建单页应用程序。或者某个特定项目可能更适合将 HTML 渲染卸载到服务器,因为 HTML 渲染历来都在服务器上处理。

无论哪种方式,最终都需要使用 JS 来增强用户体验。在 Reaktiv Studios,为此我一直试图使我们的大多数 React 组件与我们的“扁平 HTML”方法保持同步。我也一直在用原生 JS 编写常用的功能。这使我们的选择保持开放,因此我们的客户可以自由选择。它还允许我们重用相同的 CSS。

如果您允许,我想分享一下我是如何构建我们的 <Tabs><Accordion> React 组件的。我还将演示如何在不使用框架的情况下编写相同的功能。

希望这个课程会让你感觉像是在做分层蛋糕。让我们首先从基本标记开始,然后介绍原生 JS,最后介绍它在 React 中的工作原理。

为了参考,您可以修改我们的实时示例

Reaktiv Studios UI 组件

扁平 HTML 示例

由于无论如何我们都需要 JavaScript 来制作交互式小部件,我认为最简单的方法(从服务器端实现的角度来看)是只要求最少的 HTML。其余部分可以用 JS 增强。

以下是 选项卡手风琴 组件的标记示例,显示了 JS 如何影响 DOM 的前后比较。

我添加了 id="TABS_ID"id="ACCORDION_ID" 用于演示目的。这样做是为了更明显地显示发生了什么。但我会解释的 JS 会在 HTML 中没有提供任何内容的情况下自动生成唯一的 ID。无论是否指定 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 -->

选项卡(带 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 -->

手风琴(无 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 -->

手风琴(带 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 -->

原生 JavaScript 示例

好的。现在我们已经看到了上述 HTML 示例,让我们一起了解一下如何从之前之后

首先,我想介绍几个辅助函数。这些函数稍后会更有意义。我认为最好先将它们记录下来,这样我们就可以在深入研究其他代码时保持专注。

文件: getDomFallback.js

此函数提供常见的 DOM 属性和方法作为 no-op,而不是必须进行大量的 typeof foo.getAttribute 检查等等。我们可以完全放弃这些类型的确认。

由于实时 HTML 更改可能是潜在的波动环境,我一直觉得确保我的 JS 不会崩溃并带走页面其他部分会更安全一些。以下是该函数的外观。它只是返回一个包含 DOM 等效于虚假结果的对象。

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

文件: unique.js

此函数是一个简陋的 UUID 等效项。

它生成一个唯一的字符串,可用于将 DOM 元素相互关联。它很方便,因为这样 HTML 页面作者就不必确保每个**选项卡**和**手风琴**组件都有唯一的 ID。在前面的 HTML 示例中,这就是 TABS_IDACCORDION_ID 通常包含随机生成的数字字符串的地方。

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

在较大的 JavaScript 项目中,我通常会使用 npm install uuid。但由于我们希望保持简单,并且不需要加密奇偶校验,因此连接两个经过轻微编辑的 Math.random() 数字足以满足我们对 string 唯一性的需求。

文件:tablist.js

此文件完成了大部分工作。如果我这么说的话,它很酷的地方在于,**选项卡**组件和**手风琴**之间存在足够的相似之处,我们可以用同一个 *.js 文件处理两者。继续滚动浏览整个内容,然后我们将逐个分解每个函数的作用。

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

函数:getTabIdgetPanelId

这两个函数用于基于现有的(或生成的)父 ID 为循环中的元素创建唯一 ID。这有助于确保与 aria-controls="…"aria-labelledby="…" 等属性匹配的值。将它们视为 <label for="…"> 的可访问性等效项,告诉浏览器哪些元素相互关联。

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

函数:globalClick

这是一个在 document 级别应用的点击处理程序。这意味着我们不必手动将点击处理程序添加到多个元素。相反,我们使用 事件冒泡 来监听文档中更深层的点击,并允许它们向上传播到顶部。

方便的是,这也是我们如何处理键盘事件,例如 ArrowLeftArrowRightEnter(或空格键)被按下。这些对于拥有可访问的 UI 来说是必要的。

在函数的第一部分,我们从传入的 event 中解构 targetkey。接下来,我们从 target 中解构 parentNodetagName

然后,我们尝试获取包装元素。这将是具有 class="tabs"class="accordion" 的元素。因为我们实际上可能正在点击 DOM 树中最顶层的祖先元素——该元素存在但可能没有 *.closest(…) 方法——我们进行 typeof 检查。如果该函数存在,我们将尝试获取该元素。即使这样,我们也可能匹配不到。因此,我们还有另一个 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();
}

然后,我们存储一个布尔值,表示包装元素是否具有 aria-multiselectable="true"。我会回到这一点。同样,我们存储被点击的标签是否为 <li>。我们稍后需要此信息。

我们还确定点击是否发生在相关的 target 上。请记住,我们使用事件冒泡,所以实际上用户可以点击任何东西。我们还稍微询问一下事件,以确定它是否是由用户按下键触发的。如果是,那么我们确定键是否相关。

我们要确保它

  • 具有 role="tab"
  • 具有 role="tablist" 的父元素

然后我们将其他布尔值捆绑到两个类别中,isArrowEventisTriggerEvent。进而进一步组合成 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();
}

然后我们进入一个 if 条件语句,检查是否按下了左箭头键或右箭头键。如果是,那么我们希望将焦点更改为相应的相邻选项卡。如果我们已经在列表的开头,我们将跳转到结尾。或者,如果我们已经在结尾,我们将跳转到开头。

通过触发 click 事件,这会导致该函数再次执行。然后它被评估为触发事件。这在下一部分中进行了说明。

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

假设触发 event 确实有效,我们通过了下一个 if 检查。现在,我们关注的是获取 role="tabpanel" 元素,其 id 与我们选项卡的 aria-controls="…" 匹配。

一旦我们获得了它,我们检查面板是否隐藏以及选项卡是否被选中。基本上,我们首先假定我们正在处理**手风琴**,并将布尔值翻转为其相反值。

这也是我们之前 isListItem 布尔值发挥作用的地方。如果用户正在点击 <li>,那么我们知道我们正在处理**选项卡**,而不是**手风琴**。在这种情况下,我们希望将我们的面板标记为可见(通过 aria-hiddden="false")并将我们的选项卡标记为选中(通过 aria-selected="true")。

此外,我们希望确保包装器具有 aria-multiselectable="false" 或完全缺少 aria-multiselectable。如果是这种情况,那么我们遍历所有相邻的 role="tab" 和所有 role="tabpanel" 元素,并将它们设置为非活动状态。最后,我们到达为单个选项卡和面板对设置先前确定的布尔值。

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

函数:addAriaAttributes

敏锐的读者可能正在思考

你之前说过我们从最基本的标记开始,但 globalClick 函数正在查找不存在的属性。你为什么要撒谎!?

或者可能不是,因为敏锐的读者也会注意到名为 addAriaAttributes 的函数。事实上,此函数完全按照名称所述的那样执行。它通过添加所有必需的 aria-*role 属性,为基本 DOM 结构注入生命力。

这不仅使 UI 本质上对辅助技术更易访问,而且还确保功能实际有效。我更喜欢以这种方式构建原生 JS 内容,而不是围绕 class="…" 进行交互,因为它迫使我考虑整个用户体验,而不仅仅是我可以直观看到的内容。

首先,我们获取页面上具有 class="tabs" 和/或 class="accordion" 的所有元素。然后我们检查是否有东西可以处理。如果没有,那么我们将在此处退出函数。假设我们确实有一个列表,我们将遍历每个包装元素,并将它们作为 wrapper 传递到函数的作用域中。

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

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

在循环函数的作用域内,我们从 wrapper 中解构 idclassList。如果没有 ID,那么我们使用 unique() 生成一个 ID。我们设置一个布尔标志,以标识我们是否正在处理**手风琴**。这将在稍后使用。

我们还通过其类名称选择器获取 wrapper 的后代,它们是选项卡和面板。

选项卡

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

面板

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

然后我们设置包装器的 id(如果它还没有 ID)。

如果我们正在处理一个缺少 aria-multiselectable="false" 的**手风琴**,我们将将其标志设置为 true。原因是,如果开发人员正在寻求手风琴 UI 范式——并且还有选项卡可供他们使用,而选项卡本质上是互斥的——那么更安全的假设是手风琴应该支持展开和折叠多个面板。

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

接下来,我们遍历选项卡。其中,我们也处理我们的面板。

您可能想知道为什么这是旧式的 for 循环,而不是更现代的 *.forEach。原因是我们希望遍历两个 NodeList 实例:选项卡和面板。假设它们一一对应,我们知道它们都有相同的 *.length。这使我们能够使用一个循环而不是两个循环。

让我们仔细看看循环内部。首先,我们为每个选项卡和面板获取唯一的 ID。这些看起来像以下两种情况之一。这些将在稍后使用,以将选项卡与面板关联起来,反之亦然。

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

在我们循环遍历时,我们首先确保存在一个展开/折叠图标。如果需要,我们创建它,并将其设置为 aria-hidden="true",因为它纯粹是装饰性的。

接下来,我们检查当前选项卡的属性。如果选项卡上不存在 id="…",我们添加它。同样,如果 aria-controls="…" 不存在,我们也会添加它,指向我们新创建的 panelId

您会注意到这里有一个小技巧,检查我们是否没有 aria-selected,然后进一步确定我们是否不在手风琴的上下文中,以及 index 是否为 0。在这种情况下,我们希望让我们的第一个选项卡看起来被选中。原因是,虽然手风琴可以完全折叠,但选项卡内容则不能。至少始终有一个面板可见。

然后我们确保 role="tab" 存在。我们将循环的当前 index 存储为 data-index="…",以备我们稍后需要它进行键盘导航。

我们还添加了正确的 tabindex="0" 或可能 tabindex="-1",具体取决于它是哪种类型的项目。这使得**手风琴**的所有触发器都可以接收键盘 :focus,而不仅仅是**选项卡**布局中的当前活动触发器。

最后,我们检查我们是否在循环的第一次迭代中,其中 index0。如果是,我们将向上遍历一级到 parentNode。如果该元素没有 role="tablist",那么我们将添加它。

我们通过 `parentNode` 而不是 `wrapper` 来实现这一点,因为在选项卡(非手风琴)的上下文中,选项卡 `<li>` 周围有一个 `<ul>` 元素,它需要 `role="tablist"`。在手风琴的情况下,它将是最外层的 `<div>` 祖先。此代码同时考虑了这两种情况。

我们还会根据 UI 类型设置正确的 `aria-orientation`。手风琴是 `vertical`,选项卡是 `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);
    }
  }
}

继续在前面的 `for` 循环中,我们为每个 `panel` 添加属性。如果需要,我们添加一个 `id`。我们还会根据是手风琴(或不是)的上下文将 `aria-hidden` 设置为 `true` 或 `false`。

同样,我们确保面板通过 `aria-labelledby="…" ` 指向其选项卡触发器,并且 `role="tabpanel"` 已设置。我们还为它提供 `tabindex="0"`,以便它可以接收 `: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);
}

在文件的最末尾,我们有一些设置和拆卸函数。为了与页面中可能存在的其他 JS 友好相处,我们提供了一个 `unbind` 函数,它会删除我们的全局事件监听器。它可以单独调用,通过 `tablist.unbind()`,但主要存在是为了在(重新)绑定之前执行 `unbind()`。这样,我们就可以防止重复。

在我们的 `init` 函数中,我们调用 `addAriaAttributes()`,它会修改 DOM 以使其可访问。然后我们调用 `unbind()`,然后将我们的事件监听器添加到 `document` 中。

最后,我们将这两种方法捆绑到一个父对象中,并在名称为 `tablist` 的情况下导出它。这样,当将其放入一个扁平的 HTML 页面时,我们可以在准备应用我们的功能时调用 `tablist.init()`。

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

蝙蝠侠:开战时刻 中有一个场景,卢修斯·福克斯(摩根·弗里曼 饰)向正在康复的布鲁斯·韦恩(克里斯蒂安·贝尔 饰)解释了他采取的科学步骤,以救治他因中毒而濒临死亡的生命。

卢修斯·福克斯:“我分析了你的血液,分离了受体化合物和基于蛋白质的催化剂。”

布鲁斯·韦恩:“我应该理解这些吗?”

卢修斯·福克斯:“一点也不,我只是想让你知道这有多难。最重要的是,我合成了一种解毒剂。”

Morgan Freeman and Christian Bale, sitting inside the Batmobile
“我如何配置 Webpack?”

↑ 当使用框架时,我会用这样的术语思考。

现在我们知道,对原始 DOM 操作和事件绑定来说,这是多么“困难”——实际上并不难,但请容我这样说——我们可以更好地理解解毒剂的存在。React 将许多这样的复杂性抽象化,并自动为我们处理。

文件:Tabs.js

现在我们开始深入 React 示例,我们将从 `<Tabs>` 组件开始。

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

以下是我们 `Tabs.js` 文件的内容。请注意,在 React 行话中,使用与 `export default` 组件相同的首字母大写来命名文件是一种标准做法。

我们从与我们的原生 JS 方法相同的 `getTabId` 和 `getPanelId` 函数开始,因为我们仍然需要确保以可访问的方式将选项卡映射到组件。看一下整个代码,然后我们将继续分解它。

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;

函数:`getIsActive`

由于 `<Tabs>` 组件始终具有活动且可见的内容,因此此函数包含一些逻辑来确定给定选项卡的 `index` 是否应该成为幸运儿。从本质上讲,逻辑用一句话来说就是:

如果满足以下条件,则当前选项卡处于活动状态

  • 它的 `index` 与 `activeIndex` 相匹配,或者
  • 选项卡 UI 只有一个选项卡,或者
  • 它是第一个选项卡,并且 `activeIndex` 选项卡不存在。
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;
};

函数:`getTabsList`

此函数生成可点击的 `<li role="tabs">` UI,并将其包装在一个父 `<ul role="tablist">` 中。它分配所有相关的 `aria-*` 和 `role` 属性,并处理绑定 `onClick` 和 `onKeyDown` 事件。当触发事件时,`setActiveIndex` 被调用。这会更新组件的内部状态。

值得注意的是 `<li>` 的内容是如何生成的。它作为父 `<Tabs>` 组件的 `<div label="…">` 子元素传入。虽然这在扁平的 HTML 中不是一个真实的概念,但它是一种方便的方式来考虑内容之间的关系。该 `<div>` 的 `children` 稍后将成为我们 `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>
  );
};

函数:`getPanelsList`

此函数解析顶级组件传入的 `children` 并提取内容。它还使用 `getIsActive` 来确定是否应用 `aria-hidden="true"`。正如我们现在可能预期的那样,它也会添加所有其他相关的 `aria-*` 和 `role` 属性。它还应用了传入的任何额外的 `className` 或 `style`。

它还足够“智能”,可以将任何 `string` 内容(任何还没有包装标签的内容)包装在 `<p>` 标签中,以确保一致性。

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

函数:`Tabs`

这是主要组件。它为一个 `id` 设置一个内部状态,本质上是缓存任何生成的 `uuid()`,以确保它在组件的生命周期内不会改变。React 对动态变化的 `key` 属性(在前面的循环中)很挑剔,因此这可以确保它们在设置后保持静态。

我们还使用 `useState` 来跟踪当前选中的选项卡,并向下传递一个 `setActiveIndex` 函数到每个 `<li>` 中,以监控它们被点击的时间。在那之后,它就相当简单了。我们调用 `getTabsList` 和 `getPanelsList` 来构建我们的 UI,然后将它们全部包装在 `<div role="tablist">` 中。

它接受任何包装级别的 `className` 或 `style`,以防任何人希望在实现过程中进行进一步的调整。为其他开发人员(作为消费者)提供这种灵活性意味着需要对核心组件进行进一步编辑的可能性更低。最近,我一直将此作为我创建的所有组件的“最佳实践”。

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

如果您对 `<Render>` 函数感兴趣,您可以 阅读更多关于此示例的信息

文件: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>

正如您可能已经推断的那样——由于原生 JS 示例同时处理选项卡和手风琴——此文件与 `Tabs.js` 的工作方式有很多相似之处。

为了避免赘述,我将简单地提供该文件的内容以确保完整性,然后讨论逻辑与 `<Tabs>` 不同的具体区域。因此,请看一下内容,我将解释是什么让 `<Accordion>` 与众不同。

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;

函数:`handleClick`

虽然我们的大多数 `<Accordion>` 逻辑与 `<Tabs>` 相似,但它在存储当前活动选项卡的方式上有所不同。

由于 `<Tabs>` 始终是互斥的,我们实际上只需要一个单独的数字 `index`。非常简单。

但是,由于 `<Accordion>` 可以同时显示多个面板(或以互斥的方式使用),因此我们需要以一种可以处理这两种情况的方式将它表示给 `useState`。

如果您开始思考……

“我会将它存储在一个对象中。”

…那么恭喜您。您是正确的!

此函数会快速检查 `isMulti` 是否已设置为 `true`。如果是,我们将使用 扩展语法 将现有的 `activeItems` 应用于我们的 `newState` 对象。然后,我们将当前的 `index` 设置为它的布尔值相反。

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

作为参考,以下是当只有第一个手风琴面板处于活动状态,用户点击第二个面板时,我们的activeItems对象的样子。两个索引都将设置为true。 这允许同时查看两个展开的role="tabpanel"

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

{
  0: true,
  1: true,
}

而如果我们没有在isMulti模式下运行——当包装器具有aria-multiselectable="false"时——那么activeItems将只包含一个键值对。

因为我们不会扩展当前的activeItems,而是扩展null。 这实际上会将状态擦除干净,然后再记录当前活动的标签。

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

{
  1: true,
}

结论

还在吗? 太棒了。

希望您发现这篇文章信息丰富,也许还从中学习了更多关于可访问性和 JS(X) 的知识。 为了回顾,让我们再看一遍我们的纯 HTML 示例以及我们<Tabs>组件的 React 使用。 以下是我们在原生 JS 方法中编写的标记与生成相同内容所需的 JSX 的比较。

我并不是说哪一个更好,但您可以看到 React 如何使您可以将事物提炼成一个思维模型。 在 HTML 中直接工作,您始终必须关注每个标签。

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>

↑ 其中一个可能看起来更可取,取决于您的观点。

编写更接近底层的代码意味着更直接的控制,但也意味着更多繁琐的工作。 使用 React 等框架意味着您可以“免费”获得更多功能,但也可能成为一个黑盒子。

也就是说,除非您已经理解了底层细微差别。 然后,您可以在任何一个领域自如地操作。 因为你可以看到 The Matrix 的本质:仅仅是 JavaScript™。 不管你在哪里,这都是一个不错的地方。