רכיבי 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-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);
    }

מתבצעת קריאה אל _onSlotChange() בכל פעם שמוסיפים או מסירים רכיב מאחד ממשבצות ה-DOM הצלליות.

    _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;

לא מטפלים במקשי קיצור עם שינוי (modifier) שבדרך כלל משמשים לטכנולוגיה מסייעת.

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

יכול להיות שלדפדפן יש פונקציונליות מובנית שקשורה למקשי החיצים, לדף הבית או לסוף. הרכיב קורא ל-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> שנוצרו. המספר משמש ליצירת מזהים חדשים וייחודיים.

  let howtoTabCounter = 0;

HowtoTab היא כרטיסייה בחלונית הכרטיסיות <howto-tabs>. צריך תמיד להשתמש ב-<howto-tab> יחד עם role="heading" בתגי העיצוב, כדי שהסמנטיקה תמשיך להיות זמינה במקרים שבהם JavaScript נכשל.

רכיב <howto-tab> מצהיר לאיזה <howto-panel> הוא שייך באמצעות המזהה של הלוח הזה בתור הערך של המאפיין aria-controls.

אם לא צוין מזהה ייחודי, המערכת תיצור מזהה ייחודי באופן אוטומטי.

  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 load). בלי השמירה הזו, הרכיב המשודרג יפספס את המאפיין הזה, ומאפיין המכונה ימנע קריאה כלל לרכיב מגדיר המאפיין של המחלקה.

    _upgradeProperty(prop) {
      if (this.hasOwnProperty(prop)) {
        let value = this[prop];
        delete this[prop];
        this[prop] = value;
      }
    }

המאפיינים והמאפיינים התואמים שלהם צריכים להיות זהים. לשם כך, ה-setter של המאפיין selected מטפל בערכים נכונים/לא נכונים ומשקף אותם במצב של המאפיין. חשוב לציין שאין השפעות לוואי ב-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);
})();