摘要
<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
叫用剖析器,所有 <howto-tabs>
執行個體會共用陰影 DOM 內容的範本。
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
透過語意連結分頁和麵板,因此需要回應新的子項。新的子項會自動吃角子老虎,並造成樹槽變化,因此不需要 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);
}
每當從其中一個陰影 DOM 版位新增或移除元素時,都會呼叫 _onSlotChange()
。
_onSlotChange() {
this._linkPanels();
}
_linkPanels()
使用 aria 控制項和 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;
切換鈕會決定應將哪個分頁標示為有效分頁,視按下的按鍵而定。
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>
分頁面板的分頁。<howto-tab>
應一律在標記中與 role="heading"
搭配使用,以便在 JavaScript 發生故障時仍可使用語意。
<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');
}
檢查屬性是否具有例項值。如果有,請複製該值並刪除執行個體屬性,以免覆蓋類別屬性 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
中進行。一般而言,請將屬性設定器設為非常難理解,如果設定屬性或屬性應該會產生副作用 (例如設定對應的 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);
})();