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

We never share this synthetic address.

Form status:invalid
Typed value — 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.

Within limit
The amount is within the configured daily limit.

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.

Selected rating:none
View sourcerating-control.ts
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.

Lowercase letters; checked on blur.

enter a username
View sourceusername.async-validator.ts
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)),
    );
  };
}

Multi-step wizard

Account details