操作說明元件 – 操作分頁

摘要

<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,以語意方式連結分頁和麵板,因此必須回應新的子項。新的子項會自動加入運算單元,並導致運算單元變更觸發,因此不需要 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);
    }

當其中一個陰影 DOM 版位加入或移除元素時,系統就會呼叫 _onSlotChange()

    _onSlotChange() {
      this._linkPanels();
    }

_linkPanels() 會使用 aria 控制和 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() 找出目前所選元素的索引,減去一個,即可取得前一個元素的索引。

      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;

視所按下的按鍵而定,切換鈕會決定哪個分頁應標示為啟用。

      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;

如果是在 Tab 元素上,請選取該分頁。

      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>

如果沒有指定,<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 會處理趨勢/虛構值,並將這些值反映到屬性狀態。請特別注意,屬性 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);
})();