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 не знайдеться відповідного наповнення.

Наприклад, у цьому шматку shadow DOM, буде відображено Анонім, якщо у світлому DOM немає slot="username".

<div>Ім’я:
  <slot name="username">Анонім</slot>
</div>

Типовий слот: перший слот без імені

У тіньовому DOM перший слот, що не має імені, використовується як типовий. Він отримає всі вузли зі світлого DOM, що не були розміщені деінде.

Наприклад, давайте додамо типовий слот в <user-card>, який буде відображати всю інформацію про користувача, що не була розподілена до певного слота:

<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>
    <fieldset>
      <legend>Інша інформація</legend>
      <slot></slot>
    </fieldset>
    `;
  }
});
</script>

<user-card>
  <div>Я люблю плавати.</div>
  <span slot="username">Тарас Мельник</span>
  <span slot="birthday">01.01.2001</span>
  <div>...А також грати у волейбол!</div>
</user-card>

Весь вміст світлого DOM, що не був розподілений у певний слот, опиниться в полі “Інша інформація”.

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

Розгорнутий DOM матиме наступний вигляд:

<user-card>
  #shadow-root
    <div>Ім’я:
      <slot name="username">
        <span slot="username">Тарас Мельник</span>
      </slot>
    </div>
    <div>День народження:
      <slot name="birthday">
        <span slot="birthday">01.01.2001</span>
      </slot>
    </div>
    <fieldset>
      <legend>Інша інформація</legend>
      <slot>
        <div>Я люблю плавати.</div>
        <div>...А також грати у волейбол!</div>
      </slot>
    </fieldset>
</user-card>

Приклад меню

А тепер повернемось до компоненту <custom-menu>, який ми згадували на початку розділу.

Ми можемо використовувати слоти, щоб розподіляти елементи.

Ось розмітка для <custom-menu>:

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

Шаблон тіньового DOM з відповідними слотами:

<template id="tmpl">
  <style> /* стилі для меню */ </style>
  <div class="menu">
    <slot name="title"></slot>
    <ul><slot name="item"></slot></ul>
  </div>
</template>
  1. <span slot="title"> потрапить в <slot name="title">.
  2. В розмітці є декілька <li slot="item">, але в шаблоні лише один <slot name="item">. Тож усі <li slot="item"> будуть додані в <slot name="item"> один за одним утворюючи список.

Розгорнутий DOM має такий вигляд:

<custom-menu>
  #shadow-root
    <style> /* стилі для меню */ </style>
    <div class="menu">
      <slot name="title">
        <span slot="title">Меню солодощів</span>
      </slot>
      <ul>
        <slot name="item">
          <li slot="item">Льодяник</li>
          <li slot="item">Фруктовий тост</li>
          <li slot="item">Капкейк</li>
        </slot>
      </ul>
    </div>
</custom-menu>

Можна помітити, що для валідного DOM, <li> мав би бути безпосереднім нащадком <ul>. Але це розгорнутий DOM, який описує як компонент має відобразитись, тож така ситуація є цілком нормальною.

Нам залишилось додати обробник події click, щоб розгортати/згортати список, і <custom-menu> готовий:

customElements.define('custom-menu', class extends HTMLElement {
  connectedCallback() {
    this.attachShadow({mode: 'open'});

    // tmpl це шаблон для тіньового DOM (вгорі)
    this.shadowRoot.append( tmpl.content.cloneNode(true) );

    // ми не можемо обрати світлі вузли DOM, тож будемо опрацьовувати натискання на сам слот
    this.shadowRoot.querySelector('slot[name="title"]').onclick = () => {
      // розгорнути/згорнути меню
      this.shadowRoot.querySelector('.menu').classList.toggle('closed');
    };
  }
});

Ось повний приклад:

Звісно, ми можемо надати йому більше функціоналу: події, методи і таке інше.

Оновлення слотів

Що якщо зовнішній код хоче додати/прибрати пункт меню динамічно?

Браузер моніторить слоти і оновлює відображення, якщо елементи в слотах були додані/прибрані

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

Тож нам не потрібно нічого робити, щоб оновити відображення. Але якщо код компонента бажає дізнаватись про зміни у слотах, можна прослуховувати подію slotchange.

Наприклад, тут пункт меню вставляється динамічно через 1 секунду, а заголовок змінюється через 2 секунди:

<custom-menu id="menu">
  <span slot="title">Меню солодощів</span>
</custom-menu>

<script>
customElements.define('custom-menu', class extends HTMLElement {
  connectedCallback() {
    this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = `<div class="menu">
      <slot name="title"></slot>
      <ul><slot name="item"></slot></ul>
    </div>`;

    // shadowRoot не може мати обробники подій, тож використовуємо першого нащадка
    this.shadowRoot.firstElementChild.addEventListener('slotchange',
      e => alert("slotchange: " + e.target.name)
    );
  }
});

setTimeout(() => {
  menu.insertAdjacentHTML('beforeEnd', '<li slot="item">Льодяник</li>')
}, 1000);

setTimeout(() => {
  menu.querySelector('[slot="title"]').innerHTML = "Нове меню";
}, 2000);
</script>

Відображення меню оновлюється кожного разу без нашого втручання.

В цьому прикладі відбудуться дві події slotchange:

  1. Під час ініціалізації:

    slotchange: title спрацьовує миттєво, оскільки slot="title" зі світлого DOM потрапляє у відповідний слот.

  2. Через 1 секунду:

    slotchange: item спрацьовує після того, як додається <li slot="item">.

Зверніть увагу: через 2 секунди не відбувається подія slotchange, після того, як змінюється вміст елементу з атрибутом slot="title". Причиною є те, що не було змін у слотах. Ми модифікували вміст одного з елементів в слоті, а це інша річ.

Якщо ми маємо намір відслідковувати модифікації світлого DOM з JavaScript, можемо скористатись більш загальним механізмом: MutationObserver.

API слотів

Зрештою, давайте зачепимо методи JavaScript, що пов’язані зі слотами.

Як ми вже бачили, JavaScript дивиться на “справжній” DOM, без розгортання. Але якщо тіньове дерево має {mode: 'open'}, тоді ми можемо визначити які елементи призначені до слота і навпаки, знайти слот за елементом всередині нього:

  • node.assignedSlot – повертає елемент <slot> до якого був призначений node.
  • slot.assignedNodes({flatten: true/false}) – вузли DOM, що були призначені до слота. Опція flatten має типове значення false. Якщо вказати true, тоді цей метод дивиться глибше у розгорнутий DOM, повертає вкладені слоти у разі вкладених компонентів, або резервний вміст, якщо жоден вузол не був призначений.
  • slot.assignedElements({flatten: true/false}) – DOM елементи, що були призначені слоту (так само, як у попередньому методі, але повертаються лише вузли, що є елементами).

Ці методи корисні, коли ми потребуємо не лише показати вміст розподілений по слотам, але ще й відслідковувати його у JavaScript.

Наприклад, якщо компонент <custom-menu> хоче знати, що він показує, тоді можна відслідковувати slotchange і отримати елементи за допомогою slot.assignedElements:

<custom-menu id="menu">
  <span slot="title">Меню солодощів</span>
  <li slot="item">Льодяник</li>
  <li slot="item">Фруктовий тост</li>
</custom-menu>

<script>
customElements.define('custom-menu', class extends HTMLElement {
  items = []

  connectedCallback() {
    this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = `<div class="menu">
      <slot name="title"></slot>
      <ul><slot name="item"></slot></ul>
    </div>`;

    // спрацьовує коли вміст слотів змінюється
    this.shadowRoot.firstElementChild.addEventListener('slotchange', e => {
      let slot = e.target;
      if (slot.name == 'item') {
        this.items = slot.assignedElements().map(elem => elem.textContent);
        alert("Items: " + this.items);
      }
    });
  }
});

// items update after 1 second
setTimeout(() => {
  menu.insertAdjacentHTML('beforeEnd', '<li slot="item">Капкейк</li>')
}, 1000);
</script>

Підсумки

Зазвичай, якщо елемент має тіньовий DOM, тоді його світлий DOM не відображається. Слоти дозволяють показати елементи зі світлого DOM у зазначених місцях тіньового DOM.

Є два типи слотів:

  • Іменовані слоти: <slot name="X">...</slot> – отримує світлі потомки зі slot="X".
  • Слот за замовчуванням: перший <slot> без імені (наступні безіменні слоти ігноруються) – отримує світлі потомки, що не були призначені жодному слоту.
  • Якщо є багато елементів для одного й того самого слота – вони прикріпляються один за одним.
  • Вміст елементу <slot> використовується як резервний вміст. Він відображається, якщо немає світлих нащадків для слота.

Процес відображення елементів всередині їх слотів називається “композиція”. Результат цього процесу – “розгорнутий DOM”.

Композиція насправді не пересуває вузли, з точки зору JavaScript, DOM залишається без змін.

JavaScript може отримати доступ до слотів використовуючи наступні методи:

  • slot.assignedNodes/Elements() – повертає вузли/елементи всередині slot.
  • node.assignedSlot – зворотна властивість, повертає слот для певного вузла.

Є два способи за допомогою яких можна відслідковувати вміст слотів, у разі якщо ми хочемо знати, що саме в них відображається:

  • подія slotchange – спрацьовує після першого наповнення слота і надалі, у тому разі, якщо була здійснена операція додавання/прибирання/заміни елементу слоту, але не його нащадків. Слот доступний через event.target.
  • MutationObserver щоб зануритись глибше у вміст слоту, відслідковувати зміни всередині нього.

Тепер, оскільки ми знаємо як показувати елементи зі світлого DOM у тіньовому DOM, давайте поглянемо як стилізувати їх належним чином. Головне правило полягає в тому, що тіньові елементи стилізуються всередині, а світлі елементи – зовні, але є певні винятки.

Ми розглянемо подробиці у наступному розділі.

Навчальна карта