ハウツー コンポーネント – ハウツータブ

まとめ

<howto-tabs> は、表示されるコンテンツを複数のパネルに分割することで制限します。同時に表示されるパネルは 1 つだけで、対応するタブはすべて常に表示されます。パネルを切り替えるには、対応するタブを選択する必要があります。

ユーザーは、クリックするか矢印キーを使用して、アクティブなタブの選択を変更できます。

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 でパーサーが呼び出されないよう、Shadow DOM のコンテンツのテンプレートはすべての <howto-tabs> インスタンスによって共有されます。

  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() は、並べ替えてタブとパネルをグループ化し、アクティブなタブを 1 つだけ表示します。

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

_onSlotChange() は、いずれかの Shadow DOM スロットで要素が追加または削除されるたびに呼び出されます。

    _onSlotChange() {
      this._linkPanels();
    }

_linkPanels() は、アリア コントロールと aria-labelledby を使用して、タブを隣接するパネルにリンクします。また、このメソッドでは 1 つのタブのみがアクティブになるようにしています。

    _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 クエリでパフォーマンスの問題が生じた場合に結果を記憶できます。記憶することの欠点は、動的に追加されたタブやパネルが処理されないことです。

これはメソッドであり、ゲッターではありません。ゲッターは、読み取りが安価であることが暗示されているためです。

    _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) {

タブ要素自体からキーが押されたのではない場合は、パネル内または何もないスペースでのキー操作です。何もする必要はありません。

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

ブラウザには、矢印キー、ホーム、終了キーにバインドされたネイティブ機能がある場合があります。この要素は preventDefault() を呼び出し、ブラウザがアクションを実行しないようにします。

      event.preventDefault();

新しいタブを選択します(switch の場合に決定されています)。

      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> タブパネルのタブです。JavaScript が失敗してもセマンティクスを使用できるように、マークアップでは常に <howto-tab>role="heading" とともに使用する必要があります。

<howto-tab> は、aria-controls 属性の値としてそのパネルの ID を使用することで、どの <howto-panel> に属するかを宣言します。

指定しない場合、<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');
    }

プロパティにインスタンス値があるかどうかを確認します。その場合は、値をコピーし、インスタンス プロパティを削除して、クラス プロパティ セッターがシャドーイングされないようにします。最後に、値をクラス プロパティ セッターに渡して、あらゆる副作用をトリガーできるようにします。これは、たとえばフレームワークが要素をページに追加し、そのプロパティのいずれかに値を設定したが、その定義が遅延読み込みされる場合などを防ぐためです。このガードがないと、アップグレードされた要素でそのプロパティが欠落し、インスタンス プロパティでクラス プロパティ セッターが呼び出されなくなります。

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

プロパティとそれに対応する属性は相互にミラーリングする必要があります。そのために、selected のプロパティ セッターは真偽値/偽値を処理し、それらを属性の状態に反映させます。なお、プロパティ セッターで副作用が発生することはありません。たとえば、セッターは aria-selected を設定しません。その代わりに、その処理は attributeChangedCallback で行われます。原則として、プロパティ セッターは非常にばらばらです。プロパティまたは属性の設定によって副作用(対応する 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);
})();