Створення багаторазових компонентів вимагає балансування гнучкості з технічними вимогами, особливо коли користувачі мають можливість визначати власний контент. Розробляючи бібліотеку UI для Angular, я часто стикався з цією проблемою і досліджував різні способи вирішення.
У цій статті ми розглянемо цю тему з точки зору розробників бібліотек UI, зосереджуючи увагу на важливості надання гнучкого API для налаштування нашого компонента.
Ng-content
Давайте одразу перейдемо до практичного прикладу. Припустимо, ми розробляємо компонент Field, який відображає інпут. Як правило, елемент input рідко використовується сам по собі. В деяких випадках, нам потрібно додати мітку з автоматичним зв'язуванням атрибута [for]
, відображати зворотний зв'язок, наприклад повідомлення про помилку, якщо поле недійсне, або надати підказку. Іноді ми також хочемо додати іконку або інтерактивний елемент з одного боку поля вводу.
Компонент Field
На основі дизайну Field ми можемо визначити ключові області контенту, які повинні бути доступні для налаштування розробниками:
Тепер нам потрібно вирішити, який підхід дозволить заповнити ці області контентом. Початківці-розробники інколи використовують 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` дозволяє нам використовувати контекст і передавати відповідний екземпляр контролю в слот.
Концептуально, наш слот слугує **не лише як заповнювач для контенту** — він також **містить певний корисний навантаження**, яке ми можемо використовувати.

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

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

По суті, слот вже містить конкретний контент (саме число дня), але `ng-template` дозволяє переоприділити його шаблон відображення, базуючись на логіці, заданій розробником. Псевдокод використання:
...
@if (day.isLastDay) { 🌚 } {{ day }} @if (day.isFirstDay) { 🌝 }
``` Цей підхід формує основу для створення багаторазових компонентів в Angular і використовується майже всіма існуючими UI бібліотеками в екосистемі. ## Давайте об'єднаємо зусилля Після вивчення принципів існуючих підходів до проектування контенту, виникає логічне питання: коли застосовувати кожен з них? На практиці обидва підходи часто йдуть рука об руку при розробці UI компонентів, і вибір залежить в основному від конкретного випадку використання. Розглянемо ще кілька реальних прикладів. ## Comparator  - **заголовок** часто є просто текстом, але важливо не обмежувати гнучкість користувачів нашого компонента. Замість використання 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
...
Спочатку ми використовували подібний підхід, але пізніше розглянули можливість застосувати єдиний універсальний селектор для всіх наших компонентів зі слотами:
⚠️ Важливо зазначити, що в прикладах я буду використовувати селектор 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)