ניהול פוקוס ב Shadow DOM

13/06/2024

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

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

אפשר למצוא את הקוד והתוצאה בסטאקבליץ כאן: https://stackblitz.com/edit/vitejs-vite-r29prn?file=src%2Fmy-element.ts

וזה הקוד בהדבקה:

import { LitElement, css, html, unsafeCSS } from 'lit'
import { customElement, property } from 'lit/decorators.js'
import litLogo from './assets/lit.svg'
import viteLogo from '/vite.svg'
import _ from 'lodash';
import style from "./index.css?inline";

@customElement('my-cell')
export class MyCell extends LitElement {
  static styles = unsafeCSS(style);
  static shadowRootOptions = {...LitElement.shadowRootOptions, delegatesFocus: true};


  handleKeypress(ev: KeyboardEvent) {
    if (ev.key === "Enter") {
      if (this.shadowRoot?.activeElement === this.shadowRoot?.querySelector('td')) {
        // td is in focus
        this.dispatchEvent(new Event('focusinside', {composed: true, bubbles: true}))
      } else {
        this.shadowRoot?.querySelector('td').focus();
      };
    } else if (ev.key === "ArrowRight") {
      this.dispatchEvent(new Event('focusright', {composed: true, bubbles: true}))
    } else if (ev.key === "ArrowLeft") {
      this.dispatchEvent(new Event('focusleft', {composed: true, bubbles: true}))
    } else if (ev.key === "ArrowUp") {
      this.dispatchEvent(new Event('focusup', {composed: true, bubbles: true}))
    } else if (ev.key === "ArrowDown") {
      this.dispatchEvent(new Event('focusdown', {composed: true, bubbles: true}))
    }
  }

  takeFocus(ev: KeyboardEvent) {
    if (ev.key === "Enter") {
      this.shadowRoot?.querySelector('td')?.focus();
    }
  }

  render() {
    return html`<td
      tabindex="1"
      @keyup=${this.handleKeypress}
      class="border-dashed border border-1 border-gray-500 focus:border-solid focus:border-green-200 focus:border-2"
    >
      <slot></slot>
    </td>
    `
  }
}

@customElement('my-table')
export class MyTable extends LitElement {
  static styles = unsafeCSS(style);

  @property({ type: Number })
  rows = 4

  @property({ type: Number })
  columns = 2

  focusUp(e: any) {
    const me = e.target;
    const tr = me.parentNode;
    const index = Array.prototype.indexOf.call(tr.children, me);
    tr.previousElementSibling.children[index].focus();
  }

  focusDown(e: any) {
    const me = e.target;
    const tr = me.parentNode;
    const index = Array.prototype.indexOf.call(tr.children, me);
    tr.nextElementSibling.children[index].focus();
  }

  render() {
    return html`
      <table class="bg-orange border-collapse"
        @focusinside=${(e: any) => e.target.firstElementChild.focus()}
        @focusright=${(e: any) => e.target.nextElementSibling.focus()}
        @focusleft=${(e: any) => e.target.previousElementSibling.focus()}
        @focusup=${this.focusUp}
        @focusdown=${this.focusDown}
      >
        <tbody>
          ${_.range(this.rows).map((i) => (
            html`
            <tr>
              ${_.range(this.columns).map((j) => (
              html`<my-cell>
                <input
                type="text"
                value="${`${i}, ${j}`}"
                class="outline-none bg-transparent border-none border-2"
              /></my-cell>`
              ))}
            </tr>
          `
          ))}
        </tbody>
      </table>
    `
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'my-table': MyTable
    'my-cell': MyCell
  }
}

נעבור בצורה זריזה על הרעיונות החשובים שלמדתי מכתיבת הדוגמה:

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

  2. גם כשאני רוצה להעביר את הפוקוס מה td ל input שנמצא בתוכו יש לבצע את הקריאה מהקומפוננטה של הטבלה, כי היא זו שמגדירה את ה input שבתוך ה td.

  3. אבל כשאני רוצה להעביר את הפוקוס מה input ל td שמכיל אותו - פה צריך כבר לקרוא ל focus מקומפוננטת ה"תא בטבלה" כי היא זאת שיצרה את ה td. בקיצור כל קומפוננטה ב Shadow DOM אחראית על האלמנטים שהיא יוצרת.

עוד שתי נקודות שלמדתי מהדוגמה הזאת על ליט:

  1. הגדרת קובץ CSS לכל העמוד לא משפיעה על הקומפוננטות של lit. כל קומפוננטה חייבת להגדיר את קובץ ה CSS בעצמה. מאוד מעייף כשרוצים להגדיר עיצוב גלובאלי באפליקציית lit.

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