HowTo Components – вкладки с инструкциями

<howto-tabs> ограничивает видимый контент, разделяя его на несколько панелей. Одновременно видна только одна панель, при этом все соответствующие вкладки видны всегда. Для переключения с одной панели на другую необходимо выбрать соответствующую вкладку.

Щелкая мышью или используя клавиши со стрелками, пользователь может изменить выбор активной вкладки.

Если JavaScript отключен, все панели отображаются чередуясь с соответствующими вкладками. Вкладки теперь функционируют как заголовки.

Ссылка

Демо

Посмотреть живую демонстрацию на GitHub

Пример использования

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

Если JavaScript не запускается, элемент не будет соответствовать :defined . В этом случае этот стиль добавляет расстояние между вкладками и предыдущей панелью.

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

Определите коды клавиш, которые помогут обрабатывать события клавиатуры.

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

Чтобы избежать вызова синтаксического анализатора с .innerHTML для каждого нового экземпляра, шаблон содержимого теневого DOM используется всеми экземплярами <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 — это элемент-контейнер для вкладок и панелей.

Все дочерние элементы <howto-tabs> должны быть либо <howto-tab> , либо <howto-tabpanel> . Этот элемент не имеет состояния, что означает, что никакие значения не кэшируются и, следовательно, не изменяются во время работы.

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

Обработчики событий, не прикрепленные к этому элементу, должны быть привязаны, если им нужен доступ к this .

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

Для прогрессивного улучшения разметка должна чередоваться между вкладками и панелями. Элементы, которые меняют порядок своих дочерних элементов, как правило, плохо работают с фреймворками. Вместо этого используется теневой DOM для изменения порядка элементов с помощью слотов.

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

Импортируйте общий шаблон, чтобы создать слоты для вкладок и панелей.

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

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

Этот элемент должен реагировать на новых дочерних элементов, поскольку он семантически связывает вкладки и панели с помощью элементов управления aria-labelledby и aria-controls . Новые дочерние элементы будут автоматически выделены в слоты и вызовут срабатывание slotchange, поэтому MutationObserver не требуется.

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

connectedCallback() группирует вкладки и панели, изменяя их порядок, и следит за тем, чтобы была активна ровно одна вкладка.

    connectedCallback() {

Элементу необходимо выполнить некоторую обработку событий ввода вручную, чтобы можно было переключаться с помощью клавиш со стрелками и Home / End .

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

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

До недавнего времени события slotchange не вызывались при обновлении элемента синтаксическим анализатором. По этой причине элемент вызывает обработчик вручную. Как только новое поведение появится во всех браузерах, приведенный ниже код можно будет удалить.

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

disconnectedCallback() удаляет прослушиватели событий, добавленные функцией connectedCallback() .

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

_onSlotChange() вызывается всякий раз, когда элемент добавляется или удаляется из одного из теневых слотов DOM.

    _onSlotChange() {
      this._linkPanels();
    }

_linkPanels() связывает вкладки с соседними панелями с помощью элементов управления aria и aria-labelledby . Кроме того, этот метод гарантирует, что активна только одна вкладка.

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

Присвойте каждой панели атрибут aria-labelledby , который ссылается на вкладку, которая ею управляет.

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

Элемент проверяет, была ли какая-либо из вкладок отмечена как выбранная. Если нет, то теперь выбрана первая вкладка.

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

Далее переключитесь на выбранную вкладку. _selectTab() помечает все остальные вкладки как невыделенные и скрывает все остальные панели.

      this._selectTab(selectedTab);
    }

_allPanels() возвращает все панели на панели вкладок. Эта функция может запомнить результат, если запросы DOM когда-либо станут проблемой с производительностью. Недостатком запоминания является то, что динамически добавленные вкладки и панели не будут обрабатываться.

Это метод, а не геттер, потому что геттер подразумевает, что его чтение обходится дешево.

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

_allTabs() возвращает все вкладки на панели вкладок.

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

_panelForTab() возвращает панель, которой управляет данная вкладка.

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

_prevTab() возвращает вкладку, которая находится перед выбранной в данный момент, и оборачивается при достижении первой.

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

Используйте findIndex() чтобы найти индекс текущего выбранного элемента, и вычтите единицу, чтобы получить индекс предыдущего элемента.

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

Добавьте tabs.length , чтобы убедиться, что индекс является положительным числом, и при необходимости замените модуль.

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

_firstTab() возвращает первую вкладку.

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

_lastTab() возвращает последнюю вкладку.

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

_nextTab() получает вкладку, которая идет после выбранной в данный момент, и завершается при достижении последней вкладки.

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

reset() помечает все вкладки как невыделенные и скрывает все панели.

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

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

_selectTab() отмечает данную вкладку как выбранную. Кроме того, он отображает панель, соответствующую данной вкладке.

    _selectTab(newTab) {

Снимите выделение со всех вкладок и скройте все панели.

      this.reset();

Получите панель, с которой связана newTab .

      const newPanel = this._panelForTab(newTab);

Если эта панель не существует, прервите.

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

_onKeyDown() обрабатывает нажатия клавиш внутри панели вкладок.

    _onKeyDown(event) {

Если нажатие клавиши не было вызвано самим элементом вкладки, это было нажатие клавиши внутри панели или в пустом пространстве. Нечего делать.

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

Не обрабатывайте сочетания клавиш-модификаторов, которые обычно используются вспомогательными технологиями.

      if (event.altKey)
        return;

Регистр переключателя определит, какая вкладка должна быть помечена как активная, в зависимости от нажатой клавиши.

      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;

Любое другое нажатие клавиши игнорируется и передается обратно в браузер.

        default:
          return;
      }

Браузер может иметь некоторые встроенные функции, привязанные к клавишам со стрелками, домой или в конец. Элемент вызывает preventDefault() чтобы запретить браузеру выполнять какие-либо действия.

      event.preventDefault();

Выберите новую вкладку, которая была определена в переключателе.

      this._selectTab(newTab);
    }

_onClick() обрабатывает клики внутри панели вкладок.

    _onClick(event) {

Если щелчок не был нацелен на сам элемент вкладки, это был щелчок внутри панели или на пустом месте. Нечего делать.

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

Однако, если это было на элементе вкладки, выберите эту вкладку.

      this._selectTab(event.target);
    }
  }

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

howtoTabCounter подсчитывает количество созданных экземпляров <howto-tab> . Номер используется для создания новых уникальных идентификаторов.

  let howtoTabCounter = 0;

HowtoTab — это вкладка для панели вкладок <howto-tabs> . <howto-tab> всегда следует использовать с role="heading" в разметке, чтобы семантику можно было использовать даже в случае сбоя JavaScript.

<howto-tab> объявляет, к какой <howto-panel> он принадлежит, используя идентификатор этой панели в качестве значения атрибута aria-controls.

<howto-tab> автоматически сгенерирует уникальный идентификатор, если он не указан.

  class HowtoTab extends HTMLElement {

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

    constructor() {
      super();
    }

    connectedCallback() {

Если это выполнено, JavaScript работает, и элемент меняет свою роль на tab .

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

Установите четко определенное начальное состояние.

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

Проверьте, имеет ли свойство значение экземпляра. Если да, скопируйте значение и удалите свойство экземпляра, чтобы оно не затеняло установщик свойства класса. Наконец, передайте значение установщику свойства класса, чтобы он мог вызвать любые побочные эффекты. Это необходимо для защиты от случаев, когда, например, платформа могла добавить элемент на страницу и установить значение для одного из его свойств, но лениво загрузила его определение. Без этой защиты обновленный элемент пропустит это свойство, а свойство экземпляра предотвратит вызов метода установки свойства класса.

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

Свойства и соответствующие им атрибуты должны отражать друг друга. С этой целью установщик свойств для selected обрабатывает истинные/ложные значения и отображает их в состоянии атрибута. Важно отметить, что при настройке свойств не возникает никаких побочных эффектов. Например, установщик не устанавливает aria-selected . Вместо этого эта работа происходит в attributeChangedCallback . Как правило, делайте установщики свойств очень тупыми, и если установка свойства или атрибута должна вызывать побочный эффект (например, установка соответствующего атрибута ARIA), делайте это в attributeChangedCallback() . Это позволит избежать необходимости управлять сложными сценариями повторного входа атрибутов/свойств.

    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 — это панель для панели вкладок <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);
})();