Creating Custom Control Wrappers
February 16, 2026 · View on GitHub
Recommended Pattern: HostDirective on Component
The recommended approach for error display is to use FormErrorDisplayDirective as a hostDirective on a wrapper component with content projection. This pattern:
- ✅ Provides clean separation of concerns (UI in template, logic in directive)
- ✅ Supports all directive features via
contentChild()queries - ✅ Enables reusable error display components across your application
- ✅ Follows Angular best practices for directive composition
If the default ngx-control-wrapper doesn't meet your design requirements, you can easily create your own custom wrapper component using this pattern.
Basic Custom Wrapper (Recommended Pattern)
import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
import { FormErrorDisplayDirective } from 'ngx-vest-forms';
@Component({
selector: 'ngx-custom-control-wrapper',
changeDetection: ChangeDetectionStrategy.OnPush,
// ✅ RECOMMENDED: Use FormErrorDisplayDirective as hostDirective
hostDirectives: [
{
directive: FormErrorDisplayDirective,
inputs: ['errorDisplayMode'],
},
],
template: `
<div class="field-wrapper">
<!-- Content projection enables contentChild() queries in the directive -->
<ng-content />
@if (errorDisplay.shouldShowErrors()) {
<div class="error-message" role="status" aria-live="polite" aria-atomic="true">
@for (error of errorDisplay.errors(); track error) {
<span>{{ error }}</span>
}
</div>
}
@if (errorDisplay.isPending()) {
<div class="validating" role="status" aria-live="polite" aria-atomic="true">
Validating...
</div>
}
</div>
`,
})
export class CustomControlWrapperComponent {
// Inject the hostDirective to access its signals and state
protected readonly errorDisplay = inject(FormErrorDisplayDirective, {
self: true,
});
}
## When you want automatic ARIA wiring (recommended)
If you want your custom wrapper to automatically:
- merge `aria-describedby` without clobbering consumer-provided IDs, and
- toggle `aria-invalid` when errors become visible
…use `FormErrorControlDirective` (it composes `FormErrorDisplayDirective` and adds ARIA + stable IDs).
```ts
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { FormErrorControlDirective } from 'ngx-vest-forms';
@Component({
selector: 'ngx-custom-error-control',
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: FormErrorControlDirective,
inputs: ['errorDisplayMode', 'ariaAssociationMode'],
},
],
template: `
<div class="field-wrapper">
<ng-content />
<!-- Keep regions in the DOM so aria-describedby targets always exist -->
<div [id]="ec.errorId" role="status" aria-live="polite" aria-atomic="true">
@if (ec.errorDisplay.shouldShowErrors()) {
@for (error of ec.errorDisplay.errors(); track error) {
<div>{{ error }}</div>
}
}
</div>
<div [id]="ec.pendingId" role="status" aria-live="polite" aria-atomic="true">
@if (ec.showPendingMessage()) {
<div>Validating…</div>
}
</div>
</div>
`,
})
export class CustomErrorControlComponent {
protected readonly ec = inject(FormErrorControlDirective, { self: true });
}
Choosing an ARIA association mode
Both <ngx-control-wrapper> and FormErrorControlDirective support:
ariaAssociationMode="all-controls"(default) — stamps all descendant controlsariaAssociationMode="single-control"— stamps only if exactly one control existsariaAssociationMode="none"— never mutates descendant controls (group-safe)
If your wrapper targets an NgModelGroup container (or otherwise contains multiple controls), prefer using
<ngx-form-group-wrapper> instead of trying to make <ngx-control-wrapper> behave like a group wrapper.
If you still need a custom wrapper around a multi-control container, use ariaAssociationMode="none" so the
wrapper does not stamp aria-describedby / aria-invalid onto every descendant control.
ariaAssociationMode="single-control" is mainly useful when your wrapper usually contains one control, but
may sometimes contain additional focusable elements (for example, an input with an adjacent “Clear” button).
ARIA Association Utilities (Public API)
For advanced use cases or custom wrapper implementations, ngx-vest-forms exposes the underlying ARIA association utilities as a public API. These utilities are used internally by FormErrorControlDirective and can help maintain consistent accessibility behavior across your custom wrappers.
Available Utilities
import {
AriaAssociationMode,
parseAriaIdTokens,
mergeAriaDescribedBy,
resolveAssociationTargets,
} from 'ngx-vest-forms';
parseAriaIdTokens
Splits an aria-describedby attribute value into normalized token IDs:
function parseAriaIdTokens(value: string | null): string[];
// Example
const tokens = parseAriaIdTokens('hint-id error-id warning-id');
// Returns: ['hint-id', 'error-id', 'warning-id']
mergeAriaDescribedBy
Merges currently-active wrapper IDs into an existing aria-describedby value. Existing tokens owned by the wrapper are removed first, then current active IDs are appended while preserving non-owned tokens and token uniqueness:
function mergeAriaDescribedBy(
existing: string | null,
activeIds: readonly string[],
ownedIds: readonly string[]
): string | null;
// Example: Preserve consumer-provided IDs while managing wrapper IDs
const merged = mergeAriaDescribedBy(
'help-text field-error', // existing value
['field-error'], // currently active IDs
['field-error', 'field-warning'] // all IDs owned by wrapper
);
// Returns: 'help-text field-error'
// (preserved 'help-text', removed stale 'field-warning', kept active 'field-error')
resolveAssociationTargets
Resolves control targets based on ARIA association mode:
function resolveAssociationTargets(
controls: readonly HTMLElement[],
mode: AriaAssociationMode
): HTMLElement[];
// Example
const controls = [inputElement, textareaElement];
resolveAssociationTargets(controls, 'all-controls'); // Returns: [inputElement, textareaElement]
resolveAssociationTargets(controls, 'single-control'); // Returns: [] (more than one control)
resolveAssociationTargets(controls, 'none'); // Returns: []
Use Cases
These utilities are particularly useful when:
- Building custom wrapper components that need to maintain ARIA associations
- Creating wrapper libraries that need to match ngx-vest-forms accessibility behavior
- Implementing custom ARIA management logic while staying consistent with the library
- Testing accessibility implementations in downstream projects
The utilities help prevent logic duplication and ensure consistent ARIA behavior across different wrapper implementations.
Preventing Flashing Validation Messages
For async validations, you may want to prevent the "Validating..." message from flashing when validation completes quickly. Use the createDebouncedPendingState utility:
import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
import {
FormErrorDisplayDirective,
createDebouncedPendingState,
} from 'ngx-vest-forms';
@Component({
selector: 'ngx-debounced-wrapper',
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [FormErrorDisplayDirective],
template: `
<div class="field-wrapper">
<ng-content />
@if (errorDisplay.shouldShowErrors()) {
<div
class="error-message"
role="status"
aria-live="polite"
aria-atomic="true"
>
@for (error of errorDisplay.errors(); track error) {
<span>{{ error }}</span>
}
</div>
}
<!-- Only show after 200ms delay, keep visible for minimum 500ms -->
@if (showPendingMessage()) {
<div
class="validating"
role="status"
aria-live="polite"
aria-atomic="true"
>
<span class="spinner" aria-hidden="true"></span>
Validating…
</div>
}
</div>
`,
})
export class DebouncedWrapperComponent {
protected readonly errorDisplay = inject(FormErrorDisplayDirective, {
self: true,
});
// Debounced pending state prevents flashing for quick validations
private readonly pendingState = createDebouncedPendingState(
this.errorDisplay.isPending,
{ showAfter: 200, minimumDisplay: 500 }
);
protected readonly showPendingMessage = this.pendingState.showPendingMessage;
}
How it works:
- 200ms delay — Validation message only shows if validation takes longer than 200ms
- 500ms minimum — Once shown, message stays visible for at least 500ms to prevent flickering
- Better UX — Users don't see distracting flashes for quick async validations
Options:
interface DebouncedPendingStateOptions {
showAfter?: number; // Default: 200ms
minimumDisplay?: number; // Default: 500ms
}
Returns:
interface DebouncedPendingStateResult {
showPendingMessage: Signal<boolean>; // Debounced signal
cleanup: () => void; // Optional cleanup function
}
Advanced Custom Wrapper with Warnings
The FormErrorDisplayDirective also exposes warning messages from Vest.js:
import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
import { FormErrorDisplayDirective } from 'ngx-vest-forms';
@Component({
selector: 'ngx-advanced-wrapper',
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [FormErrorDisplayDirective],
template: `
<div class="form-field">
<ng-content />
<!-- Errors -->
@if (errorDisplay.shouldShowErrors()) {
<div class="errors" role="status" aria-live="polite" aria-atomic="true">
@for (error of errorDisplay.errors(); track error) {
<p class="error">{{ error }}</p>
}
</div>
}
<!-- Warnings (non-blocking feedback) -->
@if (errorDisplay.warnings().length > 0) {
<div
class="warnings"
role="status"
aria-live="polite"
aria-atomic="true"
>
@for (warning of errorDisplay.warnings(); track warning) {
<p class="warning">{{ warning }}</p>
}
</div>
}
<!-- Pending state -->
@if (errorDisplay.isPending()) {
<div
class="pending"
role="status"
aria-live="polite"
aria-atomic="true"
aria-busy="true"
>
<span class="spinner"></span>
Validating...
</div>
}
</div>
`,
})
export class AdvancedWrapperComponent {
protected readonly errorDisplay = inject(FormErrorDisplayDirective, {
self: true,
});
}
Available Signals from FormErrorDisplayDirective
The directive exposes these computed signals for building custom UIs:
// Error display control
shouldShowErrors(); // boolean - Whether to show errors based on mode and state
errors(); // string[] - Filtered errors (empty during pending)
warnings(); // string[] - Filtered warnings (empty during pending)
isPending(); // boolean - Whether async validation is running
// Raw state signals (from FormControlStateDirective)
errorMessages(); // string[] - All error messages
warningMessages(); // string[] - All warning messages
controlState(); // FormControlState - Complete control state
isTouched(); // boolean - Whether control has been touched
isDirty(); // boolean - Whether control value has changed
isValid(); // boolean - Whether control is valid
isInvalid(); // boolean - Whether control is invalid
hasPendingValidation(); // boolean - Whether validation is pending
updateOn(); // string - The ngModelOptions.updateOn value
formSubmitted(); // boolean - Whether form has been submitted
Real-World Example: Material Design Style Wrapper
import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
import { FormErrorDisplayDirective } from 'ngx-vest-forms';
@Component({
selector: 'ngx-mat-field-wrapper',
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: FormErrorDisplayDirective,
inputs: ['errorDisplayMode'],
},
],
host: {
class: 'mat-form-field',
'[class.mat-form-field-invalid]': 'errorDisplay.shouldShowErrors()',
'[attr.aria-busy]': "errorDisplay.isPending() ? 'true' : null",
},
template: `
<div class="mat-form-field-wrapper">
<div class="mat-form-field-flex">
<ng-content />
</div>
<div class="mat-form-field-subscript-wrapper">
@if (errorDisplay.shouldShowErrors()) {
<div
class="mat-error"
role="status"
aria-live="polite"
aria-atomic="true"
>
@for (error of errorDisplay.errors(); track error) {
<span>{{ error }}</span>
}
</div>
}
@if (
errorDisplay.warnings().length > 0 && !errorDisplay.shouldShowErrors()
) {
<div
class="mat-hint mat-warn"
role="status"
aria-live="polite"
aria-atomic="true"
>
@for (warning of errorDisplay.warnings(); track warning) {
<span>{{ warning }}</span>
}
</div>
}
@if (errorDisplay.isPending()) {
<div
class="mat-hint"
role="status"
aria-live="polite"
aria-atomic="true"
aria-busy="true"
>
<mat-spinner diameter="16"></mat-spinner>
Validating...
</div>
}
</div>
</div>
`,
styles: [
`
:host {
display: block;
margin-bottom: 1rem;
}
.mat-error {
color: #f44336;
font-size: 0.875rem;
}
.mat-hint {
color: rgba(0, 0, 0, 0.6);
font-size: 0.875rem;
}
.mat-warn {
color: #ff9800;
}
`,
],
})
export class MatFieldWrapperComponent {
protected readonly errorDisplay = inject(FormErrorDisplayDirective, {
self: true,
});
}
Using Your Custom Wrapper
Once created, use your custom wrapper just like the built-in ngx-control-wrapper:
@Component({
imports: [NgxVestForms, CustomControlWrapperComponent],
template: `
<form ngxVestForm [suite]="suite" (formValueChange)="formValue.set($event)">
<ngx-custom-control-wrapper>
<label>Email</label>
<input name="email" [ngModel]="formValue().email" type="email" />
</ngx-custom-control-wrapper>
</form>
`
})
Best Practices
- Use
hostDirectives- Always applyFormErrorDisplayDirectiveas a host directive for automatic state management - Respect accessibility - Use proper ARIA attributes (
role="alert",aria-live,aria-busy) - Filter during pending - The directive's
errors()andwarnings()signals automatically filter during validation - Leverage computed signals - All exposed signals are computed, so they update automatically
- Style based on state - Use host bindings to apply CSS classes based on error display state
When to Create Custom Wrappers
Create custom control wrappers when you need to:
- Match design system - Integrate with Material, PrimeNG, or custom design systems
- Custom error formatting - Display errors in specific layouts (inline, tooltip, popover)
- Additional UI elements - Add icons, help text, character counters
- Complex accessibility - Implement specific ARIA patterns for your use case
- Framework integration - Adapt to existing component libraries
The FormErrorDisplayDirective handles all the validation state management, so you can focus entirely on the presentation layer.