Komponen Petunjuk – tab petunjuk

Ringkasan

<howto-tabs> membatasi konten yang terlihat dengan memisahkannya menjadi beberapa panel. Hanya satu panel yang terlihat dalam satu waktu, sedangkan 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 akan ditampilkan secara bergantian 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 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() {

Tentukan kode tombol untuk membantu menangani peristiwa keyboard.

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

Untuk menghindari pemanggilan parser 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, yang berarti tidak ada nilai yang di-cache sehingga perubahan selama runtime akan berfungsi.

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

Penangan peristiwa yang tidak terpasang ke elemen ini harus terikat 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 dengan baik dengan framework. Sebagai gantinya, shadow DOM digunakan untuk menyusun ulang elemen 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 menautkan tab dan panel secara semantik menggunakan aria-labelledby dan aria-controls. Turunan baru akan mendapatkan slot 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 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. Oleh karena itu, elemen memanggil pengendali 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 aria-controls dan aria-labelledby. Selain itu, metode ini memastikan hanya satu tab yang aktif.

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

Berikan atribut aria-labelledby ke setiap panel 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 sekarang akan dipilih.

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

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

      this._selectTab(selectedTab);
    }

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

Ini adalah metode, bukan pengambil, karena pengambil menyiratkan bahwa metode ini mudah 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 berada sebelum tab yang saat ini dipilih, yang akan di-wrap saat mencapai tab pertama.

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

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

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

Tambahkan tabs.length untuk memastikan indeks adalah bilangan positif dan mendapatkan 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, yang akan di-wrap 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 dibatalkan pilihannya 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 diberikan.

    _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, penekanan tombol tersebut berada di dalam panel atau di ruang kosong. Tidak ada yang harus dilakukan.

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

Jangan menangani pintasan pengubah yang biasanya digunakan oleh teknologi pendukung.

      if (event.altKey)
        return;

Kasus tombol 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 memanggil preventDefault() untuk mencegah browser melakukan tindakan apa pun.

      event.preventDefault();

Pilih tab baru, yang telah ditentukan dalam kasus tombol.

      this._selectTab(newTab);
    }

_onClick() menangani klik di dalam panel tab.

    _onClick(event) {

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

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

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

      this._selectTab(event.target);
    }
  }

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

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

  let howtoTabCounter = 0;

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

<howto-tab> mendeklarasikan <howto-panel> yang dimilikinya 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 dijalankan, JavaScript akan berfungsi dan elemen akan 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 ya, salin nilainya, lalu hapus properti instance agar tidak membayangi penyetel properti class. Terakhir, teruskan nilai ke penyetel properti class agar dapat memicu efek samping apa pun. Hal ini untuk melindungi dari kasus, misalnya, framework mungkin telah menambahkan elemen ke halaman dan menetapkan nilai pada salah satu propertinya, tetapi memuat lambat definisinya. Tanpa penjaga ini, elemen yang diupgrade akan kehilangan 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 atribut terkaitnya harus saling mencerminkan. Untuk efek ini, penyetel properti untuk selected menangani nilai benar/salah dan mencerminkannya 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 bodoh, dan jika menetapkan properti atau atribut akan menyebabkan efek samping (seperti menetapkan atribut ARIA yang sesuai), lakukan tindakan tersebut di attributeChangedCallback(). Hal ini akan menghindari Anda harus mengelola skenario reentran 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);
})();