CngxFormField
projects/forms/field/form-field.component.ts
Import#
import { CngxFormField } from '@cngx/forms/field'
Description#
Invisible A11y coordination container for form fields.
Renders as display: contents - zero visual footprint.
All ARIA coordination (IDs, describedby, error gating) and CSS state classes
are handled by the hosted CngxFormFieldPresenter.
Child directives (CngxLabel, CngxInput, CngxHint, CngxError, CngxFieldErrors)
inject the presenter from this component via DI.
<cngx-form-field [field]="fields.email">
<label cngxLabel>E-Mail</label>
<input cngxInput />
<span cngxHint>Business address</span>
<cngx-field-errors />
</cngx-form-field>Metadata#
Basic input
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { form, FormField } from '@angular/forms/signals';
import { CngxFormField, CngxLabel, CngxHint } from '@cngx/forms/field';
import { CngxInput } from '@cngx/forms/input';
interface ProfileModel {
fullName: string;
}
/**
* The baseline `cngx-form-field` wiring. The component is `display: contents`
* with zero visual footprint; it only coordinates ARIA. `CngxLabel` sets
* `for`/`id`, `CngxInput` mirrors the field state onto the native input as
* ARIA attributes, and `CngxHint` is linked via `aria-describedby`. Value flow
* runs through the Signal Forms `[formField]` binding - cngx never touches the
* value itself.
*/
@Component({
selector: 'app-root',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CngxFormField, CngxLabel, CngxInput, CngxHint, FormField],
styles: `
.demo {
display: grid;
gap: 6px;
max-width: 360px;
padding: 16px;
font: 14px/1.4 system-ui, sans-serif;
}
.demo input {
width: 100%;
padding: 8px 10px;
border: 1px solid #b0b0b0;
border-radius: 4px;
box-sizing: border-box;
font: inherit;
}
.demo label {
font-weight: 500;
}
.demo .readout {
margin: 8px 0 0;
opacity: 0.7;
}
`,
template: `
<div class="demo">
<cngx-form-field [field]="profile.fullName">
<label cngxLabel>Full name</label>
<input cngxInput [formField]="profile.fullName" placeholder="Ada Lovelace" />
<span cngxHint>As it appears on your ID</span>
</cngx-form-field>
<p class="readout">Value: {{ model().fullName || '—' }}</p>
</div>
`,
})
export class BasicInputExample {
protected readonly model = signal<ProfileModel>({ fullName: '' });
protected readonly profile = form(this.model);
}
export { BasicInputExample as AppComponent };
Validation states
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import {
form,
schema,
required,
email,
disabled,
FormField,
type ValidationError,
} from '@angular/forms/signals';
import {
CngxFormField,
CngxLabel,
CngxFieldErrors,
CNGX_FORM_FIELD_CONFIG,
} from '@cngx/forms/field';
import { CngxInput } from '@cngx/forms/input';
interface AccountModel {
displayName: string;
emailAddress: string;
accountId: string;
}
/**
* The three states the presenter derives from a Signal Forms field, each as a
* pure `computed()` reflected onto a `cngx-field--*` host class and onto the
* native input's ARIA attributes:
*
* - **required** - `withRequiredMarker()` (here a `CNGX_FORM_FIELD_CONFIG`
* override) makes `CngxLabel` append the marker for fields with a `required`
* validator.
* - **error** - the gate is `invalid AND (touched OR submitted)`. The email
* field is pre-touched with an invalid value, so the message shows on load.
* - **disabled** - a schema `disabled()` rule; `CngxInput` mirrors the native
* `disabled` attribute.
*
* The readout under each field renders those derived signals live - `required`,
* `touched`, `valid`, `errors` from the field state, and `errorState` /
* `disabled` from the `#ci="cngxInput"` directive. Nothing is synced by hand.
*/
@Component({
selector: 'app-root',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CngxFormField, CngxLabel, CngxInput, CngxFieldErrors, FormField],
providers: [{ provide: CNGX_FORM_FIELD_CONFIG, useValue: { requiredMarker: '*' } }],
styles: `
.demo {
display: grid;
gap: 22px;
max-width: 440px;
padding: 16px;
font: 14px/1.4 system-ui, sans-serif;
}
.demo .field {
display: grid;
gap: 6px;
}
.demo label {
font-weight: 500;
}
.demo input {
width: 100%;
padding: 8px 10px;
border: 1px solid #b0b0b0;
border-radius: 4px;
box-sizing: border-box;
font: inherit;
}
.demo .cngx-field--error input {
border-color: var(--cngx-field-error-color, #c0392b);
}
.demo .cngx-field--disabled input {
opacity: 0.5;
}
.demo .state {
margin: 4px 0 0;
display: grid;
grid-template-columns: max-content 1fr;
gap: 3px 14px;
font: 12px/1.5 ui-monospace, monospace;
}
.demo .state dt {
margin: 0;
opacity: 0.55;
}
.demo .state dd {
margin: 0;
font-weight: 600;
color: #1a56c4;
}
`,
template: `
<div class="field">
<cngx-form-field [field]="f.displayName">
<label cngxLabel>Display name</label>
<input cngxInput #nameCi="cngxInput" [formField]="f.displayName" />
<cngx-field-errors />
</cngx-form-field>
<dl class="state">
<dt>required</dt><dd>{{ f.displayName().required() }}</dd>
<dt>touched</dt><dd>{{ f.displayName().touched() }}</dd>
<dt>valid</dt><dd>{{ f.displayName().valid() }}</dd>
<dt>errorState</dt><dd>{{ nameCi.errorState() }}</dd>
<dt>errors</dt><dd>{{ kinds(f.displayName().errors()) }}</dd>
</dl>
</div>
<div class="field">
<cngx-form-field [field]="f.emailAddress">
<label cngxLabel>Email</label>
<input cngxInput #emailCi="cngxInput" [formField]="f.emailAddress" />
<cngx-field-errors />
</cngx-form-field>
<dl class="state">
<dt>required</dt><dd>{{ f.emailAddress().required() }}</dd>
<dt>touched</dt><dd>{{ f.emailAddress().touched() }}</dd>
<dt>valid</dt><dd>{{ f.emailAddress().valid() }}</dd>
<dt>errorState</dt><dd>{{ emailCi.errorState() }}</dd>
<dt>errors</dt><dd>{{ kinds(f.emailAddress().errors()) }}</dd>
</dl>
</div>
<div class="field">
<cngx-form-field [field]="f.accountId">
<label cngxLabel>Account ID</label>
<input cngxInput #idCi="cngxInput" [formField]="f.accountId" />
</cngx-form-field>
<dl class="state">
<dt>disabled</dt><dd>{{ idCi.disabled() }}</dd>
<dt>empty</dt><dd>{{ idCi.empty() }}</dd>
<dt>valid</dt><dd>{{ f.accountId().valid() }}</dd>
</dl>
</div>
`,
})
export class ValidationStatesExample {
protected readonly model = signal<AccountModel>({
displayName: '',
emailAddress: 'not-an-email',
accountId: 'ACC-100425',
});
protected readonly f = form(
this.model,
schema<AccountModel>((root) => {
required(root.displayName, { message: 'Display name is required' });
required(root.emailAddress, { message: 'Email is required' });
email(root.emailAddress, { message: 'Enter a valid email address' });
disabled(root.accountId);
}),
);
constructor() {
// Pre-touch so the invalid email shows its error on load.
this.f.emailAddress().markAsTouched();
}
/** Joins the active error kinds for the readout, or a dash when valid. */
protected kinds(errors: readonly ValidationError[]): string {
return errors.length ? errors.map((e) => e.kind).join(', ') : '—';
}
}
export { ValidationStatesExample as AppComponent };
Form-error summary
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { form, schema, required, email, FormField } from '@angular/forms/signals';
import { CngxFormField, CngxLabel, CngxFieldErrors, CngxFormErrors } from '@cngx/forms/field';
import { CngxInput } from '@cngx/forms/input';
interface SignupModel {
email: string;
password: string;
}
/**
* `cngx-form-errors` is the form-level summary (WCAG 3.3.1): on a rejected
* submit it lists every invalid field as a focusable link, each calling
* `focusBoundControl()` so a screen-reader user lands on the offending input.
* It is a sibling of the `cngx-form-field` blocks, not a child - it aggregates
* across the whole form. The summary stays hidden until `[show]` flips. Click
* Validate to mark every field touched and reveal both the per-field errors and
* the summary.
*/
@Component({
selector: 'app-root',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CngxFormField, CngxLabel, CngxInput, CngxFieldErrors, CngxFormErrors, FormField],
styles: `
.demo {
display: grid;
gap: 16px;
max-width: 380px;
padding: 16px;
font: 14px/1.4 system-ui, sans-serif;
}
.demo .field {
display: grid;
gap: 6px;
}
.demo label {
font-weight: 500;
}
.demo input {
width: 100%;
padding: 8px 10px;
border: 1px solid #b0b0b0;
border-radius: 4px;
box-sizing: border-box;
font: inherit;
}
.demo button {
justify-self: start;
padding: 8px 16px;
border: 1px solid #888;
border-radius: 4px;
background: #f4f4f4;
cursor: pointer;
font: inherit;
}
`,
template: `
<div class="demo">
<cngx-form-errors [fields]="[f.email, f.password]" [show]="showErrors()" />
<div class="field">
<cngx-form-field [field]="f.email">
<label cngxLabel>Email</label>
<input cngxInput [formField]="f.email" />
<cngx-field-errors />
</cngx-form-field>
</div>
<div class="field">
<cngx-form-field [field]="f.password">
<label cngxLabel>Password</label>
<input cngxInput type="password" [formField]="f.password" />
<cngx-field-errors />
</cngx-form-field>
</div>
<button type="button" (click)="validate()">Validate</button>
</div>
`,
})
export class FormErrorSummaryExample {
protected readonly model = signal<SignupModel>({ email: '', password: '' });
protected readonly f = form(
this.model,
schema<SignupModel>((root) => {
required(root.email, { message: 'Email is required' });
email(root.email, { message: 'Enter a valid email address' });
required(root.password, { message: 'Password is required' });
}),
);
protected readonly showErrors = signal(false);
protected validate(): void {
this.f.email().markAsTouched();
this.f.password().markAsTouched();
this.showErrors.set(true);
}
}
export { FormErrorSummaryExample as AppComponent };
Input add-ons
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { form, FormField } from '@angular/forms/signals';
import { CngxFormField, CngxLabel, CngxHint } from '@cngx/forms/field';
import { CngxInput, CngxCharCount, CngxPasswordToggle } from '@cngx/forms/input';
interface ComposeModel {
bio: string;
password: string;
}
/**
* Input-level atoms composing with `cngxInput` inside a `cngx-form-field`:
*
* - `cngx-char-count` reads the bound field value and reports length against
* `[max]` - no manual wiring.
* - `cngxPasswordToggle` exposes `visible()` and `toggle()` for a consumer-owned
* reveal button; the directive flips the native input `type` between
* `password` and `text`.
*
* Both are behaviour-only directives; the field still owns the value via
* `[formField]`.
*/
@Component({
selector: 'app-root',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CngxFormField,
CngxLabel,
CngxHint,
CngxInput,
CngxCharCount,
CngxPasswordToggle,
FormField,
],
styles: `
.demo {
display: grid;
gap: 20px;
max-width: 380px;
padding: 16px;
font: 14px/1.4 system-ui, sans-serif;
}
.demo .field {
display: grid;
gap: 6px;
}
.demo label {
font-weight: 500;
}
.demo input,
.demo textarea {
width: 100%;
padding: 8px 10px;
border: 1px solid #b0b0b0;
border-radius: 4px;
box-sizing: border-box;
font: inherit;
}
.demo .count-row {
display: flex;
justify-content: space-between;
align-items: center;
opacity: 0.7;
}
.demo .pwd-row {
display: flex;
gap: 8px;
align-items: center;
}
.demo .pwd-row input {
flex: 1;
}
.demo button {
padding: 6px 12px;
border: 1px solid #888;
border-radius: 4px;
background: #f4f4f4;
cursor: pointer;
font: inherit;
}
`,
template: `
<div class="demo">
<div class="field">
<cngx-form-field [field]="f.bio">
<label cngxLabel>Bio</label>
<textarea cngxInput [formField]="f.bio" rows="3" placeholder="Tell us about yourself…"></textarea>
<div class="count-row">
<span cngxHint>10-140 characters</span>
<cngx-char-count [max]="140" />
</div>
</cngx-form-field>
</div>
<div class="field">
<cngx-form-field [field]="f.password">
<label cngxLabel>Password</label>
<div class="pwd-row">
<input
cngxInput
cngxPasswordToggle
#pwd="cngxPasswordToggle"
[formField]="f.password"
placeholder="At least 8 characters"
/>
<button
type="button"
(click)="pwd.toggle()"
[attr.aria-label]="pwd.visible() ? 'Hide password' : 'Show password'"
>
{{ pwd.visible() ? 'Hide' : 'Show' }}
</button>
</div>
<span cngxHint>8-64 characters</span>
</cngx-form-field>
</div>
</div>
`,
})
export class InputAddonsExample {
protected readonly model = signal<ComposeModel>({ bio: '', password: '' });
protected readonly f = form(this.model);
}
export { InputAddonsExample as AppComponent };