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 sẽ hiển thị tại một thời điểm, trong khi tất cả các thẻ tương ứng luôn hiển thị 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 đã chọn.

Bằng cách nhấp vào hoặc sử dụng các phím mũi tên, người dùng có thể thay đổi 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ử 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 các mã phím để giúp xử lý các sự kiện trê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 phiên bản <howto-tabs> đều 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();

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

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

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

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

Nhập mẫu chia sẻ để tạo vị trí 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 khi liên kết các thẻ và bảng điều khiển theo đúng ngữ nghĩa bằng cách sử dụng aria-labelledbyaria-controls. Các thành phần con mới sẽ tự động được phân vùng và kích hoạt thay đổi vị trí, do đó 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ính xác một thẻ đang hoạt động.

    connectedCallback() {

Phần tử này cần xử lý thủ công một số sự kiện nhập để cho phép chuyển đổi bằng các phím mũi tên và phím 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ử sẽ gọi trình xử lý theo cách thủ công. Khi hành vi mới được áp dụng 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() sẽ xoá 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 vào hoặc bị xoá khỏi một trong các vị trí DOM tối.

    _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ỉ 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 sẽ 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() đảm nhận việc đánh dấu tất cả các thẻ khác là đã bỏ chọn và ẩn tất cả bảng điều khiển khác.

      this._selectTab(selectedTab);
    }

_allPanels() trả về tất cả các bảng điều khiển 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 tự động sẽ không được xử lý.

Đây là một phương thức chứ không phải là phương thức getter vì phương thức getter ngụ ý việc đọc có giá rẻ.

    _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à một thẻ nhất định đ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 được chọn, gói quanh khi truy cập vào thẻ đầu tiên.

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

Dùng findIndex() để tìm chỉ mục của phần tử đang được chọn rồi 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à một số dương và lấy mô-đun bao bọc 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ẻ cuối cùng.

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

_nextTab() nhận thẻ đứng sau thẻ hiện được chọn, gói xung quanh khi đế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ả 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ả cá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 phải do một phần tử thẻ mang lại, 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. Bạn không cần làm gì.

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

Không xử lý các phím tắt thường dùng của công nghệ hỗ trợ.

      if (event.altKey)
        return;

Trường hợp chuyển đổi sẽ xác định thẻ nào sẽ được đánh dấu là đang hoạt động tuỳ thuộc vào phím người dùng 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 sẽ bị bỏ qua và chuyển trở lại trình duyệt.

        default:
          return;
      }

Trình duyệt có thể có một số chức năng gốc được liên kết với các phím mũi tên, phím Home hoặc End. 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 được nhắm đến trê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. Bạn không cần làm gì.

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

Tuy nhiên, nếu thẻ nằm trên một 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>. Phải luôn dùng <howto-tab> cùng với role="heading" trong mã đánh dấu để vẫn dùng được ngữ nghĩa khi JavaScript không thành công.

<howto-tab> khai báo <howto-panel> thuộc loại nào 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 bạn không chỉ định mã nhận dạng nào.

  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++}`;

Thiết lập 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 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ể để không làm ẩn phương thức setter thuộc tính của lớp. Cuối cùng, hãy truyền giá trị này vào phương thức setter thuộc tính lớp để phương thức này có thể kích hoạt bất kỳ hiệu ứng phụ nào. Việc này nhằm mục đích bảo vệ trong trường hợp, chẳng hạn như khung có thể đã thêm phần tử vào trang và đặt giá trị trên một trong các thuộc tính của trang nhưng tải từng phần định nghĩa của trang đó. Nếu không có lớp bảo vệ này, phần tử được nâng cấp sẽ bỏ lỡ thuộc tính đó và thuộc tính thực thể sẽ ngăn 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à các thuộc tính tương ứng phải đồng bộ hoá hai chiều. Để thực hiện điều này, phương thức setter thuộc tính cho selected xử lý các giá trị trung thực/sai và phản ánh các giá trị đó cho trạng thái của thuộc tính. Điều quan trọng cần lưu ý là không có tác dụng phụ nào xảy ra trong phương thức setter 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(). Nhờ đó, bạn sẽ không phải quản lý các tình huống phức tạp để thu hút lại thuộc tính/thuộc tính.

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