16 липня 2023 р.

Слоти тіньового DOM, композиція

Різні типи компонентів, такі як вкладки, меню, галереї зображень і таке інше, потребують вміст для відображення.

Так само, як вбудований в браузер елемент <select> очікує елементи <option>, наш компонент <custom-tabs> може очікувати що йому передадуть вміст вкладок. А <custom-menu> може очікувати елементи меню.

Ось приклад використання компонента <custom-menu>:

<custom-menu>
  <title>Меню солодощів</title>
  <item>Льодяник</item>
  <item>Фруктовий тост</item>
  <item>Капкейк</item>
</custom-menu>

…Далі наш компонент має відобразити їх належним чином, як гарне меню з наданими заголовком та елементами, опрацьовувати події взаємодії з ним та інше.

Як це реалізувати?

Ми могли б спробувати проаналізувати вміст елементу і динамічно копіювати і перегруповувати вузли DOM. Це можливо, але якщо ми помістимо елементи у “тіньовий” (shadow) DOM, то CSS стилі документу не будуть застосовані для них, що призведе до втрати візуального оформлення. Також це потребує написання певного коду.

На щастя, ми не маємо робити це. Тіньовий DOM підтримує елементи <slot>, які автоматично наповнюються вмістом зі “світлого” (light) DOM.

Іменовані слоти

Розглянемо як працюють слоти на простому прикладі.

Тут тіньовий DOM компонента <user-card> надає два слоти, що наповнені зі звичайного (світлого) DOM:

<script>
customElements.define('user-card', class extends HTMLElement {
  connectedCallback() {
    this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = `
      <div>Ім’я:
        <slot name="username"></slot>
      </div>
      <div>Дата народження:
        <slot name="birthday"></slot>
      </div>
    `;
  }
});
</script>

<user-card>
  <span slot="username">Тарас Мельник</span>
  <span slot="birthday">01.01.2001</span>
</user-card>

У тіньовому DOM, <slot name="X"> визначає “точку вставки”, тобто місце, де будуть відображені елементи з slot="X".

Далі браузер виконує “композицію”: він бере елементи зі світлого DOM і відображає їх у відповідних слотах тіньового DOM. Зрештою ми отримуємо саме те, що нам необхідно – компонент, який можна наповнити даними.

Ось структура DOM після виконання скрипту, якщо не брати до уваги композицію:

<user-card>
  #shadow-root
    <div>Ім’я:
      <slot name="username"></slot>
    </div>
    <div>Дата народження:
      <slot name="birthday"></slot>
    </div>
  <span slot="username">Тарас Мельник</span>
  <span slot="birthday">01.01.2001</span>
</user-card>

Ми створили тіньовий DOM, який можемо побачити під позначкою #shadow-root. Тепер елемент містить і світлий, і тіньовий DOM.

З метою відображення, для кожного <slot name="..."> в тіньовому DOM, браузер шукає slot="..." з таким самим іменем у світлому DOM. Такі елементи відображаються всередині слотів:

В результаті отримуємо DOM, що називається “розгорнутим” (flattened):

<user-card>
  #shadow-root
    <div>Name:
      <slot name="username">
        <!-- елемент помістили у відповідний слот -->
        <span slot="username">John Smith</span>
      </slot>
    </div>
    <div>Birthday:
      <slot name="birthday">
        <span slot="birthday">01.01.2001</span>
      </slot>
    </div>
</user-card>

…Але розгорнутий DOM існує лише з метою відображення та обробки подій. Він у певному розумінні “віртуальний”. Таким чином речі відображаються. Але вузли в документі насправді нікуди не переміщаються!

Це можна легко перевірити якщо запустити querySelectorAll: вузли все ще на своїх місцях.

// вузли <span> у світлому DOM все ще на своїх місцях всередині `<user-card>`
alert( document.querySelectorAll('user-card span').length ); // 2

Отже, розгорнутий DOM утворений з тіньового DOM за допомогою підставки слотів. Браузер відображає його і використовує для наслідування стилів, розповсюдження подій (більше про це буде далі). Але JavaScript все ще бачить документ “як є”, до розгортання.

Лише нащадки верхнього рівня можуть мати slot=“…”

Атрибут slot="..." доречний лише для безпосередніх нащадків shadow host (в нашому прикладі це елемент <user-card>). У вкладених елементів він ігнорується.

Наприклад, тут другий <span> проігнорований (оскільки він не є нащадком верхнього рівня для <user-card>)

<user-card>
  <span slot="username">John Smith</span>
  <div>
    <!-- недійсний слот, оскільки має бути безпосереднім нащадком user-card -->
    <span slot="birthday">01.01.2001</span>
  </div>
</user-card>

Якщо у світлому DOM є декілька елементів з однаковим іменем слота, вони поміщаються у слот один за одним.

Наприклад такий код:

<user-card>
  <span slot="username">Тарас</span>
  <span slot="username">Мельник</span>
</user-card>

Утворить розгорнутий DOM з двома елементами всередині <slot name="username">:

<user-card>
  #shadow-root
    <div>Ім’я:
      <slot name="username">
        <span slot="username">Тарас</span>
        <span slot="username">Мельник</span>
      </slot>
    </div>
    <div>День народження:
      <slot name="birthday"></slot>
    </div>
</user-card>

Резервний вміст слота

Все, що ми помістимо у <slot>, стане резервним типовим вмістом. Браузер відобразить його, якщо у світлому DOM не знайдеться відповідного наповнення.