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.
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
ElementRefi 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.
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.
ng generate directive nowa-40-1/podswietl
# CREATE src/app/nowa-40-1/podswietl.spec.ts
# CREATE src/app/nowa-40-1/podswietl.ts
import { Directive } from '@angular/core';
@Directive({
selector: '[appPodswietl]',
})
export class Podswietl {}
| Element | Zapis | Znaczenie |
|---|---|---|
| Selektor | selector: '[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życie | imports: [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.
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.
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);
}
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 },
];
}
<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>
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-…?
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.
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');
});
}
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 },
];
}
<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>
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?
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.
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();
});
}
}
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);
}
<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>
[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?
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.
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();
}
}
}
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('');
}
<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>
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()'?
Zestawienie elementów
| Element | Znaczenie |
|---|---|
ng generate directive folder/nazwa | tworzy plik dyrektywy z selektorem [appNazwa] |
@Directive({ selector: '[appX]', host: { … } }) | dyrektywa atrybutowa bez szablonu |
'(zdarzenie)': 'wyrazenie' w host | nasłuch zdarzenia elementu; $event dostępny jak w szablonie |
'[class.x]', '[class]', '[style.x]' w host | wiązanie klasy lub stylu elementu |
'atrybut': 'wartosc' w host | stały atrybut HTML |
readonly appX = input('') | wejście o nazwie selektora: appX="wartosc" |
inject(ElementRef).nativeElement | element HTML, na którym stoi dyrektywa |
afterNextRender(() => { … }) | kod uruchamiany raz, gdy element jest już na stronie |
zdarzenie.preventDefault() | anuluje domyślną reakcję przeglądarki |
Zanim utkniesz
| Zapis | Problem |
|---|---|
selector: 'appPodswietl' bez nawiasów | Angular szuka znacznika <appPodswietl>, a nie atrybutu. Dyrektywa nie działa i nie ma błędu. |
Dyrektywa nie dodana do imports komponentu | Brak 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 konstruktorze | Brak 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 tabeli | Klasa się dokleja, ale tła nie widać — Bootstrap koloruje nią komórki tabeli. Poza tabelą użyj np. bg-warning-subtle. |
Zadania
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)" : ""'.
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().
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.
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().
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.
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).
Co trzeba zapamiętać
- Dyrektywa atrybutowa to klasa z
@Directive({ selector: '[appNazwa]' }), bez szablonu. - Właściwość
hostnasłuchuje zdarzeń elementu i ustawia jego klasy, style i atrybuty. - Wejście o nazwie selektora pozwala ustawić dyrektywę wartością atrybutu:
appPodswietl="info". ElementRefzafterNextRender— 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.