Komponen Petunjuk – tab petunjuk

Ringkasan

<howto-tabs> membatasi konten yang terlihat dengan memisahkannya menjadi beberapa panel. Hanya satu panel terlihat pada satu waktu, sementara semua tab yang sesuai selalu terlihat. Untuk beralih dari satu panel ke panel lainnya, tab yang sesuai harus dipilih.

Dengan mengklik atau menggunakan tombol panah, pengguna dapat mengubah pilihan tab yang aktif.

Jika JavaScript dinonaktifkan, semua panel ditampilkan berselang-seling dengan tab masing-masing. Tab kini berfungsi sebagai judul.

Referensi

Demo

Lihat demo live di GitHub

Contoh penggunaan

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

Jika JavaScript tidak berjalan, elemen tidak akan cocok dengan :defined. Dalam hal ini, gaya ini menambahkan spasi antara tab dan panel sebelumnya.

  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>

Kode

(function() {

Menentukan kode tombol untuk membantu menangani peristiwa keyboard.

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

Agar tidak memanggil parser dengan .innerHTML untuk setiap instance baru, template untuk konten shadow DOM digunakan bersama oleh semua instance <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 adalah elemen penampung untuk tab dan panel.

Semua turunan dari <howto-tabs> harus <howto-tab> atau <howto-tabpanel>. Elemen ini bersifat stateless, artinya tidak ada nilai yang di-cache dan, dengan demikian, akan berubah selama pekerjaan runtime.

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

Pengendali peristiwa yang tidak dilampirkan ke elemen ini harus diikat jika memerlukan akses ke this.

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

Untuk progressive enhancement, markup harus bergantian antara tab dan panel. Elemen yang menyusun ulang turunannya cenderung tidak berfungsi dengan baik dengan framework. Sebagai gantinya, shadow DOM digunakan untuk menyusun ulang elemen dengan menggunakan slot.

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

Impor template bersama untuk membuat slot tab dan panel.

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

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

Elemen ini perlu bereaksi terhadap turunan baru saat menautkan tab dan panel secara semantik menggunakan aria-labelledby dan aria-controls. Turunan baru akan ditempatkan secara otomatis dan menyebabkan slotchange diaktifkan, sehingga MutationObserver tidak diperlukan.

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

connectedCallback() mengelompokkan tab dan panel dengan mengurutkan ulang dan memastikan hanya satu tab yang aktif.

    connectedCallback() {

Elemen perlu melakukan beberapa penanganan peristiwa input manual untuk memungkinkan peralihan dengan tombol panah dan tombol Home / End.

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

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

Hingga baru-baru ini, peristiwa slotchange tidak diaktifkan saat sebuah elemen diupgrade oleh parser. Karena alasan ini, elemen memanggil handler secara manual. Setelah perilaku baru diterapkan di semua browser, kode di bawah dapat dihapus.

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

disconnectedCallback() menghapus pemroses peristiwa yang ditambahkan connectedCallback().

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

_onSlotChange() dipanggil setiap kali elemen ditambahkan atau dihapus dari salah satu slot shadow DOM.

    _onSlotChange() {
      this._linkPanels();
    }

_linkPanels() menautkan tab dengan panel yang berdekatan menggunakan kontrol aria dan aria-labelledby. Selain itu, metode ini memastikan hanya satu tab yang aktif.

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

Beri setiap panel atribut aria-labelledby yang merujuk ke tab yang mengontrolnya.

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

Elemen ini memeriksa apakah ada tab yang telah ditandai sebagai dipilih. Jika tidak, tab pertama akan dipilih.

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

Selanjutnya, beralihlah ke tab yang dipilih. _selectTab() akan menandai semua tab lain sebagai tidak dipilih dan menyembunyikan semua panel lainnya.

      this._selectTab(selectedTab);
    }

_allPanels() menampilkan semua panel di panel tab. Fungsi ini dapat mengingat hasil jika kueri DOM menjadi masalah performa. Kelemahan menghafal adalah tab dan panel yang ditambahkan secara dinamis tidak akan ditangani.

Ini adalah metode dan bukan pengambil, karena pengambil menyiratkan bahwa variabel tersebut murah untuk dibaca.

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

_allTabs() menampilkan semua tab di panel tab.

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

_panelForTab() menampilkan panel yang dikontrol tab tertentu.

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

_prevTab() menampilkan tab yang muncul sebelum tab yang saat ini dipilih, muncul bersamaan saat mencapai tab pertama.

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

Gunakan findIndex() untuk menemukan indeks elemen yang saat ini dipilih dan kurangi satu untuk mendapatkan indeks elemen sebelumnya.

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

Tambahkan tabs.length untuk memastikan indeksnya berupa angka positif dan dapatkan modulus untuk digabungkan jika perlu.

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

_firstTab() menampilkan tab pertama.

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

_lastTab() menampilkan tab terakhir.

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

_nextTab() mendapatkan tab yang muncul setelah tab yang saat ini dipilih, muncul bersamaan saat mencapai tab terakhir.

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

reset() menandai semua tab sebagai tidak dipilih dan menyembunyikan semua panel.

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

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

_selectTab() menandai tab yang diberikan sebagai dipilih. Selain itu, tindakan ini akan menampilkan panel yang sesuai dengan tab yang ditentukan.

    _selectTab(newTab) {

Batalkan pilihan semua tab dan sembunyikan semua panel.

      this.reset();

Dapatkan panel yang terkait dengan newTab.

      const newPanel = this._panelForTab(newTab);

Jika panel tersebut tidak ada, batalkan.

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

_onKeyDown() menangani penekanan tombol di dalam panel tab.

    _onKeyDown(event) {

Jika penekanan tombol tidak berasal dari elemen tab itu sendiri, berarti penekanan tombol di dalam panel atau di ruang kosong. Tidak ada yang harus dilakukan.

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

Tidak menangani pintasan pengubah yang biasanya digunakan oleh teknologi pendukung.

      if (event.altKey)
        return;

{i>Switch-case<i} akan menentukan tab mana yang harus ditandai sebagai aktif tergantung pada tombol yang ditekan.

      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;

Penekanan tombol lainnya akan diabaikan dan diteruskan kembali ke browser.

        default:
          return;
      }

Browser mungkin memiliki beberapa fungsi native yang terikat dengan tombol panah, beranda atau akhir. Elemen ini memanggil preventDefault() untuk mencegah browser melakukan tindakan apa pun.

      event.preventDefault();

Pilih tab baru, yang telah ditentukan dalam kasus peralihan.

      this._selectTab(newTab);
    }

_onClick() menangani klik di dalam panel tab.

    _onClick(event) {

Jika klik tidak ditargetkan pada elemen tab itu sendiri, maka klik tersebut adalah klik di dalam panel atau di ruang kosong. Tidak ada yang harus dilakukan.

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

Jika itu ada di elemen tab, pilih tab itu.

      this._selectTab(event.target);
    }
  }

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

howtoTabCounter menghitung jumlah <howto-tab> instance yang dibuat. Nomor tersebut digunakan untuk membuat ID unik baru.

  let howtoTabCounter = 0;

HowtoTab adalah tab untuk panel tab <howto-tabs>. <howto-tab> harus selalu digunakan dengan role="heading" di markup agar semantik tetap dapat digunakan saat JavaScript gagal.

<howto-tab> mendeklarasikan <howto-panel> milik panel tersebut menggunakan ID panel tersebut sebagai nilai untuk atribut aria-controls.

<howto-tab> akan otomatis membuat ID unik jika tidak ada yang ditentukan.

  class HowtoTab extends HTMLElement {

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

    constructor() {
      super();
    }

    connectedCallback() {

Jika ini dijalankan, JavaScript berfungsi dan elemen mengubah perannya menjadi tab.

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

Tetapkan status awal yang jelas.

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

Periksa apakah properti memiliki nilai instance. Jika demikian, salin nilainya, dan hapus properti instance agar tidak membayangi penyetel properti class. Terakhir, teruskan nilai ke penyetel properti class agar dapat memicu efek samping. Hal ini untuk melindungi dari kasus saat, misalnya, framework mungkin telah menambahkan elemen ke halaman dan menetapkan nilai di salah satu propertinya, tetapi lambat memuat definisinya. Tanpa guard ini, elemen yang diupgrade tidak akan memiliki properti tersebut dan properti instance akan mencegah penyetel properti class dipanggil.

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

Properti dan atributnya yang sesuai harus saling mencerminkan. Oleh karena itu, penyetel properti untuk selected menangani nilai yang benar/salah dan mencerminkan nilai tersebut ke status atribut. Penting untuk diperhatikan bahwa tidak ada efek samping yang terjadi di penyetel properti. Misalnya, penyetel tidak menetapkan aria-selected. Sebagai gantinya, pekerjaan tersebut terjadi di attributeChangedCallback. Sebagai aturan umum, buat penyetel properti menjadi sangat tidak berguna, dan jika menetapkan properti atau atribut akan menyebabkan efek samping (seperti menyetel atribut ARIA yang sesuai), lakukan hal tersebut di attributeChangedCallback(). Dengan demikian, Anda tidak perlu mengelola skenario perekrutan kembali atribut/properti yang kompleks.

    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 adalah panel untuk panel tab <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);
})();