26. Content Projection
🎭 Content Projection в Angular
Заголовок раздела «🎭 Content Projection в Angular»Content Projection (проекция контента) — это механизм передачи HTML-контента от родителя в дочерний компонент. Как слоты в Web Components.
🕳️ ng-content — одиночная проекция
Заголовок раздела «🕳️ ng-content — одиночная проекция»Базовый вариант: принимаем любой контент:
@Component({ selector: 'app-card', template: ` <div class="card"> <ng-content /> </div> `})export class CardComponent {}<!-- Использование --><app-card> <h2>Заголовок</h2> <p>Любой контент сюда</p> <app-button>Действие</app-button></app-card>🎯 select — именованные слоты
Заголовок раздела «🎯 select — именованные слоты»Атрибут select фильтрует проецируемый контент по CSS-селектору:
@Component({ selector: 'app-panel', template: ` <div class="panel"> <div class="panel-header"> <ng-content select="[panel-header]" /> </div> <div class="panel-body"> <ng-content select="[panel-body]" /> </div> <div class="panel-footer"> <ng-content select="[panel-footer]" /> </div> <!-- Всё остальное без атрибутов --> <ng-content /> </div> `})export class PanelComponent {}<!-- Использование --><app-panel> <h3 panel-header>Заголовок панели</h3>
<div panel-body> <p>Основной контент</p> <ul>...</ul> </div>
<div panel-footer> <button>Отмена</button> <button>Подтвердить</button> </div>
<!-- Это попадёт в последний ng-content без select --> <p>Доп. информация</p></app-panel>🏷️ Селекторы ng-content
Заголовок раздела «🏷️ Селекторы ng-content»select поддерживает CSS-селекторы:
<!-- По атрибуту --><ng-content select="[slot='header']" />
<!-- По тегу --><ng-content select="app-icon" />
<!-- По классу --><ng-content select=".action-buttons" />
<!-- По компоненту/директиве --><ng-content select="app-toolbar" />
<!-- Комбинация --><ng-content select="button[primary]" />🔧 Полный пример: компонент Card
Заголовок раздела «🔧 Полный пример: компонент Card»@Component({ selector: 'app-card', standalone: true, template: ` <div class="card" [class.elevated]="elevated">
<div class="card-header" *ngIf="hasHeader"> <ng-content select="[card-header]" /> </div>
<div class="card-media" *ngIf="hasMedia"> <ng-content select="[card-media]" /> </div>
<div class="card-content"> <ng-content /> </div>
<div class="card-actions" *ngIf="hasActions"> <ng-content select="[card-actions]" /> </div>
</div> `, styles: [` .card { border-radius: 8px; overflow: hidden; border: 1px solid #e2e8f0; } .card-header { padding: 16px; border-bottom: 1px solid #e2e8f0; } .card-content { padding: 16px; } .card-actions { padding: 8px 16px; display: flex; gap: 8px; justify-content: flex-end; } .elevated { box-shadow: 0 4px 20px rgba(0,0,0,0.1); } `]})export class CardComponent { @Input() elevated = false; @ContentChild('[card-header]') headerContent: any; @ContentChild('[card-media]') mediaContent: any; @ContentChild('[card-actions]') actionsContent: any;
get hasHeader() { return !!this.headerContent; } get hasMedia() { return !!this.mediaContent; } get hasActions() { return !!this.actionsContent; }}<!-- Полное использование --><app-card [elevated]="true"> <div card-header> <app-avatar [src]="user.avatar" /> <span>{{ user.name }}</span> </div>
<img card-media [src]="post.imageUrl" alt="Post image" />
<p>{{ post.content }}</p>
<div card-actions> <button (click)="like()">👍 Нравится</button> <button (click)="share()">📤 Поделиться</button> </div></app-card>
<!-- Минимальное использование --><app-card> <p>Только основной контент</p></app-card>📐 ngTemplateOutlet — динамические шаблоны
Заголовок раздела «📐 ngTemplateOutlet — динамические шаблоны»ngTemplateOutlet позволяет рендерить TemplateRef в нужном месте:
@Component({ selector: 'app-list', standalone: true, imports: [NgTemplateOutlet, NgFor], template: ` <ul> <li *ngFor="let item of items">
<!-- Если передан кастомный шаблон — используем его --> <ng-container *ngTemplateOutlet="itemTemplate || defaultTemplate; context: { $implicit: item }" />
</li> </ul>
<!-- Дефолтный шаблон если ничего не передали --> <ng-template #defaultTemplate let-item> <span>{{ item.name }}</span> </ng-template> `})export class ListComponent<T> { @Input() items: T[] = []; @ContentChild('itemTemplate') itemTemplate!: TemplateRef<{ $implicit: T }>;}<!-- С кастомным шаблоном --><app-list [items]="users"> <ng-template #itemTemplate let-user> <div class="user-item"> <app-avatar [src]="user.avatar" /> <strong>{{ user.name }}</strong> <span class="badge">{{ user.role }}</span> </div> </ng-template></app-list>
<!-- Без шаблона — используется дефолтный --><app-list [items]="categories" />🎨 Паттерн: условный контент
Заголовок раздела «🎨 Паттерн: условный контент»@Component({ selector: 'app-empty-state', standalone: true, template: ` <div class="container"> <ng-content *ngIf="hasContent; else emptyTpl" />
<ng-template #emptyTpl> <ng-content select="[empty-state]" /> <!-- Если и этого нет — дефолтный --> <div class="default-empty" *ngIf="!hasEmptyState"> <span>Данных нет</span> </div> </ng-template> </div> `})export class EmptyStateComponent { @Input() hasContent = false; @ContentChild('[empty-state]') emptyState: any; get hasEmptyState() { return !!this.emptyState; }}🔄 ngProjectAs — переопределение проекции
Заголовок раздела «🔄 ngProjectAs — переопределение проекции»<!-- Компонент обёртка, но проецировать как будто это <header> --><app-fancy-header ngProjectAs="[panel-header]"> Этот компонент будет выбран по select="[panel-header]"</app-fancy-header>⚡ Динамические компоненты vs Content Projection
Заголовок раздела «⚡ Динамические компоненты vs Content Projection»| Подход | Когда использовать |
|---|---|
ng-content | Контент известен заранее, передаётся от родителя |
ngTemplateOutlet | Шаблон известен, но рендерим в другом месте |
ViewContainerRef.createComponent() | Компонент создаётся динамически в runtime |
export default function ContentProjectionPlayground() { const [activeSlots, setActiveSlots] = React.useState({ header: true, media: false, actions: true }); const [selectedVariant, setSelectedVariant] = React.useState('full');
const toggle = (slot) => setActiveSlots(p => ({ ...p, [slot]: !p[slot] }));
const variants = { full: { header: true, media: true, actions: true }, noMedia: { header: true, media: false, actions: true }, minimal: { header: false, media: false, actions: false }, noActions: { header: true, media: false, actions: false }, };
const selectVariant = (v) => { setSelectedVariant(v); setActiveSlots(variants[v]); };
const Card = ({ header, media, actions, children }) => ( <div style={{ background: '#1e293b', borderRadius: 10, border: '1px solid #334155', overflow: 'hidden', width: 280 }}> {header && ( <div style={{ padding: '12px 16px', borderBottom: '1px solid #334155', background: '#0f172a', display: 'flex', alignItems: 'center', gap: 10 }}> <div style={{ width: 32, height: 32, borderRadius: '50%', background: '#dd0031', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 14 }}>Я</div> <div> <div style={{ fontSize: 13, fontWeight: 700, color: '#e2e8f0' }}>Яша Смирнов</div> <div style={{ fontSize: 11, color: '#64748b' }}>Developer</div> </div> <div style={{ marginLeft: 'auto', fontSize: 10, color: '#dd0031', border: '1px solid #dd0031', padding: '2px 8px', borderRadius: 10 }}> [card-header] </div> </div> )}
{media && ( <div style={{ height: 120, background: 'linear-gradient(135deg, #dd003130, #7c3aed30)', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}> <span style={{ fontSize: 32 }}>🖼️</span> <div style={{ position: 'absolute', top: 6, right: 8, fontSize: 10, color: '#f59e0b', border: '1px solid #f59e0b', padding: '2px 8px', borderRadius: 10 }}> [card-media] </div> </div> )}
<div style={{ padding: 16, position: 'relative' }}> <div style={{ fontSize: 13, color: '#94a3b8', lineHeight: 1.6 }}> Основной контент через <code style={{ color: '#7dd3fc', fontSize: 11 }}>{'<ng-content />'}</code> (без select) </div> <div style={{ fontSize: 10, color: '#22c55e', border: '1px solid #22c55e', padding: '2px 8px', borderRadius: 10, display: 'inline-block', marginTop: 8 }}> ng-content (default slot) </div> </div>
{actions && ( <div style={{ padding: '10px 16px', borderTop: '1px solid #334155', display: 'flex', gap: 8, justifyContent: 'flex-end', position: 'relative' }}> <button style={{ background: 'transparent', color: '#94a3b8', border: '1px solid #334155', padding: '5px 12px', borderRadius: 6, fontSize: 12, cursor: 'pointer' }}>Отмена</button> <button style={{ background: '#dd0031', color: 'white', border: 'none', padding: '5px 12px', borderRadius: 6, fontSize: 12, cursor: 'pointer' }}>ОК</button> <div style={{ position: 'absolute', top: -12, right: 16, fontSize: 10, color: '#a78bfa', border: '1px solid #7c3aed', padding: '2px 8px', borderRadius: 10, background: '#0f172a' }}> [card-actions] </div> </div> )} </div> );
return ( <div style={{ background: '#0f172a', minHeight: 500, padding: 24, borderRadius: 12, fontFamily: 'system-ui', color: '#e2e8f0' }}> <div style={{ color: '#dd0031', fontWeight: 700, fontSize: 16, marginBottom: 20 }}> 🎭 Content Projection: Header / Body / Footer slots </div>
<div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: 24 }}> <div> <Card header={activeSlots.header} media={activeSlots.media} actions={activeSlots.actions} /> </div>
<div> <div style={{ marginBottom: 16 }}> <div style={{ color: '#94a3b8', fontSize: 12, marginBottom: 10 }}>Пресеты вариантов:</div> <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}> {Object.keys(variants).map(v => ( <button key={v} onClick={() => selectVariant(v)} style={{ background: selectedVariant === v ? '#dd0031' : '#1e293b', color: selectedVariant === v ? 'white' : '#94a3b8', border: `1px solid ${selectedVariant === v ? '#dd0031' : '#334155'}`, padding: '5px 12px', borderRadius: 6, cursor: 'pointer', fontSize: 12 }}> {v} </button> ))} </div> </div>
<div style={{ marginBottom: 16 }}> <div style={{ color: '#94a3b8', fontSize: 12, marginBottom: 10 }}>Управление слотами:</div> {[ { key: 'header', color: '#dd0031', select: '[card-header]' }, { key: 'media', color: '#f59e0b', select: '[card-media]' }, { key: 'actions', color: '#7c3aed', select: '[card-actions]' }, ].map(({ key, color, select }) => ( <div key={key} style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}> <button onClick={() => toggle(key)} style={{ background: activeSlots[key] ? color + '30' : '#1e293b', color: activeSlots[key] ? color : '#475569', border: `1px solid ${activeSlots[key] ? color : '#334155'}`, padding: '4px 14px', borderRadius: 6, cursor: 'pointer', fontSize: 12, minWidth: 70 }} > {activeSlots[key] ? 'показан' : 'скрыт'} </button> <code style={{ fontSize: 11, color: '#64748b' }}>{'<ng-content select="'}{select}{'" />'}</code> </div> ))} </div>
<div style={{ background: '#1e293b', borderRadius: 8, padding: 12, border: '1px solid #334155' }}> <div style={{ color: '#7dd3fc', fontSize: 11, marginBottom: 8 }}>Шаблон card.component.html:</div> {[ { slot: 'header', color: '#dd0031', select: '[card-header]' }, { slot: 'default', color: '#22c55e', select: null }, { slot: 'media', color: '#f59e0b', select: '[card-media]' }, { slot: 'actions', color: '#7c3aed', select: '[card-actions]' }, ].map(({ slot, color, select }) => ( <div key={slot} style={{ fontSize: 11, fontFamily: 'monospace', color: slot === 'default' || activeSlots[slot] ? color : '#334155', marginBottom: 4, transition: 'color 0.3s' }}> {'<ng-content'}{select ? ` select="${select}"` : ''}{' />'} {!activeSlots[slot] && slot !== 'default' && <span style={{ color: '#475569' }}> {/* не проецируется */}</span>} </div> ))} </div> </div> </div> </div> );}