Thành phần hướng dẫn – thẻ hướng dẫn

Tóm tắt

<howto-tabs> giới hạn nội dung hiển thị bằng cách phân tách nội dung thành nhiều bảng điều khiển. Chỉ một bảng điều khiển hiển thị tại một thời điểm, trong khi tất cả thẻ tương ứng luôn hiển thị. Để chuyển từ bảng điều khiển này sang bảng điều khiển khác, bạn phải chọn thẻ tương ứng.

Bằng cách nhấp hoặc sử dụng các phím mũi tên, người dùng có thể thay đổi lựa chọn của thẻ đang hoạt động.

Nếu JavaScript bị tắt, tất cả bảng điều khiển sẽ hiển thị xen kẽ với các thẻ tương ứng. Giờ đây, các thẻ có chức năng như tiêu đề.

Tài liệu tham khảo

Bản minh hoạ

Xem bản minh hoạ trực tiếp trên GitHub

Ví dụ về cách sử dụng

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

Nếu JavaScript không chạy, phần tử này sẽ không khớp với :defined. Trong trường hợp đó, kiểu này sẽ thêm khoảng cách giữa các thẻ và bảng điều khiển trước đó.

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

Xác định mã phím để giúp xử lý các sự kiện bàn phím.

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

Để tránh gọi trình phân tích cú pháp bằng .innerHTML cho mỗi thực thể mới, tất cả các thực thể <howto-tabs> sẽ dùng chung một mẫu cho nội dung của DOM bóng.

  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 là một phần tử vùng chứa cho các thẻ và bảng điều khiển.

Tất cả phần tử con của <howto-tabs> phải là <howto-tab> hoặc <howto-tabpanel>. Phần tử này không có trạng thái, nghĩa là không có giá trị nào được lưu vào bộ nhớ đệm và do đó, sẽ thay đổi trong thời gian chạy hoạt động.

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

Bạn cần liên kết các trình xử lý sự kiện không được đính kèm vào phần tử này nếu các trình xử lý đó cần quyền truy cập vào this.

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

Để cải tiến dần, mã đánh dấu phải thay đổi giữa các thẻ và bảng điều khiển. Các phần tử sắp xếp lại các phần tử con có xu hướng không hoạt động tốt với các khung. Thay vào đó, shadow DOM được dùng để sắp xếp lại các phần tử bằng cách sử dụng các khung.

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

Nhập mẫu dùng chung để tạo các khe cho thẻ và bảng điều khiển.

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

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

Phần tử này cần phản ứng với các phần tử con mới vì nó liên kết các thẻ và bảng điều khiển theo ngữ nghĩa bằng cách sử dụng aria-labelledbyaria-controls. Các phần tử con mới sẽ tự động được đưa vào vị trí và kích hoạt slotchange, vì vậy, bạn không cần MutationObserver.

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

connectedCallback() nhóm các thẻ và bảng điều khiển bằng cách sắp xếp lại và đảm bảo chỉ có một thẻ đang hoạt động.

    connectedCallback() {

Phần tử này cần thực hiện một số thao tác xử lý sự kiện nhập thủ công để cho phép chuyển đổi bằng các phím mũi tên và Home/End.

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

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

Cho đến gần đây, các sự kiện slotchange không kích hoạt khi trình phân tích cú pháp nâng cấp một phần tử. Vì lý do này, phần tử gọi trình xử lý theo cách thủ công. Sau khi hành vi mới xuất hiện trên tất cả trình duyệt, bạn có thể xoá mã bên dưới.

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

disconnectedCallback() xoá các trình nghe sự kiện mà connectedCallback() đã thêm.

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

_onSlotChange() được gọi bất cứ khi nào một phần tử được thêm hoặc xoá khỏi một trong các khe DOM bóng.

    _onSlotChange() {
      this._linkPanels();
    }

_linkPanels() liên kết các thẻ với bảng điều khiển liền kề bằng các nút điều khiển aria và aria-labelledby. Ngoài ra, phương thức này đảm bảo chỉ có một thẻ đang hoạt động.

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

Cung cấp cho mỗi bảng điều khiển một thuộc tính aria-labelledby tham chiếu đến thẻ kiểm soát bảng điều khiển đó.

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

Phần tử này kiểm tra xem có thẻ nào được đánh dấu là đã chọn hay không. Nếu không, thẻ đầu tiên sẽ được chọn.

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

Tiếp theo, hãy chuyển sang thẻ đã chọn. _selectTab() sẽ đánh dấu tất cả các thẻ khác là đã bỏ chọn và ẩn tất cả các bảng điều khiển khác.

      this._selectTab(selectedTab);
    }

_allPanels() trả về tất cả các bảng trong bảng điều khiển thẻ. Hàm này có thể ghi nhớ kết quả nếu các truy vấn DOM trở thành vấn đề về hiệu suất. Nhược điểm của việc ghi nhớ là các thẻ và bảng điều khiển được thêm động sẽ không được xử lý.

Đây là một phương thức chứ không phải phương thức getter, vì phương thức getter ngụ ý rằng phương thức này có chi phí đọc thấp.

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

_allTabs() trả về tất cả các thẻ trong bảng điều khiển thẻ.

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

_panelForTab() trả về bảng điều khiển mà thẻ đã cho điều khiển.

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

_prevTab() trả về thẻ đứng trước thẻ hiện đang được chọn, bao quanh khi đạt đến thẻ đầu tiên.

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

Sử dụng findIndex() để tìm chỉ mục của phần tử hiện được chọn và trừ đi 1 để lấy chỉ mục của phần tử trước đó.

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

Thêm tabs.length để đảm bảo chỉ mục là số dương và lấy số dư để gói lại nếu cần.

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

_firstTab() trả về thẻ đầu tiên.

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

_lastTab() trả về thẻ gần đây nhất.

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

_nextTab() nhận thẻ nằm sau thẻ hiện đang được chọn, bao quanh khi đạt đến thẻ cuối cùng.

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

reset() đánh dấu tất cả các thẻ là đã bỏ chọn và ẩn tất cả các bảng điều khiển.

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

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

_selectTab() đánh dấu thẻ đã cho là đã chọn. Ngoài ra, bảng điều khiển này cũng sẽ hiện bảng điều khiển tương ứng với thẻ đã cho.

    _selectTab(newTab) {

Bỏ chọn tất cả thẻ và ẩn tất cả bảng điều khiển.

      this.reset();

Lấy bảng điều khiển liên kết với newTab.

      const newPanel = this._panelForTab(newTab);

Nếu bảng điều khiển đó không tồn tại, hãy huỷ.

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

_onKeyDown() xử lý các thao tác nhấn phím bên trong bảng điều khiển thẻ.

    _onKeyDown(event) {

Nếu thao tác nhấn phím không bắt nguồn từ chính phần tử thẻ, thì đó là thao tác nhấn phím bên trong bảng điều khiển hoặc trên không gian trống. Không cần làm gì cả.

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

Không xử lý các phím tắt đối tượng sửa đổi thường được công nghệ hỗ trợ sử dụng.

      if (event.altKey)
        return;

Trường hợp chuyển đổi sẽ xác định thẻ nào được đánh dấu là đang hoạt động tuỳ thuộc vào phím đã nhấn.

      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;

Mọi thao tác nhấn phím khác đều bị bỏ qua và được chuyển lại trình duyệt.

        default:
          return;
      }

Trình duyệt có thể có một số chức năng gốc liên kết với các phím mũi tên, phím trang chủ hoặc phím kết thúc. Phần tử này gọi preventDefault() để ngăn trình duyệt thực hiện bất kỳ hành động nào.

      event.preventDefault();

Chọn thẻ mới đã được xác định trong trường hợp chuyển đổi.

      this._selectTab(newTab);
    }

_onClick() xử lý các lượt nhấp bên trong bảng điều khiển thẻ.

    _onClick(event) {

Nếu lượt nhấp không nhắm đến chính phần tử thẻ, thì đó là lượt nhấp bên trong bảng điều khiển hoặc vào không gian trống. Không cần làm gì cả.

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

Tuy nhiên, nếu thuộc tính này nằm trên phần tử thẻ, hãy chọn thẻ đó.

      this._selectTab(event.target);
    }
  }

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

howtoTabCounter tính số lượng thực thể <howto-tab> đã tạo. Số này được dùng để tạo mã nhận dạng mới và duy nhất.

  let howtoTabCounter = 0;

HowtoTab là một thẻ cho bảng điều khiển thẻ <howto-tabs>. Bạn phải luôn sử dụng <howto-tab> với role="heading" trong mã đánh dấu để ngữ nghĩa vẫn có thể sử dụng được khi JavaScript gặp lỗi.

<howto-tab> khai báo <howto-panel> thuộc về bằng cách sử dụng mã nhận dạng của bảng điều khiển đó làm giá trị cho thuộc tính aria-controls.

<howto-tab> sẽ tự động tạo một mã nhận dạng duy nhất nếu không có mã nào được chỉ định.

  class HowtoTab extends HTMLElement {

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

    constructor() {
      super();
    }

    connectedCallback() {

Nếu thực thi lệnh này thì JavaScript sẽ hoạt động và phần tử này sẽ thay đổi vai trò của nó thành tab.

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

Đặt trạng thái ban đầu được xác định rõ ràng.

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

Kiểm tra xem một thuộc tính có giá trị thực thể hay không. Nếu có, hãy sao chép giá trị và xoá thuộc tính thực thể để thuộc tính này không che khuất phương thức setter thuộc tính lớp. Cuối cùng, hãy truyền giá trị này đến phương thức setter thuộc tính lớp để phương thức này có thể kích hoạt mọi hiệu ứng phụ. Việc này nhằm mục đích bảo vệ trong trường hợp, chẳng hạn như một khung có thể đã thêm phần tử này vào trang và đặt một giá trị trên một trong các thuộc tính của trang đó nhưng lại tải từng phần định nghĩa của trang đó. Nếu không có trình bảo vệ này, phần tử đã nâng cấp sẽ thiếu thuộc tính đó và thuộc tính thực thể sẽ ngăn không cho phương thức setter thuộc tính lớp được gọi.

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

Các thuộc tính và thuộc tính tương ứng phải phản ánh lẫn nhau. Để đạt được hiệu quả này, phương thức setter của thuộc tính selected sẽ xử lý các giá trị đúng/sai và phản ánh các giá trị đó vào trạng thái của thuộc tính. Điều quan trọng cần lưu ý là không có hiệu ứng phụ nào xảy ra trong phương thức setter của thuộc tính. Ví dụ: phương thức setter không đặt aria-selected. Thay vào đó, công việc đó diễn ra trong attributeChangedCallback. Theo nguyên tắc chung, hãy làm cho phương thức setter thuộc tính trở nên thật ngu ngốc và nếu việc đặt một thuộc tính hoặc thuộc tính có thể gây ra tác dụng phụ (như đặt một thuộc tính ARIA tương ứng), thì việc đó sẽ hoạt động trong attributeChangedCallback(). Điều này giúp bạn không phải quản lý các trường hợp tái nhập thuộc tính/thuộc tính phức tạp.

    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 là bảng điều khiển cho bảng điều khiển thẻ <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);
})();