คอมโพเนนต์วิธีการ – แท็บวิธีการ

สรุป

<howto-tabs> จํากัดเนื้อหาที่มองเห็นได้โดยแยกเนื้อหาออกเป็นหลายแผง เฉพาะ แผงหนึ่งจะแสดงได้ครั้งละ 1 แผง ส่วนแท็บที่เกี่ยวข้องทั้งหมดจะแสดงเสมอ มองเห็นได้ หากต้องการสลับจากแผงหนึ่งไปยังอีกแผงหนึ่ง แท็บที่เกี่ยวข้องจะ ที่เลือกไว้

ผู้ใช้สามารถเปลี่ยนการตั้งค่า โดยคลิกหรือใช้ปุ่มลูกศร แท็บที่ใช้งานอยู่

หากปิดใช้ 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,
  };

อินสแตนซ์ <howto-tabs> ทั้งหมดจะแชร์เทมเพลตสำหรับเนื้อหาของ Shadow DOM เพื่อหลีกเลี่ยงการเรียกใช้โปรแกรมแยกวิเคราะห์ด้วย .innerHTML สำหรับอินสแตนซ์ใหม่ทุกรายการ

  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() จัดกลุ่มแท็บและแผงตามการจัดเรียงใหม่ และตรวจสอบว่ามีแท็บที่ใช้งานอยู่

    connectedCallback() {

องค์ประกอบต้องจัดการเหตุการณ์การป้อนข้อมูลด้วยตนเอง เพื่อให้สลับใช้แป้นลูกศรและหน้าแรก / สิ้นสุดได้

      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() จะนำ Listener เหตุการณ์ที่ connectedCallback() เพิ่มออก

    disconnectedCallback() {
      this.removeEventListener('keydown', this._onKeyDown);
      this.removeEventListener('click', this._onClick);
    }

ระบบจะเรียกใช้ _onSlotChange() เมื่อมีการเพิ่มหรือนำองค์ประกอบออกจากช่องโฆษณา Shadow DOM

    _onSlotChange() {
      this._linkPanels();
    }

_linkPanels() ลิงก์แท็บกับแผงที่อยู่ติดกันโดยใช้ aria-control และ 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;
      }

เบราว์เซอร์อาจมีฟังก์ชันการทำงานดั้งเดิมบางอย่างที่เชื่อมโยงกับปุ่มลูกศร หน้าแรก หรือจุดสิ้นสุด องค์ประกอบจะเรียก 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> รายการที่สร้าง ระบบจะใช้หมายเลขเพื่อสร้างรหัสที่ไม่ซ้ำกันใหม่

  let howtoTabCounter = 0;

HowtoTab คือแท็บของแผงแท็บ <howto-tabs> ควรใช้ <howto-tab> กับ role="heading" เสมอในมาร์กอัปเพื่อให้ความหมายยังคงใช้งานได้เมื่อ JavaScript ล้มเหลว

<howto-tab> จะประกาศว่า <howto-panel> นี้เป็นของใดโดยใช้รหัสของแผงเป็นค่าสำหรับแอตทริบิวต์ aria-controls

<howto-tab> จะสร้างรหัสที่ไม่ซ้ำกันโดยอัตโนมัติหากไม่มีการระบุไว้

  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');
    }

ตรวจสอบว่าพร็อพเพอร์ตี้มีค่าอินสแตนซ์หรือไม่ หากใช่ ให้คัดลอกค่าและลบพร็อพเพอร์ตี้ของอินสแตนซ์เพื่อไม่ให้เป็นเงาตัวตั้งค่าพร็อพเพอร์ตี้คลาส สุดท้าย ส่งค่าไปยังตัวตั้งค่าพร็อพเพอร์ตี้คลาสเพื่อให้ทริกเกอร์ผลข้างเคียงใดๆ ได้ ทั้งนี้เพื่อป้องกันไม่ให้เกิดกรณีต่างๆ เช่น เฟรมเวิร์กอาจเพิ่มองค์ประกอบลงในหน้าและตั้งค่าในพร็อพเพอร์ตี้รายการใดรายการหนึ่ง แต่ Lazy Loading โหลดคำจำกัดความไปแล้ว หากไม่มีการป้องกันนี้ องค์ประกอบที่อัปเกรดจะพลาดพร็อพเพอร์ตี้ดังกล่าวและพร็อพเพอร์ตี้อินสแตนซ์จะป้องกันไม่ให้มีการเรียกใช้ตัวตั้งค่าพร็อพเพอร์ตี้คลาส

    _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);
})();