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

Select — lista rozwijana

Klasa, województwo, kategoria wydarzenia. Gdy możliwości jest za dużo na przyciski radio, a wybrać trzeba jedną, używasz listy rozwijanej <select>. Z ngModel działa jak grupa radio, tylko zajmuje jedną linijkę — i potrafi przechować cały obiekt.

Angular 22 select option form-select [ngValue] null listy zależne 45 min
CEL LEKCJI

Czego się dziś nauczysz

  • Połączysz listę rozwijaną z sygnałem i zbudujesz opcje pętlą @for.
  • Odróżnisz value (tekst) od [ngValue] (dowolna wartość, także obiekt).
  • Ustawisz wartość początkową listy z obiektami tak, żeby nie była pusta.
  • Dodasz opcję „— wybierz —” z wartością null.
  • Zbudujesz dwie listy zależne od siebie i wyczyścisz drugą przy zmianie pierwszej.
  • Użyjesz typu Record<string, string[]> i Object.keys.

Przygotowanie: lekcja 21 — znasz różnicę między value a [value] i metodę find. Przewidywany czas: 45 min z zadaniami.

TEORIA

Dużo możliwości, mało miejsca

Szesnaście województw jako przyciski radio zajęłoby pół ekranu. Lista rozwijana pokazuje tylko wybraną wartość, a resztę chowa do kliknięcia. Zasada jest ta sama co przy radio: jeden sygnał przechowuje wartość wybranej opcji.

lista rozwijana
<select id="klasa" name="klasa" class="form-select" [(ngModel)]="klasa">
  <option value="1TP">1TP</option>
  <option value="2TP">2TP</option>
</select>
Zapis opcjiCo trafia do sygnału
<option value="1TP">tekst '1TP'
<option [ngValue]="3">liczba 3
<option [ngValue]="w">cały obiekt w
<option [ngValue]="null" disabled>null — opcja „— wybierz —”, której nie da się wybrać ponownie

W select: [ngValue], nie [value]

Przy radio używałeś [value]. W liście rozwijanej do wartości innych niż tekst używaj [ngValue] — tylko ono poprawnie przenosi liczby, obiekty i null. [value] zamieniłoby obiekt na napis [object Object].

PRZYKŁAD 1

Pierwsza lista rozwijana

Wybór klasy z czterech wpisanych ręcznie opcji. Wartości są tekstowe, na starcie wybrana jest druga klasa. form-select to wygląd z Bootstrapa.

src/app/nowa-22-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 klasa = signal('2TP');
}
src/app/nowa-22-1/demo.html — pełny plik
<div class="p-3" style="width: 20rem">
  <label class="form-label" for="klasa">Twoja klasa</label>
  <select id="klasa" name="klasa" class="form-select" [(ngModel)]="klasa">
    <option value="1TP">1TP</option>
    <option value="2TP">2TP</option>
    <option value="3TP">3TP</option>
    <option value="4TP">4TP</option>
  </select>
  <p class="mt-3">Wybrano: <strong>{{ klasa() }}</strong></p>
</div>
wynik po wybraniu 3TP
[3TP ▾]
Wybrano: 3TP

Na starcie lista pokazuje 2TP, bo taką wartość ma sygnał. Zamiast myszki możesz użyć strzałek na klawiaturze, gdy lista ma fokus.

Zatrzymaj się

Co pokaże lista, gdy wartość początkowa sygnału będzie '5TP', której nie ma wśród opcji?

PRZYKŁAD 2

Opcje z tablicy obiektów i [ngValue]

Lista wydarzeń z tablicy. Dzięki [ngValue] sygnał przechowuje cały obiekt wybranego wydarzenia — więc od razu możemy pokazać salę i godzinę, bez szukania find.

src/app/nowa-22-2/demo.ts — pełny plik
import { Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';

interface Wydarzenie {
    id: number;
    tytul: string;
    sala: string;
    godzina: string;
}

@Component({
    selector: 'app-demo',
    imports: [FormsModule],
    templateUrl: './demo.html'
})
export class Demo {
    readonly wydarzenia: Wydarzenie[] = [
        { id: 1, tytul: 'Turniej retro', sala: '12', godzina: '16:00' },
        { id: 2, tytul: 'Noc robotów', sala: '7', godzina: '18:30' },
        { id: 3, tytul: 'Warsztat druku 3D', sala: '14', godzina: '15:15' },
    ];
    readonly wybrane = signal<Wydarzenie>(this.wydarzenia[0]);
}
src/app/nowa-22-2/demo.html — pełny plik
<div class="p-3" style="width: 26rem">
  <label class="form-label" for="wyd">Wydarzenie</label>
  <select id="wyd" name="wyd" class="form-select" [(ngModel)]="wybrane">
    @for (w of wydarzenia; track w.id) {
      <option [ngValue]="w">{{ w.tytul }}</option>
    }
  </select>

  <div class="card mt-3">
    <div class="card-body">
      <h5 class="card-title">{{ wybrane().tytul }}</h5>
      <p class="card-text mb-0">Sala {{ wybrane().sala }}, godz. {{ wybrane().godzina }}</p>
    </div>
  </div>
</div>
wynik po wybraniu „Noc robotów”
Noc robotów
Sala 7, godz. 18:30

Wartość początkowa — z tej samej tablicy

Gdybyś napisał signal({ id: 1, tytul: 'Turniej retro', … }), lista na starcie byłaby pusta. To obiekt o tych samych danych, ale inny obiekt w pamięci, a lista porównuje opcje przez ===. Wartość początkowa to zawsze element tej samej tablicy: this.wydarzenia[0].

Zatrzymaj się

Porównaj z przykładem 3 lekcji 21, w którym wartością było id, a obiekt wyszukiwał find. Która wersja jest krótsza, a która bezpieczniejsza, gdy dane przyjdą z serwera?

PRZYKŁAD 3

Opcja „— wybierz —” i wartość null

W formularzu nie chcemy, żeby lista sama wybierała pierwszą wartość — użytkownik ma świadomie coś wybrać. Dodajemy opcję zastępczą z wartością null, a sygnał ma typ string | null.

src/app/nowa-22-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 jezyki = ['C#', 'TypeScript', 'Kotlin', 'Python', 'Java'];
    readonly jezyk = signal<string | null>(null);
}
src/app/nowa-22-3/demo.html — pełny plik
<div class="p-3" style="width: 24rem">
  <label class="form-label" for="jezyk">Ulubiony język programowania</label>
  <select id="jezyk" name="jezyk" class="form-select" [(ngModel)]="jezyk">
    <option [ngValue]="null" disabled>— wybierz —</option>
    @for (j of jezyki; track j) {
      <option [ngValue]="j">{{ j }}</option>
    }
  </select>

  @if (jezyk() === null) {
    <p class="form-text">Nic jeszcze nie wybrano.</p>
  } @else {
    <p class="mt-3">Świetny wybór: <strong>{{ jezyk() }}</strong>!</p>
    <button class="btn btn-sm btn-outline-secondary" (click)="jezyk.set(null)">Wyczyść wybór</button>
  }
</div>
wynik
na starcie:          [— wybierz — ▾]   Nic jeszcze nie wybrano.
po wybraniu Kotlin:  Świetny wybór: Kotlin!   [Wyczyść wybór]

Opcji „— wybierz —” nie da się kliknąć (disabled). Przycisk „Wyczyść wybór” ustawia sygnał na null — i lista wraca do napisu zastępczego.

Zatrzymaj się

Co zgłosi TypeScript, jeśli zostawisz samo signal(null), bez typu, a potem spróbujesz wybrać z listy „Kotlin”?

PRZYKŁAD 4

Dwie listy zależne od siebie

Najpierw kategoria, potem wydarzenie z tej kategorii. Opcje drugiej listy liczy computed. Gdy zmienia się kategoria, wybór w drugiej liście trzeba wyczyścić — robimy to zdarzeniem (change).

src/app/nowa-22-4/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 katalog: Record<string, string[]> = {
        'Gry': ['Turniej retro', 'Liga e-sportowa', 'Planszówki'],
        'Technika': ['Noc robotów', 'Warsztat druku 3D', 'Lutowanie dla początkujących'],
        'Sztuka': ['Grafika komputerowa', 'Fotografia'],
    };
    readonly kategorie = Object.keys(this.katalog);

    readonly kategoria = signal<string | null>(null);
    readonly wydarzenie = signal<string | null>(null);

    readonly wydarzenia = computed(() => {
        const k = this.kategoria();
        return k === null ? [] : this.katalog[k];
    });
}
src/app/nowa-22-4/demo.html — pełny plik
<div class="p-3" style="width: 26rem">
  <label class="form-label" for="kat">Kategoria</label>
  <select id="kat" name="kat" class="form-select mb-3"
          [(ngModel)]="kategoria" (change)="wydarzenie.set(null)">
    <option [ngValue]="null" disabled>— wybierz kategorię —</option>
    @for (k of kategorie; track k) {
      <option [ngValue]="k">{{ k }}</option>
    }
  </select>

  <label class="form-label" for="wyd">Wydarzenie</label>
  <select id="wyd" name="wyd" class="form-select" [(ngModel)]="wydarzenie"
          [disabled]="kategoria() === null">
    <option [ngValue]="null" disabled>— wybierz wydarzenie —</option>
    @for (w of wydarzenia(); track $index) {
      <option [ngValue]="w">{{ w }}</option>
    }
  </select>

  <p class="mt-3">Zapis: {{ kategoria() ?? '?' }} → {{ wydarzenie() ?? '?' }}</p>
</div>
wynik: Gry → Planszówki, potem zmiana kategorii na Sztuka
Zapis: Sztuka → ?

Record<string, string[]> to typ obiektu, w którym kluczami są teksty (nazwy kategorii), a wartościami — tablice tekstów. Object.keys(…) zwraca tablicę samych kluczy. Operator ?? z lekcji 19 zamienia null na znak zapytania.

Zatrzymaj się

Usuń (change)="wydarzenie.set(null)" i powtórz test: wybierz wydarzenie z Gier, potem przełącz na Sztukę. Co pokazuje napis na dole i dlaczego to błąd?

ELEMENTY WBUDOWANE

Zestawienie elementów

ElementZnaczenieUwagi
<select [(ngModel)]>, form-selectlista rozwijanaJeden sygnał.
<option value="…">opcja tekstowaTekst w sygnale.
<option [ngValue]="x">opcja z dowolną wartościąLiczby, obiekty, null.
[ngValue]="null" disabled„— wybierz —”Sygnał startuje od null.
signal<string | null>(null)tekst albo brak wyboruTyp obowiązkowy.
Record<string, string[]>słownik: klucz → tablicaTypeScript.
Object.keys(obiekt)tablica kluczyOpcje pierwszej listy.
(change) na selectreakcja na zmianę wyboruCzyszczenie listy zależnej.
CZĘSTE BŁĘDY

Zanim utkniesz

ZapisProblem
<option [value]="w"> z obiektemW sygnale ląduje napis [object Object]. Dla obiektów zawsze [ngValue].
Wartość początkowa jako nowy obiekt z tymi samymi danymiLista na starcie jest pusta, bo porównuje obiekty przez ===. Bierz obiekt z tej samej tablicy.
signal(null) bez typuTypeScript uznaje, że w sygnale może być tylko null. Pisz signal<string | null>(null).
Opcja „— wybierz —” z value="" przy sygnale nullNa starcie lista jest pusta zamiast pokazywać napis zastępczy: '' to nie null.
Druga lista nie czyści się po zmianie pierwszejSygnał trzyma wydarzenie z poprzedniej kategorii. Wyczyść go przy zmianie pierwszej listy.
Brak track w @for opcjiNG5002 — tak samo jak w każdej pętli.
ZADANIA

Zadania

ZAD 1Dzień tygodnia★☆☆

Lista rozwijana z dniami tygodnia zbudowana pętlą z tablicy. Pod nią plan: w poniedziałek i środę „zajęcia koła”, w pozostałe dni — „wolne”.

Kryterium sukcesu: opcje pochodzą z tablicy; komunikat zgadza się z wybranym dniem.

Podpowiedź

@if (dzien() === 'poniedziałek' || dzien() === 'środa').

ZAD 2Liczba biletów z listy★☆☆

Lista rozwijana z liczbami 1–8 (wartości liczbowe przez [ngValue]) i cena biletu 15 zł. Pod listą kwota do zapłaty. Sprawdź, że kwota jest liczona, a nie doklejana.

Kryterium sukcesu: dla 3 biletów widać 45 zł, a nie „315”.

Podpowiedź

Tablicę liczb zrób jak w lekcji 13: Array.from({ length: 8 }, (_, i) => i + 1).

ZAD 3Przelicznik walut★★☆

Pole z kwotą w złotych (number) i lista walut — obiekty { kod, nazwa, kurs } (EUR, USD, GBP, CHF). Pokaż kwotę po przeliczeniu z dwoma miejscami po przecinku i kodem waluty.

Kryterium sukcesu: wynik zmienia się przy zmianie kwoty i waluty; wartość początkowa listy działa.

Podpowiedź

Lista z [ngValue]="w", sygnał startuje od this.waluty[0]. Kursy wpisz na stałe.

ZAD 4Plan sal★★☆

Lista rozwijana z salami (obiekty { nr, nazwa, miejsc, rzutnik }) i opcją „— wybierz salę —”. Po wyborze pokaż kartę sali: nazwę, liczbę miejsc i informację o rzutniku (zielona odznaka „rzutnik” albo szara „brak rzutnika”). Przycisk „Wyczyść” wraca do opcji zastępczej.

Kryterium sukcesu: na starcie lista pokazuje „— wybierz salę —”, karta pojawia się po wyborze i znika po wyczyszczeniu.

Podpowiedź

Sygnał typu Sala | null i @if (sala(); as s).

ZAD 5Województwo i powiat★★★

Dwie listy zależne: trzy województwa i po trzy powiaty w każdym (np. pomorskie: lęborski, słupski, wejherowski). Pod listami tekst „Mieszkasz w powiecie … (woj. …)”, widoczny dopiero po wybraniu obu.

Kryterium sukcesu: druga lista pokazuje tylko powiaty z wybranego województwa, jest nieaktywna bez województwa i czyści się przy jego zmianie.

Podpowiedź

Przykład 4 to gotowy szkielet — zmień dane i komunikat.

ZAD 6Zamówienie w stołówce★★★

Trzy listy: zupa, drugie danie, napój — każda z obiektami { nazwa, cena } i opcją „— brak —” (null). Pokaż zamówienie jako listę wybranych pozycji, sumę oraz — gdy wybrano wszystkie trzy — dopisek „Zestaw dnia: −10%” i kwotę po rabacie.

Kryterium sukcesu: suma uwzględnia tylko wybrane pozycje, a rabat pojawia się wyłącznie przy komplecie trzech.

Podpowiedź

Trzy sygnały typu Danie | null; sumę policz w computed z ?.cena ?? 0, np. (this.zupa()?.cena ?? 0).

PODSUMOWANIE

Co trzeba zapamiętać

  • <select [(ngModel)]="sygnal"> działa jak grupa radio: sygnał trzyma wartość wybranej opcji.
  • value daje tekst; [ngValue] — dowolną wartość, także obiekt i null.
  • Wartość początkowa przy obiektach musi być tym samym obiektem co w tablicy opcji.
  • Opcja [ngValue]="null" disabled i sygnał startujący od null wymuszają świadomy wybór.
  • Przy listach zależnych czyść drugą listę po zmianie pierwszej.

Sprawdź się: lista rozwijana na starcie jest pusta, choć sygnał ma wartość. Wymień dwie najczęstsze przyczyny.

Dokumentacja: angular.dev — opcje listy (ngValue).

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