概要
<howto-tabs>
複数のパネルに分割して、表示されるコンテンツを制限します。一度に表示されるパネルは 1 つのみですが、対応するタブはすべて常に表示されます。パネルを切り替えるには、対応するタブを選択する必要があります。
ユーザーは、クリックするか、矢印キーを使用して、アクティブなタブの選択を変更できます。
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>
にする必要があります。この要素はステートレスです。つまり、値はキャッシュに保存されず、実行時に変更されます。
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()
は、タブとパネルを並べ替えてグループ化し、必ず 1 つのタブがアクティブになるようにします。
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
を使用して、タブと隣接するパネルをリンクします。また、このメソッドでは、1 つのタブのみがアクティブになります。
_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()
を使用して現在選択されている要素のインデックスを取得し、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>
タブパネルのタブです。<howto-tab>
は、JavaScript が失敗した場合でもセマンティクスが使用できるように、マークアップで常に role="heading"
とともに使用する必要があります。
<howto-tab>
は、そのパネルの ID を aria-controls 属性の値として使用して、どの <howto-panel>
に属するかを宣言します。
<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');
}
プロパティにインスタンス値があるかどうかを確認します。同じ場合は、値をコピーし、インスタンス プロパティを削除して、クラス プロパティ セッターがシャドーされないようにします。最後に、値をクラス プロパティ セッターに通して、副作用をトリガーできるようにします。これは、たとえば、フレームワークが要素をページに追加し、そのプロパティのいずれかに値を設定したものの、定義を遅延読み込みした場合などに備えて、安全を確保するためのものです。このガードがない場合は、アップグレードされた要素にそのプロパティがないため、インスタンス プロパティによってクラス プロパティ セッターが呼び出されることがありません。
_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);
})();