요약
<howto-tabs>
표시되는 콘텐츠를 여러 패널로 구분하여 제한합니다. 한 번에 하나의 패널만 표시되지만 해당하는 모든 탭은 항상 표시됩니다. 한 패널에서 다른 패널로 전환하려면 해당 탭을 선택해야 합니다.
사용자는 클릭하거나 화살표 키를 사용하여 활성 탭의 선택을 변경할 수 있습니다.
JavaScript가 사용 중지된 경우 모든 패널이 각 탭과 교차로 표시됩니다. 이제 탭이 제목으로 작동합니다.
참조
데모
사용 예
<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-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()
는 요소가 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();
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>
를 선언합니다.
지정된 고유 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);
})();