HowTo 组件 - HowTo 标签页

摘要

<howto-tabs> 通过将可见内容拆分到多个面板中加以限制。仅限 一次只能显示一个面板,而所有对应的标签页始终会 可见。要从一个面板切换到另一个面板,必须 已选择。

通过点击或使用箭头键,用户可以更改 有效标签页的选择。

如果 JavaScript 已停用,所有面板都会显示在 各个标签这些标签页现在可用作标题。

参考

演示

在 GitHub 上观看实时演示

用法示例

<style>
  howto-tab {
    border: 1px solid black;
    padding: 20px;
  }
  howto-panel {
    padding: 20px;
    background-color: lightgray;
  }
  howto-tab[selected] {
    background-color: bisque;
  }

如果 JavaScript 未运行,则该元素将与 :defined 不匹配。在这种情况下,此样式会增加标签页与上一个面板之间的间距。

  howto-tabs:not(:defined), howto-tab:not(:defined), howto-panel:not(:defined) {
    display: block;
  }
</style>

<howto-tabs>
  <howto-tab role="heading" slot="tab">Tab 1</howto-tab>
  <howto-panel role="region" slot="panel">Content 1</howto-panel>
  <howto-tab role="heading" slot="tab">Tab 2</howto-tab>
  <howto-panel role="region" slot="panel">Content 2</howto-panel>
  <howto-tab role="heading" slot="tab">Tab 3</howto-tab>
  <howto-panel role="region" slot="panel">Content 3</howto-panel>
</howto-tabs>

代码

(function() {

定义按键代码以帮助处理键盘事件。

  const KEYCODE = {
    DOWN: 40,
    LEFT: 37,
    RIGHT: 39,
    UP: 38,
    HOME: 36,
    END: 35,
  };

为避免为每个新实例使用 .innerHTML 调用解析器,所有 <howto-tabs> 实例都会共享 shadow DOM 内容的模板。

  const template = document.createElement('template');
  template.innerHTML = `
    <style>
      :host {
        display: flex;
        flex-wrap: wrap;
      }
      ::slotted(howto-panel) {
        flex-basis: 100%;
      }
    </style>
    <slot name="tab"></slot>
    <slot name="panel"></slot>
  `;

HowtoTabs 是标签页和面板的容器元素。

<howto-tabs> 的所有子项都应为 <howto-tab><howto-tabpanel>。此元素是无状态的,这意味着系统不会缓存任何值,因此在运行时工作期间会进行更改。

  class HowtoTabs extends HTMLElement {
    constructor() {
      super();

未附加到此元素的事件处理脚本如果需要访问 this,则需要进行绑定。

      this._onSlotChange = this._onSlotChange.bind(this);

要进行渐进式增强,应在标签和面板之间交替显示标记。对子项重新排序的元素往往无法在框架中正常发挥作用。而 shadow DOM 则通过槽位来对元素进行重新排序。

      this.attachShadow({ mode: 'open' });

导入共享模板,为标签页和面板创建广告位。

      this.shadowRoot.appendChild(template.content.cloneNode(true));

      this._tabSlot = this.shadowRoot.querySelector('slot[name=tab]');
      this._panelSlot = this.shadowRoot.querySelector('slot[name=panel]');

此元素需要使用 aria-labelledbyaria-controls 在语义上关联标签页和面板,因此需要对新的子元素做出响应。新的子项将自动被分配到槽位,并导致 slotchange 触发,因此不需要 MutationObserver。

      this._tabSlot.addEventListener('slotchange', this._onSlotChange);
      this._panelSlot.addEventListener('slotchange', this._onSlotChange);
    }

connectedCallback() 通过重新排序对标签页和面板进行分组,并确保只有一个标签页处于活动状态。

    connectedCallback() {

该元素需要执行一些手动输入事件处理,以便使用箭头键和 Home / End 进行切换。

      this.addEventListener('keydown', this._onKeyDown);
      this.addEventListener('click', this._onClick);

      if (!this.hasAttribute('role'))
        this.setAttribute('role', 'tablist');

直到最近,当解析器升级某个元素时,slotchange 事件都不会触发。因此, 元素会手动调用处理程序。一旦这种新行为在所有浏览器中生效,您就可以移除以下代码。

      Promise.all([
        customElements.whenDefined('howto-tab'),
        customElements.whenDefined('howto-panel'),
      ])
        .then(() => this._linkPanels());
    }

disconnectedCallback() 会移除 connectedCallback() 添加的事件监听器。

    disconnectedCallback() {
      this.removeEventListener('keydown', this._onKeyDown);
      this.removeEventListener('click', this._onClick);
    }

每当向其中一个 shadow DOM 槽位添加或移除元素时,都会调用 _onSlotChange()

    _onSlotChange() {
      this._linkPanels();
    }

_linkPanels() 使用 aria-controls 和 aria-labelledby 将标签页与相邻面板关联。此外,该方法可确保只有一个标签页处于活动状态。

    _linkPanels() {
      const tabs = this._allTabs();

为每个面板指定一个 aria-labelledby 属性,用以指示控制该面板的标签页。

      tabs.forEach((tab) => {
        const panel = tab.nextElementSibling;
        if (panel.tagName.toLowerCase() !== 'howto-panel') {
          console.error(`Tab #${tab.id} is not a` +
            `sibling of a <howto-panel>`);
          return;
        }

        tab.setAttribute('aria-controls', panel.id);
        panel.setAttribute('aria-labelledby', tab.id);
      });

元素会检查是否有任何标签页被标记为“选中”。如果没有,系统会立即选择第一个标签页。

      const selectedTab =
        tabs.find((tab) => tab.selected) || tabs[0];

接下来,切换到所选标签页。_selectTab() 负责将所有其他标签页标记为未选中,并隐藏所有其他面板。

      this._selectTab(selectedTab);
    }

_allPanels() 会返回标签页面板中的所有面板。如果 DOM 查询出现性能问题,此函数可以记住结果。记住,这样做的缺点是,系统不会处理动态添加的标签页和面板。

这是一个方法而不是 getter,因为 getter 意味着读取开销较低。

    _allPanels() {
      return Array.from(this.querySelectorAll('howto-panel'));
    }

_allTabs() 会返回标签页面板中的所有标签页。

    _allTabs() {
      return Array.from(this.querySelectorAll('howto-tab'));
    }

_panelForTab() 用于返回指定标签页所控制的面板。

    _panelForTab(tab) {
      const panelId = tab.getAttribute('aria-controls');
      return this.querySelector(`#${panelId}`);
    }

_prevTab() 会返回当前所选标签页之前的标签页,并在到达第一个标签页时换行。

    _prevTab() {
      const tabs = this._allTabs();

使用 findIndex() 查找当前所选元素的索引,并减去 1 以获取上一个元素的索引。

      let newIdx = tabs.findIndex((tab) => tab.selected) - 1;

添加 tabs.length 以确保索引为正数,并根据需要获取模数。

      return tabs[(newIdx + tabs.length) % tabs.length];
    }

_firstTab() 会返回第一个标签页。

    _firstTab() {
      const tabs = this._allTabs();
      return tabs[0];
    }

_lastTab() 会返回最后一个标签页。

    _lastTab() {
      const tabs = this._allTabs();
      return tabs[tabs.length - 1];
    }

_nextTab() 会获取当前所选标签页后面的标签页,并在到达最后一个标签页时换行。

    _nextTab() {
      const tabs = this._allTabs();
      let newIdx = tabs.findIndex((tab) => tab.selected) + 1;
      return tabs[newIdx % tabs.length];
    }

reset() 会将所有标签页标记为未选中,并隐藏所有面板。

    reset() {
      const tabs = this._allTabs();
      const panels = this._allPanels();

      tabs.forEach((tab) => tab.selected = false);
      panels.forEach((panel) => panel.hidden = true);
    }

_selectTab() 会将给定标签页标记为选中状态。此外,还会取消隐藏与指定标签页对应的面板。

    _selectTab(newTab) {

取消选择所有标签页并隐藏所有面板。

      this.reset();

获取与 newTab 关联的面板。

      const newPanel = this._panelForTab(newTab);

如果该面板不存在,请取消。

      if (!newPanel)
        throw new Error(`No panel with id ${newPanelId}`);
      newTab.selected = true;
      newPanel.hidden = false;
      newTab.focus();
    }

_onKeyDown() 处理标签页面板内的按键操作。

    _onKeyDown(event) {

如果按键操作并非源自 Tab 元素本身,则为面板内部或空白区域的按键操作。无需任何操作。

      if (event.target.getAttribute('role') !== 'tab')
        return;

不要处理辅助技术通常使用的修饰符快捷键。

      if (event.altKey)
        return;

switch-case 会根据按下的键确定将哪个标签页标记为使用中。

      let newTab;
      switch (event.keyCode) {
        case KEYCODE.LEFT:
        case KEYCODE.UP:
          newTab = this._prevTab();
          break;

        case KEYCODE.RIGHT:
        case KEYCODE.DOWN:
          newTab = this._nextTab();
          break;

        case KEYCODE.HOME:
          newTab = this._firstTab();
          break;

        case KEYCODE.END:
          newTab = this._lastTab();
          break;

系统将忽略任何其他按键操作,并将其传递回浏览器。

        default:
          return;
      }

浏览器可能会将一些本机功能绑定到箭头键(Home 键或 End 键)。此元素会调用 preventDefault(),以防止浏览器执行任何操作。

      event.preventDefault();

选择在切换情况下已确定的新标签页。

      this._selectTab(newTab);
    }

_onClick() 处理标签页面板内的点击操作。

    _onClick(event) {

如果点击并非针对标签页元素本身,则为面板内部或空白区域的点击。无需任何操作。

      if (event.target.getAttribute('role') !== 'tab')
        return;

但是,如果它位于标签元素上,请选择该标签。

      this._selectTab(event.target);
    }
  }

  customElements.define('howto-tabs', HowtoTabs);

howtoTabCounter 计算创建的 <howto-tab> 实例的数量。该编号用于生成新的唯一 ID。

  let howtoTabCounter = 0;

HowtoTab<howto-tabs> 标签页面板的标签页。在标记中,<howto-tab> 应始终与 role="heading" 一起使用,以便在 JavaScript 失败时语义依然可用。

<howto-tab> 通过将该面板的 ID 用作 aria-controls 属性的值来声明其属于哪个 <howto-panel>

如果未指定唯一 ID,<howto-tab> 会自动生成唯一 ID。

  class HowtoTab extends HTMLElement {

    static get observedAttributes() {
      return ['selected'];
    }

    constructor() {
      super();
    }

    connectedCallback() {

如果执行了此语句,则表示 JavaScript 运行正常,且元素将其角色更改为 tab

      this.setAttribute('role', 'tab');
      if (!this.id)
        this.id = `howto-tab-generated-${howtoTabCounter++}`;

设置明确定义的初始状态。

      this.setAttribute('aria-selected', 'false');
      this.setAttribute('tabindex', -1);
      this._upgradeProperty('selected');
    }

检查属性是否具有实例值。如果是这样,请复制该值并删除实例属性,使其不会覆盖类属性 setter。最后,将值传递给类属性 setter,以便它触发任何附带效应。这是为了防止出现以下情况:例如,框架可能向页面添加了该元素,并在其某个属性上设置了值,但延迟加载了其定义。如果没有这一防护,升级后的元素将缺少该属性,实例属性将阻止调用类属性 setter。

    _upgradeProperty(prop) {
      if (this.hasOwnProperty(prop)) {
        let value = this[prop];
        delete this[prop];
        this[prop] = value;
      }
    }

属性及其对应的属性应相互镜像。为此,selected 的属性 setter 会处理 true/falsy 值,并将这些值反映到属性的状态。请务必注意,属性 setter 中不会产生副作用。例如,setter 不会设置 aria-selected。相反,这项工作在 attributeChangedCallback 中进行。一般来说,应让属性 setter 过于笨拙,如果设置某个属性或属性应产生副作用(例如设置相应的 ARIA 属性),则应在 attributeChangedCallback() 中执行此操作。这样就不必处理复杂的属性/媒体资源可重入场景。

    attributeChangedCallback() {
      const value = this.hasAttribute('selected');
      this.setAttribute('aria-selected', value);
      this.setAttribute('tabindex', value ? 0 : -1);
    }

    set selected(value) {
      value = Boolean(value);
      if (value)
        this.setAttribute('selected', '');
      else
        this.removeAttribute('selected');
    }

    get selected() {
      return this.hasAttribute('selected');
    }
  }

  customElements.define('howto-tab', HowtoTab);

  let howtoPanelCounter = 0;

HowtoPanel<howto-tabs> 标签页面板的面板。

  class HowtoPanel extends HTMLElement {

    constructor() {
      super();
    }

    connectedCallback() {
      this.setAttribute('role', 'tabpanel');
      if (!this.id)
        this.id = `howto-panel-generated-${howtoPanelCounter++}`;
    }
  }

  customElements.define('howto-panel', HowtoPanel);
})();