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

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.

Angular 22 type=”number” type=”range” min/max/step null ?? form-range 45 min
CEL LEKCJI

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 typu number | null.
  • Obsłużysz puste pole operatorem ??.
  • Ograniczysz zakres atrybutami min, max, step i 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.

TEORIA

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

ZasadaCo 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 nullPo skasowaniu zawartości sygnał dostaje null, a nie 0. Typ: signal<number | null>(1).
min, max, step to podpowiedziStrzał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).

PRZYKŁAD 1

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.

src/app/nowa-19-1/demo.ts — pełny plik
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 + ')');
    }
}
src/app/nowa-19-1/demo.html — pełny plik
<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>
wynik po kliknięciu Dodaj
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?

PRZYKŁAD 2

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.

src/app/nowa-19-2/demo.ts — pełny plik
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));
}
src/app/nowa-19-2/demo.html — pełny plik
<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>
wynik dla wpisów 3, 25, 2.5 i pustego pola
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?

PRZYKŁAD 3

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.

src/app/nowa-19-3/demo.ts — pełny plik
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);
}
src/app/nowa-19-3/demo.html — pełny plik
<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>
wynik po przesunięciu suwaków
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?

PRZYKŁAD 4

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.

src/app/nowa-19-4/demo.ts — pełny plik
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));
}
src/app/nowa-19-4/demo.html — pełny plik
<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>
wynik dla 20 osób, 3 dni i dopłaty 12.5
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?

ELEMENTY WBUDOWANE

Zestawienie elementów

ElementZnaczenieUwagi
type="number" + ngModelliczba w sygnalePuste pole = null.
type="range", form-rangesuwakZawsze ma wartość.
min, max, stepzakres i krokNie blokują klawiatury.
signal<number | null>(1)sygnał na liczbę albo nicDla pól number.
a ?? 0zero zamiast nullDziała też w szablonie.
x!„tu na pewno nie ma null”Tylko po sprawdzeniu.
typeof xtyp wartości jako tekst'string', 'number', 'object'.
Number(tekst), Number.isInteger(n)zamiana na liczbę, czy całkowitaZ TypeScriptu.
[style.opacity]przezroczystość 0–1Z lekcji 15.
CZĘSTE BŁĘDY

Zanim utkniesz

ZapisProblem
pole.value + 1 przy zmiennej szablonowejDoklejanie zamiast dodawania: z 5 robi się 51. Użyj Number(pole.value) albo ngModel.
[(ngModel)] na type="text", a liczysz jak na liczbieTekst zamiast liczby — znów doklejanie. Do liczb używaj type="number".
signal(0) dla pola numberPo 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 obokDziała, ale użytkownik nie wie, jaką liczbę wybrał.
this.bilety()! * cena bez sprawdzeniaPrzy pustym polu wynik to 0 i nikt nie wie dlaczego. Najpierw sprawdź !== null.
ZADANIA

Zadania

ZAD 1Przelicznik temperatur★☆☆

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

ZAD 2Głośność★☆☆

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.%].

ZAD 3Mieszacz kolorów★★☆

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

ZAD 4Średnia z trzech ocen★★☆

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.

ZAD 5Raty za laptopa★★★

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

ZAD 6Kalkulator czasu nauki★★★

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

PODSUMOWANIE

Co trzeba zapamiętać

  • ngModel na type="number" i type="range" daje liczbę; pole.value zawsze daje tekst.
  • Puste pole liczbowe to null: typ sygnału number | null i operator ??.
  • min, max, step ograniczają 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.

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