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-controls и 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;
      }

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

      event.preventDefault();

Выберите новую вкладку, определенную в switch-case.

      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 обрабатывает значения true/falsy и отражает их в состоянии атрибута. Важно отметить, что в сеттере свойств нет побочных эффектов. Например, сеттер не устанавливает 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);
})();