רכיבי 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 לכל מכונה חדשה, תבנית לתוכן של ה-DOM של הצללית משותפת לכל <howto-tabs> המכונות.

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

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

הרכיב צריך לבצע טיפול ידני באירועי קלט כדי לאפשר מעבר באמצעות מקשי החיצים ובאמצעות דף הבית / סיום.

      this.addEventListener('keydown', this._onKeyDown);
      this.addEventListener('click', this._onClick);

      if (!this.hasAttribute('role'))
        this.setAttribute('role', 'tablist');

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

      Promise.all([
        customElements.whenDefined('howto-tab'),
        customElements.whenDefined('howto-panel'),
      ])
        .then(() => this._linkPanels());
    }

המערכת של disconnectedCallback() מסירה את פונקציות event listener שנוספו על ידי connectedCallback().

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

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

    _onSlotChange() {
      this._linkPanels();
    }

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

זו שיטה ולא מקבלת, כי מקבל מרמז על כך שהוא זול לקריאה.

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

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

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

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