Слоти: зробіть ваше Angular API гнучким

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

У цій статті ми розглянемо цю тему з точки зору розробників бібліотек UI, зосереджуючи увагу на важливості надання гнучкого API для налаштування нашого компонента.

pic

Ng-content

Давайте одразу перейдемо до практичного прикладу. Припустимо, ми розробляємо компонент Field, який відображає інпут. Як правило, елемент input рідко використовується сам по собі. В деяких випадках, нам потрібно додати мітку з автоматичним зв'язуванням атрибута [for], відображати зворотний зв'язок, наприклад повідомлення про помилку, якщо поле недійсне, або надати підказку. Іноді ми також хочемо додати іконку або інтерактивний елемент з одного боку поля вводу.

pic

Компонент Field

На основі дизайну Field ми можемо визначити ключові області контенту, які повинні бути доступні для налаштування розробниками:

pic

Тепер нам потрібно вирішити, який підхід дозволить заповнити ці області контентом. Початківці-розробники інколи використовують inputs для передачі текстового контенту, наприклад мітки або повідомлень про помилки/підказки. Однак це не найгнучкіший спосіб обробки контенту, оскільки він значно обмежує нас. Навіть таке, здавалося б, незначне завдання, як додавання іконки (як показано в прикладі, де підказка містить іконку клавіші >), може нас здивувати.

Природний і гнучкий підхід — це проектування контенту. Він дозволяє визначити частину шаблону, яка буде вставлена у потрібну область. Angular, як і інші сучасні фреймворки, впровадив концепцію слотів з Web Components і дозволяє визначати місця для кожної області контенту в нашому компоненті за допомогою ng-content з атрибутом [select] (за аналогією з іменованими слотами).

📋 Псевдокод для шаблону, що використовує цей підхід, може виглядати наступним чином ([attribute] селектори використовуються для стислості в цьому прикладі):






 ```  👤 А тепер, **з точки зору користувача компонента**, його визначення може виглядати ось так:  ```         Мітка                  Підказка    ```  Однак користувач компонента може не потребувати визначати контент для всіх наявних слотів.
Це призводить до наступних проблем:

- Якщо слот не визначено, він має **уникати створення додаткових вузлів** або, принаймні, **не впливати на макет** (наприклад, контейнери слотів `[prefix]` / `[suffix]` можуть додавати зайве відступи до елемента вводу)
- Вузли, пов’язані з невизначеним слотом, не повинні впливати на роботу екранних читалок або інших допоміжних технологій (якщо контейнер елемента потенційного контенту слота залишається в дереві)

🚧 На цьому етапі ми стикаємось з обмеженням: немає способу визначити під час виконання, чи був визначений конкретний `ng-content` для компонента.

## Обхідне рішення через CSS

Наявність контенту в відповідному контейнерному елементі можна визначити за допомогою CSS. Використовуючи псевдоклас `:empty` та `display: none`, ми можемо видалити контейнер з потоку документа, якщо всередині нього немає контенту (крім того, він також буде виключений з [дерева доступності](https://developer.mozilla.org/en-US/docs/Glossary/Accessibility_tree)):

.suffix:empty {
display: none;
}
```

Псевдоклас :empty працює не тільки з елементами, але й з текстом, що якраз і нам потрібно. Цей підхід використовується, наприклад, Angular Material для приховування порожнього лейбла елемента ``, який додає зайві відступи.

相对 новий псевдоклас :has (з точки зору основних браузерів підтримка) дозволяє маніпулювати стилями через батьківський елемент. Якщо розробник додає контент до слота [suffix], можливо, потрібно буде налаштувати відступи поля вводу для забезпечення правильного вирівнювання та відображення:

:host:has(.suffix:not(:empty)) .infix {  
 padding-inline-end: 2rem;  
}

⚠️ Важливо не зловживати такими селекторами через їх високу специфічність. Наприклад, описаний вище селектор має специфічність 0.4.0 (перевірте це), що ускладнює його перевизначення.

Обхідне рішення за допомогою CSS — це досить ситуаційний трюк і не завжди дозволяє ефективно керувати слотами компонентів. Це включає потенційно "важкі" селектори в складних випадках, а також CSS сам по собі може бути недостатнім. Ідеально, ми також повинні контролювати наявність контенту на рівні TS.

Ng-template

Фрагменти шаблонів пропонують ще один варіант для проектування контенту, що дозволяє виконувати операції під час виконання. Цей підхід дозволяє нам уникнути вставки вузлів, пов'язаних з невизначеними слотами, в дерево. Наприклад:

readonly suffix = contentChild('suffix', { read: TemplateRef });

А потім в шаблоні:

@if (suffix(); as template) {  

   } ```  Крім того, **це дозволяє передавати контекст у слот**, що в деяких випадках стає надзвичайно потужним інструментом налаштування:  ```  ```  Як показано у очікуваному результаті нашого _Field_, ми маємо конкретну вимогу до кнопки, що відображається в слоті `[suffix]`: якщо контрол не дійсний, ми хочемо, щоб кнопка була пофарбована в червоний. Зазвичай це було б просто, якщо б ми мали прямий доступ до екземпляра [FormControl](https://angular.dev/guide/forms/reactive-forms#generate-a-new-component-with-a-formcontrol). Однак, якщо ми прив'язуємо глибоко вкладений контроль з [FormArray](https://angular.dev/guide/forms/reactive-forms#define-a-formarray-control) або [FormGroup](https://angular.dev/guide/forms/reactive-forms#create-a-formgroup-instance) за його ім'ям, це стає досить складно.  Використання `ng-template` дозволяє нам використовувати контекст і передавати відповідний екземпляр контролю в слот.
Концептуально, наш слот слугує **не лише як заповнювач для контенту** — він також **містить певний корисний навантаження**, яке ми можемо використовувати.

![pic](https://drive.javascript.org.ua/6efa28e2631_k7IBw7YYiGMwcHmmK1StSg_png)

Тепер розробник може визначити слот `#suffix` і отримати доступ до даних з контексту за допомогою `let-*`:

...


## Не просто слот, а конфігурований шаблон

Тут варто обговорити ментальну модель `ng-template`, яка трохи відрізняється від `ng-content`. Завдяки контексту, шаблони можуть слугувати не лише для вставки чогось у слот, а й для **модифікації того, як компонент відображає існуючий контент**. Наприклад, використовуючи компонент _Calendar_, ми можемо налаштувати відображення окремих клітинок дня.

![pic](https://drive.javascript.org.ua/10a5a7b8b11_hAbkGuFYmjgFDkUX8_0jxw_png)

_Компонент Calendar_

Як видно з другої версії _Calendar_, що показана вище, модифікація клітинки часто вимагає більше, ніж просто зміни стилів або створення псевдоелементів за допомогою CSS. В деяких випадках нам потрібно вставити **іконки / маркери**, а в інших, може бути необхідним **підказка** при наведенні миші. Такі сценарії є звичними для бізнес-логіки, що керується на стороні клієнта, що робить важливим для компонента надавати гнучкість для налаштування.

У випадку з _Calendar_, шаблон клітинки може включати контекст з обчисленими метаданими для відображеного дня:

![pic](https://drive.javascript.org.ua/cd763b37c91_sezxweLmD6itIj7Iheh1KA_png)

По суті, слот вже містить конкретний контент (саме число дня), але `ng-template` дозволяє переоприділити його шаблон відображення, базуючись на логіці, заданій розробником. Псевдокод використання:

...

@if (day.isLastDay) {    🌚    }    {{ day }}    @if (day.isFirstDay) {    🌝    }    
    ```  Цей підхід формує основу для створення багаторазових компонентів в Angular і використовується майже всіма існуючими UI бібліотеками в екосистемі.  ## Давайте об'єднаємо зусилля  Після вивчення принципів існуючих підходів до проектування контенту, виникає логічне питання: коли застосовувати кожен з них? На практиці обидва підходи часто йдуть рука об руку при розробці UI компонентів, і вибір залежить в основному від конкретного випадку використання.  Розглянемо ще кілька реальних прикладів.  ## Comparator  ![pic](https://drive.javascript.org.ua/5bc45a15251_7okZ2glWd8__Z_2A7uEGqQ_gif)  - **заголовок** часто є просто текстом, але важливо не обмежувати гнучкість користувачів нашого компонента. Замість використання input, краще запропонувати слот через `ng-content`:  ```     Заголовок     ```  - **іконка** має стандартний контент — стрілку.

Хоча Angular 18 ввів можливість визначати резервний контент для ng-content, цей слот може використовувати контекст, наприклад, чи є поточний елемент відкритим чи закритим. У цьому випадку налаштування за допомогою ng-template може бути розумним підходом:


 Title  

 {{ open ? '🙉' : '🙈' }}  


  • content — це один з тих випадків, коли необхідні обидва підходи. ng-content дозволяє нам негайно ініціалізувати контент, навіть якщо він схований всередині батьківського елемента (за допомогою @if / @switch). Це застосовується до проекційних дочірніх компонентів, життєвий цикл яких ініціалізується, щойно вони проекціюються в слот. З іншого боку, ng-template ініціалізує контент ліниво — лише коли шаблон фактично вставлений в дерево. Ми повинні дозволити користувачам вибирати між обома варіантами:

 Title  

 {{ open ? '🙉' : '🙈' }}  



 ... Енергетичний контент  



 ... Лінивий контент  


Уніфіковане використання

Працюючи з проектуванням контенту в UI бібліотеці, моя команда стикалася з деякими незручностями через відсутність уніфікованого методу для визначення іменованих слотів.

Повертаючись до компонента Field, ми могли б створити специфічні директиви, такі як [appLabel], [appSuffix] і так далі. Це дозволило б нам, у випадку з ng-content, мати більш безпечний селектор (оскільки при виборі за атрибутом з одним словом ми можемо зіткнутися з колізіями з нативними атрибутами HTML5). Крім того, якщо ми працюємо з ng-template, директива може бути прикріплена до `і ми могли б запитати її за допомогоюcontentChild` через локатор директиви. Псевдокод використання:


 ...  
 Label  
 ...  

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

pic

⚠️ Важливо зазначити, що в прикладах я буду використовувати селектор slot-* без будь-яких префіксів. Однак при розробці системи, варто розглянути використання префікса, щоб уникнути конфліктів з нативним атрибутом slot. Наприклад, Vue використовує найменування v-slot (можливо, одного дня ми побачимо ng-slot в Angular? 😏).

Перш за все, це дозволяє чітко ідентифікувати проекційний контент при використанні компонента:


 ...
Label  
 ...  

Крім того, при документуванні компонентів стає простіше описувати проектований контент — достатньо просто вказати імена слотів та їх контекст, якщо це застосовно.

Реалізація

Реалізація досить проста при використанні ng-content:


При використанні шаблонів, ми можемо створити легку директиву, яка реєструє токен SLOT (щоб абстрагуватися від конкретної реалізації) і приймає ім’я слота як вхід:

export const SLOT = new InjectionToken('SLOT');  

@Directive({  
 selector: 'ng-template[slot]',  
 providers: [{ provide: SLOT, useExisting: Slot }],  
})  
export class Slot {  
 readonly template = inject(TemplateRef);  
 readonly name = input.required({ alias: 'slot' });  
}

Тепер, у UI компоненті з проектованим контентом, ми можемо запитати список слотів за допомогою токен-локатора через contentChildren:

readonly slots = contentChildren(SLOT);

⚠️ contentChildren функція повинна викликатись лише в ініціалізаторі члена класу, що забороняє нам безпосередньо використовувати її в обгортці для перетворення результату в запис у .ts. Мені було зручніше створити допоміжну трубку (pipe) для перетворення безпосередньо в шаблоні, уникаючи необхідності окремої властивості класу:

@Pipe({ name: 'asRecord' })  
export class SlotsAsRecordPipe implements PipeTransform {  
 transform(slots: readonly Slot[]): Record<string, TemplateRef | undefined> {  
 return Object.fromEntries(slots.map(slot => [slot.name(), slot.template]));  
 }  
}

✨ Тепер у нас є запис усіх визначених користувачем шаблонів слотів:

@let templates = slots() | asRecord;  

@if (templates.label; as label) {  



}  

@if (templates.suffix; as suffix) {  

   }      ... ```  👀 По суті, ми наближаємось до чогось подібного до [Умовних слотів](https://vuejs.org/guide/components/slots#conditional-slots) у Vue, де `$slots` дозволяє налаштувати рендеринг на основі наявності конкретного слота.  

## Висновок

Ця стаття не є глибоким дослідженням проектування контенту на рівні вихідного коду, а скоріше роздумами про те, як цей основоположний механізм працює в Angular. Хоча ці роздуми можуть не завжди бути пріоритетними в коді додатків, вони стають необхідними при проектуванні систем. 

Підхід до уніфікованого визначення слотів виявився досить гнучким, і ми впевнено використовуємо його при проектуванні API для низькорівневих компонентів. Однак немає єдиного правильного рішення — **як ви будете обробляти проектування контенту в Angular, зрештою залежить від архітектури вашої системи**.

🫡 Побачимося в наступній статті, де ми розглянемо ще більше способів вдосконалення API бібліотек!



Перекладено з: [Slots: Make your Angular API flexible](https://medium.com/coreteq/slots-make-your-angular-api-flexible-89e707ffae4c)

Leave a Reply

Your email address will not be published. Required fields are marked *