Liczby: number i range
Uczeń wpisuje 2 i 3, klika „Dodaj” i dostaje 23. To nie żart, tylko najczęstszy błąd w zadaniach z formularzami: wartość pola bywa tekstem, nawet gdy wygląda jak liczba. Zobaczysz, kiedy tak się dzieje, jak tego uniknąć i jak pilnować zakresu liczby.
Czego się dziś nauczysz
- Wyjaśnisz, kiedy wartość pola jest tekstem, a kiedy liczbą, i dlaczego
'2' + '3'daje'23'. - Połączysz pole
type="number"z sygnałem typunumber | null. - Obsłużysz puste pole operatorem
??. - Ograniczysz zakres atrybutami
min,max,stepi dopilnujesz go w kodzie. - Użyjesz suwaka
type="range"z podglądem wartości. - Zbudujesz kalkulator z kilku pól liczbowych i sformatujesz wynik pipe’em
number.
Przygotowanie: lekcje 16–18 — znasz pipe number i używasz [(ngModel)] z polami tekstowymi. Przewidywany czas: 45 min z zadaniami.
„2” + „3” = „23”
W JavaScripcie znak + robi dwie różne rzeczy. Dla dwóch liczb dodaje: 2 + 3 daje 5. Gdy choć jedna strona jest tekstem, skleja: '2' + '3' daje '23'. pole.value ze zmiennej szablonowej zawsze zwraca tekst — nawet z pola typu number.
Dobra wiadomość: ngModel na polu type="number" albo type="range" sam zamienia tekst na liczbę. Trzeba tylko pamiętać o jednym wyjątku — pustym polu.
| Zasada | Co to znaczy w kodzie |
|---|---|
| Typ pola decyduje | <input type="number" [(ngModel)]="ile"> — do sygnału trafia liczba. To samo pole z type="text" dałoby tekst. |
Puste pole to null | Po skasowaniu zawartości sygnał dostaje null, a nie 0. Typ: signal<number | null>(1). |
min, max, step to podpowiedzi | Strzałki trzymają się zakresu, ale z klawiatury da się wpisać 500 w pole z max="10". Zakres sprawdzasz też w kodzie. |
| Suwak zawsze ma wartość | type="range" nie bywa pusty i nie wyjdzie poza zakres — ale użytkownik nie widzi liczby, trzeba ją pokazać obok. |
Operator ??
a() ?? 0 czyta się: „wartość a(), a jeśli to null — zero”. Przydaje się przy każdym polu liczbowym, które może zostać puste. Gdy musisz zamienić tekst na liczbę ręcznie, użyj Number(tekst) (lekcja 08).
Tekst czy liczba? Ten sam kalkulator dwa razy
Dwa kalkulatory obok siebie. Pierwszy czyta pola przez zmienne szablonowe, drugi przez ngModel. Operator typeof pokazuje typ wartości.
import { Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-demo',
imports: [FormsModule],
templateUrl: './demo.html'
})
export class Demo {
readonly wynikRef = signal('');
readonly a = signal<number | null>(2);
readonly b = signal<number | null>(3);
dodajRef(x: string, y: string): void {
this.wynikRef.set(x + y + ' (typ: ' + typeof x + ')');
}
}
<div class="p-3">
<div class="row g-4" style="max-width: 50rem">
<div class="col-md-6">
<h6>Zmienne szablonowe</h6>
<input #x type="number" class="form-control mb-2" value="2">
<input #y type="number" class="form-control mb-2" value="3">
<button class="btn btn-outline-danger" (click)="dodajRef(x.value, y.value)">Dodaj</button>
<p class="mt-2">Wynik: {{ wynikRef() }}</p>
</div>
<div class="col-md-6">
<h6><code>ngModel</code></h6>
<input name="a" type="number" class="form-control mb-2" [(ngModel)]="a">
<input name="b" type="number" class="form-control mb-2" [(ngModel)]="b">
<p class="mt-2">Wynik: {{ (a() ?? 0) + (b() ?? 0) }} (typ: {{ typeof a() }})</p>
</div>
</div>
</div>
Zmienne szablonowe — Wynik: 23 (typ: string)
ngModel — Wynik: 5 (typ: number)
Prawa strona przelicza wynik przy każdej zmianie. Skasuj całą zawartość jednego z pól po prawej: typ zmienia się na object — tak JavaScript opisuje null.
Zatrzymaj się
Lewy kalkulator da się naprawić bez ngModel. Co dopiszesz w metodzie dodajRef?
Zakres: min, max, step — i kontrola w kodzie
Liczba biletów od 1 do 10. Atrybuty pilnują strzałek, a computed pilnuje tego, co da się wpisać z klawiatury.
import { Component, computed, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-demo',
imports: [FormsModule],
templateUrl: './demo.html'
})
export class Demo {
readonly cena = 15;
readonly bilety = signal<number | null>(1);
readonly poprawne = computed(() => {
const n = this.bilety();
return n !== null && Number.isInteger(n) && n >= 1 && n <= 10;
});
readonly razem = computed(() => (this.poprawne() ? this.bilety()! * this.cena : 0));
}
<div class="p-3" style="width: 22rem">
<label class="form-label" for="bilety">Liczba biletów (1–10)</label>
<input id="bilety" name="bilety" type="number" class="form-control"
min="1" max="10" step="1" [(ngModel)]="bilety">
@if (poprawne()) {
<p class="mt-3 fs-5">Do zapłaty: {{ razem() }} zł</p>
} @else {
<p class="mt-3 text-danger">Podaj liczbę całkowitą od 1 do 10.</p>
}
</div>
3 → Do zapłaty: 45 zł
25 → Podaj liczbę całkowitą od 1 do 10.
2.5 → Podaj liczbę całkowitą od 1 do 10.
puste → Podaj liczbę całkowitą od 1 do 10.
Wykrzyknik w this.bilety()! mówi TypeScriptowi: „wiem, że tu nie ma null”. Wolno go użyć tylko wtedy, gdy naprawdę to sprawdziłeś — tu gwarantuje to poprawne(). Number.isInteger sprawdza, czy liczba jest całkowita.
Zatrzymaj się
Dlaczego nie wystarczy sprawdzić n >= 1 && n <= 10? Jaką wartość przepuściłby taki warunek?
Suwak type=”range”
Dwa suwaki Bootstrapa (form-range): rozmiar napisu w pikselach i widoczność w procentach. Wartości od razu sterują stylem z lekcji 15.
import { Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-demo',
imports: [FormsModule],
templateUrl: './demo.html'
})
export class Demo {
readonly rozmiar = signal(32);
readonly widocznosc = signal(100);
}
<div class="p-3" style="width: 28rem">
<label class="form-label" for="rozmiar">Rozmiar: {{ rozmiar() }} px</label>
<input id="rozmiar" name="rozmiar" type="range" class="form-range"
min="12" max="72" step="2" [(ngModel)]="rozmiar">
<label class="form-label" for="widocznosc">Widoczność: {{ widocznosc() }}%</label>
<input id="widocznosc" name="widocznosc" type="range" class="form-range"
min="0" max="100" step="5" [(ngModel)]="widocznosc">
<div class="border p-3 mt-3 text-center">
<span class="fw-bold"
[style.font-size.px]="rozmiar()"
[style.opacity]="widocznosc() / 100">NOC ROBOTÓW</span>
</div>
</div>
Rozmiar: 48 px Widoczność: 50%
NOC ROBOTÓW (duży, półprzezroczysty)
step="2" sprawia, że rozmiar zmienia się co 2 px. Przezroczystość w CSS to liczba od 0 do 1, dlatego dzielimy przez 100.
Zatrzymaj się
Sygnały suwaków nie mają typu number | null, choć pole number miało. Dlaczego?
Kalkulator kosztów wycieczki
Wszystko razem: liczba uczestników (number), liczba dni (range) i dopłata za osobę (number z groszami, step="0.01"). Koszt to computed, wynik formatuje pipe number.
import { Component, computed, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { DecimalPipe } from '@angular/common';
@Component({
selector: 'app-demo',
imports: [FormsModule, DecimalPipe],
templateUrl: './demo.html'
})
export class Demo {
readonly cenaZaDzien = 120;
readonly osoby = signal<number | null>(20);
readonly dni = signal(3);
readonly doplata = signal<number | null>(0);
readonly naOsobe = computed(() => this.dni() * this.cenaZaDzien + (this.doplata() ?? 0));
readonly razem = computed(() => this.naOsobe() * (this.osoby() ?? 0));
}
<div class="card m-3 p-3" style="width: 28rem">
<label class="form-label" for="osoby">Liczba uczestników</label>
<input id="osoby" name="osoby" type="number" class="form-control mb-3"
min="1" max="40" [(ngModel)]="osoby">
<label class="form-label" for="dni">Liczba dni: {{ dni() }}</label>
<input id="dni" name="dni" type="range" class="form-range mb-3"
min="1" max="7" [(ngModel)]="dni">
<label class="form-label" for="doplata">Dopłata za osobę (zł)</label>
<input id="doplata" name="doplata" type="number" class="form-control mb-3"
min="0" step="0.01" [(ngModel)]="doplata">
<p class="mb-1">Na osobę: {{ naOsobe() | number:'1.2-2' }} zł</p>
<p class="fs-5 fw-bold">Razem: {{ razem() | number:'1.2-2' }} zł</p>
</div>
Na osobę: 372,50 zł
Razem: 7 450,00 zł
W polu liczbowym część dziesiętną wpisujesz tak, jak pozwala przeglądarka — w polskiej wersji często przecinkiem. Do sygnału i tak trafi liczba z kropką. Skasuj liczbę uczestników — „Razem” pokazuje zero zamiast błędu, bo ?? 0 zamienia null na zero.
Zatrzymaj się
Kwota na osobę nie zależy od liczby uczestników. Dlaczego warto mieć ją jako osobny computed?
Zestawienie elementów
| Element | Znaczenie | Uwagi |
|---|---|---|
type="number" + ngModel | liczba w sygnale | Puste pole = null. |
type="range", form-range | suwak | Zawsze ma wartość. |
min, max, step | zakres i krok | Nie blokują klawiatury. |
signal<number | null>(1) | sygnał na liczbę albo nic | Dla pól number. |
a ?? 0 | zero zamiast null | Działa też w szablonie. |
x! | „tu na pewno nie ma null” | Tylko po sprawdzeniu. |
typeof x | typ wartości jako tekst | 'string', 'number', 'object'. |
Number(tekst), Number.isInteger(n) | zamiana na liczbę, czy całkowita | Z TypeScriptu. |
[style.opacity] | przezroczystość 0–1 | Z lekcji 15. |
Zanim utkniesz
| Zapis | Problem |
|---|---|
pole.value + 1 przy zmiennej szablonowej | Doklejanie zamiast dodawania: z 5 robi się 51. Użyj Number(pole.value) albo ngModel. |
[(ngModel)] na type="text", a liczysz jak na liczbie | Tekst zamiast liczby — znów doklejanie. Do liczb używaj type="number". |
signal(0) dla pola number | Po skasowaniu zawartości w sygnale ląduje null, a TypeScript o tym nie wie. Pisz signal<number | null>(0). |
Poleganie tylko na max="10" | Z klawiatury da się wpisać więcej. Zakres sprawdzaj także w kodzie. |
| Suwak bez wartości obok | Działa, ale użytkownik nie wie, jaką liczbę wybrał. |
this.bilety()! * cena bez sprawdzenia | Przy pustym polu wynik to 0 i nikt nie wie dlaczego. Najpierw sprawdź !== null. |
Zadania
Pole liczbowe ze stopniami Celsjusza i wynik w Fahrenheitach: F = C × 9/5 + 32, z jednym miejscem po przecinku.
Kryterium sukcesu: wynik zmienia się podczas pisania; przy pustym polu zamiast wyniku jest komunikat „Podaj temperaturę”.
Podpowiedź
signal<number | null> i @if (c() !== null); formatowanie number:'1.1-1'.
Suwak głośności 0–100 co 10. Obok liczba i ikona: 🔇 przy 0, 🔈 do 30, 🔉 do 70, 🔊 powyżej. Pod spodem pasek postępu o szerokości równej głośności.
Kryterium sukcesu: ikona i pasek zmieniają się podczas przesuwania suwaka.
Podpowiedź
Ikonę wybierz w computed z kilkoma if; pasek — [style.width.%].
Trzy suwaki 0–255: czerwony, zielony, niebieski. Pod nimi kwadrat w wybranym kolorze i zapis koloru w postaci rgb(…). Przycisk „Losuj” ustawia trzy losowe wartości.
Kryterium sukcesu: każdy suwak zmienia kolor kwadratu na bieżąco, a „Losuj” przesuwa wszystkie trzy suwaki.
Podpowiedź
computed buduje tekst 'rgb(' + r() + ', ' + g() + ', ' + b() + ')', a [style.background-color] go używa. Losowa liczba: Math.floor(Math.random() * 256).
Trzy pola liczbowe na oceny (1–6, krok 1). Pokaż średnią z dwoma miejscami po przecinku. Gdy któreś pole jest puste albo poza zakresem, zamiast średniej pokaż, które pole jest błędne.
Kryterium sukcesu: dla 4, 5, 3 widać 4,00; dla oceny 7 albo pustego pola — komunikat z numerem pola.
Podpowiedź
Funkcja pomocnicza w klasie poprawna(n: number | null): boolean i computed z komunikatem.
Cena laptopa (number), wpłata własna w procentach (suwak 0–50, co 5) i liczba rat (suwak 3–24). Pokaż kwotę wpłaty, kwotę kredytu, wysokość jednej raty (bez odsetek) i ostrzeżenie, gdy rata przekracza 300 zł.
Kryterium sukcesu: wszystkie kwoty przeliczają się na bieżąco z dwoma miejscami po przecinku; puste pole ceny nie psuje strony.
Podpowiedź
Trzy computed: wpłata, kredyt, rata. Formatowanie — number:'1.2-2'.
Pola: liczba stron do przeczytania (number, 1–500), tempo czytania w stronach na godzinę (suwak 5–60) i liczba dni do sprawdzianu (number, 1–30). Pokaż łączny czas w godzinach i minutach (np. „6 h 40 min”) oraz ile stron dziennie trzeba czytać (zaokrąglone w górę). Gdy dziennie wychodzi więcej niż 50 stron, pokaż czerwone ostrzeżenie.
Kryterium sukcesu: wyniki przeliczają się po każdej zmianie, czas jest w formacie „h i min”, a puste pole daje komunikat zamiast NaN.
Podpowiedź
Minuty łącznie: Math.round(strony / tempo * 60); godziny: Math.floor(min / 60), reszta: min % 60. Strony dziennie: Math.ceil(strony / dni).
Co trzeba zapamiętać
ngModelnatype="number"itype="range"daje liczbę;pole.valuezawsze daje tekst.- Puste pole liczbowe to
null: typ sygnałunumber | nulli operator??. min,max,stepograniczają strzałki i suwak, ale nie klawiaturę — sprawdzaj zakres w kodzie.- Suwak zawsze ma wartość z zakresu; pokazuj ją użytkownikowi obok.
- Wynik z groszami formatuj pipe’em
number:'1.2-2'.
Sprawdź się: kolega ma w zadaniu wynik „1510” zamiast „25”. Jakie dwa pytania mu zadasz, żeby znaleźć przyczynę?
Dokumentacja: Bootstrap — suwak range.