Typed forms & wizard
Strongly typed reactive forms with the design-system controls — a fully typed FormGroup, a group-level cross-field validator, and a signal-driven multi-step wizard. Every value here is synthetic; nothing here is real or client data.
Typed reactive form
form.getRawValue(){
"fullName": "",
"email": "",
"accountType": "Checking",
"amount": 0,
"region": "Americas"
}Cross-field validation
A group-level validator keeps the transfer amount within the daily limit. The error only appears once both fields are touched.
Custom form control (ControlValueAccessor)
<foo-rating> is a star rating that implements ControlValueAccessor, so it binds to a reactive FormControl like any native input — validation, touched, and disabled state all flow through Angular. It's a radiogroup under the hood: arrow keys move the selection, a roving tabindex keeps it a single tab stop, and screen readers announce the "3 of 5" position.
View sourcerating-control.ts
import {
ChangeDetectionStrategy,
Component,
forwardRef,
input,
signal,
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
/*
* A 1–5 star rating that participates in reactive forms as a first-class control — meaning
* you bind it exactly like an `<input>`, and `formControlName`, validators, `setValue`,
* `disable()`, and `touched` all just work:
*
* <foo-rating formControlName="rating" />
*
* That "just works" is bought by implementing `ControlValueAccessor`, the seam Angular uses
* to talk to any custom control. It's a contract of four methods, and it's worth knowing what
* each one buys you, because together they're the entire difference between a styled widget
* and a real form control:
*
* - `writeValue(v)` — the form pushes a value IN (initial value, `setValue`, a reset). This
* is the ONLY inbound path; the model speaks to the widget through here.
* - `registerOnChange(fn)` — Angular hands us a callback; we call it to push a value OUT
* when the user acts. That's what updates the control and fires
* `valueChanges`. Forget this and the field looks interactive but never
* actually updates the form — a silent, maddening bug.
* - `registerOnTouched(fn)` — another callback; we call it on blur so the control flips to
* `touched`. Most "required" errors are gated on touched, so without
* this the error message never shows after the user leaves the field.
* - `setDisabledState(d)` — the form drives disabled state (e.g. `control.disable()`); we
* reflect it. This is why programmatic `disable()` reaches the widget at
* all — a plain `[disabled]` input wouldn't hear it.
*
* The shape to keep straight: values come IN only through `writeValue`; they go OUT only
* through the `onChange` callback. One door each way. Get that boundary right and the control
* is indistinguishable from a native one to every consumer.
*
* Accessibility is a `radiogroup` of `radio`s rather than a pile of buttons, because a rating
* IS single-select and the platform already has a role for that. Screen readers then announce
* "3 of 5" position for free, and the arrow-key behavior matches what users expect from native
* radios. Same roving-tabindex discipline as a real radio group: the whole rating is one tab
* stop (one star holds `tabindex=0`, the rest `-1`), and arrows move the selection within it.
* Nothing browser-only runs at construction, so this prerenders cleanly under SSR.
*/
@Component({
selector: 'foo-rating',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => RatingControl),
multi: true,
},
],
template: `
<div
class="foo-rating"
role="radiogroup"
[attr.aria-label]="label()"
[attr.aria-disabled]="disabled() ? 'true' : null"
>
@for (star of stars; track star) {
<span
class="foo-rating__star"
role="radio"
[class.foo-rating__star--on]="star <= displayValue()"
[attr.aria-checked]="star === value()"
[attr.aria-label]="star + ' of ' + stars.length"
[attr.tabindex]="disabled() ? null : star === focusTarget() ? 0 : -1"
(click)="select(star)"
(keydown)="onKeydown($event, star)"
(mouseenter)="hover.set(star)"
(mouseleave)="hover.set(0)"
(focus)="focusTarget.set(star)"
(blur)="onTouched()"
>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path
d="M12 2.5l2.9 6.06 6.6.79-4.9 4.55 1.32 6.5L12 17.9 6.08 20.4l1.32-6.5L2.5 9.35l6.6-.79z"
/>
</svg>
</span>
}
</div>
`,
styleUrl: './rating-control.scss',
})
export class RatingControl implements ControlValueAccessor {
/*
* A radiogroup needs an accessible name or a screen reader announces it as an unlabeled
* group of radios. Defaulting to "Rating" means the control is never silently nameless, but
* the consumer should override it with the real question ("Overall satisfaction").
*/
readonly label = input('Rating');
protected readonly stars = [1, 2, 3, 4, 5] as const;
protected readonly value = signal(0);
protected readonly disabled = signal(false);
/*
* Hover is kept entirely separate from `value` on purpose. Mousing over the fourth star
* should light four stars as a preview WITHOUT committing 4 to the form — only a click
* commits. Folding hover into the real value would make the form change as the pointer drifts
* across the widget, which is exactly the bug this separation avoids.
*/
protected readonly hover = signal(0);
/*
* The roving tab stop. Exactly one star carries `tabindex=0`; this signal tracks which. It
* follows the selection so that tabbing into the control lands on the chosen star, not always
* the first — the same roving-tabindex mechanic a native radio group uses.
*/
protected readonly focusTarget = signal(1);
/*
* What the stars actually fill to. Hover preview wins when present so the widget feels live
* under the pointer; with no hover it falls back to the committed value. `0` is falsy, so an
* empty hover cleanly defers to `value` with no extra branch.
*/
protected displayValue(): number {
return this.hover() || this.value();
}
/*
* These two start as no-ops and get REPLACED by Angular's real callbacks when the control is
* bound to a form. Initializing them to empty functions (not leaving them undefined) means a
* standalone `<foo-rating>` used outside a form still works without null-checks on every call.
*/
private onChange: (value: number) => void = () => {
// set by registerOnChange
};
protected onTouched: () => void = () => {
// set by registerOnTouched
};
/*
* INBOUND from the form: the model sets our value. The crucial discipline is what's missing —
* we do NOT call `onChange` here. `writeValue` reflects a value the form already knows about;
* echoing it back through `onChange` would be a feedback loop, the control telling the form
* what the form just told it. Nudging `focusTarget` to the written value is a courtesy so a
* pre-filled rating tabs to the right star.
*/
writeValue(value: number | null): void {
const next = value ?? 0;
this.value.set(next);
if (next > 0) this.focusTarget.set(next);
}
// Angular hands us the callback to fire on every user-driven change; we stash it for `select`.
registerOnChange(fn: (value: number) => void): void {
this.onChange = fn;
}
// Same handoff for the touched callback; we fire it on blur (see the star's `(blur)` binding).
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
/*
* The form drives disabled state, not a template input. When a parent calls `control.disable()`
* Angular routes it here, and we mirror it into a signal the template reads to drop tabindex and
* ignore gestures. This is the path a plain `[disabled]` binding would miss entirely.
*/
setDisabledState(isDisabled: boolean): void {
this.disabled.set(isDisabled);
}
/*
* OUTBOUND to the form, and the one place a user gesture commits. It updates our own signal,
* moves the roving tab stop to the chosen star, and — the line that makes this a real form
* control — calls `onChange(star)` to push the value into Angular's model. Drop that last
* call and the stars would light up while the form never hears a thing.
*/
protected select(star: number): void {
if (this.disabled()) return;
this.value.set(star);
this.focusTarget.set(star);
this.onChange(star);
}
protected onKeydown(event: KeyboardEvent, star: number): void {
if (this.disabled()) return;
let next: number;
switch (event.key) {
case 'ArrowRight':
case 'ArrowUp':
next = Math.min(this.stars.length, (this.value() || star) + 1);
break;
case 'ArrowLeft':
case 'ArrowDown':
next = Math.max(1, (this.value() || star) - 1);
break;
case 'Home':
next = 1;
break;
case 'End':
next = this.stars.length;
break;
case ' ':
case 'Enter':
next = star;
break;
default:
return;
}
event.preventDefault();
this.select(next);
/*
* select() updated the value and roved the tab stop, but it didn't move DOM focus — and
* with roving tabindex it has to, or focus is left on a star that's now `tabindex=-1` and
* the next Tab jumps somewhere surprising. So after selecting, walk to the new star in the
* group and focus it, which is what makes arrow-key navigation feel like a native radio.
* `event.currentTarget` is the star we're on; its siblings are the other stars.
*/
const current = event.currentTarget as HTMLElement;
const group = current.parentElement;
const target = group?.querySelectorAll<HTMLElement>('.foo-rating__star')[next - 1];
target?.focus();
}
}
Async validator (debounced)
An AsyncValidatorFn checks username availability against a synthetic in-memory list. A timer + switchMap debounce the lookup and cancel stale checks, and the control runs on updateOn: 'blur'. While the check is in flight the control is pending — surfaced here as a live busy state. Try admin, demo, or angular to see a taken result.
View sourceusername.async-validator.ts
import { AbstractControl, AsyncValidatorFn, ValidationErrors } from '@angular/forms';
import { Observable, of, switchMap, timer } from 'rxjs';
import { map } from 'rxjs/operators';
/*
* Stands in for the server's "is this username taken" endpoint. A hardcoded Set keeps the demo
* deterministic and offline, but the validator's shape is exactly what you'd write against a
* real API — swap `of(TAKEN_USERNAMES.has(value))` for the HTTP call and nothing else changes.
*/
const TAKEN_USERNAMES = new Set([
'admin',
'root',
'miguel',
'support',
'test',
'demo',
'angular',
]);
/*
* Async username-availability validator.
*
* Checking availability while someone types is a race, and `timer(delay)` + `switchMap`
* together is how you win it — they do two jobs in one move. Picture the user typing "miguel"
* fast. Every keystroke re-runs the validator and returns a new inner observable. `switchMap`
* subscribes to the newest and UNSUBSCRIBES from whatever was still running, so the check for
* "migue" is torn down the instant "miguel" arrives. That kills the classic bug where a slow
* early request resolves after a fast later one and stamps a stale "taken" over a name the
* user already fixed — only the latest keystroke's result can ever land.
*
* The `timer(delayMs)` is the debounce, and it works precisely BECAUSE `switchMap` cancels.
* The inner observable doesn't emit until the timer fires; while the user is still typing,
* each new keystroke makes `switchMap` tear down the previous timer before it ever reaches
* the lookup. So the lookup only runs once typing pauses for `delayMs` — no per-keystroke
* server calls, no manual debounce bookkeeping. One operator pair, both problems.
*
* Returning the observable (rather than a resolved result) is what lets Angular own the
* lifecycle: from dispatch until the observable emits, the control sits in `pending`, and the
* template surfaces that as a live "checking…" state. Pair it with `updateOn: 'blur'` (or a
* pre-debounced value stream) so it isn't re-armed on literally every keystroke.
*/
export function usernameAvailableValidator(delayMs = 600): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
const value = String(control.value ?? '').trim().toLowerCase();
/*
* An empty field is the "required" validator's job, not ours — bail with `of(null)` (valid)
* and synchronously, so a blank control never sits in `pending` waiting on a check we'd skip.
*/
if (!value) return of(null);
return timer(delayMs).pipe(
switchMap(() => of(TAKEN_USERNAMES.has(value))),
/*
* The error-shape convention: return `null` for valid, or an object for invalid. The key
* (`taken`) is what the template checks (`control.hasError('taken')`) to show the message.
*/
map((taken) => (taken ? { taken: true } : null)),
);
};
}