방법 구성요소 – 방법 탭

요약

<howto-tabs>는 표시되는 콘텐츠를 여러 패널로 분리하여 제한합니다. 한 번에 하나의 패널만 표시되며 해당하는 모든 탭은 항상 표시됩니다. 한 패널에서 다른 패널로 전환하려면 상응하는 탭을 선택해야 합니다.

클릭하거나 화살표 키를 사용하여 활성 탭의 선택을 변경할 수 있습니다.

자바스크립트가 사용 중지되면 모든 패널이 각 탭과 인터리브 처리되어 표시됩니다. 이제 탭이 제목처럼 작동합니다.

참조

데모

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로 파서를 호출하지 않도록 shadow 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>여야 합니다. 이 요소는 스테이트리스(Stateless)입니다. 즉, 캐시된 값이 없으므로 런타임 작업 중에 변경됩니다.

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

이 요소에 연결되지 않은 이벤트 핸들러는 this에 액세스해야 하는 경우 결합되어야 합니다.

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

점진적인 개선을 위해서는 마크업이 탭과 패널을 번갈아 사용해야 합니다. 하위 요소를 재정렬하는 요소는 프레임워크에서 잘 작동하지 않는 경우가 많습니다. 대신 슬롯을 사용하여 요소를 재정렬하는 데 Shadow 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-labelledbyaria-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()는 Shadow 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 쿼리가 성능 문제가 될 경우 그 결과를 기억할 수 있습니다. 기억하기의 단점은 동적으로 추가된 탭과 패널이 처리되지 않는다는 것입니다.

getter는 읽기 비용이 저렴하다고 암시하므로 getter가 아닌 메서드입니다.

    _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()를 사용하여 현재 선택된 요소의 색인을 찾고 이전 요소의 색인을 가져오려면 1을 뺍니다.

      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;

switch-case는 눌러진 키에 따라 어떤 탭을 활성으로 표시할지 결정합니다.

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

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> 인스턴스의 수를 계산합니다. 이 번호는 새로운 고유 ID를 생성하는 데 사용됩니다.

  let howtoTabCounter = 0;

HowtoTab<howto-tabs> 탭 패널의 탭입니다. JavaScript가 실패하더라도 시맨틱을 계속 사용할 수 있도록 <howto-tab>는 항상 마크업에서 role="heading"와 함께 사용해야 합니다.

<howto-tab>은 패널의 ID를 aria-controls 속성의 값으로 사용하여 어떤 <howto-panel>에 속하는지 선언합니다.

지정하지 않으면 <howto-tab>에서 고유 ID를 자동으로 생성합니다.

  class HowtoTab extends HTMLElement {

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

    constructor() {
      super();
    }

    connectedCallback() {

이 작업이 실행되면 자바스크립트가 작동하고, 요소의 역할이 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');
    }

속성에 인스턴스 값이 있는지 확인합니다. 그렇다면 값을 복사하고 인스턴스 속성을 삭제하여 클래스 속성 setter를 섀도잉하지 않도록 합니다. 마지막으로, 부작용을 트리거할 수 있도록 값을 클래스 속성 setter에 전달합니다. 이는 예를 들어 프레임워크에서 페이지에 요소를 추가하고 속성 중 하나에 값을 설정했지만 정의를 지연 로드한 경우를 방지하기 위한 것입니다. 이 가드가 없으면 업그레이드된 요소에 해당 속성이 누락되고 인스턴스 속성으로 인해 클래스 속성 setter가 호출되지 않습니다.

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

속성과 해당 속성은 서로 미러링되어야 합니다. 이를 위해 selected의 속성 setter는 진실/거짓 값을 처리하고 이 값을 속성 상태에 반영합니다. 속성 setter에서는 부작용이 발생하지 않습니다. 예를 들어, setter는 aria-selected를 설정하지 않습니다. 대신 attributeChangedCallback에서 작업이 발생합니다. 일반적으로 속성 setter를 매우 멍청하게 만들고, 속성이나 속성을 설정하여 부작용 (예: 상응하는 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);
})();