A page becomes a tree of small components. For that, each one needs three things: inputs (values from outside), outputs (events to the outside) and, where needed, content that the parent
hands in. This lesson breaks the contact list from 0102 into building blocks: kontakt-karte, treffer-anzeige with an announcement for screen readers and a filter field with two-way binding. The part of the
Kladde that the lesson touches is src/app/kontakte and src/app/shared.
State of the checks. The code of the solution ran in the project from 0102 (Angular 22.2, Node 24.22.3):
ng buildexit code 0,ng test --no-watchexit code 0 (3 files, 3 tests passed: the list with filter field, hit count and selection, the card with loading the tags after the click and the output, the root; the tests are not printed here). The build puttag-listein a file of its own. Not run: a browser, a screen reader, operation with the keyboard and the display of the page; sentences about them are inferences from the HTML. Statements about Angular come from angular.dev (pages on inputs, outputs, content projection,@defer, accessibility, style guide, read on the day of writing).
Inputs: input and input.required
An input is a field that the class creates with input. The "Inputs" page shows value = input(0) (type InputSignal<number>, default value 0) and value = input.required<number>(): if the binding is missing
when the component is used, the build reports an error. An input is a signal that is read-only ("Signals created by the input function are read-only"); you read it with parentheses, in computed too.
input may only appear in field initializers of the class. There are alias (the name in the template) and transform (conversion of the passed value); the Kladde needs neither here.
The contact card gets the contact from outside and does not give it away:
readonly kontakt = input.required<ContactSummary>();
<kl-kontakt-karte [kontakt]="kontakt" />
The Style Guide demands readonly for everything that Angular initializes (input, model, output, queries).
Outputs: output
A building block that reports something without deciding itself what follows from it declares an output (page "Outputs"):
readonly ausgewaehlt = output<number>();
protected waehlen(): void {
this.ausgewaehlt.emit(this.kontakt().id);
}
The parent listens with (ausgewaehlt)="auswaehlen($event)"; $event is the value that was sent. The page advises camelCase, no prefix "on" and no names that a DOM event carries (click,
input). The card knows neither the list nor the page after it: it reports an id.
model: two-way binding
For fields that the building block changes itself and that parent and child should see, there is model ("model inputs allow the component author to write values into the property"). The filter field holds the text:
readonly beschriftung = input.required<string>();
readonly wert = model('');
<kl-filter-feld beschriftung="Filter by name or company" [(wert)]="filter" />
The parent binds a signal (here filter), and both sides may write. Use model sparingly: a field that only the parent changes stays input.
Content projection: ng-content
Some building blocks only provide the frame, and the parent hands in the content. <ng-content /> is the placeholder for that (page "Content projection"): a placeholder at build time, "not a component or DOM
element", to which you attach nothing. The contact card passes on the place for additions, and the list fills it with the contact number:
<kl-kontakt-karte [kontakt]="kontakt" (ausgewaehlt)="auswaehlen($event)">
<p>Contact no. {{ kontakt.id }}</p>
</kl-kontakt-karte>
With select, a building block has several slots: <ng-content select="card-title" /> takes only matching elements, an <ng-content /> without select the rest; a text in the <ng-content> is the fallback display
when nothing matches; ngProjectAs (static values only) lets an element match a slot.
@defer: load only when you need it
@defer pulls parts of the template into files of their own that the browser loads only when a trigger fires (page "Deferred loading"):
The blocks behind it: @placeholder stands before the trigger, @loading (after 100ms; minimum 1s) during loading, @error on a failure. Triggers are idle (default), viewport, interaction (click or key on the placeholder), hover (mouse or focus), immediate, timer(500ms) and when condition; several, separated by a semicolon,
count as OR. Only standalone components are deferred, and only if the file does not need them anywhere else outside a @defer. On the server, @defer renders only the
placeholder by default (SSR is a topic in 0403). The benefit is a smaller initial bundle; an entry pays off when the part is large or rarely needed. The Kladde's tag list serves the mechanism.
Accessibility: The "Accessibility" page advises putting @defer blocks in an element with a live region so that screen readers announce the new content. The Kladde's placeholder is a <button>: it
gets focus and is operated by keyboard, also by interaction, which reacts to click and key.
Accessibility of your own building blocks
- Native elements first: The "Accessibility" page advises reusing native elements "directly when possible". The card is an
<article>with<h2>and real<button>;kl-filter-feldcombines<label>and<input>. - Labeling: Every field has a
<label>with a matchingid. A component that occurs several times needs its ownidper instance:filter-feld.tscounts up a counter. - Visible text in the name: The button shows "Select" and carries
[attr.aria-label]="'Select: ' + kontakt().name": the visible text is at the start of the name, and several identical buttons can be told apart. - Announcement:
<p aria-live="polite">intreffer-anzeigeannounces the hit count. The region is in the document from the start; only its text changes. That screen readers announce it is expected, not checked.
Style and file names (Style Guide)
From the "Style Guide" page: words in file names with a hyphen, the name describes the content (not helpers.ts, utils.ts), .ts, .html and styles of a component are named the same, tests end
in .spec.ts; one concept per file; protected for members that only the template reads, readonly for what does not change, inject() before constructor parameters. The Kladde adds: selectors carry the prefix kl-, classes no suffix (KontaktKarte), building blocks without domain meaning live in shared, contact-related ones in kontakte.
Exercise 1
The contact card and the hit-count announcement as components of their own. In the project from 0102:
- Generate
kontakte/kontakt-karte,kontakte/tag-liste,shared/treffer-anzeigeandshared/filter-feld(ng generate component ...) and write them to the specifications of the text: card withinput.required,output,<ng-content />and a@defer (on interaction)with the placeholder button "Show tags" forkl-tag-liste;treffer-anzeigewith twoinput.required<number>()(anzahl,gesamt) and a live region;filter-feldwithinput.required<string>()andmodel(''). - Change
kontakt-listeso that it uses the building blocks (imports), shows the cards with@for, hands in the contact number and outputs the selected number in a live region. From now on the list shows cards instead of<li>rows: the test from 0102 checksliand is switched toarticleandh2. npx ng buildandnpx ng test --no-watch; check in the browser with the keyboard alone: Tab through the field, buttons and cards; "Show tags" with the Enter key.
How to check: the build ends with 0, and tag-liste appears in the Lazy chunk files section; four cards; "muster" leaves two and the announcement reads "2 of 4 contacts"; "Select" outputs "Selected: contact …";
"Show tags" loads the tags only on the click (the browser's network tool: one additional JavaScript file).
Solution 1
src/app/shared/filter-feld/filter-feld.ts and .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 and .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()} of ${this.gesamt()} contacts`);
}
<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>No tags</li>
}
</ul>
`,
})
export class TagListe {
readonly tags = input.required<string[]>();
}
src/app/kontakte/kontakt-karte/kontakt-karte.ts and .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">Show tags</button>
}
</div>
<button type="button" [attr.aria-label]="'Select: ' + kontakt().name" (click)="waehlen()">
Select
</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>Contacts</h1>
<kl-filter-feld beschriftung="Filter by name or company" [(wert)]="filter" />
<kl-treffer-anzeige [anzahl]="treffer().length" [gesamt]="kontakte().length" />
@if (filter() !== '') {
<button type="button" (click)="filter.set('')">Clear filter</button>
}
<button type="button" (click)="sortierungWechseln()">
Sorted by:
@switch (sortierung()) {
@case ('name') { Name }
@case ('company') { Company }
}
</button>
@for (kontakt of treffer(); track kontakt.id) {
<kl-kontakt-karte [kontakt]="kontakt" (ausgewaehlt)="auswaehlen($event)">
<p>Contact no. {{ kontakt.id }}</p>
</kl-kontakt-karte>
} @empty {
<p>No contacts found.</p>
}
<p aria-live="polite">
@if (gewaehlteId() !== undefined) {
Selected: contact {{ gewaehlteId() }}
}
</p>
Typical mistakes: a component is missing from imports (NG8001, 0102); [kontakt] is not bound (input.required breaks the build); [(wert)] is attached to a value instead of a signal; the live region only comes into existence with the change; the test from 0102 reports four li that no longer exist.
What you can do now
- You give components values with
inputandinput.requiredand report events withoutput. - You write a field for
[(wert)]withmodeland know wheninputis enough. - You hand in content with
<ng-content />and load parts only on demand with@deferand a trigger, in a live region. - You build your own building blocks from native elements, with a label, a unique
idandaria-live. - You name files, selectors and classes by the Style Guide and have built the Kladde's contact card and hit-count announcement.
Further reading
- Inputs:
input,input.required,model,alias,transform. - Outputs:
output,emit, names. - Content projection:
ng-content,select, fallback display. - Deferred loading: placeholder, triggers, prefetching, server.
- Accessibility: native elements, ARIA bindings, live regions.
- Style Guide: file names,
protected,readonly,inject().
The Kladde: src/app/kontakte holds the list, card and tag list, src/app/shared the filter field and the hit display. Next come the pages and routing (0201).