摘要
<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>
实例均共用一个 shadow 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 来使用 slot 对元素重新排序。
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);
}
每当在某个 shadow DOM 槽位中添加或移除元素时,系统都会调用 _onSlotChange()
。
_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;
切换大小写将根据按下的键确定应将哪个标签页标记为活动。
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 会处理 truthy/falsy 值,并将这些值反映到属性的状态。请务必注意,属性 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);
})();