ng-content — komponent z miejscem na treść
Jedna karta ma zawierać tabelę, druga zdjęcie z podpisem, trzecia formularz. Przekazywanie tego przez wejścia byłoby koszmarem. Zamiast tego rodzic wpisuje treść między znacznikami komponentu — tak jak między <div> a </div> — a komponent wskazuje, gdzie ją pokazać.
Czego się dziś nauczysz
- Zbudujesz komponent-ramkę, do którego rodzic wkłada dowolną treść między znacznikami.
- Podzielisz komponent na kilka miejsc na treść za pomocą
select. - Wyjaśnisz, czyje sygnały i style działają we wkładanej treści.
- Połączysz
ng-contentzinputimodelw panelu zwijanym. - Zbudujesz okno dialogowe z
ng-contenti zdarzeniemoutput.
Przygotowanie: lekcja 04 (style komponentu), lekcje 29–32 (input, output, model), lekcja 12 (@if). Przewidywany czas: 45 min z zadaniami.
Opakowanie zamiast formularza wejść
Komponenty z lekcji 29–32 dostawały dane — tekst, liczbę, obiekt. Ramka, panel czy okno dialogowe potrzebują czegoś innego: kawałka HTML-a, który za każdym razem jest inny. Mechanizm, który na to pozwala, nazywa się projekcją treści (content projection).
| Krok | Kod | Gdzie |
|---|---|---|
| 1. Rodzic wkłada treść | <app-ramka><p>Dowolny HTML</p></app-ramka> | szablon rodzica |
| 2. Komponent wskazuje miejsce | <div class="…"><ng-content /></div> | szablon ramki |
| 3. Kilka miejsc | <ng-content select="[naglowek]" /> | tylko elementy z atrybutem naglowek; zwykłe ng-content zbiera resztę |
Czyja jest wkładana treść?
Treść między <app-ramka> a </app-ramka> jest częścią szablonu rodzica. Korzysta z jego sygnałów, metod i imports — ramka nic o nich nie wie. Także style: w lekcji 04 widziałeś, że style komponentu nie wychodzą poza jego szablon, więc wkładana treść jest stylowana jak u rodzica — klasy Bootstrapa działają w niej normalnie.
Ramka na dowolną treść
Komponent app-ramka to tylko obramowanie z cieniem i miejscem na treść. Rodzic używa go trzy razy z zupełnie różną zawartością: tekstem, obrazkiem z lekcji 07 i przyciskiem.
import { Component } from '@angular/core';
@Component({
selector: 'app-ramka',
imports: [],
template: `
<div class="border border-2 rounded-3 shadow-sm p-3 mb-3">
<ng-content />
</div>
`
})
export class Ramka {}
import { Component, signal } from '@angular/core';
import { Ramka } from './ramka';
@Component({
selector: 'app-demo',
imports: [Ramka],
templateUrl: './demo.html'
})
export class Demo {
readonly polubienia = signal(0);
polub(): void {
this.polubienia.update(n => n + 1);
}
}
<div class="p-3">
<div style="max-width: 30rem">
<app-ramka>
<h5>Zwykły tekst</h5>
<p class="mb-0">Turniej retro odbędzie się w piątek w sali 12.</p>
</app-ramka>
<app-ramka>
<img src="/img/turniej.svg" alt="Plakat turnieju" class="img-fluid rounded">
</app-ramka>
<app-ramka>
<button class="btn btn-outline-danger" (click)="polub()">♥ Lubię to</button>
<span class="ms-2">{{ polubienia() }}</span>
</app-ramka>
</div>
</div>
┌──────────────────────────────────────┐
│ Zwykły tekst │
│ Turniej retro odbędzie się w piątek… │
└──────────────────────────────────────┘
┌──────────────────────────────────────┐
│ [plakat turnieju] │
└──────────────────────────────────────┘
┌──────────────────────────────────────┐
│ [♥ Lubię to] 2 │
└──────────────────────────────────────┘
Przycisk działa, choć jest wewnątrz ramki — jego (click) i sygnał polubienia należą do rodzica. Klasa Ramka jest pusta: cały komponent to szablon.
Zatrzymaj się
Usuń <ng-content /> z szablonu ramki. Co zobaczysz? Czy kompilator coś zgłosi?
Kilka miejsc na treść: select
Karta ma trzy części: nagłówek, treść i stopkę. Rodzic oznacza elementy atrybutami naglowek i stopka, a karta rozkłada je we właściwe miejsca. Wszystko bez oznaczenia trafia do środka.
import { Component } from '@angular/core';
@Component({
selector: 'app-karta',
imports: [],
template: `
<div class="card mb-3">
<div class="card-header fw-bold"><ng-content select="[naglowek]" /></div>
<div class="card-body"><ng-content /></div>
<div class="card-footer text-secondary small"><ng-content select="[stopka]" /></div>
</div>
`
})
export class Karta {}
import { Component } from '@angular/core';
import { Karta } from './karta';
@Component({
selector: 'app-demo',
imports: [Karta],
templateUrl: './demo.html'
})
export class Demo {}
<div class="p-3">
<div style="max-width: 30rem">
<app-karta>
<span naglowek>Noc robotów</span>
<p>Budujemy i programujemy roboty do wyścigu po linii.</p>
<p class="mb-0">Zabierz laptopa i ładowarkę.</p>
<span stopka>sobota, sala 7</span>
</app-karta>
<app-karta>
<span stopka>stopka wpisana jako pierwsza</span>
<span naglowek>Kolejność nie ma znaczenia</span>
<ul class="mb-0"><li>nagłówek trafia na górę</li><li>stopka na dół</li></ul>
</app-karta>
</div>
</div>
┌ Kolejność nie ma znaczenia ──────────┐ ← nagłówek
│ • nagłówek trafia na górę │
│ • stopka na dół │
├──────────────────────────────────────┤
│ stopka wpisana jako pierwsza │ ← stopka
└──────────────────────────────────────┘
O miejscu decyduje select w karcie, nie kolejność w szablonie rodzica. select przyjmuje selektor CSS: [naglowek] to „element z atrybutem naglowek”. Można też wybierać po znaczniku (select="h5") albo po klasie (select=".tytul"), ale własne atrybuty są najczytelniejsze.
Zatrzymaj się
Co pokaże karta, jeśli rodzic nie poda stopki? Czy pusta szara belka na dole to problem?
Panel zwijany: ng-content + input + model
Panel ma tytuł z wejścia, przycisk zwijania i stan otwarty. Treść wkłada rodzic. Tak zbudowane są sekcje „Najczęstsze pytania” na wielu stronach.
import { Component, input, model } from '@angular/core';
@Component({
selector: 'app-panel',
imports: [],
template: `
<div class="border rounded mb-2">
<button type="button" class="btn w-100 text-start d-flex justify-content-between"
(click)="otwarty.set(!otwarty())">
<strong>{{ tytul() }}</strong>
<span>{{ otwarty() ? '−' : '+' }}</span>
</button>
@if (otwarty()) {
<div class="px-3 pb-3"><ng-content /></div>
}
</div>
`
})
export class Panel {
readonly tytul = input.required<string>();
readonly otwarty = model(false);
}
import { Component } from '@angular/core';
import { Panel } from './panel';
@Component({
selector: 'app-demo',
imports: [Panel],
templateUrl: './demo.html'
})
export class Demo {}
<div class="p-3">
<div style="max-width: 32rem">
<h5>Najczęstsze pytania</h5>
<app-panel tytul="Kto może przyjść na turniej?" [otwarty]="true">
Każdy uczeń ZSMI. Uczniowie spoza szkoły — za zgodą opiekuna koła.
</app-panel>
<app-panel tytul="Czy trzeba mieć własny sprzęt?">
Nie. Gramy na szkolnych komputerach, ale <strong>własny pad</strong> jest mile widziany.
</app-panel>
<app-panel tytul="Gdzie zapisać drużynę?">
<ol class="mb-0"><li>Wypełnij formularz na stronie klubu.</li><li>Potwierdź zapis u opiekuna.</li></ol>
</app-panel>
</div>
</div>
Najczęstsze pytania
[ Kto może przyjść na turniej? − ]
Każdy uczeń ZSMI. Uczniowie spoza szkoły — za zgodą opiekuna koła.
[ Czy trzeba mieć własny sprzęt? − ]
Nie. Gramy na szkolnych komputerach, ale własny pad jest mile widziany.
[ Gdzie zapisać drużynę? + ]
otwarty to model z lekcji 32. Rodzic podaje stan początkowy przez [otwarty]="true", a panel zmienia go sam po kliknięciu. Gdyby rodzic potrzebował wiedzieć, który panel jest otwarty, wystarczy zmienić zapis na [(otwarty)]="sygnal".
Zatrzymaj się
<ng-content /> jest wewnątrz @if. Treść należy do rodzica — czy rodzic tworzy ją od nowa przy każdym rozwinięciu panelu, czy tylko raz?
Okno dialogowe: ng-content + output
Okno z przyciemnionym tłem, tytułem z wejścia, treścią od rodzica i zdarzeniem zamknij. Rodzic decyduje, kiedy okno istnieje (@if), i sam reaguje na zamknięcie. W treść okna wkładamy mały formularz — z sygnałami rodzica.
import { Component, input, output } from '@angular/core';
@Component({
selector: 'app-okno',
imports: [],
template: `
<div class="position-fixed top-0 start-0 w-100 h-100 bg-dark bg-opacity-50
d-flex align-items-center justify-content-center" (click)="zamknij.emit()">
<div class="bg-white rounded shadow p-3" style="width: 26rem" (click)="$event.stopPropagation()">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0">{{ tytul() }}</h5>
<button type="button" class="btn-close" aria-label="Zamknij" (click)="zamknij.emit()"></button>
</div>
<ng-content />
</div>
</div>
`
})
export class Okno {
readonly tytul = input.required<string>();
readonly zamknij = output<void>();
}
import { Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Okno } from './okno';
@Component({
selector: 'app-demo',
imports: [Okno, FormsModule],
templateUrl: './demo.html'
})
export class Demo {
readonly otwarte = signal(false);
readonly nick = signal('');
readonly zapisani = signal<string[]>([]);
zapisz(): void {
if (this.nick().trim() === '') {
return;
}
this.zapisani.update(l => [...l, this.nick().trim()]);
this.nick.set('');
this.otwarte.set(false);
}
}
<div class="p-3">
<div style="max-width: 30rem">
<button class="btn btn-primary" (click)="otwarte.set(true)">Zapisz się na turniej</button>
<p class="mt-3">Zapisani: {{ zapisani().join(', ') || 'nikt' }}</p>
</div>
@if (otwarte()) {
<app-okno tytul="Zapis na turniej retro" (zamknij)="otwarte.set(false)">
<form (ngSubmit)="zapisz()">
<input name="nick" class="form-control mb-3" placeholder="Twój nick" [(ngModel)]="nick">
<div class="text-end">
<button type="button" class="btn btn-outline-secondary me-2" (click)="otwarte.set(false)">Anuluj</button>
<button type="submit" class="btn btn-primary">Zapisz</button>
</div>
</form>
</app-okno>
}
</div>
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░ ┌ Zapis na turniej retro ────── ✕ ┐ ░░
░░ │ [Twój nick ] │ ░░
░░ │ [Anuluj] [Zapisz] │ ░░
░░ └──────────────────────────────────┘ ░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
Okno zamkniesz krzyżykiem, przyciskiem „Anuluj” albo kliknięciem w ciemne tło. Po zatwierdzeniu nicku okno znika, a nick pojawia się na liście. Formularz i jego logika należą do rodzica; okno odpowiada tylko za wygląd i zamykanie. Klasy position-fixed top-0 start-0 w-100 h-100 rozciągają tło na całe okno przeglądarki.
$event.stopPropagation()
Kliknięcie „wędruje” od elementu w górę — do wszystkich elementów, które go zawierają. Bez (click)="$event.stopPropagation()" na białym prostokącie kliknięcie w pole tekstowe dotarłoby do ciemnego tła i zamknęło okno. stopPropagation() zatrzymuje tę wędrówkę.
Zatrzymaj się
To samo okno możesz wykorzystać do potwierdzenia usunięcia albo podglądu zdjęcia. Czy trzeba coś zmieniać w komponencie Okno?
Zestawienie elementów
| Element | Znaczenie |
|---|---|
<ng-content /> | miejsce na treść wpisaną przez rodzica między znacznikami komponentu |
<ng-content select="[nazwa]" /> | miejsce tylko na elementy z atrybutem nazwa |
select="h5", select=".klasa" | wybór po znaczniku albo klasie |
<span naglowek>…</span> | oznaczenie elementu u rodzica (atrybut bez wartości) |
$event.stopPropagation() | zatrzymuje „wędrówkę” zdarzenia do elementów nadrzędnych |
position-fixed top-0 start-0 w-100 h-100 | warstwa na całe okno przeglądarki (Bootstrap) |
bg-dark bg-opacity-50 | półprzezroczyste ciemne tło |
btn-close | przycisk ✕ z Bootstrapa |
Zanim utkniesz
| Zapis | Problem |
|---|---|
Treść między znacznikami komponentu, a w jego szablonie brak <ng-content /> | Treść znika bez śladu i bez błędu. Komponent musi wskazać miejsce. |
Dwa zwykłe <ng-content /> bez select | Cała treść trafia tylko do ostatniego z nich. Każde dodatkowe miejsce potrzebuje select. |
select="naglowek" zamiast select="[naglowek]" | Angular szuka znacznika <naglowek>, a nie atrybutu. Nagłówek ląduje w treści głównej. |
Sygnał karty użyty we wkładanej treści, np. {{ otwarty() }} w demo.html | TS2339: Property 'otwarty’ does not exist on type 'Demo’. Treść należy do rodzica i widzi tylko jego pola. |
| Kliknięcie wewnątrz okna zamyka okno | Brakuje $event.stopPropagation() na wewnętrznym prostokącie. |
Zadania
Komponent app-ostrzezenie: żółta ramka z ikoną ⚠ po lewej i dowolną treścią od rodzica po prawej. Użyj go trzy razy z różnym tekstem, w tym raz z linkiem.
Kryterium sukcesu: wszystkie trzy ostrzeżenia mają ten sam wygląd i różną treść; link działa.
Podpowiedź
Klasy Bootstrapa alert alert-warning d-flex gap-2.
Komponent app-dymek z wejściem logicznym moj (booleanAttribute) i treścią od rodzica. Moje dymki są niebieskie i po prawej, cudze — szare i po lewej. Ułóż krótką rozmowę z pięciu dymków.
Kryterium sukcesu: treść dymków pochodzi z rodzica (może zawierać <strong> czy emoji); wygląd zależy tylko od moj.
Podpowiedź
[class.ms-auto]="moj()" i [class.text-bg-primary]="moj()" na dymku o ograniczonej szerokości.
Komponent app-produkt z trzema miejscami: [zdjecie], treść i [cena] (w prawym dolnym rogu, pogrubiona). Wyświetl w siatce cztery produkty sklepiku.
Kryterium sukcesu: każda karta ma zdjęcie na górze, opis w środku i cenę w rogu, niezależnie od kolejności w szablonie rodzica.
Podpowiedź
Przykład 2 z innym układem. Zamiast obrazków możesz użyć emoji w dużej czcionce (fs-1).
Komponent app-zakladki z wejściem nazwy (tablica tekstów) i modelem aktywna (numer). Rysuje przyciski zakładek (nav nav-tabs), a pod nimi <ng-content />. Rodzic wkłada trzy bloki i sam decyduje @if, który pokazać, na podstawie swojego sygnału połączonego przez [(aktywna)].
Kryterium sukcesu: kliknięcie zakładki zmienia aktywny przycisk i pokazywaną treść; zakładki działają dla dowolnej liczby nazw.
Podpowiedź
W zakładkach @for (n of nazwy(); track $index; let i = $index) i (click)="aktywna.set(i)".
Lista zadań z przyciskiem „Usuń” przy każdym. Kliknięcie otwiera okno z przykładu 4 z pytaniem „Czy na pewno usunąć nazwa zadania?” i przyciskami „Tak” i „Nie”. Usunięcie następuje dopiero po „Tak”.
Kryterium sukcesu: zadanie znika tylko po potwierdzeniu; „Nie”, krzyżyk i kliknięcie w tło zamykają okno bez usuwania.
Podpowiedź
Rodzic trzyma w sygnale zadanie do usunięcia albo null; @if (doUsuniecia(); as z) wokół okna.
Okno dialogowe z trzema krokami rejestracji na wycieczkę (dane ucznia → wybór wycieczki → podsumowanie). Treść każdego kroku wkłada rodzic, a okno dostaje dodatkowe miejsce [przyciski] na dole, w którym rodzic umieszcza „Wstecz”/„Dalej”/„Zapisz”.
Kryterium sukcesu: przechodzenie między krokami nie zamyka okna; przyciski w stopce okna zmieniają się w zależności od kroku; po „Zapisz” zgłoszenie trafia na listę rodzica.
Podpowiedź
Rozbuduj Okno o <ng-content select="[przyciski]" /> w stopce. Numer kroku to sygnał rodzica; w treści @switch (krok()) z lekcji 14.
Co trzeba zapamiętać
- Treść wpisana między znacznikami komponentu pojawia się w miejscu
<ng-content />w jego szablonie. - Kilka miejsc na treść rozróżnia
selectz selektorem CSS, np.select="[stopka]". - Wkładana treść należy do rodzica: korzysta z jego sygnałów, metod i stylów.
- Komponenty-opakowania (ramka, panel, okno) łączą
ng-contentzinput,modelioutput.
Sprawdź się: kiedy przekażesz coś do komponentu przez input, a kiedy przez ng-content? Podaj po jednym przykładzie.
Dokumentacja: angular.dev — Content projection with ng-content.