HowTo 组件 - HowTo 标签页

摘要

<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 调用解析器,所有 <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-labelledbyaria-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);     } 

每当向某个阴影 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() 查找当前选中元素的索引,然后减一,即可获取上一个元素的索引。

      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> 应始终与 role="heading" 搭配使用,以便在 JavaScript 失败时仍能使用语义。

<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 未设置 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); })();