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

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.

Angular 22 model() [(nazwa)] nazwaChange własne kontrolki 45 min
CEL LEKCJI

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, output albo model.

Przygotowanie: lekcja 17 (mechanizm [(ngModel)]), lekcje 29–31 (input i output). Przewidywany czas: 45 min z zadaniami.

TEORIA

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.

KrokKodGdzie
1. Dziecko deklaruje modelreadonly 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)]
to samo, rozpisane na dwie części (por. lekcja 17)
<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ędzieKiedy
inputdziecko tylko pokazuje dane (karta, wizytówka)
outputdziecko zgłasza, że coś się stało (kliknięcie, usunięcie, nowy obiekt z formularza)
modeldziecko jest kontrolką, która edytuje jedną wartość rodzica (licznik, ocena, przełącznik, kolor)
PRZYKŁAD 1

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

src/app/nowa-32-1/licznik.ts — pełny plik
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()));
    }
}
src/app/nowa-32-1/licznik.html — pełny plik
<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>
src/app/nowa-32-1/demo.ts — pełny plik
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);
}
src/app/nowa-32-1/demo.html — pełny plik
<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>
wynik po kilku kliknięciach „+”
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?

PRZYKŁAD 2

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.

src/app/nowa-32-2/licznik.ts — pełny plik
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()));
    }
}
src/app/nowa-32-2/licznik.html — pełny plik
<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>
src/app/nowa-32-2/demo.ts — pełny plik
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]);
    }
}
src/app/nowa-32-2/demo.html — pełny plik
<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>
wynik po kliknięciach + , − w drugim liczniku
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?

PRZYKŁAD 3

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

src/app/nowa-32-3/gwiazdki.ts — pełny plik
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];
}
src/app/nowa-32-3/gwiazdki.html — pełny plik
<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>
src/app/nowa-32-3/demo.ts — pełny plik
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);
    }
}
src/app/nowa-32-3/demo.html — pełny plik
<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>
wynik po kliknięciu trzeciej gwiazdki przy „Noc robotów”
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.)

PRZYKŁAD 4

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.

src/app/nowa-32-4/przelacznik.ts — pełny plik
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>();
}
src/app/nowa-32-4/przelacznik.html — pełny plik
<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>
src/app/nowa-32-4/demo.ts — pełny plik
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
    );
}
src/app/nowa-32-4/demo.html — pełny plik
<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>
wynik po włączeniu „Tryb ciemny”
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?

ELEMENTY WBUDOWANE

Zestawienie elementów

ElementZnaczenie
model z @angular/corefunkcja 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).lengthliczba wartości true w tablicy
CZĘSTE BŁĘDY

Zanim utkniesz

ZapisProblem
[(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 dzieckuTS2339: 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 rodzicaKontrolka pokazuje nową wartość, a sygnał rodzica — starą. Metoda w (xChange) musi zapisać $event.
model dla danych, których dziecko nie zmieniaDziała, ale wprowadza w błąd: czytelnik myśli, że dziecko edytuje wartość. Do samego wyświetlania wystarczy input.
ZADANIA

Zadania

ZAD 1Licznik w koszyku★☆☆

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.

ZAD 2Polubienie★☆☆

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

ZAD 3Wybór koloru z palety★★☆

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

ZAD 4Suwak głośności z historią★★☆

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.

ZAD 5Ankieta★★★

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.

ZAD 6Wybór miejsc w kinie★★★

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

PODSUMOWANIE

Co trzeba zapamiętać

  • readonly x = model(wartosc) to wejście, które dziecko może zmieniać przez set i update.
  • 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)].
  • model służy do własnych kontrolek; do wyświetlania wystarczy input, 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.

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