# Validation

- Page: https://koobiq.io/en/other/validation/overview

## Overview

Form-field controls display errors via [`ErrorStateMatcher`](https://koobiq.io/en/other/validation.md) — a small policy object that decides _when_ to surface a control's existing errors.

### Summary

- The submit button is never disabled
- Invalid character input is reported as it's typed
- Error messages are shown on blur (focus loss)
- Empty required fields don't show errors on blur
- Fields already in error state are validated as user types

### Opening the form

Focus is placed on the first field. Unfilled fields are not highlighted in red by default. An exception is a saved draft with already-visible errors that the user resumes filling.

**Example: Validation on open** (`validation-on-open`)

```ts validation-on-open-example.ts
import { ChangeDetectionStrategy, Component, inject, TemplateRef, viewChildren } from '@angular/core';
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
import { KbqButtonModule } from '@koobiq/components/button';
import {
    kbqErrorStateMatcherProvider,
    KbqFormsModule,
    ShowOnFormSubmitErrorStateMatcher
} from '@koobiq/components/core';
import { KbqFormField } from '@koobiq/components/form-field';
import { KbqInputModule } from '@koobiq/components/input';
import { KBQ_MODAL_DATA, KbqModalRef, KbqModalService, ModalSize } from '@koobiq/components/modal';
import { take } from 'rxjs';

type DocsFormData = {
    firstName: string;
    lastName: string;
};

@Component({
    selector: 'docs-empty-form',
    imports: [KbqFormsModule, FormsModule, KbqButtonModule, KbqInputModule, ReactiveFormsModule],
    template: `
        <form class="kbq-form-vertical" id="docs-form" novalidate [formGroup]="userDetailsForm" (ngSubmit)="onSubmit()">
            <div class="kbq-form__fieldset">
                <div class="kbq-form__row">
                    <div class="kbq-form__label">Name</div>
                    <kbq-form-field class="kbq-form__control">
                        <input formControlName="firstName" kbqInput />
                    </kbq-form-field>
                </div>

                <div class="kbq-form__row">
                    <div class="kbq-form__label">Surname</div>
                    <kbq-form-field class="kbq-form__control">
                        <input formControlName="lastName" kbqInput />
                    </kbq-form-field>
                </div>
            </div>
        </form>
    `,
    styles: `
        form {
            padding: 1px;
        }
    `,
    providers: [
        kbqErrorStateMatcherProvider(ShowOnFormSubmitErrorStateMatcher)
    ],
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class DocsNameFormComponent {
    protected readonly modalData = inject<DocsFormData>(KBQ_MODAL_DATA, { optional: true });
    protected readonly modalRef = inject(KbqModalRef);
    protected readonly formFieldList = viewChildren(KbqFormField);
    readonly userDetailsForm = new FormGroup({
        firstName: new FormControl(this.modalData?.firstName || '', Validators.required),
        lastName: new FormControl(this.modalData?.lastName || '', Validators.required)
    });

    onSubmit(): void {
        this.focusFirstInvalidControl();

        if (this.userDetailsForm.invalid) return;

        this.modalRef.close(this.userDetailsForm.value);
    }

    private focusFirstInvalidControl(): void {
        setTimeout(() => {
            const invalidControl = this.formFieldList().find((control) => control.invalid);

            invalidControl?.focus();
        });
    }
}

@Component({
    selector: 'validation-on-open-example',
    imports: [
        ReactiveFormsModule,
        KbqInputModule,
        KbqButtonModule
    ],
    template: `
        <button kbq-button color="contrast" (click)="openEmptyForm(footer)">Open empty form</button>
        <button kbq-button color="contrast-fade" (click)="openDraftForm(footer)">Open draft</button>

        <ng-template #footer>
            <button type="submit" form="docs-form" kbq-button color="contrast">Save</button>
            <button
                kbq-button
                color="contrast-fade"
                (click)="modalRef.close(modalRef.getContentComponent().userDetailsForm.value)"
            >
                Cancel
            </button>
        </ng-template>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush,
    host: {
        class: 'layout-margin-5xl layout-align-center-center layout-row layout-gap-l'
    }
})
export class ValidationOnOpenExample {
    protected readonly modalService = inject(KbqModalService);
    protected modalRef: KbqModalRef<DocsNameFormComponent>;

    openEmptyForm(footer: TemplateRef<any>): void {
        this.modalRef = this.modalService.create({
            kbqSize: ModalSize.Small,
            kbqContent: DocsNameFormComponent,
            kbqFooter: footer
        });

        this.modalRef.afterOpen.pipe(take(1)).subscribe(() => {
            this.modalRef.getElement().getElementsByTagName('input').item(0)?.focus();
        });
    }

    openDraftForm(footer: TemplateRef<any>): void {
        this.modalRef = this.modalService.create({
            kbqSize: ModalSize.Small,
            kbqContent: DocsNameFormComponent,
            kbqFooter: footer,
            data: {
                firstName: 'Romano',
                lastName: ''
            } satisfies DocsFormData
        });

        this.modalRef.afterOpen.pipe(take(1)).subscribe(() => {
            this.modalRef.getElement().getElementsByTagName('button').item(0)?.click();
        });
    }
}
```

### Required fields

Required fields are not marked as such if most of the fields are required. Optional fields can be labeled with a tag or placeholder like “Optional.” It's even better to explain why filling in this info is helpful to the user.

**Example: Validation optional label** (`validation-optional-label`)

```ts validation-optional-label-example.ts
import { ChangeDetectionStrategy, Component, viewChildren } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { KbqButtonModule } from '@koobiq/components/button';
import {
    kbqErrorStateMatcherProvider,
    KbqFormsModule,
    ShowOnFormSubmitErrorStateMatcher
} from '@koobiq/components/core';
import { KbqFormField } from '@koobiq/components/form-field';
import { KbqInputModule } from '@koobiq/components/input';

@Component({
    selector: 'validation-optional-label-example',
    imports: [
        ReactiveFormsModule,
        KbqInputModule,
        KbqButtonModule,
        KbqFormsModule
    ],
    template: `
        <form class="kbq-form-vertical" novalidate [formGroup]="form" (ngSubmit)="onSubmit()">
            <div class="kbq-form__fieldset">
                <div class="kbq-form__row">
                    <div class="kbq-form__label">Name</div>
                    <kbq-form-field class="kbq-form__control">
                        <input formControlName="firstName" kbqInput />
                    </kbq-form-field>
                </div>

                <div class="kbq-form__row">
                    <div class="kbq-form__label">Last name</div>
                    <kbq-form-field class="kbq-form__control">
                        <input formControlName="lastName" kbqInput />
                    </kbq-form-field>
                </div>

                <div class="kbq-form__row layout-margin-bottom-xxl">
                    <div class="kbq-form__label">Patronymic</div>
                    <kbq-form-field class="kbq-form__control">
                        <input formControlName="patronymic" kbqInput placeholder="Optional" />
                    </kbq-form-field>
                </div>

                <div class="kbq-form__row">
                    <button class="flex-25" color="contrast" kbq-button type="submit">Send</button>
                </div>
            </div>
        </form>
    `,
    styles: `
        form {
            width: 320px;
            padding: 1px;
        }
    `,
    providers: [
        kbqErrorStateMatcherProvider(ShowOnFormSubmitErrorStateMatcher)
    ],
    changeDetection: ChangeDetectionStrategy.OnPush,
    host: {
        class: 'layout-margin-5xl layout-align-center-center layout-row'
    }
})
export class ValidationOptionalLabelExample {
    protected readonly form = new FormGroup({
        firstName: new FormControl('', Validators.required),
        lastName: new FormControl('', Validators.required),
        patronymic: new FormControl('')
    });
    private readonly formFieldList = viewChildren(KbqFormField);

    onSubmit(): void {
        this.focusFirstInvalidControl();
    }

    private focusFirstInvalidControl(): void {
        setTimeout(() => {
            const invalidControl = this.formFieldList().find((control) => control.invalid);

            invalidControl?.focus();
        });
    }
}
```

Some fields can go unmarked as optional if their auxiliary purpose is obvious. For example, “Description.”

If most fields in the form are optional, then the few required ones should be marked. You can add a red asterisk after the label or a gray “Required” tag below the field. Don't use multiple methods for indicating required fields in the same form.

**Example: Validation required label** (`validation-required-label`)

```ts validation-required-label-example.ts
import { ChangeDetectionStrategy, Component, viewChildren } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { KbqButtonModule } from '@koobiq/components/button';
import {
    kbqErrorStateMatcherProvider,
    KbqFormsModule,
    KbqOptionModule,
    ShowOnFormSubmitErrorStateMatcher
} from '@koobiq/components/core';
import { KbqFormField } from '@koobiq/components/form-field';
import { KbqInputModule } from '@koobiq/components/input';
import { KbqSelectModule } from '@koobiq/components/select';
import { KbqTextareaModule } from '@koobiq/components/textarea';

@Component({
    selector: 'validation-required-label-example',
    imports: [
        ReactiveFormsModule,
        KbqInputModule,
        KbqButtonModule,
        KbqFormsModule,
        KbqOptionModule,
        KbqSelectModule,
        KbqTextareaModule
    ],
    template: `
        <form class="kbq-form-horizontal" novalidate [formGroup]="form" (ngSubmit)="onSubmit()">
            <div class="kbq-form__fieldset">
                <div class="kbq-form__row">
                    <label class="kbq-form__label flex-20">Name</label>
                    <kbq-form-field class="kbq-form__control flex-80">
                        <input formControlName="firstName" kbqInput [required]="true" />

                        <kbq-error>Required</kbq-error>
                    </kbq-form-field>
                </div>

                <div class="kbq-form__row">
                    <label class="kbq-form__label flex-20">Last name</label>
                    <kbq-form-field class="kbq-form__control flex-80">
                        <input formControlName="lastName" kbqInput />
                    </kbq-form-field>
                </div>

                <div class="kbq-form__row">
                    <label class="kbq-form__label flex-20">Patronymic</label>
                    <kbq-form-field class="kbq-form__control flex-80">
                        <input formControlName="thirdName" kbqInput placeholder="Optional" />
                    </kbq-form-field>
                </div>

                <div class="kbq-form__row">
                    <label class="kbq-form__label flex-20">Email</label>
                    <kbq-form-field class="kbq-form__control flex-80">
                        <input formControlName="email" kbqInput type="email" />
                    </kbq-form-field>
                </div>

                <div class="kbq-form__row">
                    <label class="kbq-form__label flex-20">Request reason</label>
                    <kbq-form-field class="kbq-form__control flex-80">
                        <kbq-select formControlName="reason">
                            @for (option of docsReasons; track option.value) {
                                <kbq-option [value]="option.value">
                                    {{ option.label }}
                                </kbq-option>
                            }
                        </kbq-select>
                    </kbq-form-field>
                </div>

                <div class="kbq-form__row">
                    <label class="kbq-form__label flex-20">Estimation</label>
                    <kbq-form-field class="kbq-form__control flex-80">
                        <kbq-select formControlName="estimation" [required]="true">
                            @for (option of estimation; track option.value) {
                                <kbq-option [value]="option.value">
                                    {{ option.label }}
                                </kbq-option>
                            }
                        </kbq-select>

                        <kbq-error>Required</kbq-error>
                    </kbq-form-field>
                </div>

                <div class="kbq-form__row layout-margin-bottom-xxl">
                    <label class="kbq-form__label flex-20">Comment</label>
                    <kbq-form-field class="kbq-form__control flex-80">
                        <textarea formControlName="comment" kbqTextarea></textarea>
                    </kbq-form-field>
                </div>

                <div class="kbq-form__row">
                    <div class="kbq-form__label flex-20"></div>
                    <div class="kbq-form__control">
                        <button color="contrast" kbq-button type="submit">Send</button>
                    </div>
                </div>
            </div>
        </form>
    `,
    styles: `
        :host {
            label:has(+ .kbq-form-field input:required, + .kbq-form-field .kbq-select[required])::after {
                content: ' *';
                color: var(--kbq-foreground-error);
            }
        }

        .kbq-form__row:has(> .kbq-form-field-type-select) {
            --kbq-forms-size-vertical-row-margin-bottom: var(--kbq-size-xl);
        }

        form {
            width: 574px;
            padding: 1px;
        }
    `,
    providers: [
        kbqErrorStateMatcherProvider(ShowOnFormSubmitErrorStateMatcher)
    ],
    changeDetection: ChangeDetectionStrategy.OnPush,
    host: {
        class: 'layout-margin-5xl layout-align-center-center layout-row'
    }
})
export class ValidationRequiredLabelExample {
    protected readonly docsReasons = [
        { value: 1, label: 'Parenting issues' },
        { value: 2, label: 'Depression, anxiety, fear' },
        { value: 3, label: 'Life crises' },
        { value: 4, label: 'Low self-confidence' },
        { value: 5, label: 'Relationships' },
        { value: 6, label: 'Psychosomatic illnesses' },
        { value: 7, label: 'Recurring unpleasant situations' },
        { value: 8, label: 'Difficult life situation' },
        { value: 9, label: 'Emotional disorders' },
        { value: 10, label: 'Other' }
    ];

    protected readonly estimation = [
        { value: 1, label: 'Bad' },
        { value: 2, label: 'Satisfactory' },
        { value: 3, label: 'Good' },
        { value: 4, label: 'Best of all' }
    ];

    protected readonly form = new FormGroup({
        firstName: new FormControl<string>('', { nonNullable: true, validators: [Validators.required] }),
        lastName: new FormControl<string>('', { nonNullable: true }),
        thirdName: new FormControl<string>('', { nonNullable: true }),
        email: new FormControl<string>('', { nonNullable: true, validators: [Validators.email] }),
        reason: new FormControl<number>(this.docsReasons[2].value, { nonNullable: true }),
        estimation: new FormControl<string>('', { nonNullable: true, validators: [Validators.required] }),
        comment: new FormControl<string>('', { nonNullable: true })
    });

    private readonly formFieldList = viewChildren(KbqFormField);

    onSubmit(): void {
        this.focusFirstInvalidControl();
    }

    private focusFirstInvalidControl(): void {
        setTimeout(() => {
            const invalidControl = this.formFieldList().find((control) => control.invalid);

            invalidControl?.focus();
        });
    }
}
```

### When to validate for errors

There are three main strategies for validation:

1. While typing
2. On blur (when leaving the field)
3. After submitting the form

#### While typing

Used to check for valid characters. Invalid characters are filtered out during input, and an error tooltip is shown near the text caret. The message disappears when a valid character is entered or the field loses focus.

**Example: Validation on type** (`validation-on-type`)

```ts validation-on-type-example.ts
import { ChangeDetectionStrategy, Component, viewChild } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { KbqComponentColors, PopUpPlacements } from '@koobiq/components/core';
import { KbqInputModule } from '@koobiq/components/input';
import { KbqToolTipModule, KbqTooltipTrigger } from '@koobiq/components/tooltip';

const restSymbolsRegex = /[^0-9a-zA-Zа-яА-ЯйЙёЁ]+/g;

@Component({
    selector: 'validation-on-type-example',
    imports: [
        ReactiveFormsModule,
        KbqToolTipModule,
        KbqInputModule
    ],
    template: `
        <form novalidate [formGroup]="checkOnFlyForm">
            <kbq-form-field>
                <kbq-label>Folder name</kbq-label>
                <input
                    formControlName="folderName"
                    kbqInput
                    [kbqEnterDelay]="10"
                    [kbqPlacement]="popUpPlacements.Top"
                    [kbqRelativeToCaret]="true"
                    [kbqTrigger]="'manual'"
                    [kbqTooltip]="'Letters and numbers'"
                    [kbqTooltipColor]="colors.Error"
                    (blur)="onBlur()"
                    (input)="onInput($event)"
                />

                <kbq-cleaner />

                <kbq-hint>Only letters and numbers</kbq-hint>
            </kbq-form-field>
        </form>
    `,
    styles: `
        form {
            width: 320px;
            padding: 1px;
        }
    `,
    changeDetection: ChangeDetectionStrategy.OnPush,
    host: {
        class: 'layout-margin-5xl layout-align-center-center layout-row'
    }
})
export class ValidationOnTypeExample {
    protected readonly tooltip = viewChild(KbqTooltipTrigger);

    protected readonly popUpPlacements = PopUpPlacements;
    protected readonly colors = KbqComponentColors;

    protected readonly checkOnFlyForm = new FormGroup({
        folderName: new FormControl('')
    });

    protected onInput(event: Event): void {
        if (!(event.target instanceof HTMLInputElement)) return;

        const { value } = event.target;
        const allowedValue = value.replace(restSymbolsRegex, '');

        // A valid character leaves nothing to filter out, so the hint goes as soon as one is typed.
        if (allowedValue === value) {
            this.tooltip()?.hide();

            return;
        }

        this.checkOnFlyForm.controls.folderName.setValue(allowedValue);
        this.tooltip()?.show();
    }

    protected onBlur(): void {
        this.tooltip()?.hide();
    }
}
```

#### On blur

For fields with complex requirements (like IP address or email), they shouldn't enter an error state during typing to avoid distracting the user. Validation is done on blur — the field is marked invalid and an error message appears beneath it.

**Example: Validation on blur** (`validation-on-blur`)

```ts validation-on-blur-example.ts
import {
    ChangeDetectionStrategy,
    Component,
    Directive,
    ElementRef,
    inject,
    Injectable,
    viewChild
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
    AbstractControl,
    FormControl,
    FormGroupDirective,
    NgControl,
    NgForm,
    ReactiveFormsModule,
    ValidatorFn,
    Validators
} from '@angular/forms';
import {
    ErrorStateMatcher,
    KbqComponentColors,
    kbqErrorStateMatcherProvider,
    PopUpPlacements
} from '@koobiq/components/core';
import { KbqInputModule } from '@koobiq/components/input';
import { KbqToolTipModule, KbqTooltipTrigger } from '@koobiq/components/tooltip';
import { fromEvent, switchMap } from 'rxjs';
import { take } from 'rxjs/operators';

const IP_PATTERN =
    /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/;

const restSymbolsRegex = /[^0-9.]+/g;

@Injectable()
export class CustomErrorStateMatcher implements ErrorStateMatcher {
    isErrorState(control: AbstractControl | null, form: FormGroupDirective | NgForm | null): boolean {
        return !!(control?.invalid && control.touched && (form?.submitted ?? true));
    }
}

@Directive({
    selector: '[exampleResetTouchedOnFirstInput]',
    exportAs: 'exampleResetTouchedOnFirstInput'
})
class ExampleResetTouchedOnFirstInput {
    protected readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
    protected readonly control = inject(NgControl, { optional: true, host: true });
    protected validators: ValidatorFn | null = null;

    constructor() {
        const inputEvent = fromEvent(this.elementRef.nativeElement, 'input').pipe(take(1));

        fromEvent(this.elementRef.nativeElement, 'focus')
            .pipe(
                switchMap(() => inputEvent),
                takeUntilDestroyed()
            )
            .subscribe(() => {
                if (this.control?.control) {
                    this.control.control.markAsUntouched();
                }
            });
    }
}

@Component({
    selector: 'validation-on-blur-example',
    imports: [
        ReactiveFormsModule,
        KbqInputModule,
        KbqToolTipModule,
        ExampleResetTouchedOnFirstInput
    ],
    template: `
        <kbq-form-field style="width: 320px;">
            <kbq-label>IPv4-address</kbq-label>
            <input
                kbqInput
                exampleResetTouchedOnFirstInput
                [formControl]="ipAddressControl"
                [kbqEnterDelay]="10"
                [kbqPlacement]="popUpPlacements.Top"
                [kbqRelativeToCaret]="true"
                [kbqTrigger]="'manual'"
                [kbqTooltip]="'Numbers and dots only'"
                [kbqTooltipColor]="colors.Error"
                (blur)="onBlur()"
                (input)="onInput($event)"
            />

            <kbq-error>The IP address does not comply with RFC standards</kbq-error>

            <kbq-cleaner />
        </kbq-form-field>
    `,
    providers: [
        kbqErrorStateMatcherProvider(CustomErrorStateMatcher)
    ],
    changeDetection: ChangeDetectionStrategy.OnPush,
    host: {
        class: 'layout-margin-5xl layout-align-center-center layout-row'
    }
})
export class ValidationOnBlurExample {
    protected readonly tooltip = viewChild(KbqTooltipTrigger);

    protected readonly ipAddressControl = new FormControl('', [Validators.pattern(IP_PATTERN)]);
    protected readonly popUpPlacements = PopUpPlacements;
    protected readonly colors = KbqComponentColors;

    protected onInput(event: Event): void {
        if (!(event.target instanceof HTMLInputElement)) return;

        const { value } = event.target;
        const allowedValue = value.replace(restSymbolsRegex, '');

        // A valid character leaves nothing to filter out, so the hint goes as soon as one is typed.
        if (allowedValue === value) {
            this.tooltip()?.hide();

            return;
        }

        this.ipAddressControl.setValue(allowedValue);
        this.tooltip()?.show();
    }

    protected onBlur(): void {
        this.tooltip()?.hide();
    }
}
```

Empty fields are not validated on blur.

Real-time validation works for fields already marked as invalid. If the value is corrected, the error message disappears and the field is restored to normal state.

Invalid fields remain styled as such if the user leaves the field without correcting the error.

**Example: Validation on blur filled** (`validation-on-blur-filled`)

```ts validation-on-blur-filled-example.ts
import {
    afterNextRender,
    ChangeDetectionStrategy,
    Component,
    Directive,
    ElementRef,
    inject,
    Injectable,
    viewChild
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
    AbstractControl,
    FormControl,
    FormGroupDirective,
    NgControl,
    NgForm,
    ReactiveFormsModule,
    Validators
} from '@angular/forms';
import {
    ErrorStateMatcher,
    KbqComponentColors,
    kbqErrorStateMatcherProvider,
    PopUpPlacements
} from '@koobiq/components/core';
import { KbqInputModule } from '@koobiq/components/input';
import { KbqToolTipModule, KbqTooltipTrigger } from '@koobiq/components/tooltip';
import { fromEvent, switchMap } from 'rxjs';
import { take } from 'rxjs/operators';

const IP_PATTERN =
    /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/;

const restSymbolsRegex = /[^0-9.]+/g;

@Injectable()
export class CustomErrorStateMatcher implements ErrorStateMatcher {
    isErrorState(control: AbstractControl | null, form: FormGroupDirective | NgForm | null): boolean {
        return !!(control?.invalid && control.touched && (form?.submitted ?? true));
    }
}

@Directive({
    selector: '[exampleResetTouchedOnFirstInput]',
    exportAs: 'exampleResetTouchedOnFirstInput'
})
class ExampleResetTouchedOnFirstInput {
    protected readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
    protected readonly control = inject(NgControl, { optional: true, host: true });

    constructor() {
        const inputEvent = fromEvent(this.elementRef.nativeElement, 'input').pipe(take(1));

        fromEvent(this.elementRef.nativeElement, 'focus')
            .pipe(
                switchMap(() => inputEvent),
                takeUntilDestroyed()
            )
            .subscribe(() => {
                if (this.control?.control) {
                    this.control.control.markAsUntouched();
                }
            });
    }
}

@Component({
    selector: 'validation-on-blur-filled-example',
    imports: [
        ReactiveFormsModule,
        KbqInputModule,
        KbqToolTipModule,
        ExampleResetTouchedOnFirstInput
    ],
    template: `
        <kbq-form-field style="width: 320px;">
            <kbq-label>IPv4-address</kbq-label>
            <input
                kbqInput
                exampleResetTouchedOnFirstInput
                [formControl]="ipAddressControl"
                [kbqEnterDelay]="10"
                [kbqPlacement]="popUpPlacements.Top"
                [kbqTrigger]="'manual'"
                [kbqTooltip]="'Numbers and dot only'"
                [kbqTooltipColor]="colors.Error"
                (blur)="onBlur()"
                (input)="onInput($event)"
            />

            <kbq-error>The IP address does not comply with RFC standards</kbq-error>

            <kbq-cleaner />
        </kbq-form-field>
    `,
    providers: [
        kbqErrorStateMatcherProvider(CustomErrorStateMatcher)
    ],
    changeDetection: ChangeDetectionStrategy.OnPush,
    host: {
        class: 'layout-margin-5xl layout-align-center-center layout-row'
    }
})
export class ValidationOnBlurFilledExample {
    protected readonly tooltip = viewChild(KbqTooltipTrigger);
    protected readonly ipAddressControl = new FormControl('123...12123123', [
        Validators.pattern(IP_PATTERN)
    ]);

    constructor() {
        afterNextRender(() => {
            this.ipAddressControl.markAsTouched();
            this.ipAddressControl.updateValueAndValidity();
        });
    }

    protected readonly colors = KbqComponentColors;
    protected readonly popUpPlacements = PopUpPlacements;

    protected onInput(event: Event): void {
        if (!(event.target instanceof HTMLInputElement)) return;

        const { value } = event.target;
        const allowedValue = value.replace(restSymbolsRegex, '');

        // A valid character leaves nothing to filter out, so the hint goes as soon as one is typed.
        if (allowedValue === value) {
            this.tooltip()?.hide();

            return;
        }

        this.ipAddressControl.setValue(allowedValue);
        this.tooltip()?.show();
    }

    protected onBlur(): void {
        this.tooltip()?.hide();
    }
}
```

#### On form submission

By default, errors are shown only for **invalid** (incorrectly filled) fields after the user interacts with them.
To display errors immediately after form submission, use `ShowOnFormSubmitErrorStateMatcher` — an implementation of [ErrorStateMatcher](https://github.com/koobiq/angular-components/blob/main/packages/components/core/error/error-state-matcher.ts).

Used for checking empty required fields and things that can't be validated client-side.

The submit button is **not disabled**, even if the form is empty or contains invalid fields. When clicked, validation and submission are triggered. The button enters a **Progress** state during submission. You can also display a [Loader Overlay](https://koobiq.io/en/components/loader-overlay.md) over the form during this time.

If validation fails, focus is set to the first field with an error. If the error isn't tied to a specific field, show an alert above the form and focus on it — ensuring visibility even if the form's top section is off-screen.

If the same alert message remains valid on repeated submissions, don't hide and re-show it — instead, fade it out and back in while re-focusing.

**Example: Validation on submit** (`validation-on-submit`)

```ts validation-on-submit-example.ts
import { FocusMonitor, FocusOrigin } from '@angular/cdk/a11y';
import {
    AfterViewInit,
    ChangeDetectionStrategy,
    Component,
    ElementRef,
    inject,
    OnDestroy,
    signal,
    viewChild,
    viewChildren
} from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { KbqAlertModule } from '@koobiq/components/alert';
import { KbqButtonModule } from '@koobiq/components/button';
import {
    kbqErrorStateMatcherProvider,
    KbqFormsModule,
    ShowOnFormSubmitErrorStateMatcher
} from '@koobiq/components/core';
import { KbqFormField } from '@koobiq/components/form-field';
import { KbqIconModule } from '@koobiq/components/icon';
import { KbqInputModule } from '@koobiq/components/input';

@Component({
    selector: 'validation-on-submit-example',
    imports: [
        KbqAlertModule,
        KbqIconModule,
        ReactiveFormsModule,
        KbqInputModule,
        KbqButtonModule,
        KbqFormsModule
    ],
    template: `
        <div class="example-container">
            <div
                #alertContainer
                class="example-alert__container"
                tabindex="0"
                [class.example-alert__container_hidden]="!showServerErrors()"
                [class.layout-margin-bottom-l]="showServerErrors()"
            >
                <kbq-alert class="example-alert" alertColor="error" alertStyle="colored" [compact]="true">
                    <i color="error" kbq-icon="kbq-circle-info_16"></i>
                    The server didn’t respond while sending data. Please try again.
                </kbq-alert>
            </div>

            <form class="kbq-form-vertical" novalidate [formGroup]="globalErrorForm" (ngSubmit)="submitForm()">
                <div class="kbq-form__fieldset">
                    <div class="kbq-form__row">
                        <kbq-form-field>
                            <kbq-label>Name</kbq-label>
                            <input formControlName="firstName" kbqInput />
                        </kbq-form-field>
                    </div>

                    <div class="kbq-form__row layout-margin-bottom-xxl">
                        <kbq-form-field>
                            <kbq-label>Last name</kbq-label>
                            <input formControlName="lastName" kbqInput />
                        </kbq-form-field>
                    </div>

                    <div class="kbq-form__row">
                        <button
                            #submitButton
                            class="flex-25"
                            color="contrast"
                            kbq-button
                            type="submit"
                            [class.kbq-progress]="inProgress()"
                            [disabled]="inProgress()"
                            (keydown.enter)="submitOrigin = 'keyboard'"
                            (keydown.space)="submitOrigin = 'keyboard'"
                            (mouseleave)="submitOrigin = 'mouse'"
                        >
                            Send
                        </button>
                    </div>
                </div>
            </form>
        </div>
    `,
    styles: `
        .example-alert {
            transition: opacity 50ms ease-in-out;
        }

        .example-alert.example-alert_in-progress {
            opacity: var(--kbq-opacity-disabled);
        }

        .example-container {
            width: 320px;
        }

        form {
            width: 100%;
            padding: 1px;
        }

        .example-alert__container {
            border: 2px solid transparent;
            border-radius: 14px;
        }

        .example-alert__container_hidden {
            border: 0;
            overflow: hidden;
            height: 0;
        }

        .example-alert__container.cdk-mouse-focused:focus-visible {
            border-color: transparent;
            outline: none;
        }

        .example-alert__container.cdk-keyboard-focused:focus-visible {
            border-color: var(--kbq-states-line-focus);
            outline: none;
        }
    `,
    providers: [
        kbqErrorStateMatcherProvider(ShowOnFormSubmitErrorStateMatcher)
    ],
    changeDetection: ChangeDetectionStrategy.OnPush,
    host: {
        class: 'layout-margin-5xl layout-align-center-center layout-column'
    }
})
export class ValidationOnSubmitExample implements AfterViewInit, OnDestroy {
    protected readonly alertContainer = viewChild<ElementRef<HTMLDivElement>>('alertContainer');

    protected readonly showServerErrors = signal(false);
    protected readonly inProgress = signal(false);
    protected readonly globalErrorForm = new FormGroup({
        firstName: new FormControl('', [Validators.required]),
        lastName: new FormControl('')
    });

    protected readonly focusMonitor = inject(FocusMonitor);
    protected submitOrigin: FocusOrigin | null = null;
    private readonly formFieldList = viewChildren(KbqFormField);

    ngAfterViewInit() {
        const alert = this.alertContainer();

        if (alert) {
            this.focusMonitor.monitor(alert.nativeElement);
        }
    }

    ngOnDestroy(): void {
        const alert = this.alertContainer();

        if (alert) {
            this.focusMonitor.stopMonitoring(alert.nativeElement);
        }
    }

    submitForm(): void {
        if (this.globalErrorForm.invalid) {
            this.focusFirstInvalidControl();

            return;
        }

        this.inProgress.set(true);

        setTimeout(() => {
            this.inProgress.set(false);
            this.showServerErrors.set(Math.random() > 0.5);

            const alert = this.alertContainer();

            if (this.showServerErrors() && alert) {
                this.focusMonitor.focusVia(alert.nativeElement, this.submitOrigin);
                this.submitOrigin = null;
            }
        }, 1000);
    }

    private focusFirstInvalidControl(): void {
        setTimeout(() => {
            const invalidControl = this.formFieldList().find((control) => control.invalid);

            invalidControl?.focus();
        });
    }
}
```

### Error messages

#### Field-specific message

Field-level error messages are shown below the field, **before** any helper text. If the error message duplicates the helper text, you can replace one with the other.

**Example: Validation message for specific field** (`validation-message-for-specific-field`)

```ts validation-message-for-specific-field-example.ts
import { ChangeDetectionStrategy, Component, viewChildren } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { KbqButtonModule } from '@koobiq/components/button';
import {
    kbqErrorStateMatcherProvider,
    KbqFormsModule,
    ShowOnFormSubmitErrorStateMatcher
} from '@koobiq/components/core';
import { KbqFormField } from '@koobiq/components/form-field';
import { KbqInputModule } from '@koobiq/components/input';

@Component({
    selector: 'validation-message-for-specific-field-example',
    imports: [
        ReactiveFormsModule,
        KbqInputModule,
        KbqButtonModule,
        KbqFormsModule
    ],
    template: `
        <form class="kbq-form-vertical" novalidate [formGroup]="form" (ngSubmit)="onSubmit()">
            <div class="kbq-form__fieldset">
                <div class="kbq-form__row">
                    <kbq-form-field>
                        <kbq-label>The error message appears above the hint</kbq-label>
                        <input formControlName="first" kbqInput />

                        <kbq-error>Required</kbq-error>

                        <kbq-hint>Hint under the field</kbq-hint>
                    </kbq-form-field>
                </div>

                <div class="kbq-form__row layout-margin-bottom-xxl">
                    <kbq-form-field>
                        <kbq-label>The error message replaces the hint</kbq-label>
                        <input #input="kbqInput" formControlName="last" kbqInput />

                        @if (input.errorState()) {
                            <kbq-error>Required</kbq-error>
                        } @else {
                            <kbq-hint>Hint under the field</kbq-hint>
                        }
                    </kbq-form-field>
                </div>

                <div class="kbq-form__row">
                    <button class="flex-25" color="contrast" kbq-button type="submit">Send</button>
                </div>
            </div>
        </form>
    `,
    styles: `
        form {
            width: 320px;
            padding: 1px;
        }
    `,
    providers: [
        kbqErrorStateMatcherProvider(ShowOnFormSubmitErrorStateMatcher)
    ],
    changeDetection: ChangeDetectionStrategy.OnPush,
    host: {
        class: 'layout-margin-5xl layout-align-center-center layout-row'
    }
})
export class ValidationMessageForSpecificFieldExample {
    protected readonly form = new FormGroup({
        first: new FormControl('', [Validators.required]),
        last: new FormControl('', [Validators.required])
    });

    private readonly formFieldList = viewChildren(KbqFormField);

    onSubmit(): void {
        this.focusFirstInvalidControl();
    }

    private focusFirstInvalidControl(): void {
        setTimeout(() => {
            const invalidControl = this.formFieldList().find((control) => control.invalid);

            invalidControl?.focus();
        });
    }
}
```

You don't always need to display an error message just for a required field — marking it as invalid may be enough.

**Example: Validation no message** (`validation-no-message`)

```ts validation-no-message-example.ts
import { ChangeDetectionStrategy, Component, viewChildren } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { KbqButtonModule } from '@koobiq/components/button';
import {
    kbqErrorStateMatcherProvider,
    KbqFormsModule,
    ShowOnFormSubmitErrorStateMatcher
} from '@koobiq/components/core';
import { KbqFormField } from '@koobiq/components/form-field';
import { KbqInputModule } from '@koobiq/components/input';

@Component({
    selector: 'validation-no-message-example',
    imports: [
        ReactiveFormsModule,
        KbqInputModule,
        KbqButtonModule,
        KbqFormsModule
    ],
    template: `
        <form class="kbq-form-vertical" novalidate [formGroup]="form" (ngSubmit)="onSubmit()">
            <div class="kbq-form__fieldset">
                <div class="kbq-form__row">
                    <kbq-form-field>
                        <kbq-label>Address</kbq-label>
                        <input formControlName="first" kbqInput />
                    </kbq-form-field>
                </div>

                <div class="kbq-form__row layout-margin-bottom-xxl">
                    <kbq-form-field>
                        <kbq-label>Comment</kbq-label>
                        <input formControlName="last" kbqInput />
                    </kbq-form-field>
                </div>

                <div class="kbq-form__row">
                    <button class="flex-25" color="contrast" kbq-button type="submit">Send</button>
                </div>
            </div>
        </form>
    `,
    styles: `
        form {
            width: 320px;
            padding: 1px;
        }
    `,
    providers: [
        kbqErrorStateMatcherProvider(ShowOnFormSubmitErrorStateMatcher)
    ],
    changeDetection: ChangeDetectionStrategy.OnPush,
    host: {
        class: 'layout-margin-5xl layout-align-center-center layout-row'
    }
})
export class ValidationNoMessageExample {
    protected readonly form = new FormGroup({
        first: new FormControl('', [Validators.required]),
        last: new FormControl('')
    });

    private readonly formFieldList = viewChildren(KbqFormField);

    onSubmit(): void {
        this.focusFirstInvalidControl();
    }

    private focusFirstInvalidControl(): void {
        setTimeout(() => {
            const invalidControl = this.formFieldList().find((control) => control.invalid);

            invalidControl?.focus();
        });
    }
}
```

#### Global message

General form errors are shown in an alert **above the first field** and focused. The alert appears higher than any informational blocks above the form fields.

In this example, the message appears after clicking the submit button. For mouse users, scrolling to the alert is enough — no need to show a visible focus outline. If the button is activated via keyboard, show a visible focus outline on the alert.

**Example: Validation message global** (`validation-message-global`)

```ts validation-message-global-example.ts
import { FocusMonitor, FocusOrigin } from '@angular/cdk/a11y';
import {
    afterNextRender,
    AfterViewInit,
    ChangeDetectionStrategy,
    Component,
    ElementRef,
    inject,
    Injector,
    OnDestroy,
    signal,
    viewChild,
    viewChildren
} from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { KbqAlertModule } from '@koobiq/components/alert';
import { KbqButtonModule } from '@koobiq/components/button';
import {
    kbqErrorStateMatcherProvider,
    KbqFormsModule,
    ShowOnFormSubmitErrorStateMatcher
} from '@koobiq/components/core';
import { KbqFormField } from '@koobiq/components/form-field';
import { KbqIconModule } from '@koobiq/components/icon';
import { KbqInputModule } from '@koobiq/components/input';
import { KbqLoaderOverlayModule } from '@koobiq/components/loader-overlay';

@Component({
    selector: 'validation-message-global-example',
    imports: [
        KbqAlertModule,
        KbqIconModule,
        ReactiveFormsModule,
        KbqInputModule,
        KbqButtonModule,
        KbqFormsModule,
        KbqLoaderOverlayModule
    ],
    template: `
        <div class="example-container">
            @if (inProgress()) {
                <kbq-loader-overlay [size]="'compact'" />
            }

            <div
                #alertContainer
                class="example-alert__container"
                tabindex="0"
                [class.example-alert__container_hidden]="!showServerErrors()"
                [class.layout-margin-bottom-l]="showServerErrors()"
            >
                <kbq-alert class="example-alert" alertColor="error" alertStyle="colored" [compact]="true">
                    <i color="error" kbq-icon="kbq-circle-info_16"></i>
                    The server didn’t respond while sending data. Please try again.
                </kbq-alert>
            </div>

            <form class="kbq-form-vertical" novalidate [formGroup]="globalErrorForm" (ngSubmit)="submitForm()">
                <div class="kbq-form__fieldset">
                    <div class="kbq-form__row">
                        <kbq-form-field>
                            <kbq-label>Name</kbq-label>
                            <input formControlName="firstName" kbqInput />
                        </kbq-form-field>
                    </div>

                    <div class="kbq-form__row layout-margin-bottom-xxl">
                        <kbq-form-field>
                            <kbq-label>Last name</kbq-label>
                            <input formControlName="lastName" kbqInput />
                        </kbq-form-field>
                    </div>

                    <div class="kbq-form__row">
                        <button
                            #submitButton
                            class="flex-25"
                            color="contrast"
                            kbq-button
                            type="submit"
                            [class.kbq-progress]="inProgress()"
                            [disabled]="inProgress()"
                            (keydown.enter)="submitOrigin = 'keyboard'"
                            (keydown.space)="submitOrigin = 'keyboard'"
                            (mouseleave)="submitOrigin = 'mouse'"
                        >
                            Send
                        </button>
                    </div>
                </div>
            </form>
        </div>
    `,
    styles: `
        .example-container {
            width: 320px;
        }

        form {
            width: 100%;
        }

        .example-alert__container {
            border: 2px solid transparent;
            border-radius: 14px;
        }

        .example-alert__container_hidden {
            border: 0;
            overflow: hidden;
            height: 0;
        }

        .example-alert__container.cdk-mouse-focused:focus-visible {
            border-color: transparent;
            outline: none;
        }

        .example-alert__container.cdk-keyboard-focused:focus-visible {
            border-color: var(--kbq-states-line-focus);
            outline: none;
        }
    `,
    providers: [
        kbqErrorStateMatcherProvider(ShowOnFormSubmitErrorStateMatcher)
    ],
    changeDetection: ChangeDetectionStrategy.OnPush,
    host: {
        class: 'layout-margin-5xl layout-align-center-center layout-column'
    }
})
export class ValidationMessageGlobalExample implements AfterViewInit, OnDestroy {
    protected readonly alertContainer = viewChild<ElementRef<HTMLDivElement>>('alertContainer');

    protected readonly showServerErrors = signal(false);
    protected readonly inProgress = signal(false);
    protected readonly globalErrorForm = new FormGroup({
        firstName: new FormControl('John', [Validators.required]),
        lastName: new FormControl('Smith')
    });

    protected readonly focusMonitor = inject(FocusMonitor);
    private readonly injector = inject(Injector);
    protected submitOrigin: FocusOrigin | null = null;
    private readonly formFieldList = viewChildren(KbqFormField);
    private submitTimeout: ReturnType<typeof setTimeout> | undefined;

    ngAfterViewInit() {
        const alert = this.alertContainer();

        if (alert) {
            this.focusMonitor.monitor(alert.nativeElement);
        }
    }

    ngOnDestroy(): void {
        clearTimeout(this.submitTimeout);

        const alert = this.alertContainer();

        if (alert) {
            this.focusMonitor.stopMonitoring(alert.nativeElement);
        }
    }

    submitForm(): void {
        if (this.globalErrorForm.invalid) {
            this.showServerErrors.set(false);
            this.focusFirstInvalidControl();

            return;
        }

        this.inProgress.set(true);

        this.submitTimeout = setTimeout(() => {
            this.inProgress.set(false);
            this.showServerErrors.set(true);

            // The alert stays inert until the loader overlay is removed from the DOM.
            afterNextRender(
                () => {
                    const alert = this.alertContainer();

                    if (alert) {
                        this.focusMonitor.focusVia(alert.nativeElement, this.submitOrigin);
                        this.submitOrigin = null;
                    }
                },
                { injector: this.injector }
            );
        }, 2000);
    }

    private focusFirstInvalidControl(): void {
        setTimeout(() => {
            const invalidControl = this.formFieldList().find((control) => control.invalid);

            invalidControl?.focus();
        });
    }
}
```

#### One required field from several

Some forms require at least **one** of several fields to be filled. In this case, fields aren't marked as invalid after submission. Instead, a general error alert appears above the form. You can list the problematic fields and include pseudo-links to move focus to them.

**Example: Validation message global with links** (`validation-message-global-with-links`)

```ts validation-message-global-with-links-example.ts
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { KbqAlertModule } from '@koobiq/components/alert';
import { KbqButtonModule } from '@koobiq/components/button';
import { KbqFormsModule, PopUpPlacements } from '@koobiq/components/core';
import { KbqIconModule } from '@koobiq/components/icon';
import { KbqInputModule } from '@koobiq/components/input';
import { KbqLink } from '@koobiq/components/link';
import { KbqToolTipModule, KbqTooltipTrigger } from '@koobiq/components/tooltip';

const restSymbolsRegex = /[^0-9]+/g;

@Component({
    selector: 'validation-message-global-with-links-example',
    imports: [
        ReactiveFormsModule,
        KbqAlertModule,
        KbqIconModule,
        KbqInputModule,
        KbqButtonModule,
        KbqFormsModule,
        KbqLink,
        KbqToolTipModule,
        FormsModule
    ],
    templateUrl: 'validation-message-global-with-links-example.html',
    styles: `
        form {
            width: 320px;
            padding: 1px;
        }

        .example-alert {
            transition: opacity 50ms ease-in-out;
        }

        .example-alert.example-alert_in-progress {
            opacity: var(--kbq-opacity-disabled);
        }
    `,
    changeDetection: ChangeDetectionStrategy.OnPush,
    host: {
        class: 'layout-margin-5xl layout-align-center-center layout-column'
    }
})
export class ValidationMessageGlobalWithLinksExample {
    protected readonly popUpPlacements = PopUpPlacements;

    protected readonly inProgress = signal(false);
    protected readonly showError = signal(false);
    protected readonly form = new FormGroup({
        series: new FormControl(''),
        number: new FormControl(''),
        lastName: new FormControl(''),
        firstName: new FormControl(''),
        patronymic: new FormControl('')
    });

    onInput(event: Event, tooltip: KbqTooltipTrigger, control: FormControl): void {
        if (event.target instanceof HTMLInputElement && event.target.value && /\D/.test(event.target.value)) {
            control.setValue(event.target.value.replace(restSymbolsRegex, ''));

            if (!tooltip.isOpen) {
                tooltip.show();

                setTimeout(() => tooltip.hide(), 3000);
            }
        }
    }

    submitForm() {
        this.inProgress.set(true);

        setTimeout(() => {
            this.checkForm();
            this.inProgress.set(false);
        }, 1000);
    }

    checkForm() {
        this.showError.set(
            [this.form.controls.series, this.form.controls.number, this.form.controls.lastName].every(
                (control) => !control.value
            )
        );
    }
}
```

```html validation-message-global-with-links-example.html
<form novalidate class="kbq-form-vertical" [formGroup]="form" (ngSubmit)="submitForm()">
    @if (showError()) {
        <kbq-alert
            alertColor="error"
            alertStyle="colored"
            class="example-alert layout-margin-bottom-l"
            [class.example-alert_in-progress]="inProgress()"
            [compact]="true"
        >
            <i kbq-icon="kbq-circle-info_16" [color]="'error'"></i>
            <span>
                Fill in at least one of the fields:
                <span kbqLink (click)="seriesInput.focus()">Series,</span>
                <span>&nbsp;</span>
                <span kbqLink (click)="numberInput.focus()">Number</span>
                or
                <span kbqLink (click)="lastNameInput.focus()">Last name</span>
            </span>
        </kbq-alert>
    }

    <div class="kbq-form__fieldset layout-margin-bottom-xxl">
        <div class="kbq-form__fieldset kbq-horizontal">
            <div class="kbq-form__row flex-25">
                <kbq-form-field class="kbq-form__control">
                    <kbq-label>Series</kbq-label>
                    <input
                        #seriesInput
                        #seriesTooltip="kbqTooltip"
                        formControlName="series"
                        kbqInput
                        name="input"
                        kbqTooltipColor="error"
                        [kbqEnterDelay]="10"
                        [kbqPlacement]="popUpPlacements.Top"
                        [kbqTooltip]="'Only numbers'"
                        [kbqTrigger]="'manual'"
                        (blur)="seriesTooltip.hide()"
                        (input)="onInput($event, seriesTooltip, form.controls.series)"
                    />
                </kbq-form-field>
            </div>

            <div class="kbq-form__row flex-40">
                <kbq-form-field class="kbq-form__control">
                    <kbq-label>Number</kbq-label>
                    <input
                        #numberInput
                        #numberTooltip="kbqTooltip"
                        formControlName="number"
                        kbqInput
                        name="input"
                        kbqTooltipColor="error"
                        [kbqEnterDelay]="10"
                        [kbqPlacement]="popUpPlacements.Top"
                        [kbqTooltip]="'Only numbers'"
                        [kbqTrigger]="'manual'"
                        (blur)="numberTooltip.hide()"
                        (input)="onInput($event, numberTooltip, form.controls.number)"
                    />
                </kbq-form-field>
            </div>
        </div>

        <div class="kbq-form__row">
            <kbq-form-field class="kbq-form__control">
                <kbq-label>Last name</kbq-label>
                <input #lastNameInput formControlName="lastName" kbqInput name="input" />
            </kbq-form-field>
        </div>

        <div class="kbq-form__row">
            <kbq-form-field class="kbq-form__control">
                <kbq-label>Name</kbq-label>
                <input formControlName="firstName" kbqInput name="input" />

                <kbq-hint>Optional</kbq-hint>
            </kbq-form-field>
        </div>

        <div class="kbq-form__row">
            <kbq-form-field class="kbq-form__control">
                <kbq-label>Patronymic</kbq-label>
                <input formControlName="patronymic" kbqInput name="input" />

                <kbq-hint>Optional</kbq-hint>
            </kbq-form-field>
        </div>
    </div>

    <div class="kbq-form__row">
        <button
            color="contrast"
            kbq-button
            type="submit"
            class="flex-30"
            [class.kbq-progress]="inProgress()"
            [disabled]="inProgress()"
        >
            Send
        </button>
    </div>
</form>
```

## Examples

### Validation methods

Angular validators define **what** is invalid — they set error keys on the `FormControl` (`required`, `minlength`, etc.). They do not control **when** those errors are shown to the user. Without any additional mechanism, errors would appear immediately when the page loads, before the user has touched anything.

Koobiq solves this with `ErrorStateMatcher`.

#### ErrorStateMatcher

`ErrorStateMatcher` is a pure display policy. It reads the control's existing errors and the form's submission state, then answers a single question: **should the error be visible right now?**

Validators and the `FormControl` remain unchanged — `ErrorStateMatcher` never adds or removes errors. It only decides when `kbq-form-field` should reveal the `<kbq-error>` children and apply the `kbq-invalid` CSS class.

In other words, **errors belong to the `FormControl`**. For a required field that starts empty, `control.hasError('required')` returns `true` as soon as the control is created. `ErrorStateMatcher` only decides when to tell the user about it.

**Registration:**

Apply a matcher to all controls inside a component via providers:

```ts
@Component({
    providers: [kbqErrorStateMatcherProvider(ShowOnFormSubmitErrorStateMatcher)]
})
```

Override for a single control using the `[errorStateMatcher]` input (supported by `kbq-tag-list`, `kbq-file-upload`, and similar):

```html
<kbq-file-upload [errorStateMatcher]="myMatcher" ... />
```

### Displaying errors

#### Single validator

**Example: Validation: single validator** (`validation-basic-single-validator`)

```ts validation-basic-single-validator-example.ts
import { ChangeDetectionStrategy, Component, viewChildren } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { KbqButtonModule } from '@koobiq/components/button';
import {
    kbqErrorStateMatcherProvider,
    KbqFormsModule,
    ShowOnFormSubmitErrorStateMatcher
} from '@koobiq/components/core';
import { KbqFormField } from '@koobiq/components/form-field';
import { KbqInputModule } from '@koobiq/components/input';

@Component({
    selector: 'validation-basic-single-validator-example',
    imports: [ReactiveFormsModule, KbqInputModule, KbqButtonModule, KbqFormsModule],
    template: `
        <form class="kbq-form-vertical" [formGroup]="form" (ngSubmit)="onSubmit()">
            <div class="kbq-form__fieldset">
                <div class="kbq-form__row">
                    <kbq-form-field>
                        <kbq-label>Name</kbq-label>
                        <input formControlName="name" kbqInput />

                        <kbq-error>Required</kbq-error>
                    </kbq-form-field>
                </div>

                <div class="kbq-form__row">
                    <button color="contrast" kbq-button type="submit">Submit</button>
                </div>
            </div>
        </form>
    `,
    styles: `
        form {
            width: 320px;
        }
    `,
    providers: [
        kbqErrorStateMatcherProvider(ShowOnFormSubmitErrorStateMatcher)
    ],
    changeDetection: ChangeDetectionStrategy.OnPush,
    host: {
        class: 'layout-margin-5xl layout-align-center-center layout-row'
    }
})
export class ValidationBasicSingleValidatorExample {
    protected readonly form = new FormGroup({
        name: new FormControl('', [Validators.required])
    });

    private readonly formFieldList = viewChildren(KbqFormField);

    onSubmit(): void {
        setTimeout(() => {
            this.formFieldList()
                .find((f) => f.invalid)
                ?.focus();
        });
    }
}
```

#### Multiple validators

**Example: Validation: multiple validators** (`validation-basic-multiple-validators`)

```ts validation-basic-multiple-validators-example.ts
import { ChangeDetectionStrategy, Component, viewChildren } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { KbqButtonModule } from '@koobiq/components/button';
import {
    kbqErrorStateMatcherProvider,
    KbqFormsModule,
    ShowOnFormSubmitErrorStateMatcher
} from '@koobiq/components/core';
import { KbqFormField } from '@koobiq/components/form-field';
import { KbqInputModule } from '@koobiq/components/input';

@Component({
    selector: 'validation-basic-multiple-validators-example',
    imports: [ReactiveFormsModule, KbqInputModule, KbqButtonModule, KbqFormsModule],
    template: `
        <form class="kbq-form-vertical" [formGroup]="form" (ngSubmit)="onSubmit()">
            <div class="kbq-form__fieldset">
                <div class="kbq-form__row">
                    <kbq-form-field>
                        <kbq-label>Username</kbq-label>
                        <input formControlName="username" kbqInput />

                        @if (form.controls.username.hasError('required')) {
                            <kbq-error>Required</kbq-error>
                        } @else if (form.controls.username.hasError('minlength')) {
                            <kbq-error>Minimum 5 characters</kbq-error>
                        } @else if (form.controls.username.hasError('maxlength')) {
                            <kbq-error>Maximum 20 characters</kbq-error>
                        }
                    </kbq-form-field>
                </div>

                <div class="kbq-form__row">
                    <button color="contrast" kbq-button type="submit">Submit</button>
                </div>
            </div>
        </form>
    `,
    styles: `
        form {
            width: 320px;
        }
    `,
    providers: [
        kbqErrorStateMatcherProvider(ShowOnFormSubmitErrorStateMatcher)
    ],
    changeDetection: ChangeDetectionStrategy.OnPush,
    host: {
        class: 'layout-margin-5xl layout-align-center-center layout-row'
    }
})
export class ValidationBasicMultipleValidatorsExample {
    protected readonly form = new FormGroup({
        username: new FormControl('', [Validators.required, Validators.minLength(5), Validators.maxLength(20)])
    });

    private readonly formFieldList = viewChildren(KbqFormField);

    onSubmit(): void {
        setTimeout(() => {
            this.formFieldList()
                .find((f) => f.invalid)
                ?.focus();
        });
    }
}
```

### Components

#### Tag list

`kbq-tag-list` supports `ErrorStateMatcher` and integrates with `kbq-form-field` the same way as other controls.

**Example: Validation tag list** (`validation-tag-list`)

```ts validation-tag-list-example.ts
﻿import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core';
import {
    AbstractControl,
    FormBuilder,
    FormGroup,
    ReactiveFormsModule,
    ValidationErrors,
    ValidatorFn,
    Validators
} from '@angular/forms';
import { KbqButtonModule } from '@koobiq/components/button';
import { COMMA, ENTER, kbqErrorStateMatcherProvider, ShowOnFormSubmitErrorStateMatcher } from '@koobiq/components/core';
import { KbqIcon } from '@koobiq/components/icon';
import { KbqInputModule } from '@koobiq/components/input';
import { KbqTagInputEvent, KbqTagsModule } from '@koobiq/components/tags';
import { KbqToolTipModule } from '@koobiq/components/tooltip';

const LATIN_PATTERN = /^[a-zA-Z]+$/;

/** Validators live on `<kbq-tag-list>`, so the control value is the whole tag array. */
const latinValidator = (): ValidatorFn => {
    return ({ value }: AbstractControl<string[] | null>): ValidationErrors | null => {
        const invalidTags = (value || []).filter((tag) => !LATIN_PATTERN.test(tag));

        return invalidTags.length ? { latin: { invalidTags } } : null;
    };
};

@Component({
    selector: 'validation-tag-list-example',
    imports: [
        ReactiveFormsModule,
        KbqInputModule,
        KbqToolTipModule,
        KbqButtonModule,
        KbqIcon,
        KbqTagsModule
    ],
    template: `
        <form
            novalidate
            style="width: 320px"
            [formGroup]="reactiveForm"
            (ngSubmit)="onSubmitReactiveForm(reactiveForm)"
        >
            <kbq-form-field class="layout-margin-bottom-l">
                <kbq-tag-list #inputTagList formControlName="reactiveTypeaheadValue">
                    @for (tag of reactiveForm.controls['reactiveTypeaheadValue'].value; track tag) {
                        <kbq-tag [value]="tag" (removed)="reactiveInputOnRemoveTag(tag)">
                            {{ tag }}
                            <i kbq-icon="kbq-xmark-s_16" kbqTagRemove></i>
                        </kbq-tag>
                    }
                    <input
                        autocomplete="off"
                        placeholder="New tag..."
                        [kbqTagInputFor]="inputTagList"
                        [kbqTagInputSeparatorKeyCodes]="separatorKeysCodes"
                        (kbqTagInputTokenEnd)="reactiveInputOnCreate($event)"
                    />
                </kbq-tag-list>

                @if (inputTagList.errorState()) {
                    @if (tags.hasError('required')) {
                        <kbq-error>Required</kbq-error>
                    }

                    @if (tags.getError('latin'); as error) {
                        <kbq-error>Invalid tags: {{ error.invalidTags.join(', ') }}</kbq-error>
                    }
                }

                <kbq-hint>Provide only latin letters</kbq-hint>
            </kbq-form-field>

            <button type="submit" kbq-button>Submit</button>
        </form>
    `,
    providers: [
        kbqErrorStateMatcherProvider(ShowOnFormSubmitErrorStateMatcher)
    ],
    changeDetection: ChangeDetectionStrategy.OnPush,
    host: {
        class: 'layout-margin-5xl layout-align-center-center layout-row'
    }
})
export class ValidationTagListExample implements OnInit {
    protected readonly reactiveForm: FormGroup;
    protected readonly separatorKeysCodes: number[] = [ENTER, COMMA];

    protected get tags(): AbstractControl {
        return this.reactiveForm.controls['reactiveTypeaheadValue'];
    }

    private readonly formBuilder = inject(FormBuilder);

    constructor() {
        this.reactiveForm = this.formBuilder.group({
            reactiveTypeaheadValue: this.formBuilder.control([], [Validators.required, latinValidator()])
        });
    }

    ngOnInit(): void {
        setTimeout(() => {
            this.reactiveForm.controls['reactiveTypeaheadValue'].setValue(['asd', 'efd']);
        });
    }

    reactiveInputOnCreate({ input, value }: KbqTagInputEvent): void {
        const control = this.reactiveForm.controls.reactiveTypeaheadValue;
        const updatedControlValue: string[] = control.value.slice();

        if ((value || '').trim()) {
            updatedControlValue.push(value.trim());
            control.patchValue(updatedControlValue);
            control.markAsDirty();
        }

        if (input) {
            input.value = '';
        }
    }

    reactiveInputOnRemoveTag(tag: string): void {
        const control = this.reactiveForm.controls['reactiveTypeaheadValue'];
        const controlValue: string[] = control.value.slice();
        const index = controlValue.indexOf(tag);

        if (index >= 0) {
            controlValue.splice(index, 1);
            control.patchValue(controlValue);
            control.markAsDirty();
        }
    }

    onSubmitReactiveForm(form: FormGroup) {
        console.log('onSubmitReactiveForm: ', form);
    }
}
```

#### File upload

File upload components support `ErrorStateMatcher` but have their own layout outside of `kbq-form-field`, so error output is handled differently from input-based controls. See all validation examples on the [File upload](https://koobiq.io/en/components/file-upload.md) page.

### Custom ErrorStateMatcher

Use `ShowRequiredOnSubmitErrorStateMatcher` when you need split behavior:

- If the field is empty, don't show the `required` error until the form is submitted.
- If the field is filled, validate it on blur.

**Example: Validation on submit with custom matcher** (`validation-on-submit-custom-matcher`)

```ts validation-on-submit-custom-matcher-example.ts
import { ChangeDetectionStrategy, Component, signal, viewChildren } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { KbqButtonModule } from '@koobiq/components/button';
import {
    kbqErrorStateMatcherProvider,
    KbqFormsModule,
    ShowRequiredOnSubmitErrorStateMatcher
} from '@koobiq/components/core';
import { KbqFormField } from '@koobiq/components/form-field';
import { KbqInputModule } from '@koobiq/components/input';
import { timer } from 'rxjs';

@Component({
    selector: 'validation-on-submit-custom-matcher-example',
    imports: [
        ReactiveFormsModule,
        KbqFormsModule,
        KbqInputModule,
        KbqButtonModule
    ],
    template: `
        <form class="kbq-form-vertical" [formGroup]="globalErrorForm" (ngSubmit)="submitForm()">
            <div class="kbq-form__fieldset">
                <div class="kbq-form__row">
                    <kbq-form-field>
                        <kbq-label>Name</kbq-label>
                        <input formControlName="firstName" kbqInput />
                    </kbq-form-field>
                </div>

                <div class="kbq-form__row layout-margin-bottom-xxl">
                    <kbq-form-field>
                        <kbq-label>Last name</kbq-label>
                        <input formControlName="lastName" kbqInput />
                    </kbq-form-field>
                </div>

                <div class="kbq-form__row">
                    <button
                        class="flex-25"
                        color="contrast"
                        kbq-button
                        type="submit"
                        [class.kbq-progress]="inProgress()"
                        [disabled]="inProgress()"
                    >
                        Send
                    </button>
                </div>
            </div>
        </form>
    `,
    styles: `
        form {
            width: 320px;
        }
    `,
    providers: [
        kbqErrorStateMatcherProvider(ShowRequiredOnSubmitErrorStateMatcher)
    ],
    changeDetection: ChangeDetectionStrategy.OnPush,
    host: {
        class: 'layout-margin-5xl layout-align-center-center layout-column'
    }
})
export class ValidationOnSubmitCustomMatcherExample {
    protected readonly inProgress = signal(false);
    protected readonly globalErrorForm = new FormGroup({
        firstName: new FormControl('', [Validators.minLength(5), Validators.required]),
        lastName: new FormControl('')
    });

    private readonly formFieldList = viewChildren(KbqFormField);

    protected submitForm(): void {
        if (this.globalErrorForm.invalid) {
            this.focusFirstInvalidControl();

            return;
        }

        this.inProgress.set(true);
        timer(1000).subscribe(() => {
            this.inProgress.set(false);
        });
    }

    private focusFirstInvalidControl(): void {
        setTimeout(() => {
            const invalidControl = this.formFieldList().find((control) => control.invalid);

            invalidControl?.focus();
        });
    }
}
```

### Cross-field validation

Some rules compare more than one field: "confirm the password", "the new password must differ from the old
one", "the end date can't precede the start date". A single `FormControl` can't see its sibling's value. So
the natural home for a rule like this is a validator on the `FormGroup`, not on either field.

#### Why a FormGroup error highlights nothing

`<kbq-error>` and the red border come from one thing: the control's `errorState`.

`errorState` is just the return value of `ErrorStateMatcher.isErrorState(control, form)`. The `control` here
is always the field's own `FormControl` — the matcher never sees the group.

So a validator can make the group invalid, and the check still says no. No field turns red. No `<kbq-error>`
appears.

This is not a bug. It's a division of labor: validators decide **what** is invalid, the matcher decides
**when and where** to show it. The fix belongs on the matcher's side.

#### Recommended pattern

Keep the validator on the group — its logic and its error shape stay entirely yours.
`ShowOnCrossFieldErrorStateMatcher` adds the missing half: it shows a group-level error on the controls that
error concerns.

It needs one thing from you: a function that takes each error and returns the names of the controls it's
about, or `null` for an error it doesn't recognize. A name can point into a nested group
(`'passwords.confirm'`) or a `FormArray` row, and the rule itself can live on any ancestor group, not just
the immediate parent.

The error only shows once **every** control it concerns has been touched, or the form is submitted — showing
it earlier would turn a pair red while the user is still typing the second value.

That "any ancestor group" reach has a catch: each control only re-checks its own `errorState` from its own
`ngDoCheck`. If the named controls sit in separate `OnPush` components (each with its own `formGroupName`),
typing in one marks only that component's view dirty — a sibling component showing another named control
won't refresh on its own. Subscribe to the group's `statusChanges` and call `markForCheck()` on the affected
views if your form is split up that way.

**Example: Validation: cross-field** (`validation-cross-field-password`)

```ts validation-cross-field-password-example.ts
import { ChangeDetectionStrategy, Component, viewChildren } from '@angular/core';
import {
    AbstractControl,
    FormControl,
    FormGroup,
    FormGroupDirective,
    ReactiveFormsModule,
    ValidationErrors,
    Validators,
    type ValidatorFn
} from '@angular/forms';
import { KbqButtonModule } from '@koobiq/components/button';
import {
    kbqErrorStateMatcherProvider,
    KbqFormsModule,
    PasswordValidators,
    ShowOnCrossFieldErrorStateMatcher,
    ShowRequiredOnSubmitErrorStateMatcher
} from '@koobiq/components/core';
import { KbqFormField } from '@koobiq/components/form-field';
import { KbqInputModule } from '@koobiq/components/input';

/**
 * Payload every cross-field validator below publishes, so that a single `ErrorStateMatcher` can tell which
 * controls a group-level error belongs to. The names are relative to the group holding the error.
 */
type ExampleCrossFieldError = { controls: string[] };

const isExampleCrossFieldError = (value: unknown): value is ExampleCrossFieldError =>
    typeof value === 'object' && value !== null && 'controls' in value && Array.isArray(value.controls);

/**
 * Compares two control values for equality — both rules below only ever ask "same or not", never "which is
 * greater". The default (`===`) is enough for the strings here; an ordering comparator, needed for the date
 * pair in `validation-cross-field-dates-example.ts`, is a different job with a different default.
 */
type ExampleCompare = (a: unknown, b: unknown) => boolean;

const exampleValuesEqual: ExampleCompare = (a, b) => a === b;

// A control that is missing or still empty is not a cross-field problem: `Validators.required` owns that
// case, and reporting a mismatch against an empty field only gets in the user's way.
const isExampleValueEmpty = (value: unknown): boolean => value === null || value === undefined || value === '';

/** All the listed controls must hold the same value. */
const exampleMatchAll =
    (controls: string[], compare: ExampleCompare = exampleValuesEqual): ValidatorFn =>
    (group: AbstractControl): ValidationErrors | null => {
        const values = controls.map((name) => group.get(name)?.value);

        if (values.some(isExampleValueEmpty)) {
            return null;
        }

        const matches = values.every((value) => compare(value, values[0]));

        return matches ? null : { matchAll: { controls } satisfies ExampleCrossFieldError };
    };

/** All the listed controls must hold different values. */
const exampleDistinct =
    (controls: string[], compare: ExampleCompare = exampleValuesEqual): ValidatorFn =>
    (group: AbstractControl): ValidationErrors | null => {
        const values = controls.map((name) => group.get(name)?.value);

        if (values.some(isExampleValueEmpty)) {
            return null;
        }

        const distinct = values.every((value, index) =>
            values.every((other, otherIndex) => index === otherIndex || !compare(value, other))
        );

        return distinct ? null : { distinct: { controls } satisfies ExampleCrossFieldError };
    };

/**
 * The library matcher does the display half: it shows a group-level error on the controls that error concerns,
 * once every one of them is touched. All it needs is a way to map an error to its controls — here the
 * validators publish that list themselves, so the scope is a single lookup.
 *
 * `ShowRequiredOnSubmitErrorStateMatcher` as the second argument keeps the validation guide's rule for
 * `required` — an empty field shouldn't turn red on blur — without affecting the cross-field errors above,
 * which still reveal once every control they name has been touched, or on submit.
 */
const exampleCrossFieldMatcher = new ShowOnCrossFieldErrorStateMatcher(
    (_key, value) => (isExampleCrossFieldError(value) ? value.controls : null),
    new ShowRequiredOnSubmitErrorStateMatcher()
);

@Component({
    selector: 'validation-cross-field-password-example',
    imports: [ReactiveFormsModule, KbqInputModule, KbqButtonModule, KbqFormsModule],
    template: `
        <form #formDirective="ngForm" class="kbq-form-vertical" [formGroup]="form" (ngSubmit)="onSubmit(formDirective)">
            <div class="kbq-form__fieldset">
                <div class="kbq-form__row">
                    <kbq-form-field>
                        <kbq-label>Current password</kbq-label>
                        <input formControlName="currentPassword" kbqInputPassword />
                        <kbq-password-toggle />

                        @if (form.controls.currentPassword.hasError('required')) {
                            <kbq-error>Required</kbq-error>
                        }
                    </kbq-form-field>
                </div>

                <div class="kbq-form__row">
                    <kbq-form-field>
                        <kbq-label>New password</kbq-label>
                        <input formControlName="newPassword" kbqInputPassword />
                        <kbq-password-toggle />

                        @if (form.controls.newPassword.hasError('required')) {
                            <kbq-error>Required</kbq-error>
                        } @else if (form.controls.newPassword.hasError('minLength')) {
                            <kbq-error>Minimum 8 characters</kbq-error>
                        } @else if (form.hasError('distinct')) {
                            <kbq-error>The new password must differ from the current one</kbq-error>
                        }
                    </kbq-form-field>
                </div>

                <div class="kbq-form__row">
                    <kbq-form-field>
                        <kbq-label>Confirm new password</kbq-label>
                        <input formControlName="confirmPassword" kbqInputPassword />
                        <kbq-password-toggle />

                        @if (form.controls.confirmPassword.hasError('required')) {
                            <kbq-error>Required</kbq-error>
                        } @else if (form.hasError('matchAll')) {
                            <kbq-error>Passwords do not match</kbq-error>
                        }
                    </kbq-form-field>
                </div>

                <div class="kbq-form__row">
                    <button color="contrast" kbq-button type="submit">Change password</button>
                </div>
            </div>
        </form>
    `,
    styles: `
        form {
            width: 320px;
        }
    `,
    providers: [
        kbqErrorStateMatcherProvider(exampleCrossFieldMatcher)
    ],
    changeDetection: ChangeDetectionStrategy.OnPush,
    host: {
        class: 'layout-margin-5xl layout-align-center-center layout-row'
    }
})
export class ValidationCrossFieldPasswordExample {
    protected readonly form = new FormGroup(
        {
            currentPassword: new FormControl('', Validators.required),
            newPassword: new FormControl('', [Validators.required, PasswordValidators.minLength(8)]),
            confirmPassword: new FormControl('', Validators.required)
        },
        {
            // Both rules live on the group and both are served by the same matcher. Their control sets differ
            // and overlap on `newPassword`, so the highlight follows the error payload rather than lighting
            // up the whole group.
            validators: [
                exampleDistinct(['currentPassword', 'newPassword']),
                exampleMatchAll(['newPassword', 'confirmPassword'])
            ]
        }
    );

    private readonly formFieldList = viewChildren(KbqFormField);

    protected onSubmit(formDirective: FormGroupDirective): void {
        if (this.form.invalid) {
            this.focusFirstInvalidControl();

            return;
        }

        // `FormGroupDirective.resetForm()`, not `this.form.reset()`: the latter only resets the model, not
        // the directive's own `submitted` flag, which would otherwise stay `true` forever and make every
        // cross-field error reveal immediately on the next attempt instead of waiting for both fields touched.
        formDirective.resetForm();
    }

    private focusFirstInvalidControl(): void {
        // The matcher re-evaluates `errorState` on the next change detection, triggered by the submit event
        // itself — deferred a tick so `formFieldList()` reports each field's post-submit invalid state.
        setTimeout(() => {
            const invalidControl = this.formFieldList().find((control) => control.invalid);

            invalidControl?.focus();
        });
    }
}
```

One matcher instance is enough for any number of rules on a group — the example above puts two rules on one
group, and both are shown by the same matcher.

#### Comparing values that are not primitives

The default `===` comparator silently fails on dates and other object values — use `DateAdapter`'s
`compareDate()` / `compareDateTime()` / `sameDate()`, or your own comparator, instead.

**Example: Validation: cross-field over dates** (`validation-cross-field-dates`)

```ts validation-cross-field-dates-example.ts
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import {
    AbstractControl,
    FormControl,
    FormGroup,
    ReactiveFormsModule,
    ValidationErrors,
    type ValidatorFn
} from '@angular/forms';
import { LuxonDateModule } from '@koobiq/angular-luxon-adapter/adapter';
import {
    DateAdapter,
    kbqErrorStateMatcherProvider,
    KbqFormsModule,
    ShowOnCrossFieldErrorStateMatcher
} from '@koobiq/components/core';
import { KbqDatepickerModule } from '@koobiq/components/datepicker';
import { KbqFormFieldModule } from '@koobiq/components/form-field';
import { DateTime } from 'luxon';

/**
 * Payload the cross-field validator publishes, so that the `ErrorStateMatcher` can tell which controls a
 * group-level error belongs to. The names are relative to the group holding the error.
 */
type ExampleCrossFieldError = { controls: string[] };

const isExampleCrossFieldError = (value: unknown): value is ExampleCrossFieldError =>
    typeof value === 'object' && value !== null && 'controls' in value && Array.isArray(value.controls);

/**
 * Compares two control values. The default below is enough for strings and numbers; a date needs a comparator
 * of its own, which is the whole point of this example.
 */
type ExampleCompare = (a: unknown, b: unknown) => number;

/** The listed controls must hold values in ascending order. */
const exampleOrder =
    (controls: string[], compare: ExampleCompare): ValidatorFn =>
    (group: AbstractControl): ValidationErrors | null => {
        const values = controls.map((name) => group.get(name)?.value);

        // An empty control is `Validators.required`'s business, not an ordering problem.
        if (values.some((value) => value === null || value === undefined || value === '')) {
            return null;
        }

        const ordered = values.every((value, index) => index === 0 || compare(values[index - 1], value) <= 0);

        return ordered ? null : { order: { controls } satisfies ExampleCrossFieldError };
    };

/**
 * The library matcher does the display half: it shows a group-level error on the controls that error concerns,
 * once every one of them is touched. All it needs is a way to map an error to its controls — here the
 * validators publish that list themselves, so the scope is a single lookup.
 */
const exampleCrossFieldMatcher = new ShowOnCrossFieldErrorStateMatcher((_key, value) =>
    isExampleCrossFieldError(value) ? value.controls : null
);

@Component({
    selector: 'validation-cross-field-dates-example',
    imports: [ReactiveFormsModule, KbqFormFieldModule, KbqFormsModule, KbqDatepickerModule, LuxonDateModule],
    template: `
        <form class="kbq-form-vertical" [formGroup]="form">
            <div class="kbq-form__fieldset">
                <div class="kbq-form__row">
                    <kbq-form-field>
                        <kbq-label>Valid from</kbq-label>
                        <input formControlName="validFrom" [kbqDatepicker]="validFromPicker" />
                        <kbq-datepicker-toggle-icon kbqSuffix [for]="validFromPicker" />
                        <kbq-datepicker #validFromPicker />
                    </kbq-form-field>
                </div>

                <div class="kbq-form__row">
                    <kbq-form-field>
                        <kbq-label>Valid until</kbq-label>
                        <input formControlName="validUntil" [kbqDatepicker]="validUntilPicker" />
                        <kbq-datepicker-toggle-icon kbqSuffix [for]="validUntilPicker" />
                        <kbq-datepicker #validUntilPicker />

                        @if (form.hasError('order')) {
                            <kbq-error>The end of the period must not precede its start</kbq-error>
                        }
                    </kbq-form-field>
                </div>
            </div>
        </form>
    `,
    styles: `
        form {
            width: 320px;
        }

        /* The datepicker's own kbq-form-field is a fixed 136px — enough for a date, not for this message.
           Widening only the error to the form's width keeps the field compact and the message on one line. */
        kbq-error {
            width: 320px;
        }
    `,
    providers: [
        kbqErrorStateMatcherProvider(exampleCrossFieldMatcher)
    ],
    changeDetection: ChangeDetectionStrategy.OnPush,
    host: {
        class: 'layout-margin-5xl layout-align-center-center layout-row'
    }
})
export class ValidationCrossFieldDatesExample {
    private readonly adapter = inject<DateAdapter<DateTime>>(DateAdapter);

    protected readonly form = new FormGroup(
        {
            validFrom: new FormControl<DateTime | null>(null),
            validUntil: new FormControl<DateTime | null>(null)
        },
        {
            // `<` happens to order dates correctly, because they coerce to a number, but `===` never reports
            // two of them as equal. Comparing them by hand would reject a period that starts and ends on the
            // same day; the adapter compares them properly, whichever date library is installed.
            validators: exampleOrder(['validFrom', 'validUntil'], (a, b) =>
                this.adapter.compareDate(a as DateTime, b as DateTime)
            )
        }
    );
}
```

#### When not to use it

One case doesn't belong in this pattern: "at least one of these fields must be filled". Don't mark any field
invalid for that rule — show a general alert above the form instead. See the
[validation guide](https://koobiq.io/en/other/validation.md) and its "One required field from several" example.

#### Asynchronous rules

A rule only the server can decide goes on the group as an `AsyncValidatorFn`. The answer arrives outside any
event on the field's own view — no click, no keystroke, nothing an `OnPush` view would normally react to — so
it won't re-check itself when the result lands, whether the error ends up on the group or on a child control.
It needs a signal from the group's `statusChanges`.

**Example: Validation: asynchronous cross-field** (`validation-cross-field-async`)

```ts validation-cross-field-async-example.ts
import { ChangeDetectionStrategy, Component, computed } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import {
    AbstractControl,
    type AsyncValidatorFn,
    FormControl,
    FormGroup,
    ReactiveFormsModule,
    ValidationErrors,
    Validators
} from '@angular/forms';
import {
    kbqErrorStateMatcherProvider,
    KbqFormsModule,
    ShowOnCrossFieldErrorStateMatcher
} from '@koobiq/components/core';
import { KbqInputModule } from '@koobiq/components/input';
import { map, Observable, of, timer } from 'rxjs';

/**
 * Payload the cross-field validator publishes, so that the `ErrorStateMatcher` can tell which controls a
 * group-level error belongs to. The names are relative to the group holding the error.
 */
type ExampleCrossFieldError = { controls: string[] };

const isExampleCrossFieldError = (value: unknown): value is ExampleCrossFieldError =>
    typeof value === 'object' && value !== null && 'controls' in value && Array.isArray(value.controls);

/**
 * Stands in for a policy check only the server can make — it is the side holding the password history and the
 * rules. A real implementation would call `HttpClient` here.
 */
const exampleCheckOnServer = (current: string, next: string): Observable<boolean> =>
    timer(800).pipe(map(() => !next.toLowerCase().includes(current.toLowerCase().slice(0, 4))));

/** Asks the server whether the pair of values is acceptable. */
const exampleAsyncRule =
    (controls: string[], check: (values: string[]) => Observable<boolean>): AsyncValidatorFn =>
    (group: AbstractControl): Observable<ValidationErrors | null> => {
        const values = controls.map((name) => group.get(name)?.value);

        // Never spend a request on a half-filled form. `0`/`false` would be real values here, but every
        // control in this example is a password, so the string check stays consistent with the other examples.
        if (values.some((value) => value === null || value === undefined || value === '')) {
            return of(null);
        }

        return check(values as string[]).pipe(
            map((accepted) => (accepted ? null : { tooSimilar: { controls } satisfies ExampleCrossFieldError }))
        );
    };

/**
 * The library matcher does the display half. While the group is PENDING its `errors` are null, so nothing is
 * shown until the answer arrives — which is what keeps the fields from flashing red and back on every round
 * trip.
 */
const exampleCrossFieldMatcher = new ShowOnCrossFieldErrorStateMatcher((_key, value) =>
    isExampleCrossFieldError(value) ? value.controls : null
);

@Component({
    selector: 'validation-cross-field-async-example',
    imports: [ReactiveFormsModule, KbqInputModule, KbqFormsModule],
    template: `
        <form class="kbq-form-vertical" [formGroup]="form">
            <div class="kbq-form__fieldset">
                <div class="kbq-form__row">
                    <kbq-form-field>
                        <kbq-label>Current password</kbq-label>
                        <input formControlName="currentPassword" kbqInputPassword />
                        <kbq-password-toggle />
                    </kbq-form-field>
                </div>

                <div class="kbq-form__row">
                    <kbq-form-field>
                        <kbq-label>New password</kbq-label>
                        <input formControlName="newPassword" kbqInputPassword />
                        <kbq-password-toggle />

                        <!--
                            Reading a signal fed by the group's status is also what makes this OnPush view
                            re-check itself when the answer arrives: the child controls' own status does not
                            change when the error lands on the group, so nothing else would mark the view dirty.
                        -->
                        @if (pending()) {
                            <kbq-hint>Checking with the server&hellip;</kbq-hint>
                        }

                        @if (form.hasError('tooSimilar')) {
                            <kbq-error>The new password is too similar to the current one</kbq-error>
                        }
                    </kbq-form-field>
                </div>
            </div>
        </form>
    `,
    styles: `
        form {
            width: 320px;
        }
    `,
    providers: [
        kbqErrorStateMatcherProvider(exampleCrossFieldMatcher)
    ],
    changeDetection: ChangeDetectionStrategy.OnPush,
    host: {
        class: 'layout-margin-5xl layout-align-center-center layout-row'
    }
})
export class ValidationCrossFieldAsyncExample {
    protected readonly form = new FormGroup(
        {
            currentPassword: new FormControl('', Validators.required),
            newPassword: new FormControl('', Validators.required)
        },
        {
            asyncValidators: exampleAsyncRule(['currentPassword', 'newPassword'], ([current, next]) =>
                exampleCheckOnServer(current, next)
            ),
            // Without this the request would leave on every keystroke. It also means the model lags behind the
            // input until the field is left.
            updateOn: 'blur'
        }
    );

    private readonly status = toSignal(this.form.statusChanges, { initialValue: this.form.status });

    protected readonly pending = computed(() => this.status() === 'PENDING');
}
```
