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

Własna dyrektywa atrybutowa

routerLink, ngModel, NgClass nie mają szablonu — dopisujesz je jako atrybut i zmieniają zachowanie elementu. Gdy to samo zachowanie powtarza się w wielu miejscach (podświetlanie wierszy, fokus na pierwszym polu, pole tylko na cyfry), piszesz własną dyrektywę: jedna klasa, jeden atrybut.

Angular 22 @Directive host input w dyrektywie ElementRef afterNextRender 45 min
CEL LEKCJI

Czego się dziś nauczysz

  • Wygenerujesz dyrektywę atrybutową z selektorem w nawiasach kwadratowych.
  • Obsłużysz zdarzenia elementu i ustawisz jego klasy przez właściwość host.
  • Przekażesz do dyrektywy ustawienie przez wejście o nazwie selektora.
  • Sięgniesz do elementu HTML przez ElementRef i ustawisz fokus po wyświetleniu.
  • Zablokujesz w polu klawisze inne niż cyfry, zachowując współpracę z ngModel.

Przygotowanie: lekcja 08 (zdarzenia, $event), lekcja 15 (klasy zależne od danych), lekcja 29 (input), lekcja 37 (constructor, inject). Przewidywany czas: 45 min z zadaniami.

TEORIA

Zachowanie zamiast komponentu

Komponent to szablon z logiką — wstawiasz go jako nowy znacznik (<app-karta />). Dyrektywa atrybutowa nie ma szablonu: dopisujesz ją jako atrybut do istniejącego elementu (<tr appPodswietl>) i zmienia jego zachowanie lub wygląd.

terminal
ng generate directive nowa-40-1/podswietl
# CREATE src/app/nowa-40-1/podswietl.spec.ts
# CREATE src/app/nowa-40-1/podswietl.ts
src/app/nowa-40-1/podswietl.ts — plik wygenerowany
import { Directive } from '@angular/core';

@Directive({
  selector: '[appPodswietl]',
})
export class Podswietl {}
ElementZapisZnaczenie
Selektorselector: '[appPodswietl]'nawiasy kwadratowe = „element z takim atrybutem”, jak w CSS
Zdarzenie elementu'(mouseenter)': 'nad.set(true)'w host — jak (mouseenter) w szablonie
Klasa elementu'[class.table-warning]': 'nad()'w host — jak [class.x] w szablonie
Stały atrybut'inputmode': 'numeric'w host — bez nawiasów: zwykły atrybut HTML
Użycieimports: [Podswietl] i <tr appPodswietl>jak komponent: najpierw imports, potem atrybut

Przedrostek app

Przedrostek app w nazwie atrybutu odróżnia twoje dyrektywy od wbudowanych i od zwykłych atrybutów HTML. Wpisy w host mają tę samą składnię co szablon, tylko zapisaną jako tekst w cudzysłowach: z lewej co (zdarzenie, klasa), z prawej wyrażenie odwołujące się do pól dyrektywy.

PRZYKŁAD 1

Podświetlenie wiersza po najechaniu

Dyrektywa pamięta w sygnale, czy kursor jest nad elementem, i na tej podstawie włącza klasę Bootstrapa table-warning. Używamy jej w każdym wierszu tabeli.

src/app/nowa-40-1/podswietl.ts — pełny plik
import { Directive, signal } from '@angular/core';

@Directive({
    selector: '[appPodswietl]',
    host: {
        '(mouseenter)': 'nad.set(true)',
        '(mouseleave)': 'nad.set(false)',
        '[class.table-warning]': 'nad()',
    }
})
export class Podswietl {
    readonly nad = signal(false);
}
src/app/nowa-40-1/demo.ts — pełny plik
import { Component } from '@angular/core';
import { Podswietl } from './podswietl';

@Component({
    selector: 'app-demo',
    imports: [Podswietl],
    templateUrl: './demo.html'
})
export class Demo {
    readonly gracze = [
        { nick: 'Pixel', punkty: 980 },
        { nick: 'Bajt', punkty: 870 },
        { nick: 'Kursor', punkty: 815 },
        { nick: 'Ping', punkty: 640 },
    ];
}
src/app/nowa-40-1/demo.html — pełny plik
<div class="p-3">
  <table class="table" style="max-width: 28rem">
    <thead><tr><th>Gracz</th><th>Punkty</th></tr></thead>
    <tbody>
      @for (g of gracze; track g.nick) {
        <tr appPodswietl><td>{{ g.nick }}</td><td>{{ g.punkty }}</td></tr>
      }
    </tbody>
  </table>
</div>
kursor nad trzecim wierszem
Gracz     Punkty
Pixel     980
Bajt      870
Kursor    815     ← żółte tło
Ping      640

Dyrektywa nie wie nic o graczach — doklejasz ją do dowolnego wiersza dowolnej tabeli. Ten sam efekt dałoby się uzyskać samym CSS-em (tr:hover); dyrektywa opłaca się, gdy zachowanie wymaga logiki: koloru zależnego od danych, reakcji na klawiaturę — jak w kolejnych przykładach.

Zatrzymaj się

Dopisz appPodswietl do akapitu <p> pod tabelą. Klasa się dokleja (sprawdź w F12), a tła nie widać. Dlaczego? Do jakich elementów Bootstrap przeznaczył klasy table-…?

PRZYKŁAD 2

Kolor podświetlenia z wejścia

Dyrektywa przyjmuje kolor jako wartość swojego atrybutu: appPodswietl="info". Wejście ma tę samą nazwę co selektor, dlatego jeden atrybut jednocześnie włącza dyrektywę i ją ustawia. Gdy wartości nie podasz, kolor będzie żółty.

src/app/nowa-40-2/podswietl.ts — pełny plik
import { Directive, computed, input, signal } from '@angular/core';

@Directive({
    selector: '[appPodswietl]',
    host: {
        '(mouseenter)': 'nad.set(true)',
        '(mouseleave)': 'nad.set(false)',
        '[class]': 'klasa()',
    }
})
export class Podswietl {
    readonly appPodswietl = input('');
    readonly nad = signal(false);

    readonly klasa = computed(() => {
        if (!this.nad()) {
            return '';
        }
        return 'table-' + (this.appPodswietl() || 'warning');
    });
}
src/app/nowa-40-2/demo.ts — pełny plik
import { Component } from '@angular/core';
import { Podswietl } from './podswietl';

@Component({
    selector: 'app-demo',
    imports: [Podswietl],
    templateUrl: './demo.html'
})
export class Demo {
    readonly gracze = [
        { nick: 'Pixel', punkty: 980 },
        { nick: 'Bajt', punkty: 870 },
        { nick: 'Kursor', punkty: 815 },
        { nick: 'Ping', punkty: 640 },
    ];
}
src/app/nowa-40-2/demo.html — pełny plik
<div class="p-3">
  <h6>Ranking</h6>
  <table class="table" style="max-width: 28rem">
    <thead><tr><th>Gracz</th><th>Punkty</th></tr></thead>
    <tbody>
      @for (g of gracze; track g.nick; let pierwszy = $first) {
        <tr [appPodswietl]="pierwszy ? 'success' : ''">
          <td>{{ g.nick }}</td><td>{{ g.punkty }}</td>
        </tr>
      }
    </tbody>
  </table>

  <h6>Najbliższe wydarzenia</h6>
  <table class="table" style="max-width: 28rem">
    <tbody>
      <tr appPodswietl="info"><td>Turniej retro</td><td>piątek</td></tr>
      <tr appPodswietl="info"><td>Noc robotów</td><td>sobota</td></tr>
      <tr appPodswietl="danger"><td>Warsztat druku 3D</td><td>odwołany</td></tr>
    </tbody>
  </table>
</div>
kursor kolejno nad wierszami
Ranking:   Pixel → zielony, pozostali → żółty
Wydarzenia: Turniej retro, Noc robotów → niebieski; Warsztat druku 3D → czerwony

Wiązanie '[class]' w host dokłada klasę obliczoną w computed, nie usuwając klas zapisanych na elemencie zwykłym atrybutem — tak samo jak w lekcji 15. Zapis bez nawiasów (appPodswietl="info") przekazuje tekst; z nawiasami ([appPodswietl]="…") — wynik wyrażenia, tu zależny od $first. this.appPodswietl() || 'warning' podstawia 'warning', gdy tekst jest pusty.

Zatrzymaj się

Gdyby wejście nazywało się kolor, jak wyglądałby zapis w szablonie dla niebieskiego podświetlenia?

PRZYKŁAD 3

Automatyczny fokus: ElementRef

Dyrektywa appAutoFokus ustawia kursor w polu zaraz po pojawieniu się go na ekranie. Do samego elementu HTML sięga przez inject(ElementRef), a moment „element już jest na ekranie” wyznacza afterNextRender.

src/app/nowa-40-3/auto-fokus.ts — pełny plik
import { Directive, ElementRef, afterNextRender, inject } from '@angular/core';

@Directive({
    selector: '[appAutoFokus]'
})
export class AutoFokus {
    private readonly element = inject(ElementRef<HTMLElement>);

    constructor() {
        afterNextRender(() => {
            this.element.nativeElement.focus();
        });
    }
}
src/app/nowa-40-3/demo.ts — pełny plik
import { Component, signal } from '@angular/core';
import { AutoFokus } from './auto-fokus';

@Component({
    selector: 'app-demo',
    imports: [AutoFokus],
    templateUrl: './demo.html'
})
export class Demo {
    readonly szukaj = signal(false);
}
src/app/nowa-40-3/demo.html — pełny plik
<div class="p-3">
  <div style="max-width: 26rem">
    <input class="form-control mb-3" placeholder="Imię (fokus przy starcie)" appAutoFokus>
    <input class="form-control mb-3" placeholder="Nazwisko">

    <button class="btn btn-outline-primary mb-2" (click)="szukaj.set(!szukaj())">Pokaż wyszukiwarkę</button>
    @if (szukaj()) {
      <input class="form-control" placeholder="Szukaj… (fokus po pokazaniu)" appAutoFokus>
    }
  </div>
</div>
wynik
[Imię (fokus przy starcie) | ]   ← kursor miga od razu
[Nazwisko                    ]
[Pokaż wyszukiwarkę]
(po kliknięciu: kursor przechodzi do pola „Szukaj…”)

ElementRef daje bezpośredni dostęp do elementu HTML przez nativeElement — podobnie jak document.querySelector. Używaj go tylko do rzeczy, których nie da się zrobić wiązaniem: fokus, przewinięcie, pomiar rozmiaru. Klas i stylów nie zmieniaj przez nativeElement — od tego jest host.

Zatrzymaj się

Dlaczego nie wystarczy wywołać focus() bezpośrednio w konstruktorze? Gdzie jest element w chwili tworzenia dyrektywy?

PRZYKŁAD 4

Pole przyjmujące tylko cyfry

Dyrektywa appTylkoCyfry blokuje każdy klawisz, który nie jest cyfrą ani klawiszem sterującym (Backspace, strzałki, Tab). Przydaje się przy numerze telefonu, kodzie PIN, numerze sali — i współpracuje z ngModel.

src/app/nowa-40-4/tylko-cyfry.ts — pełny plik
import { Directive } from '@angular/core';

@Directive({
    selector: '[appTylkoCyfry]',
    host: {
        '(keydown)': 'sprawdz($event)',
        'inputmode': 'numeric',
    }
})
export class TylkoCyfry {
    private readonly dozwolone = ['Backspace', 'Delete', 'Tab', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'Enter'];

    sprawdz(zdarzenie: KeyboardEvent): void {
        const klawisz = zdarzenie.key;
        const cyfra = klawisz >= '0' && klawisz <= '9' && klawisz.length === 1;
        const skrot = zdarzenie.ctrlKey || zdarzenie.metaKey;      // Ctrl+C, Ctrl+V…
        if (!cyfra && !skrot && !this.dozwolone.includes(klawisz)) {
            zdarzenie.preventDefault();
        }
    }
}
src/app/nowa-40-4/demo.ts — pełny plik
import { Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { TylkoCyfry } from './tylko-cyfry';

@Component({
    selector: 'app-demo',
    imports: [FormsModule, TylkoCyfry],
    templateUrl: './demo.html'
})
export class Demo {
    readonly telefon = signal('');
    readonly pin = signal('');
}
src/app/nowa-40-4/demo.html — pełny plik
<div class="p-3">
  <div style="max-width: 24rem">
    <label class="form-label" for="tel">Telefon (9 cyfr)</label>
    <input id="tel" name="tel" class="form-control mb-1" maxlength="9" appTylkoCyfry [(ngModel)]="telefon">
    <p class="small text-secondary">Wpisano {{ telefon().length }} z 9 cyfr.</p>

    <label class="form-label" for="pin">PIN</label>
    <input id="pin" name="pin" type="password" class="form-control mb-1" maxlength="4" appTylkoCyfry [(ngModel)]="pin">
    <p class="small text-secondary">PIN: {{ pin().length === 4 ? 'kompletny' : 'za krótki' }}</p>
  </div>
</div>
po wpisaniu „abc 600-100” w pole telefonu
Telefon (9 cyfr)
[600100        ]
Wpisano 6 z 9 cyfr.

zdarzenie.preventDefault() anuluje domyślną reakcję przeglądarki — tu: wpisanie znaku. Skróty z Ctrl (kopiuj, wklej) przepuszczamy. Atrybut inputmode="numeric", który dyrektywa dokleja sama, pokazuje na telefonie klawiaturę numeryczną.

Dyrektywa to pomoc, nie walidacja

Dyrektywa blokuje klawiaturę, ale nie wklejanie: Ctrl+V z tekstem „abc” przejdzie. Dane i tak sprawdza walidacja (lekcja 26, pattern="[0-9]{9}") — dyrektywa tylko pomaga użytkownikowi nie popełnić błędu.

Zatrzymaj się

W host jest 'inputmode': 'numeric' bez nawiasów. Czym różni się od '[class.table-warning]': 'nad()'?

ELEMENTY WBUDOWANE

Zestawienie elementów

ElementZnaczenie
ng generate directive folder/nazwatworzy plik dyrektywy z selektorem [appNazwa]
@Directive({ selector: '[appX]', host: { … } })dyrektywa atrybutowa bez szablonu
'(zdarzenie)': 'wyrazenie' w hostnasłuch zdarzenia elementu; $event dostępny jak w szablonie
'[class.x]', '[class]', '[style.x]' w hostwiązanie klasy lub stylu elementu
'atrybut': 'wartosc' w hoststały atrybut HTML
readonly appX = input('')wejście o nazwie selektora: appX="wartosc"
inject(ElementRef).nativeElementelement HTML, na którym stoi dyrektywa
afterNextRender(() => { … })kod uruchamiany raz, gdy element jest już na stronie
zdarzenie.preventDefault()anuluje domyślną reakcję przeglądarki
CZĘSTE BŁĘDY

Zanim utkniesz

ZapisProblem
selector: 'appPodswietl' bez nawiasówAngular szuka znacznika <appPodswietl>, a nie atrybutu. Dyrektywa nie działa i nie ma błędu.
Dyrektywa nie dodana do imports komponentuBrak błędu — atrybut jest zwykłym atrybutem HTML, który nic nie robi. Gdy dyrektywa „nie działa”, najpierw sprawdź imports.
[appPodswietl]="info" zamiast appPodswietl="info"TS2339: Property 'info’ does not exist on type 'Demo’ — w nawiasach jest wyrażenie. Tekst: bez nawiasów albo [appPodswietl]="'info'".
this.element.nativeElement.focus() w konstruktorzeBrak efektu — elementu nie ma jeszcze na stronie. Użyj afterNextRender.
this.element.nativeElement.style.background = …Działa, ale omija Angulara i trudno to potem kontrolować. Klasy i style ustawiaj przez host z sygnałem.
Klasa table-warning na elemencie spoza tabeliKlasa się dokleja, ale tła nie widać — Bootstrap koloruje nią komórki tabeli. Poza tabelą użyj np. bg-warning-subtle.
ZADANIA

Zadania

ZAD 1Powiększenie obrazka★☆☆

Dyrektywa appPowieksz, która po najechaniu na element powiększa go o 10% i przywraca rozmiar po zjechaniu. Użyj jej na trzech plakatach z lekcji 07.

Kryterium sukcesu: każdy plakat powiększa się osobno po najechaniu.

Podpowiedź

Przykład 1, ale zamiast klasy wiązanie stylu: '[style.transform]': 'nad() ? "scale(1.1)" : ""'.

ZAD 2Wyróżnienie karty★☆☆

Dyrektywa appCien, która po najechaniu dokłada elementowi klasę shadow-lg i zmienia kursor na rączkę ('[style.cursor]'). Użyj jej na czterech kartach wydarzeń.

Kryterium sukcesu: tylko karta pod kursorem ma duży cień; dyrektywa działa na dowolnym elemencie.

Podpowiedź

Dwa wpisy wiązań w host czytające ten sam sygnał nad().

ZAD 3Licznik kliknięć★★☆

Dyrektywa appLiczKlik liczy kliknięcia w element i ustawia atrybut title na „kliknięto X razy”. Użyj jej na dwóch przyciskach.

Kryterium sukcesu: najechanie na przycisk pokazuje dymek z liczbą jego własnych kliknięć — każdy przycisk liczy osobno.

Podpowiedź

Sygnał w dyrektywie, '(click)' i '[title]' w host; tekst sklej w computed.

ZAD 4Kolor oceny★★☆

Dyrektywa appOcena z wejściem o nazwie selektora (liczba 1–6, z numberAttribute). Na komórce tabeli ustawia kolor tekstu: 5–6 zielony i pogrubiony, 3–4 bez zmian, 1–2 czerwony. Zbuduj tabelę ocen trzech uczniów z pięcioma ocenami każdy.

Kryterium sukcesu: kolory w tabeli zgadzają się z ocenami; ocena wpisana jako appOcena="2" i jako [appOcena]="o" w pętli działa tak samo.

Podpowiedź

readonly appOcena = input(3, { transform: numberAttribute }); w host trzy wiązania '[class.text-success]', '[class.fw-bold]', '[class.text-danger]' z warunkami na appOcena().

ZAD 5Duże litery w polu★★★

Dyrektywa appWielkieLitery dla pól tekstowych: każda wpisana litera pojawia się od razu jako wielka (np. numer rejestracyjny), a pole współpracuje z ngModel.

Kryterium sukcesu: wpisanie „gl 123” daje w polu i w sygnale „GL 123”.

Podpowiedź

Najpierw sprawdź prostą drogę: styl text-transform: uppercase zmienia tylko wygląd — zobacz, co jest wtedy w sygnale. Pełne rozwiązanie znasz z lekcji 25 ((ngModelChange)). Zastanów się i zapisz w komentarzu, czy dyrektywa jest tu w ogóle potrzebna.

ZAD 6Licznik znaków★★★

Dyrektywa appLimitZnakow z wejściem o nazwie selektora (liczba, np. appLimitZnakow="140" z numberAttribute). Na polu textarea blokuje wpisywanie po przekroczeniu limitu i ustawia czerwoną ramkę (is-invalid), gdy zostało mniej niż 10 znaków.

Kryterium sukcesu: nie da się wpisać więcej niż limit (poza wklejeniem); ramka robi się czerwona przy ostatnich 10 znakach; dyrektywa działa na dwóch polach z różnymi limitami.

Podpowiedź

W '(input)' wywołaj metodę, która odczyta długość tekstu przez ElementRef (this.element.nativeElement.value.length) i zapisze ją w sygnale; blokada — w '(keydown)' jak w przykładzie 4 (przepuszczaj Backspace, Delete i strzałki).

PODSUMOWANIE

Co trzeba zapamiętać

  • Dyrektywa atrybutowa to klasa z @Directive({ selector: '[appNazwa]' }), bez szablonu.
  • Właściwość host nasłuchuje zdarzeń elementu i ustawia jego klasy, style i atrybuty.
  • Wejście o nazwie selektora pozwala ustawić dyrektywę wartością atrybutu: appPodswietl="info".
  • ElementRef z afterNextRender — do rzeczy, których nie da się zrobić wiązaniem, np. fokus.
  • Dyrektywę, jak komponent, trzeba dodać do imports — inaczej atrybut nic nie robi.

Sprawdź się: kiedy napiszesz komponent, a kiedy dyrektywę atrybutową? Podaj po jednym przykładzie z aplikacji szkolnej.

Dokumentacja: angular.dev — Attribute directives.

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