Komponen Petunjuk – tab petunjuk

Ringkasan

<howto-tabs> membatasi konten yang terlihat dengan memisahkannya menjadi beberapa panel. Hanya satu panel yang terlihat pada satu waktu, sedangkan semua tab yang terkait 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 aktif.

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

Referensi

Demo

Lihat demo langsung 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 akan 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 parser tidak dipanggil dengan .innerHTML untuk setiap instance baru, template untuk konten shadow DOM dibagikan 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 <howto-tabs> harus berupa <howto-tab> atau <howto-tabpanel>. Elemen ini bersifat stateless, artinya tidak ada nilai yang di-cache, sehingga terjadi perubahan selama 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 mengurutkan ulang turunannya cenderung tidak berfungsi 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 bagi 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 karena dapat menautkan tab dan panel secara semantik menggunakan aria-labelledby dan aria-controls. Turunan baru akan ditempatkan secara otomatis dan menyebabkan slotchange terpicu, 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 ini perlu melakukan beberapa penanganan peristiwa input manual untuk memungkinkan peralihan dengan tombol panah dan 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 elemen diupgrade oleh parser. Karena alasan ini, elemen memanggil pengendali secara manual. Setelah perilaku baru ini 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 bersebelahan 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 mengacu pada 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 yang akan dipilih.

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

Selanjutnya, beralihlah ke tab yang dipilih. _selectTab() menangani penandaan semua tab lainnya 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 pernah menjadi masalah performa. Kelemahan dari menghafal adalah tab dan panel yang ditambahkan secara dinamis tidak akan ditangani.

Ini adalah metode dan bukan pengambil, karena pengambil menyiratkan bahwa metode ini 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, yang diputar saat membuka 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 indeks 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, melingkar saat membuka 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 ditentukan sebagai dipilih. Selain itu, panel yang sesuai dengan tab yang ditentukan akan diperlihatkan.

    _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, tekan 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 bergantung 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, home atau end. Elemen ini memanggil preventDefault() untuk mencegah browser melakukan tindakan apa pun.

      event.preventDefault();

Pilih tab baru, yang telah ditentukan dalam {i>switch-case<i}.

      this._selectTab(newTab);
    }

_onClick() menangani klik di dalam panel tab.

    _onClick(event) {

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

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

Namun, jika aktivitas itu berada di elemen tab, pilih tab tersebut.

      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 sehingga semantik tetap dapat digunakan saat JavaScript gagal.

<howto-tab> mendeklarasikan <howto-panel> miliknya dengan 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 dieksekusi, JavaScript berfungsi dan elemen akan mengubah perannya menjadi tab.

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

Menetapkan 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 nilai dan hapus properti instance sehingga tidak membayangi penyetel properti class. Terakhir, teruskan nilai ke penyetel properti class sehingga dapat memicu efek samping. Hal ini untuk melindungi dari kasus ketika, misalnya, framework mungkin telah menambahkan elemen ke halaman dan menetapkan nilai di salah satu propertinya, tetapi memuat definisinya secara lambat. Tanpa pelindung ini, elemen yang diupgrade akan melewatkan 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 mencerminkan satu sama lain. Oleh karena itu, penyetel properti untuk selected menangani nilai benar/salah dan mencerminkan nilai tersebut ke status atribut. Penting untuk diperhatikan bahwa tidak ada efek samping yang terjadi pada penyetel properti. Misalnya, penyetel tidak menetapkan aria-selected. Sebagai gantinya, tugas tersebut dilakukan di attributeChangedCallback. Sebagai aturan umum, buat penyetel properti 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 reentransi 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);
})();