Przejdź do treści
School IT / Przestrzeń do nauki

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ć.

Angular 22 ng-content select projekcja treści panel zwijany okno dialogowe 45 min
CEL LEKCJI

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-content z input i model w panelu zwijanym.
  • Zbudujesz okno dialogowe z ng-content i zdarzeniem output.

Przygotowanie: lekcja 04 (style komponentu), lekcje 29–32 (input, output, model), lekcja 12 (@if). Przewidywany czas: 45 min z zadaniami.

TEORIA

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).

KrokKodGdzie
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.

PRZYKŁAD 1

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.

src/app/nowa-39-1/ramka.ts — pełny plik
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 {}
src/app/nowa-39-1/demo.ts — pełny plik
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);
    }
}
src/app/nowa-39-1/demo.html — pełny plik
<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>
wynik po dwóch kliknięciach „Lubię to”
┌──────────────────────────────────────┐
│ 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?

PRZYKŁAD 2

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.

src/app/nowa-39-2/karta.ts — pełny plik
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 {}
src/app/nowa-39-2/demo.ts — pełny plik
import { Component } from '@angular/core';
import { Karta } from './karta';

@Component({
    selector: 'app-demo',
    imports: [Karta],
    templateUrl: './demo.html'
})
export class Demo {}
src/app/nowa-39-2/demo.html — pełny plik
<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>
druga karta
┌ 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?

PRZYKŁAD 3

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.

src/app/nowa-39-3/panel.ts — pełny plik
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);
}
src/app/nowa-39-3/demo.ts — pełny plik
import { Component } from '@angular/core';
import { Panel } from './panel';

@Component({
    selector: 'app-demo',
    imports: [Panel],
    templateUrl: './demo.html'
})
export class Demo {}
src/app/nowa-39-3/demo.html — pełny plik
<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>
po rozwinięciu drugiego panelu
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?

PRZYKŁAD 4

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.

src/app/nowa-39-4/okno.ts — pełny plik
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>();
}
src/app/nowa-39-4/demo.ts — pełny plik
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);
    }
}
src/app/nowa-39-4/demo.html — pełny plik
<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>
po kliknięciu „Zapisz się na turniej”
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░  ┌ 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?

ELEMENTY WBUDOWANE

Zestawienie elementów

ElementZnaczenie
<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-100warstwa na całe okno przeglądarki (Bootstrap)
bg-dark bg-opacity-50półprzezroczyste ciemne tło
btn-closeprzycisk ✕ z Bootstrapa
CZĘSTE BŁĘDY

Zanim utkniesz

ZapisProblem
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 selectCał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.htmlTS2339: Property 'otwarty’ does not exist on type 'Demo’. Treść należy do rodzica i widzi tylko jego pola.
Kliknięcie wewnątrz okna zamyka oknoBrakuje $event.stopPropagation() na wewnętrznym prostokącie.
ZADANIA

Zadania

ZAD 1Ostrzeżenie★☆☆

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.

ZAD 2Dymek czatu★☆☆

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.

ZAD 3Karta produktu★★☆

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).

ZAD 4Zakładki★★☆

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)".

ZAD 5Potwierdzenie usunięcia★★★

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.

ZAD 6Kreator w oknie★★★

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.

PODSUMOWANIE

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 select z 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-content z input, model i output.

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.

Postęp zapisuje się w tej przeglądarce.