방법 구성요소 – 방법 탭

<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로 파서를 호출하지 않도록 하려면 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>이어야 합니다. 이 요소는 스테이트리스입니다. 즉, 값이 캐시되지 않으므로 런타임 중에 변경됩니다.

  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를 사용하여 탭과 패널을 의미론적으로 연결하므로 새 하위 요소에 반응해야 합니다. 새 하위 요소가 자동으로 슬롯 변경되고 슬롯 변경이 실행되므로 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;
      }

브라우저에 화살표 키, Home 또는 End에 바인딩된 일부 기본 기능이 있을 수 있습니다. 이 요소는 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> 인스턴스 수를 계산합니다. 이 번호는 새 고유 ID를 생성하는 데 사용됩니다.

  let howtoTabCounter = 0;

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

<howto-tab>는 패널의 ID를 aria-controls 속성의 값으로 사용하여 자신이 속한 <howto-panel>를 선언합니다.

지정된 고유 ID가 없으면 <howto-tab>가 자동으로 고유 ID를 생성합니다.

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

속성에 인스턴스 값이 있는지 확인합니다. 그렇다면 값을 복사하고 인스턴스 속성을 삭제하여 클래스 속성 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);
})();