src/app/core/common-components/basic-autocomplete/custom-form-control.directive.ts

Description

Extend this base class to implement custom input controls to be used as form fields.

To use in mat-form-field, add a provider to your component: providers: [{ provide: MatFormFieldControl, useExisting: MyCustomComponent }]


When building a subclass you only need the "Reactive API" below:

  • formControl — the bound Angular FormControl (when used as an EditComponent).
  • valueSignal — the current value as a signal (reactive, authoritative in both modes).
  • enabled — whether the control is enabled, as a signal.
  • errorState — whether the control currently shows an error (for <mat-error>).

Read state through these signals in your template; do not read formControl.value or formControl.enabled directly (those are not reactive under OnPush).

Everything below the "MatFormFieldControl / ControlValueAccessor plumbing" marker implements the Angular Material and Forms contracts and can be ignored when writing a control.

also refer to available public resources on Custom Form Controls:

Implements

ControlValueAccessor MatFormFieldControl OnDestroy DoCheck

Example

Relationships

Used by

No results matching.

Depends on

Index

Properties
Methods
Inputs
Outputs
Accessors

Constructor

constructor()

Inputs

aria-describedby
Type : string
disabled
Type : boolean
ngControl
Type : any
Default value : inject(NgControl, { optional: true, self: true })
placeholder
Type : string
required
Type : boolean
value
Type : T

Outputs

valueChange
Type : EventEmitter

Methods

blur
blur()
Returns : void
focus
focus()
Returns : void
onContainerClick
onContainerClick(event: MouseEvent)
Parameters :
Name Type Optional
event MouseEvent No
Returns : void
registerOnChange
registerOnChange(fn: any)
Parameters :
Name Type Optional
fn any No
Returns : void
registerOnTouched
registerOnTouched(fn: any)
Parameters :
Name Type Optional
fn any No
Returns : void
setDescribedByIds
setDescribedByIds(ids: string[])
Parameters :
Name Type Optional
ids string[] No
Returns : void
setDisabledState
setDisabledState(isDisabled: boolean)
Parameters :
Name Type Optional
isDisabled boolean No
Returns : void
writeValue
writeValue(value: T, notifyFormControl: unknown)

Implementation for Angular ControlValueAccessor interface that links the form control value to the component value

Parameters :
Name Type Optional Default value Description
value T No

The new value to set

notifyFormControl unknown No false

Whether to notify the FormControl of this change (for internal updates)

Returns : void

Properties

controlType
Type : string
Default value : "custom-control"
elementRef
Type : unknown
Default value : inject<ElementRef<HTMLElement>>(ElementRef)
Readonly enabled
Type : Signal<boolean>
Default value : computed(() => !this._disabled())

Whether the control is currently enabled, as a signal (tracks disabled).

errorStateMatcher
Type : unknown
Default value : inject(ErrorStateMatcher)
id
Type : unknown
Default value : `custom-form-control-${CustomFormControlDirective.nextId++}`
Static nextId
Type : number
Default value : 0
onChange
Type : unknown
Default value : () => {...}
onTouched
Type : unknown
Default value : () => {...}
parentForm
Type : unknown
Default value : inject(NgForm, { optional: true })
parentFormGroup
Type : unknown
Default value : inject(FormGroupDirective, { optional: true })
stateChanges
Type : unknown
Default value : new Subject<void>()
Readonly valueSignal
Type : Signal<T>
Default value : computed(() => this._value())

The current value of the control as a signal. Authoritative in both modes: it reflects the bound FormControl (synced in ngDoCheck) as well as [(value)] / writeValue updates.

Accessors

formControl
getformControl()

The Angular FormControl bound to this component when used as an EditComponent (i.e. the control provided via ngControl). Bind it in your template with [formControl]="formControl".

Returns : FormControl<T>
required
getrequired()
setrequired(req: boolean)
Parameters :
Name Type Optional
req boolean No
Returns : void
focused
getfocused()

Whether the control is focused, derived from the live DOM focus rather than a flag toggled by focus/blur events. This is robust: when an inner element that had focus is removed during a re-render the browser fires no blur/focusout, so an event-driven flag (or even the CDK FocusMonitor) would stay stuck true and leave the field showing a focus overlay forever. Material re-reads this getter on every change-detection pass, so the state self-corrects as soon as focus actually leaves.

touched
gettouched()
errorState
geterrorState()
empty
getempty()
shouldLabelFloat
getshouldLabelFloat()
disabled
getdisabled()
setdisabled(value: boolean)
Parameters :
Name Type Optional
value boolean No
Returns : void
value
getvalue()
setvalue(value: T)
Parameters :
Name Type Optional
value T No
Returns : void
import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { FocusMonitor } from "@angular/cdk/a11y";
import {
  DestroyRef,
  Directive,
  DoCheck,
  ElementRef,
  EventEmitter,
  Input,
  OnDestroy,
  Output,
  Signal,
  computed,
  inject,
  signal,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import {
  AbstractControl,
  ControlValueAccessor,
  FormControl,
  FormGroupDirective,
  NgControl,
  NgForm,
  Validators,
} from "@angular/forms";
import { ErrorStateMatcher } from "@angular/material/core";
import { MatFormFieldControl } from "@angular/material/form-field";
import { Subject } from "rxjs";

/**
 * Extend this base class to implement custom input controls to be used as form fields.
 *
 * To use in mat-form-field, add a provider to your component:
 *   `providers: [{ provide: MatFormFieldControl, useExisting: MyCustomComponent }]`
 *
 * ---
 *
 * **When building a subclass you only need the "Reactive API" below:**
 * - {@link formControl} — the bound Angular `FormControl` (when used as an `EditComponent`).
 * - {@link valueSignal} — the current value as a signal (reactive, authoritative in both modes).
 * - {@link enabled} — whether the control is enabled, as a signal.
 * - {@link errorState} — whether the control currently shows an error (for `<mat-error>`).
 *
 * Read state through these signals in your template; do **not** read `formControl.value`
 * or `formControl.enabled` directly (those are not reactive under OnPush).
 *
 * Everything below the "MatFormFieldControl / ControlValueAccessor plumbing" marker implements
 * the Angular Material and Forms contracts and can be ignored when writing a control.
 *
 * also refer to available public resources on Custom Form Controls:
 * - https://material.angular.io/guide/creating-a-custom-form-field-control
 * - https://www.youtube.com/watch?v=CD_t3m2WMM8
 */
@Directive()
export abstract class CustomFormControlDirective<T>
  implements ControlValueAccessor, MatFormFieldControl<T>, OnDestroy, DoCheck
{
  // =========================================================================
  // Reactive API — USE THESE when building a custom control.
  // =========================================================================

  /**
   * The Angular `FormControl` bound to this component when used as an `EditComponent`
   * (i.e. the control provided via {@link ngControl}). Bind it in your template with
   * `[formControl]="formControl"`.
   */
  get formControl(): FormControl<T> {
    return this.ngControl?.control as FormControl<T>;
  }

  /**
   * The current value of the control as a signal.
   * Authoritative in both modes: it reflects the bound `FormControl` (synced in {@link ngDoCheck})
   * as well as `[(value)]` / `writeValue` updates.
   */
  readonly valueSignal: Signal<T> = computed(() => this._value());

  /** Whether the control is currently enabled, as a signal (tracks {@link disabled}). */
  readonly enabled: Signal<boolean> = computed(() => !this._disabled());

  // =========================================================================
  // MatFormFieldControl / ControlValueAccessor plumbing — framework-internal.
  // Subclasses generally do not need to touch anything below this line.
  // =========================================================================

  elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
  errorStateMatcher = inject(ErrorStateMatcher);
  private readonly _destroyRef = inject(DestroyRef);
  private readonly focusMonitor = inject(FocusMonitor);
  @Input() ngControl = inject(NgControl, { optional: true, self: true });
  parentForm = inject(NgForm, { optional: true });
  parentFormGroup = inject(FormGroupDirective, { optional: true });

  static nextId = 0;
  id = `custom-form-control-${CustomFormControlDirective.nextId++}`;
  // eslint-disable-next-line @angular-eslint/no-input-rename
  @Input("aria-describedby") userAriaDescribedBy: string;
  @Input() placeholder: string;

  @Input()
  get required() {
    return this._required();
  }

  set required(req: boolean) {
    this._required.set(coerceBooleanProperty(req));
    this.stateChanges.next();
  }

  private readonly _required = signal(false);

  stateChanges = new Subject<void>();
  controlType = "custom-control";
  onChange = (_: any) => {};
  onTouched = () => {};

  /** Backing signal for the current value (single source of truth). */
  private readonly _value = signal<T>(undefined);
  private readonly _disabled = signal(false);
  private readonly _touched = signal(false);
  private readonly _errorState = signal(false);
  private readonly _empty = computed(() => !this._value());

  /**
   * Whether the control is focused, derived from the *live* DOM focus rather than a flag
   * toggled by focus/blur events. This is robust: when an inner element that had focus is
   * removed during a re-render the browser fires no `blur`/`focusout`, so an event-driven
   * flag (or even the CDK FocusMonitor) would stay stuck `true` and leave the field showing
   * a focus overlay forever. Material re-reads this getter on every change-detection pass,
   * so the state self-corrects as soon as focus actually leaves.
   */
  get focused() {
    const host = this.elementRef.nativeElement;
    return !!document.activeElement && host.contains(document.activeElement);
  }

  get touched() {
    return this._touched();
  }

  get errorState() {
    return this._errorState();
  }

  get empty() {
    return this._empty();
  }

  get shouldLabelFloat() {
    return this.focused || !this._empty();
  }

  @Input()
  get disabled(): boolean {
    return this._disabled();
  }

  set disabled(value: boolean) {
    this._disabled.set(coerceBooleanProperty(value));
    this.stateChanges.next();
  }

  @Input() get value(): T {
    return this._value();
  }

  set value(value: T) {
    this.writeValue(value, true);
  }

  @Output() valueChange = new EventEmitter<T>();

  constructor() {
    if (this.ngControl != null) {
      // register the `writeValue` method with the Angular FormControl
      this.ngControl.valueAccessor = this;
    }

    // Track focus via the CDK FocusMonitor instead of manual focusin/focusout
    // listeners: it reports focus entering/leaving the whole subtree and, crucially,
    // still emits a "blurred" (null) event when the currently-focused inner element
    // is removed during a re-render — manual `focusout` is dropped in that case,
    // which previously left the field stuck in the focused state.
    this.focusMonitor
      .monitor(this.elementRef, true)
      .pipe(takeUntilDestroyed(this._destroyRef))
      .subscribe((origin) => (origin ? this.focus() : this.blur()));
  }

  ngOnDestroy() {
    this.focusMonitor.stopMonitoring(this.elementRef);
    this.stateChanges.complete();
  }

  focus() {
    // `focused` reads live DOM focus; just notify Material to re-read its state.
    this.stateChanges.next();
  }

  blur() {
    // The FocusMonitor can emit synchronously while Angular is rendering the template
    // (the focused element is removed during a re-render, e.g. selecting an autocomplete
    // option closes the panel), so defer the signal write to the next microtask to avoid
    // NG0600 ("writing to signals is not allowed while Angular renders the template").
    queueMicrotask(() => this._touched.set(true));
    this.onTouched();
    this.stateChanges.next();
  }

  setDescribedByIds(ids: string[]) {
    this.elementRef.nativeElement.setAttribute(
      "aria-describedby",
      ids.join(" "),
    );
  }

  onContainerClick(event: MouseEvent) {}

  /**
   * Implementation for Angular ControlValueAccessor interface
   * that links the form control value to the component value
   * @param value The new value to set
   * @param notifyFormControl Whether to notify the FormControl of this change (for internal updates)
   */
  writeValue(value: T, notifyFormControl = false): void {
    if (JSON.stringify(value) === JSON.stringify(this._value())) return;

    this._value.set(value);
    if (notifyFormControl) {
      this.onChange(value);
    }
    this.valueChange.emit(value);
    this.stateChanges.next();
  }

  registerOnChange(fn: any): void {
    this.onChange = fn;
  }

  registerOnTouched(fn: any): void {
    this.onTouched = fn;
  }

  setDisabledState(isDisabled: boolean): void {
    this.disabled = isDisabled;
  }

  ngDoCheck() {
    const control = this.ngControl
      ? (this.ngControl.control as AbstractControl)
      : null;

    this.subscribeToControl(control);
    this.checkUpdateValue(control);
    this.checkUpdateDisabled(control);
    this.checkUpdateErrorState(control);
    this.checkUpdateRequired(control);
    this.checkUpdateFocused();
  }

  /** Last focused state seen, to detect changes across ngDoCheck calls. */
  private _lastFocused = false;

  /**
   * Notify Material when the focused state changed without a focus/blur event reaching us
   * (e.g. the focused inner element was removed during a re-render). The `focused` getter
   * reads live DOM focus, but Material only re-reads it on a `stateChanges` emission, so we
   * push one here when the value actually changed.
   */
  private checkUpdateFocused() {
    const focusedNow = this.focused;
    if (focusedNow !== this._lastFocused) {
      this._lastFocused = focusedNow;
      this.stateChanges.next();
    }
  }

  private _controlSubscribed = false;

  /**
   * Subscribe to the bound control's value/status streams the first time it appears
   * (`ngControl` may be set late via `setInput`, so we cannot do this in the constructor).
   *
   * This keeps the reactive signals ({@link valueSignal}, {@link enabled}, {@link errorState})
   * in sync *and* marks the OnPush view dirty when the control changes at runtime: writing a
   * signal notifies its template consumers. Relying on {@link ngDoCheck} alone is not enough
   * once the template consumes these signals, because `ngDoCheck` only runs while the view is
   * already being checked — a runtime `disable()` / `setValue()` on the control would otherwise
   * never reach the template.
   */
  private subscribeToControl(control: AbstractControl | null) {
    if (this._controlSubscribed || !control) {
      // standalone mode (no control): writeValue / the value setter drive the signals.
      return;
    }
    this._controlSubscribed = true;

    control.valueChanges
      .pipe(takeUntilDestroyed(this._destroyRef))
      .subscribe(() => this.checkUpdateValue(control));
    control.statusChanges
      .pipe(takeUntilDestroyed(this._destroyRef))
      .subscribe(() => {
        this.checkUpdateDisabled(control);
        this.checkUpdateErrorState(control);
        this.checkUpdateRequired(control);
      });
  }

  /** Stringified last control value seen, to detect *external* control changes. */
  private _lastControlValueJson: string | undefined;

  /**
   * Mirror the bound control's value into {@link valueSignal}.
   *
   * In `EditComponent` mode the value flows through the control's own value accessor
   * (the inner `[formControl]` binding), so {@link writeValue} is never called on this
   * directive — this keeps {@link valueSignal} authoritative there too.
   *
   * Only mirrors when the control changed *externally* (e.g. via its bound input or a form
   * reset). It deliberately does not overwrite a value the component set on itself but that
   * has not yet propagated to the control (e.g. when no `onChange` is registered).
   * Inbound only: it must not emit `valueChange` / call `onChange`.
   */
  private checkUpdateValue(control: AbstractControl | null) {
    if (!control) {
      // standalone mode: writeValue / the value setter still drive _value
      return;
    }

    const controlValueJson = JSON.stringify(control.value);
    if (controlValueJson === this._lastControlValueJson) {
      return;
    }
    this._lastControlValueJson = controlValueJson;

    if (controlValueJson !== JSON.stringify(this._value())) {
      this._value.set(control.value);
    }
  }

  private checkUpdateDisabled(control: AbstractControl | null) {
    if (!control) {
      return;
    }

    if (this.disabled !== coerceBooleanProperty(control.disabled)) {
      this.disabled = coerceBooleanProperty(control.disabled);
    }
  }

  /**
   * Updates the error state based on the form control
   * Taken from {@link https://github.com/angular/components/blob/a1d5614f18066c0c2dc2580c7b5099e8f68a8e74/src/material/core/common-behaviors/error-state.ts#L59}
   */
  private checkUpdateErrorState(control: AbstractControl | null) {
    const oldState = this._errorState();
    const parent = this.parentFormGroup || this.parentForm;
    const newState = this.errorStateMatcher.isErrorState(control, parent);

    if (newState !== oldState) {
      this._errorState.set(newState);
      this.stateChanges.next();
    }
  }

  private checkUpdateRequired(control: AbstractControl | null) {
    if (!control) {
      return;
    }

    if (
      this.required !==
      coerceBooleanProperty(control.hasValidator(Validators.required))
    ) {
      this.required = control.hasValidator(Validators.required);
    }
  }
}

results matching ""

    No results matching ""