model() — własna kontrolka z [( )]
Licznik biletów dostaje liczbę od rodzica, zmienia ją po kliknięciu i oddaje nową. Potrzebuje więc wejścia i zdarzenia naraz. model() łączy je w jeden sygnał, a rodzic używa kontrolki tym samym „bananem w pudełku” co [(ngModel)] z lekcji 17.
Czego się dziś nauczysz
- Zadeklarujesz w dziecku
model()i zmienisz jego wartość z wnętrza komponentu. - Połączysz własną kontrolkę z sygnałem rodzica zapisem
[(nazwa)]="sygnal". - Rozpiszesz
[(nazwa)]na wejście[nazwa]i zdarzenie(nazwaChange)— tak jak[(ngModel)]w lekcji 17. - Zbudujesz kontrolki, których nie ma w HTML: licznik, ocenę w gwiazdkach i przełącznik.
- Wybierzesz właściwe narzędzie:
input,outputalbomodel.
Przygotowanie: lekcja 17 (mechanizm [(ngModel)]), lekcje 29–31 (input i output). Przewidywany czas: 45 min z zadaniami.
Wejście, które może się zmienić
Wejście z lekcji 29 jest tylko do odczytu. Zdarzenie z lekcji 31 wysyła informację w górę. Kontrolka — licznik, gwiazdki, przełącznik — potrzebuje obu: dostaje wartość od rodzica, zmienia ją i oddaje nową. model() to sygnał, który dziecko może zmieniać: zmiana w dziecku trafia do rodzica, zmiana u rodzica — do dziecka.
| Krok | Kod | Gdzie |
|---|---|---|
| 1. Dziecko deklaruje model | readonly wartosc = model(0); | licznik.ts; jak input(0), ale z set i update |
| 2. Dziecko zmienia wartość | this.wartosc.update(n => n + 1); | jak zwykły sygnał |
| 3. Rodzic łączy model ze swoim sygnałem | <app-licznik [(wartosc)]="bilety" /> | sam sygnał, bez nawiasów () — jak przy [(ngModel)] |
<app-licznik [(wartosc)]="bilety" />
<app-licznik [wartosc]="bilety()" (wartoscChange)="bilety.set($event)" />
Zdarzenie wartoscChange tworzy za ciebie Angular — nazwa modelu z dopiskiem Change. To ten sam mechanizm, dzięki któremu działa [(ngModel)]: dyrektywa ngModel ma wejście ngModel i zdarzenie ngModelChange.
| Narzędzie | Kiedy |
|---|---|
input | dziecko tylko pokazuje dane (karta, wizytówka) |
output | dziecko zgłasza, że coś się stało (kliknięcie, usunięcie, nowy obiekt z formularza) |
model | dziecko jest kontrolką, która edytuje jedną wartość rodzica (licznik, ocena, przełącznik, kolor) |
Własny licznik z [(wartosc)]
Komponent app-licznik z modelem wartosc i dwoma zwykłymi wejściami, które ograniczają zakres. Rodzic używa go do wyboru liczby biletów i od razu liczy kwotę.
import { Component, input, model } from '@angular/core';
@Component({
selector: 'app-licznik',
imports: [],
templateUrl: './licznik.html'
})
export class Licznik {
readonly wartosc = model(0);
readonly min = input(0);
readonly max = input(10);
plus(): void {
this.wartosc.update(n => Math.min(n + 1, this.max()));
}
minus(): void {
this.wartosc.update(n => Math.max(n - 1, this.min()));
}
}
<div class="input-group" style="width: 9rem">
<button class="btn btn-outline-secondary" type="button" (click)="minus()">−</button>
<span class="form-control text-center">{{ wartosc() }}</span>
<button class="btn btn-outline-secondary" type="button" (click)="plus()">+</button>
</div>
import { Component, computed, signal } from '@angular/core';
import { Licznik } from './licznik';
@Component({
selector: 'app-demo',
imports: [Licznik],
templateUrl: './demo.html'
})
export class Demo {
readonly bilety = signal(2);
readonly razem = computed(() => this.bilety() * 15);
}
<div class="card m-3 p-3" style="width: 22rem">
<p class="mb-2">Liczba biletów (1–6):</p>
<app-licznik [(wartosc)]="bilety" [min]="1" [max]="6" />
<p class="fs-5 mt-3 mb-2">Do zapłaty: {{ razem() }} zł</p>
<button class="btn btn-sm btn-outline-secondary align-self-start" (click)="bilety.set(1)">Wróć do 1</button>
</div>
Liczba biletów (1–6):
[ − ][ 6 ][ + ]
Do zapłaty: 90 zł
Przyciski licznika zmieniają kwotę u rodzica — zmiana płynie w górę. Przycisk „Wróć do 1” u rodzica ustawia licznik — zmiana płynie w dół. Licznik nie zejdzie poniżej 1 i nie przekroczy 6, bo sam pilnuje zakresu (Math.min, Math.max). Przyciski mają type="button", żeby licznik umieszczony w formularzu nie wysyłał go (lekcja 24).
Zatrzymaj się
Licznik ma jeden model i dwa input. Dlaczego min i max nie muszą być modelami?
Pod maską: [wartosc] i (wartoscChange)
Ten sam licznik użyty dwa razy. Pierwszy — skrótem [( )], drugi — w wersji rozpisanej, w której rodzic wywołuje własną metodę i zapisuje historię zmian. Dokładnie tak jak (ngModelChange) w lekcji 25.
import { Component, input, model } from '@angular/core';
@Component({
selector: 'app-licznik',
imports: [],
templateUrl: './licznik.html'
})
export class Licznik {
readonly wartosc = model(0);
readonly min = input(0);
readonly max = input(10);
plus(): void {
this.wartosc.update(n => Math.min(n + 1, this.max()));
}
minus(): void {
this.wartosc.update(n => Math.max(n - 1, this.min()));
}
}
<div class="input-group" style="width: 10rem">
<button class="btn btn-outline-secondary" type="button" (click)="minus()">−</button>
<span class="form-control text-center">{{ wartosc() }}</span>
<button class="btn btn-outline-secondary" type="button" (click)="plus()">+</button>
</div>
import { Component, signal } from '@angular/core';
import { Licznik } from './licznik';
@Component({
selector: 'app-demo',
imports: [Licznik],
templateUrl: './demo.html'
})
export class Demo {
readonly a = signal(3);
readonly b = signal(3);
readonly historia = signal<number[]>([]);
zmienB(nowa: number): void {
this.b.set(nowa);
this.historia.update(h => [...h, nowa]);
}
}
<div class="p-3">
<p class="mb-1">Skrót <code>[(wartosc)]</code>: {{ a() }}</p>
<app-licznik [(wartosc)]="a" />
<p class="mt-4 mb-1">Rozbite <code>[wartosc]</code> + <code>(wartoscChange)</code>: {{ b() }}</p>
<app-licznik [wartosc]="b()" (wartoscChange)="zmienB($event)" />
<p class="small text-secondary mt-2">Historia: {{ historia().join(' → ') || '—' }}</p>
</div>
Skrót [(wartosc)]: 3
[ − ][ 3 ][ + ]
Rozbite [wartosc] + (wartoscChange): 3
[ − ][ 3 ][ + ]
Historia: 4 → 3
Oba liczniki działają tak samo. Drugi dodatkowo zapisuje każdą nową wartość, bo między zdarzeniem a sygnałem stoi metoda zmienB. W (wartoscChange) $event to gotowa nowa liczba.
Zatrzymaj się
Usuń z zmienB linię this.b.set(nowa). Co pokaże licznik, a co napis nad nim po kliknięciu „+”? Dlaczego się rozjechały?
Ocena w gwiazdkach
Kontrolka, której nie ma w HTML: pięć gwiazdek, kliknięcie ustawia ocenę. Model ocena przechowuje liczbę od 0 do 5. Rodzic używa kontrolki dla dwóch wydarzeń i pokazuje średnią.
import { Component, model } from '@angular/core';
@Component({
selector: 'app-gwiazdki',
imports: [],
templateUrl: './gwiazdki.html'
})
export class Gwiazdki {
readonly ocena = model(0);
readonly numery = [1, 2, 3, 4, 5];
}
<span class="fs-3" role="radiogroup">
@for (n of numery; track n) {
<span role="radio" style="cursor: pointer"
[attr.aria-checked]="n === ocena()"
[class.text-warning]="n <= ocena()"
[class.text-secondary]="n > ocena()"
(click)="ocena.set(n)">★</span>
}
</span>
import { Component, computed, signal } from '@angular/core';
import { Gwiazdki } from './gwiazdki';
@Component({
selector: 'app-demo',
imports: [Gwiazdki],
templateUrl: './demo.html'
})
export class Demo {
readonly turniej = signal(4);
readonly roboty = signal(0);
readonly srednia = computed(() => (this.turniej() + this.roboty()) / 2);
wyczysc(): void {
this.turniej.set(0);
this.roboty.set(0);
}
}
<div class="p-3">
<div style="width: 24rem">
<p class="mb-0">Turniej retro: {{ turniej() }}/5</p>
<app-gwiazdki [(ocena)]="turniej" />
<p class="mb-0 mt-2">Noc robotów: {{ roboty() }}/5</p>
<app-gwiazdki [(ocena)]="roboty" />
<p class="mt-3 fs-5">Średnia: {{ srednia() }}</p>
<button class="btn btn-sm btn-outline-secondary" (click)="wyczysc()">Wyczyść oceny</button>
</div>
</div>
Turniej retro: 4/5
★★★★☆
Noc robotów: 3/5
★★★☆☆
Średnia: 3.5
Gwiazdka zapala się (text-warning), gdy jej numer jest mniejszy lub równy ocenie. [attr.aria-checked] ustawia atrybut HTML (lekcja 06) — razem z role mówi czytnikowi ekranu, że to grupa wyboru i która gwiazdka jest wybrana. „Wyczyść oceny” zmienia sygnały rodzica, a obie kontrolki od razu gasną.
Zatrzymaj się
Kontrolka działa tylko myszką. Co trzeba by dodać, żeby dało się ją obsłużyć klawiaturą? (Podpowiedź: element, który z natury przyjmuje fokus.)
Przełącznik z modelem logicznym
Komponent app-przelacznik z modelem typu boolean i obowiązkowym wejściem z etykietą. Rodzic ma trzy ustawienia i liczy w computed, ile z nich jest włączonych.
import { Component, input, model } from '@angular/core';
@Component({
selector: 'app-przelacznik',
imports: [],
templateUrl: './przelacznik.html'
})
export class Przelacznik {
readonly wlaczony = model(false);
readonly etykieta = input.required<string>();
}
<div class="d-flex justify-content-between align-items-center border rounded p-2 mb-2">
<span>{{ etykieta() }}</span>
<button type="button" class="btn btn-sm" style="width: 5rem"
[class.btn-success]="wlaczony()" [class.btn-outline-secondary]="!wlaczony()"
(click)="wlaczony.set(!wlaczony())">
{{ wlaczony() ? 'WŁ.' : 'WYŁ.' }}
</button>
</div>
import { Component, computed, signal } from '@angular/core';
import { Przelacznik } from './przelacznik';
@Component({
selector: 'app-demo',
imports: [Przelacznik],
templateUrl: './demo.html'
})
export class Demo {
readonly powiadomienia = signal(true);
readonly ciemny = signal(false);
readonly dzwiek = signal(false);
readonly ileWlaczonych = computed(() =>
[this.powiadomienia(), this.ciemny(), this.dzwiek()].filter(x => x).length
);
}
<div class="p-3">
<div class="border rounded p-3" style="width: 22rem" [class.text-bg-dark]="ciemny()">
<h5>Ustawienia</h5>
<app-przelacznik etykieta="Powiadomienia" [(wlaczony)]="powiadomienia" />
<app-przelacznik etykieta="Tryb ciemny" [(wlaczony)]="ciemny" />
<app-przelacznik etykieta="Dźwięki" [(wlaczony)]="dzwiek" />
<p class="mb-0 small">Włączonych: {{ ileWlaczonych() }} z 3</p>
</div>
</div>
Ustawienia (ciemne tło)
Powiadomienia [WŁ.]
Tryb ciemny [WŁ.]
Dźwięki [WYŁ.]
Włączonych: 2 z 3
Każdy przełącznik zmienia swój sygnał u rodzica. Tryb ciemny od razu przyciemnia ramkę, bo rodzic używa sygnału ciemny w [class.text-bg-dark]. filter(x => x) zostawia w tablicy tylko wartości true, a length je liczy.
Zatrzymaj się
Jak inaczej — pętlą for…of i licznikiem — policzyłbyś liczbę włączonych opcji?
Zestawienie elementów
| Element | Znaczenie |
|---|---|
model z @angular/core | funkcja tworząca wejście, które dziecko może zmieniać |
readonly x = model(0) | model z wartością domyślną; typ wynika z wartości |
this.x(), this.x.set(v), this.x.update(f) | odczyt i zmiana — jak zwykły sygnał |
[(x)]="sygnal" | połączenie dwukierunkowe u rodzica |
[x]="sygnal()" + (xChange)="metoda($event)" | wersja rozpisana; $event = nowa wartość |
Math.min(a, b), Math.max(a, b) | mniejsza / większa z liczb — pilnowanie zakresu |
[attr.aria-checked], role="radio" | informacje dla czytnika ekranu |
tablica.filter(x => x).length | liczba wartości true w tablicy |
Zanim utkniesz
| Zapis | Problem |
|---|---|
[(wartosc)]="bilety()" | NG5002: Unsupported expression in a two-way binding. Jak przy ngModel — podaj sam sygnał. |
[(wartosc)], a w dziecku input(0) zamiast model(0) | NG8007: The property and event halves of the two-way binding 'wartosc’ are not bound to the same target. Wejście nie ma zdarzenia wartoscChange — potrzebny model. |
input(0) i this.wartosc.set(…) w dziecku | TS2339: Property 'set’ does not exist on type 'InputSignal<number>’. Do edytowanej wartości użyj model. |
(wartoscChanged)="zmienB()" albo (zmianaWartosci) | Brak błędu, zdarzenie nigdy nie przychodzi. Zdarzenie modelu nazywa się zawsze nazwaModeluChange. |
Wersja rozpisana bez set w metodzie rodzica | Kontrolka pokazuje nową wartość, a sygnał rodzica — starą. Metoda w (xChange) musi zapisać $event. |
model dla danych, których dziecko nie zmienia | Działa, ale wprowadza w błąd: czytelnik myśli, że dziecko edytuje wartość. Do samego wyświetlania wystarczy input. |
Zadania
Użyj licznika z przykładu 1 przy trzech produktach w koszyku (każdy z własnym sygnałem). Pokaż łączną liczbę sztuk.
Kryterium sukcesu: trzy liczniki działają niezależnie; suma zgadza się po każdym kliknięciu.
Podpowiedź
Trzy sygnały, trzy [(wartosc)], jeden computed.
Komponent app-serce z modelem polubione (boolean). Kliknięcie przełącza ♥ (czerwone) i ♡ (szare). Rodzic używa go przy dwóch postach i pokazuje napis „Polubiłeś 1 z 2 postów”.
Kryterium sukcesu: kliknięcie serca zmienia napis u rodzica; przycisk „Cofnij wszystkie” u rodzica gasi oba serca.
Podpowiedź
Wzoruj się na przełączniku z przykładu 4: polubione.set(!polubione()).
Komponent app-paleta z modelem kolor (tekst) i tablicą sześciu kolorów. Kliknięcie w kwadrat wybiera kolor, a wybrany kwadrat ma grubą ramkę. Rodzic używa palety do ustawienia koloru tła plakatu.
Kryterium sukcesu: wybór w palecie zmienia tło plakatu; przycisk „domyślny” u rodzica przesuwa zaznaczenie w palecie.
Podpowiedź
Każdy kwadrat to div z [style.background-color]="k", [class.border-4]="k === kolor()" i (click)="kolor.set(k)".
Komponent app-glosnosc z modelem poziom (0–100) i przyciskami „−10”, „+10” oraz „wycisz”. Rodzic używa go w wersji rozpisanej i zapisuje pięć ostatnich poziomów w historii.
Kryterium sukcesu: poziom nie wychodzi poza 0–100; historia ma najwyżej 5 pozycji, najnowsza na początku.
Podpowiedź
[poziom]="glosnosc()" (poziomChange)="zmien($event)", a w zmien: [nowy, ...h].slice(0, 5) jak w lekcji 25.
Ankieta po warsztatach: pięć pytań, każde z oceną gwiazdkową (komponent z przykładu 3) i przełącznikiem „chcę więcej takich zajęć” (przykład 4). Pytania trzymaj w tablicy obiektów u rodzica. Pokaż średnią ocen i procent odpowiedzi „tak”.
Kryterium sukcesu: każde pytanie ma własną ocenę i przełącznik; podsumowanie zmienia się po każdym kliknięciu.
Podpowiedź
Przy tablicy obiektów użyj wersji rozpisanej: [ocena]="p.ocena" (ocenaChange)="ustawOcene(p.id, $event)", a w metodzie — update z map.
Komponent app-sala z modelem wybrane (tablica numerów miejsc, np. [3, 7]) i wejściem zajete (tablica). Rysuje 20 miejsc; kliknięcie wolnego miejsca dodaje je do wybranych albo usuwa. Zajęte są szare i nieklikalne. Rodzic pokazuje listę wybranych miejsc i cenę (18 zł za miejsce).
Kryterium sukcesu: wybór w sali zmienia listę i cenę u rodzica; przycisk „Wyczyść wybór” u rodzica odznacza wszystkie miejsca w sali.
Podpowiedź
W sali metoda przelacz(nr): jeśli wybrane().includes(nr), to filter, w przeciwnym razie [...l, nr]. Miejsca jako przyciski z [disabled]="zajete().includes(nr)".
Co trzeba zapamiętać
readonly x = model(wartosc)to wejście, które dziecko może zmieniać przezsetiupdate.- Rodzic łączy model ze swoim sygnałem zapisem
[(x)]="sygnal"— bez nawiasów przy sygnale. [(x)]to skrót od[x]="sygnal()"i(xChange)="sygnal.set($event)"— ten sam mechanizm co[(ngModel)].modelsłuży do własnych kontrolek; do wyświetlania wystarczyinput, do zgłoszeń —output.
Sprawdź się: masz komponent suwaka głośności. Rodzic ma go ustawiać i odczytywać, a dodatkowo zapisywać każdą zmianę w historii. Jak wygląda zapis w szablonie rodzica?
Dokumentacja: angular.dev — Model inputs.