רכיבי 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. צאצאים חדשים ימוקמו באופן אוטומטי ויגרמו להפעלה של 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);
    }

_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() כדי למצוא את האינדקס של הרכיב שנבחר כרגע ומחסרים אחד כדי לקבל את האינדקס של הרכיב הקודם.

      let newIdx = tabs.findIndex((tab) => tab.selected) - 1;

מוסיפים את tabs.length כדי לוודא שהאינדקס הוא מספר חיובי, ומקבלים את המכפלה ב-mod אם צריך.

      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) {

אם הקשה על מקש לא נבעה מרכיב Tab עצמו, היא הייתה הקשה על מקש בתוך הלוח או במרחב ריק. אין מה לעשות.

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

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

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

בדיקה אם לנכס יש ערך של מכונה. אם כן, מעתיקים את הערך ומוחקים את מאפיין המופע כדי שהוא לא יהיה בצל של הגדרת מאפיין הכיתה. לבסוף, מעבירים את הערך למגדיר של מאפיין הכיתה כדי שהוא יוכל להפעיל את כל ההשפעות הנלוות. המטרה היא למנוע מקרים שבהם, לדוגמה, מסגרת הוסיפה את הרכיב לדף והגדירה ערך לאחד מהמאפיינים שלו, אבל הטעינה האיטית של ההגדרה שלו לא בוצעה. בלי המחסום הזה, הרכיב המשודרג יפסיד את הנכס הזה, ונכס המופע ימנע קריאה ל-setter של נכס הכיתה.

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

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