There is no FormBuilder here. A form is derived from a state — its field
tree, its validity and its error types are all consequences of that state and of
the mutation it submits to, so they cannot drift apart from them.
Use it when you collect input that needs validation and a typed submission.
Not when a single input maps to a single state — a plain
state with a set is enough.
::: tip Start with the guided version Learn step 8 builds a small form end to end before you dig into the individual insertions. :::
Three pillars, all of which follow from deriving rather than declaring:
All of this is possible because the logic is entirely derived from the state.
Form insertions enable modular composition of functionality:
The primary insertion that creates an Angular Signal-Form from a primitive.
import { craftUse, state } from '@craft-ng/core';
import {
insertForm,
insertFormAttributes,
insertNoopTypingAnchor,
insertSelectFormTree,
cRequired,
cEmail,
} from '@craft-ng/core';
const userFormState = craftUse(
state(
'userFormState',
{ name: '', email: '' },
insertForm(
insertSelectFormTree(
'name',
insertNoopTypingAnchor, // TS limitation
insertFormAttributes(() => ({
validators: [cRequired()],
})),
),
insertSelectFormTree(
'email',
insertNoopTypingAnchor, // TS limitation
insertFormAttributes(() => ({
validators: [cRequired(), cEmail()],
})),
),
),
),
);
const form = userFormState.form;
const nameField = form.selectName();
const emailField = form.selectEmail();
Note: It only works with the
stateprimitive from now.
insertNoopTypingAnchoris a special insertion that does not add any logic but allows to anchor the typing of the form field. It is required for the form system to infer the correct types of fields and exceptions. (TS limitations…)
Adds attributes and validators to a form field.
const formState = craftUse(
state(
'formState',
{ email: '' },
insertForm(
insertSelectFormTree(
'email',
insertNoopTypingAnchor,
insertFormAttributes(() => ({
validators: [cRequired(), cEmail()],
disable: () => isLoading(),
hidden: () => !showField(),
})),
),
),
),
);
// Access email field and its exceptions
const form = formState.form;
const emailField = form.selectEmail();
const errors = emailField()().exceptions.list; // fully typed list of exceptions
const emailError = emailField()().exceptions.byValidator['cEmail'];
CraftFieldDirective is the DOM adapter for a CraftField. It binds the field
in both directions, marks it touched on blur, and reflects field state through
native attributes and craft-* CSS classes.
In a Craft template, apply the functional directive to the concrete node:
import { CraftFieldDirective } from '@craft-ng/core';
input({
type: 'email',
}).pipe(CraftFieldDirective(loginForm.form.selectEmail()));
insertSelectFormTree materializes its branch lazily. When validators or other
insertions are attached through it, bind the field returned by selectEmail()
(or the corresponding selectXxx() method). Binding the raw
loginForm.form.email field bypasses that materialization, so those insertions
are not registered.
The directive supports text inputs and textareas, numeric and temporal inputs,
checkboxes, radio groups and selects. Validators also project native constraints
such as required, min, max, minlength and maxlength.
Angular templates can keep using the deprecated compatibility wrapper during the migration:
import { LegacyCraftFieldDirective } from '@craft-ng/core';
@Component({
imports: [LegacyCraftFieldDirective],
template: ` <input type="email" [craftField]="emailField" /> `,
})
export class LoginComponent {
protected readonly emailField = this.loginForm.form.selectEmail();
}
For a custom Angular control, provide CRAFT_FIELD_VALUE_CONTROL or
CRAFT_FIELD_CHECKBOX_CONTROL on the host component and use the compatibility
wrapper. Native Craft nodes use the functional directive directly.
fieldExceptionBlock.exhaustive turns validation cases carried by
CraftFieldDirective or exposed by the component logic into compile-time UI
obligations. Every reachable code must have one handler, and an unreachable
handler is also rejected.
import { fieldExceptionBlock, input, p } from '@craft-ng/component';
input({ id: 'email', type: 'email' })
.pipe(CraftFieldDirective(loginForm.form.selectEmail()))
.pipe(
fieldExceptionBlock.exhaustive({
required: () => p('Email is required.'),
email: () => p('Enter a valid email.'),
}),
);
The field stays mounted and invalid while a message is visible. The block adds
and merges aria-invalid and aria-describedby; it does not throw an
exception or feed route handleExceptions.
Use fieldExceptionBlock.partial when only some codes belong near the field.
Handled codes are removed from its contract and the remaining codes continue
to the next field-exception boundary:
input({ id: 'password', type: 'password' })
.pipe(CraftFieldDirective(loginForm.form.selectPassword()))
.pipe(
fieldExceptionBlock.partial({
required: () => p('Password is required.'),
}),
);
Here password.required is handled locally, while password.minLength must
still be handled by an enclosing partial or exhaustive block. A partial
block may omit reachable codes, but an unreachable handler remains a TypeScript
error.
At a component boundary, group handlers by static field path. Identical codes on different fields remain separate obligations:
const SafeLoginForm = BaseLoginForm.pipe(
fieldExceptionBlock.exhaustive({
email: {
required: () => p('Email is required.'),
email: () => p('Enter a valid email.'),
},
password: {
required: () => p('Password is required.'),
minLength: ({ exception }) =>
p(`Use at least ${exception.payload} characters.`),
},
}),
);
Object branches may also carry group or cross-field validators. Materialize the branch in the component logic and return it from the factory:
const credentials = registration.form.selectCredentials();
return { registration, credentials };
Its cases, for example credentials.passwordMismatch, are part of the
component contract even when the group itself is not passed to
CraftFieldDirective. Handle the grouped path on an enclosing template VNode
or with BaseComponent.pipe(fieldExceptionBlock.exhaustive(...)). If it remains
unhandled, rendering, mounting, and loadCraftComponent reject the component
at compile time. See Form exception handling for the
complete group example.
By default the block reads the field’s visibleExceptions directly. The form
owns that visibility policy; the default is touched or submitted:
insertFormAttributes(() => ({
validators: [cRequired(), cEmail()],
exceptionVisibility: { anyOf: ['touched', 'submitted'] },
}));
After a blur, only that field’s visible exceptions are rendered. A submit
attempt reveals the remaining exceptions for every field. Available states are
dirty, touched, and submitted; a block can override
the inherited policy with visibility: 'always', another anyOf combination,
or a predicate. mode is first (validator order) or all, and position is
before or after. Resetting the form clears dirty, touched, and submitted,
so inherited messages are hidden again.
Custom and async validators participate through their declared exception union exactly like built-ins: their codes must be handled even when the current visibility policy hides them.
Adds a form-level StandardSchemaV1 validator. Issues are projected onto the
matching fields by their schema path, while root and unmaterialized issues stay
available through schemaExceptions().
const formState = craftUse(
state(
'formState',
{ email: '' },
insertForm(insertFormSchema(userSchema), insertFormSubmit(saveUser)),
),
);
const form = formState.form;
form.email.errors();
form.hasSchemaExceptions();
form.schemaExceptions();
The form keeps the schema input value. Schema transformations belong at the
submit boundary, for example through the mutation’s methodSchema.
insertFormSubmit connects the form to a mutation. It submits only validated
form values and exposes the mutation’s loading and typed exception state on the
form.
See Submitting a form for the complete submission workflow, including success handling and exception transformations.