Eine Seite wird zu einem Baum kleiner Komponenten. Dafür braucht jede drei Dinge: Eingaben (Werte von außen), Ausgaben (Ereignisse nach außen) und gegebenenfalls Inhalte, die das Elternteil
hineinreicht. Diese Lektion zerlegt die Kontaktliste aus 0102 in Bausteine: kontakt-karte, treffer-anzeige mit Ansage für Screenreader und ein Filterfeld mit zweiseitiger Bindung. Der Teil der
Kladde, den die Lektion berührt, ist src/app/kontakte und src/app/shared.
Stand der Prüfung. Der Code der Lösung lief im Projekt aus 0102 (Angular 22.2, Node 24.22.3):
ng buildExit-Code 0,ng test --no-watchExit-Code 0 (3 Dateien, 3 Tests bestanden: die Liste mit Filterfeld, Trefferzahl und Auswahl, die Karte mit dem Laden der Tags nach dem Klick und der Ausgabe, die Wurzel; die Tests sind hier nicht abgedruckt). Der Bau legtetag-listein eine eigene Datei. Nicht ausgeführt: ein Browser, ein Screenreader, die Bedienung mit der Tastatur und die Anzeige der Seite; Sätze dazu sind Folgerungen aus dem HTML. Aussagen über Angular stammen von angular.dev (Seiten zu Eingaben, Ausgaben, Inhaltsprojektion,@defer, Barrierefreiheit, Style Guide, am Schreibtag gelesen).
Eingaben: input und input.required
Eine Eingabe ist ein Feld, das die Klasse mit input anlegt. Die Seite „Inputs“ zeigt value = input(0) (Typ InputSignal<number>, Standardwert 0) und value = input.required<number>(): Fehlt die Bindung
beim Verwenden, meldet der Bau einen Fehler. Eine Eingabe ist ein Signal, das nur lesbar ist („Signals created by the input function are read-only“); Sie lesen sie mit Klammern, auch in computed.
input darf nur in Feld-Initialisierern der Klasse stehen. Es gibt alias (Name in der Vorlage) und transform (Umwandlung des übergebenen Werts); beides braucht die Kladde hier nicht.
Die Kontaktkarte bekommt den Kontakt von außen und gibt ihn nicht her:
readonly kontakt = input.required<ContactSummary>();
<kl-kontakt-karte [kontakt]="kontakt" />
Der Style Guide verlangt readonly für alles, was Angular initialisiert (input, model, output, Abfragen).
Ausgaben: output
Ein Baustein, der etwas meldet, ohne selbst zu entscheiden, was daraus folgt, deklariert eine Ausgabe (Seite „Outputs“):
readonly ausgewaehlt = output<number>();
protected waehlen(): void {
this.ausgewaehlt.emit(this.kontakt().id);
}
Das Elternteil hört mit (ausgewaehlt)="auswaehlen($event)" zu; $event ist der gesendete Wert. Die Seite rät zu camelCase, ohne Vorsilbe „on“ und ohne Namen, die ein DOM-Ereignis tragen (click,
input). Die Karte kennt weder die Liste noch die Seite danach: Sie meldet eine id.
model: zweiseitige Bindung
Für Felder, die der Baustein selbst ändert und die Elternteil und Kind sehen sollen, gibt es model („model inputs allow the component author to write values into the property“). Das Filterfeld hält den Text:
readonly beschriftung = input.required<string>();
readonly wert = model('');
<kl-filter-feld beschriftung="Filter nach Name oder Firma" [(wert)]="filter" />
Das Elternteil bindet ein signal (hier filter), und beide Seiten dürfen schreiben. Benutzen Sie model sparsam: Ein Feld, das nur das Elternteil ändert, bleibt input.
Inhaltsprojektion: ng-content
Manche Bausteine geben nur den Rahmen vor, den Inhalt reicht das Elternteil nach. <ng-content /> ist dafür der Platzhalter (Seite „Content projection“): ein Platzhalter beim Bau, „not a component or DOM
element“, an dem Sie nichts anhängen. Die Kontaktkarte reicht die Stelle für Zusätze weiter, die Liste füllt sie mit der Kontakt-Nummer:
<kl-kontakt-karte [kontakt]="kontakt" (ausgewaehlt)="auswaehlen($event)">
<p>Kontakt-Nr. {{ kontakt.id }}</p>
</kl-kontakt-karte>
Mit select hat ein Baustein mehrere Plätze: <ng-content select="card-title" /> nimmt nur passende Elemente auf, ein <ng-content /> ohne select den Rest; ein Text im <ng-content> ist die Ersatzanzeige,
wenn nichts passt; ngProjectAs (nur statische Werte) lässt ein Element für einen Platz passen.
@defer: erst laden, wenn man es braucht
@defer zieht Teile der Vorlage in eigene Dateien, die der Browser erst lädt, wenn ein Auslöser feuert (Seite „Deferred loading“):
Die Blöcke dahinter: @placeholder steht vor dem Auslöser, @loading (after 100ms; minimum 1s) während des Ladens, @error bei einem Fehler. Auslöser sind idle (Standard), viewport, interaction (Klick oder Taste auf dem Platzhalter), hover (Maus oder Fokus), immediate, timer(500ms) und when bedingung; mehrere, durch Semikolon getrennt,
gelten als ODER. Zurückgestellt werden nur Standalone-Komponenten, und nur, wenn die Datei sie sonst nirgends außerhalb eines @defer braucht. Auf dem Server rendert @defer standardmäßig nur den
Platzhalter (SSR ist Thema in 0403). Der Nutzen ist ein kleineres Anfangsbündel; ein Eintrag lohnt, wenn der Teil groß ist oder selten gebraucht wird. Die Tag-Liste der Kladde dient dem Mechanismus.
Barrierefreiheit: Die Seite „Accessibility“ rät, @defer-Blöcke in ein Element mit Live-Region zu setzen, damit Screenreader den neuen Inhalt melden. Der Platzhalter der Kladde ist eine <button>: Er
bekommt Fokus und wird per Tastatur bedient, auch von interaction, das auf Klick und Taste reagiert.
Barrierefreiheit der eigenen Bausteine
- Native Elemente zuerst: Die Seite „Accessibility“ rät, native Elemente „directly when possible“ wiederzuverwenden. Die Karte ist ein
<article>mit<h2>und echten<button>;kl-filter-feldfasst<label>und<input>zusammen. - Beschriftung: Jedes Feld hat ein
<label>mit passenderid. Eine Komponente, die mehrfach vorkommt, braucht je Instanz eine eigeneid:filter-feld.tszählt einen Zähler hoch. - Sichtbarer Text im Namen: Die Taste zeigt „Auswählen“ und trägt
[attr.aria-label]="'Auswählen: ' + kontakt().name": Der sichtbare Text steht am Anfang des Namens, und mehrere gleiche Tasten sind unterscheidbar. - Ansage:
<p aria-live="polite">intreffer-anzeigesagt die Trefferzahl an. Die Region ist ab dem Anfang im Dokument; nur ihr Text ändert sich. Dass Screenreader es melden, ist erwartet, nicht geprüft.
Stil und Dateinamen (Style Guide)
Aus der Seite „Style Guide“: Wörter in Dateinamen mit Bindestrich, der Name beschreibt den Inhalt (nicht helpers.ts, utils.ts), .ts, .html und Stile einer Komponente heißen gleich, Tests enden
auf .spec.ts; ein Konzept je Datei; protected für Mitglieder, die nur die Vorlage liest, readonly für Unveränderliches, inject() vor Konstruktor-Parametern. Die Kladde hält dazu: Selektoren tragen das Präfix kl-, Klassen kein Suffix (KontaktKarte), Bausteine ohne Fachlichkeit liegen in shared, Kontaktbezogene in kontakte.
Übung 1
Die Kontaktkarte und die Trefferzahl-Ansage als eigene Komponenten. Im Projekt aus 0102:
- Erzeugen Sie
kontakte/kontakt-karte,kontakte/tag-liste,shared/treffer-anzeigeundshared/filter-feld(ng generate component ...) und schreiben Sie sie nach den Vorgaben des Textes: Karte mitinput.required,output,<ng-content />und einem@defer (on interaction)mit Platzhaltertaste „Tags zeigen“ fürkl-tag-liste;treffer-anzeigemit zweiinput.required<number>()(anzahl,gesamt) und einer Live-Region;filter-feldmitinput.required<string>()undmodel(''). - Ändern Sie
kontakt-listeso, dass sie die Bausteine benutzt (imports), die Karten mit@forzeigt, die Kontakt-Nummer hineinreicht und die gewählte Nummer in einer Live-Region ausgibt. Ab jetzt zeigt die Liste Karten statt<li>-Zeilen: Der Test aus 0102 prüftliund wird aufarticleundh2umgestellt. npx ng buildundnpx ng test --no-watch; prüfen Sie im Browser mit der Tastatur allein: Tab durch Feld, Tasten und Karten; „Tags zeigen“ mit der Eingabetaste.
So prüfen Sie: Der Bau endet mit 0, im Abschnitt Lazy chunk files steht tag-liste; vier Karten; „muster“ lässt zwei übrig und die Ansage lautet „2 von 4 Kontakten“; „Auswählen“ gibt „Ausgewählt: Kontakt …“ aus;
„Tags zeigen“ lädt die Tags erst beim Klick (Netzwerk-Werkzeug des Browsers: eine zusätzliche JavaScript-Datei).
Lösung 1
src/app/shared/filter-feld/filter-feld.ts und .html:
import { Component, input, model } from '@angular/core';
let naechsteId = 0;
@Component({
selector: 'kl-filter-feld',
templateUrl: './filter-feld.html',
})
export class FilterFeld {
readonly beschriftung = input.required<string>();
readonly wert = model('');
protected readonly feldId = `kl-filter-feld-${naechsteId++}`;
protected eingegeben(ereignis: Event): void {
this.wert.set((ereignis.target as HTMLInputElement).value);
}
}
<label [for]="feldId">{{ beschriftung() }}</label>
<input [id]="feldId" type="search" [value]="wert()" (input)="eingegeben($event)" />
src/app/shared/treffer-anzeige/treffer-anzeige.ts und .html:
import { Component, computed, input } from '@angular/core';
@Component({
selector: 'kl-treffer-anzeige',
templateUrl: './treffer-anzeige.html',
})
export class TrefferAnzeige {
readonly anzahl = input.required<number>();
readonly gesamt = input.required<number>();
protected readonly text = computed(() => `${this.anzahl()} von ${this.gesamt()} Kontakten`);
}
<p aria-live="polite" aria-atomic="true">{{ text() }}</p>
src/app/kontakte/tag-liste/tag-liste.ts:
import { Component, input } from '@angular/core';
@Component({
selector: 'kl-tag-liste',
template: `
<ul>
@for (tag of tags(); track tag) {
<li>{{ tag }}</li>
} @empty {
<li>Keine Tags</li>
}
</ul>
`,
})
export class TagListe {
readonly tags = input.required<string[]>();
}
src/app/kontakte/kontakt-karte/kontakt-karte.ts und .html:
import { Component, input, output } from '@angular/core';
import { ContactSummary } from '../contact-summary';
import { TagListe } from '../tag-liste/tag-liste';
@Component({
imports: [TagListe],
selector: 'kl-kontakt-karte',
templateUrl: './kontakt-karte.html',
})
export class KontaktKarte {
readonly kontakt = input.required<ContactSummary>();
readonly ausgewaehlt = output<number>();
protected waehlen(): void {
this.ausgewaehlt.emit(this.kontakt().id);
}
}
<article>
<h2>{{ kontakt().name }}</h2>
<p>{{ kontakt().company }}</p>
<ng-content />
<div aria-live="polite">
@defer (on interaction) {
<kl-tag-liste [tags]="kontakt().tags" />
} @placeholder {
<button type="button">Tags zeigen</button>
}
</div>
<button type="button" [attr.aria-label]="'Auswählen: ' + kontakt().name" (click)="waehlen()">
Auswählen
</button>
</article>
src/app/kontakte/kontakt-liste/kontakt-liste.ts:
import { Component, computed, inject, signal } from '@angular/core';
import { FilterFeld } from '../../shared/filter-feld/filter-feld';
import { TrefferAnzeige } from '../../shared/treffer-anzeige/treffer-anzeige';
import { KontaktDaten } from '../kontakt-daten';
import { KontaktKarte } from '../kontakt-karte/kontakt-karte';
@Component({
imports: [FilterFeld, TrefferAnzeige, KontaktKarte],
selector: 'kl-kontakt-liste',
styleUrl: './kontakt-liste.scss',
templateUrl: './kontakt-liste.html',
})
export class KontaktListe {
private readonly daten = inject(KontaktDaten);
protected readonly kontakte = signal(this.daten.liste());
protected readonly filter = signal('');
protected readonly sortierung = signal<'name' | 'company'>('name');
protected readonly gewaehlteId = signal<number | undefined>(undefined);
protected readonly treffer = computed(() => {
const suche = this.filter().trim().toLowerCase();
const feld = this.sortierung();
return this.kontakte()
.filter((k) => `${k.name} ${k.company}`.toLowerCase().includes(suche))
.sort((a, b) => a[feld].localeCompare(b[feld], 'de'));
});
protected auswaehlen(id: number): void {
this.gewaehlteId.set(id);
}
protected sortierungWechseln(): void {
this.sortierung.update((alt) => (alt === 'name' ? 'company' : 'name'));
}
}
src/app/kontakte/kontakt-liste/kontakt-liste.html:
<h1>Kontakte</h1>
<kl-filter-feld beschriftung="Filter nach Name oder Firma" [(wert)]="filter" />
<kl-treffer-anzeige [anzahl]="treffer().length" [gesamt]="kontakte().length" />
@if (filter() !== '') {
<button type="button" (click)="filter.set('')">Filter löschen</button>
}
<button type="button" (click)="sortierungWechseln()">
Sortiert nach:
@switch (sortierung()) {
@case ('name') { Name }
@case ('company') { Firma }
}
</button>
@for (kontakt of treffer(); track kontakt.id) {
<kl-kontakt-karte [kontakt]="kontakt" (ausgewaehlt)="auswaehlen($event)">
<p>Kontakt-Nr. {{ kontakt.id }}</p>
</kl-kontakt-karte>
} @empty {
<p>Keine Kontakte gefunden.</p>
}
<p aria-live="polite">
@if (gewaehlteId() !== undefined) {
Ausgewählt: Kontakt {{ gewaehlteId() }}
}
</p>
Typische Fehler: Eine Komponente fehlt in imports (NG8001, 0102); [kontakt] ist nicht gebunden (input.required bricht den Bau); [(wert)] hängt an einem Wert statt an einem Signal; die Live-Region entsteht erst mit der Änderung; der Test aus 0102 meldet vier li, die es nicht mehr gibt.
Was Sie jetzt können
- Sie geben Komponenten Werte mit
inputundinput.requiredund melden Ereignisse mitoutput. - Sie schreiben mit
modelein Feld für[(wert)]und wissen, wanninputgenügt. - Sie reichen Inhalte mit
<ng-content />hinein und laden Teile mit@deferund einem Auslöser erst bei Bedarf, in einer Live-Region. - Sie bauen eigene Bausteine aus nativen Elementen, mit Beschriftung, eindeutiger
idundaria-live. - Sie benennen Dateien, Selektoren und Klassen nach dem Style Guide und haben die Kontaktkarte und die Trefferzahl-Ansage der Kladde gebaut.
Weiterlesen
- Inputs:
input,input.required,model,alias,transform. - Outputs:
output,emit, Namen. - Content projection:
ng-content,select, Ersatzanzeige. - Deferred loading: Platzhalter, Auslöser, Vorabladen, Server.
- Accessibility: native Elemente, ARIA-Bindungen, Live-Regionen.
- Style Guide: Dateinamen,
protected,readonly,inject().
Die Kladde: src/app/kontakte hält Liste, Karte und Tag-Liste, src/app/shared das Filterfeld und die Trefferanzeige. Als Nächstes folgen die Seiten und das Routing (0201).