Перейти к содержимому

26. Content Projection

Content Projection (проекция контента) — это механизм передачи HTML-контента от родителя в дочерний компонент. Как слоты в Web Components.


Базовый вариант: принимаем любой контент:

card.component.ts
@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 фильтрует проецируемый контент по 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>

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]" />

@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 позволяет рендерить 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; }
}

<!-- Компонент обёртка, но проецировать как будто это <header> -->
<app-fancy-header ngProjectAs="[panel-header]">
Этот компонент будет выбран по select="[panel-header]"
</app-fancy-header>

ПодходКогда использовать
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>
);
}