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

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

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

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

זוהי שיטה ולא מצליחה, כי ניסוח ( 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');
    }

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

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

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