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 build Exit-Code 0, ng test --no-watch Exit-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 legte tag-liste in 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:

TypeScript
readonly kontakt = input.required<ContactSummary>();
HTML
<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“):

TypeScript
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:

TypeScript
readonly beschriftung = input.required<string>();
readonly wert = model('');
HTML
<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:

HTML
<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-feld fasst <label> und <input> zusammen.
  • Beschriftung: Jedes Feld hat ein <label> mit passender id. Eine Komponente, die mehrfach vorkommt, braucht je Instanz eine eigene id: filter-feld.ts zä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"> in treffer-anzeige sagt 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:

  1. Erzeugen Sie kontakte/kontakt-karte, kontakte/tag-liste, shared/treffer-anzeige und shared/filter-feld (ng generate component ...) und schreiben Sie sie nach den Vorgaben des Textes: Karte mit input.required, output, <ng-content /> und einem @defer (on interaction) mit Platzhaltertaste „Tags zeigen“ für kl-tag-liste; treffer-anzeige mit zwei input.required<number>() (anzahl, gesamt) und einer Live-Region; filter-feld mit input.required<string>() und model('').
  2. Ändern Sie kontakt-liste so, dass sie die Bausteine benutzt (imports), die Karten mit @for zeigt, 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üft li und wird auf article und h2 umgestellt.
  3. npx ng build und npx 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:

TypeScript
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);
  }
}
HTML
<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:

TypeScript
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`);
}
HTML
<p aria-live="polite" aria-atomic="true">{{ text() }}</p>

src/app/kontakte/tag-liste/tag-liste.ts:

TypeScript
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:

TypeScript
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);
  }
}
HTML
<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:

TypeScript
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:

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 input und input.required und melden Ereignisse mit output.
  • Sie schreiben mit model ein Feld für [(wert)] und wissen, wann input genügt.
  • Sie reichen Inhalte mit <ng-content /> hinein und laden Teile mit @defer und einem Auslöser erst bei Bedarf, in einer Live-Region.
  • Sie bauen eigene Bausteine aus nativen Elementen, mit Beschriftung, eindeutiger id und aria-live.
  • Sie benennen Dateien, Selektoren und Klassen nach dem Style Guide und haben die Kontaktkarte und die Trefferzahl-Ansage der Kladde gebaut.

Weiterlesen

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