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

Рассмотрим ее с точки зрения разработчиков UI-библиотек, сосредоточившись на важности предоставления пользователям гибкой настройки API.

Ng-content

Сразу перейдем к практическому примеру. Предположим, мы разрабатываем компонент Field, который отображает ввод. Как правило, элемент ввода редко используется сам по себе. В некоторых случаях необходимо включить метку (label) с автоматической привязкой атрибута [for], отобразить обратную связь, например сообщение об ошибке (error message), если поле недействительно, или предоставить текст подсказки (hint). Иногда может потребоваться добавить иконку (icon) или интерактивный элемент с одной стороны поля ввода.

Компонент Field

Учитывая дизайн Field, можно определить ключевые области контента, которые должны быть открыты для настройки разработчиком:

Теперь нужно определиться с подходом, который позволит заполнить эти области контентом. Начинающие разработчики иногда используют функцию input для передачи текстового контента, например метки или текста ошибки/подсказки. Однако это не самый гибкий способ работы с контентом, поскольку он существенно ограничивает нас. Даже такое, казалось бы, незначительное требование, как добавление иконки (как показано в примере, где в подсказке используется иконка ключа >), может застать нас врасплох.

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

Псевдокод шаблона, использующего этот подход, может выглядеть следующим образом (селекторы [attribute] используются в этом примере для краткости):

<label [attr.for]="id" class="label">
<ng-content select="[label]" />
</label>

<div class="field">
<div class="prefix">
<ng-content select="[prefix]" />
</div>
<div class="infix">
<!-- Делегируйте определение собственных <input> или <textarea> разработчикам.
developers. Это позволяет им напрямую привязывать атрибуты доступности, применять директивы форм и так далее.
-->
<ng-content select="input, textarea" />
</div>
<div class="suffix">
<ng-content select="[suffix]" />
</div>
</div>

<div class="subscript">
<div class="error">
<ng-content select="[error]" />
</div>
<div class="hint">
<ng-content select="[hint]" />
</div>
</div>

А с точки зрения пользователя компонента, его определение может выглядеть примерно так:

<app-field>
<!-- проекция узла текста -->
<ng-container label>Label</ng-container>

<input ... >

<!-- проекция узла элемента -->
<b hint>Hint</b>
</app-field>

Однако пользователю компонента может не потребоваться определять контент для всех существующих слотов. Это приводит к следующим проблемам:

  • Слот, который не определен, не должен создавать дополнительный узел или, по крайней мере, не должен влиять на компоновку макета (например, контейнеры слотов [prefix]/[suffix] могут добавлять дополнительные отступы к элементу ввода).
  • Узлы, связанные с неопределенным слотом, также не должны влиять на работу программ чтения с экрана или других вспомогательных технологий (если элемент-контейнер контента потенциального слота остается в дереве).

На этом этапе мы сталкиваемся с ограничением: нет способа обнаружить во время выполнения, был ли определен конкретный ng-content для компонента.

Обходное решение с использованием CSS

Наличие контента внутри соответствующего элемента-контейнера можно определить с помощью CSS. Используя псевдокласс :empty и display: none, можно удалить контейнер из потока документов, если внутри нет контента (более того, он также будет исключен из дерева доступности):

.suffix:empty {
display: none;
}

Псевдокласс :empty работает не только с узлами элементов, но и с текстом, а это именно то, что нам нужно. Такой подход используется, например, в Angular Material для скрытия пустой метки <mat-checkbox>, при этом добавляется ненужный отступ.

Относительно новый :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) {
<div class="suffix">
<ng-container *ngTemplateOutlet="template" />
</div>
}

Кроме того, он позволяет передавать в слот контекст, что в некоторых случаях становится чрезвычайно мощным инструментом настройки:

<ng-container *ngTemplateOutlet="template; context: { ... }" />

Как показано в ожидаемом результате Field, имеется особое требование к кнопке, отображаемой в слоте [suffix]: если элемент управления недействителен, кнопка должна окрашиваться в красный цвет. При наличии прямого доступа к экземпляру FormControl это было бы просто. Но становится довольно утомительным, если глубоко вложенный элемент управления привязывается из FormArray или FormGroup по его имени.

Использование ng-template позволяет использовать контекст и передавать соответствующий экземпляр элемента управления в слот. Концептуально слот не только служит плейсхолдером для контента — он также обладает определенной полезной нагрузкой, которую можно использовать.

Теперь разработчик может определить слот #suffix и получить доступ к данным из контекста с помощью let-*:

<app-field>
...
<input formControlName="name">

<ng-template #suffix let-control>
<button [class.bg-red]="control.invalid"
[attr.disabled]="control.disabled || null"
... >
<app-icon icon="search" />
</button>
</ng-template>
</app-field>

Не просто слот, а настраиваемый шаблон

Здесь стоит обсудить ментальную модель ng-template, которая мало чем отличается от ng-content. Благодаря контексту, шаблоны (templates) могут служить не только для вставки чего-либо в слот, но и для изменения того, как компонент отображает существующий контент. Например, при использовании компонента Calendar можно с помощью контекста настроить рендеринг отдельных ячеек дня.

Компонент Calendar: стандартная (слева) и настроенная (справа) версии

Как видно из второй версии Calendar, показанной выше, для изменения ячейки часто требуется нечто большее, чем просто изменение стилей или создание псевдоэлементов с помощью CSS. В одних случаях нужно вставить иконки/»булавки», а в других — оперативные подсказки, выводимые на экран при наведении курсора. Такие сценарии часто встречаются в бизнес-логике, управляемой клиентом, поэтому очень важно, чтобы компонент был гибким для настройки.

В случае с Calendar шаблон ячейки может включать контекст с вычисляемыми метаданными для отображаемого дня:

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

<app-calendar>
...
<ng-template #cell let-day>
<div [appTooltip]="day.disabled ? 'Not available' : null">
@if (day.isLastDay) {
🌚
}
<span [class.line-through]="day.isAdjacent">{{ day }}</span>
@if (day.isFirstDay) {
🌝
}
</div>
</ng-template>
</app-calendar>

Этот подход является основой для создания многократно используемых компонентов в Angular и применяется почти всеми существующими UI-библиотеками в экосистеме.

Объединим усилия

После изучения принципов существующих подходов к проецированию контента возникает логичный вопрос: когда следует применять каждый из них? На практике при разработке UI-компонентов оба способа часто идут рука об руку, и выбор во многом зависит от конкретного случая использования.

Рассмотрим еще несколько реальных примеров.

Компонент Comparator

Компонент Comparator 
  • Comparator имеет 2 слота, которые всегда будут определены в соответствии с дизайном компонента.
  • Контент слотов не подвержен условному рендерингу и может быть инициализирован сразу вместе с представлением компонента.
  • Оба слота также не требуют никакого контекста.

Это наглядный пример применения ng-content. Псевдокод использования:

<app-comparator>
<img left src="1.jpg" alt="1" >
<img right src="2.jpg" alt="2" >
</app-comparator>

Компонент Accordion

Компонент Accordion

Accordion — отличный пример вариативности подходов. Складной элемент имеет 3 слота: title, icon и content.

  • Слот title часто является обычным текстом, но важно не ограничивать гибкость пользователей компонента. Вместо того чтобы использовать ввод, лучше предложить слот с помощью ng-content:
<app-accordion-item>
Title <!-- Предполагается, что это не именованный слот -->
</app-accordion-item>
  • Слот icon содержит контент по умолчанию — шеврон. Хотя в Angular 18 появилась возможность определять резервный контент для ng-content, этот слот мог бы использовать контекст, например, позволяющий определить, является ли текущий элемент открытым или закрытым. В этом случае разумным подходом будет настройка с помощью ng-template:
<app-accordion-item>
Title
<ng-template #icon let-open>
{{ open ? '🙉' : '🙈' }}
</ng-template>
</app-accordion-item>
  • Слот content — один из тех случаев, когда необходимы оба подходаng-content позволяет немедленно инициализировать контент, даже если он скрыт внутри родителя (с помощью @if/@switch). Он применяется к проецируемым дочерним компонентам, жизненный цикл которых начинается сразу после их проецирования в слот. С другой стороны, ng-template инициализирует контент лениво — только когда шаблон вставляется в дерево. Стоит предложить пользователям оба варианта на выбор:
<app-accordion-item>
Title
<ng-template #icon let-open>
{{ open ? '🙉' : '🙈' }}
</ng-template>

<ng-container content>
... Eager content
</ng-container>

<ng-template #content>
... Lazy content
</ng-template>
</app-accordion-item>

Унифицированное использование

При работе с проекцией контента в UI-библиотеке моя команда столкнулась с некоторыми неудобствами из-за отсутствия унифицированного метода определения именованных слотов.

Обращаясь к компоненту Field, мы могли бы создать специальные директивы, такие как [appLabel][appSuffix] и так далее. Это позволило бы нам в случае ng-content иметь более безопасный селектор (поскольку при выборе по атрибуту с одним словом можно столкнуться с коллизиями с атрибутами нативного HTML5). Кроме того, если иметь дело с ng-template, директиву можно прикрепить к <ng-template /> и запрашивать ее с помощью contentChild по локатору директивы. Псевдокод использования:

<app-field>
...
<span appLabel>Label</span>
<ng-template appSuffix let-ctx>...</ng-template>
</app-field>

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

Важно отметить, что в примерах я использую селектор slot-* без каких-либо префиксов. Однако при разработке системы стоит учитывать префикс, чтобы избежать конфликтов с нативным атрибутом slot. Например, в Vue используется именование v-slot (возможно, когда-нибудь мы увидим ng-slot «из коробки» в Angular?).

В первую очередь это позволяет четко идентифицировать проецируемый контент при использовании компонента:

<app-field>
...
<span slot="label">Label</span>
<ng-template slot="suffix" let-ctx>...</ng-template>
</app-field>

Кроме того, при документировании компонентов становится проще описывать проецируемый контент — достаточно указать имена слотов и их контекст, если это применимо.

Реализация

При использовании ng-content реализация проста:

<ng-content select="[slot='name']" />

При использовании шаблонов можно создать легковесную директиву, которая предоставляет токен SLOT (чтобы абстрагироваться от конкретной реализации) и принимает имя слота в качестве входных данных:

export const SLOT = new InjectionToken<Slot>('SLOT');

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

В UI-компоненте с проецируемым контентом можно запросить список слотов по локатору токена через contentChildren:

readonly slots = contentChildren(SLOT);

Функция contentChildren должна вызываться только в инициализаторе члена класса, что не позволяет напрямую использовать ее в обертке для преобразования результата в запись в .ts. Нам представилось удобным создать вспомогательный конвейер для преобразования непосредственно в шаблоне, избежав необходимости в отдельном свойстве класса:

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

И вот мы имеем запись всех пользовательских слотов шаблона:

@let templates = slots() | asRecord;

@if (templates.label; as label) {
<label [attr.for]="id" class="label">
<ng-container *ngTemplateOutlet="label; context: { ... }" />
</label>
}

@if (templates.suffix; as suffix) {
<div class="suffix">
<ng-container *ngTemplateOutlet="suffix; context: { ... }" />
</div>
}

...

По сути, мы приходим к чему-то вроде Conditional Slots в Vue, где  $slots позволяет правильно настроить рендеринг на основе наличия определенного слота.

Заключение

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

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

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Vyacheslav Borodin: Slots: Make your Angular API flexible

Предыдущая статья6 рекомендаций по запуску современной кодовой базы Android с нуля
Следующая статья15 общедоступных проектов, которые каждый разработчик должен добавить в закладки