Installation
import { WR_FIELD } from 'ngwr/form';
import { WrSchemaForm } from 'ngwr/schema-form';
@Component({
imports: [WrSchemaForm],
})
export class MyComponent {}Basic usage
Describe each field once, next to its rules. <wr-schema-form> walks the field tree, draws the control the kind names inside a <wr-form-field>, and lets the schema own everything else — the * marker, the disabled state, the error copy.
import { form, metadata, required, schema } from '@angular/forms/signals';
import { WR_FIELD } from 'ngwr/form';
interface Signup {
fullName: string;
workEmail: string;
role: string;
}
const signupSchema = schema<Signup>(path => {
required(path.fullName);
metadata(path.fullName, WR_FIELD, () => ({ kind: 'input', placeholder: 'Ada Lovelace' }));
required(path.workEmail);
metadata(path.workEmail, WR_FIELD, () => ({
kind: 'input',
type: 'email',
label: 'Work email',
hint: 'We only use it for the invoice.',
}));
metadata(path.role, WR_FIELD, () => ({ kind: 'select', label: 'Plan', options: PLANS }));
});<wr-schema-form [field]="signup" [columns]="2" />Layout
columns sets the grid; a field's span widens it and is clamped to the grid it sits in, so a two-column spec dropped into a one-column form still lines up.
<wr-schema-form [field]="signup" [columns]="2" />A spec is a function, so it can react
metadata() takes a LogicFn, which means a label, a hint or a set of options may depend on the value of another field — without a branch anywhere in the template.
// The spec is a LogicFn, so it can read the rest of the form. An admin
// sees a longer list, and nothing in the template knows about it.
metadata(path.role, WR_FIELD, ctx => ({
kind: 'select',
options: ctx.stateOf(path.workEmail).value().endsWith('@acme.com') ? ALL_PLANS : PUBLIC_PLANS,
}));What the spec deliberately does not carry
required, disabled, readonly, hidden, min and max are absent from WrFieldSpec, and their absence is the design.
Every one of them is already an Angular schema function that drives the control through [formField]. Repeating them in the spec would be a second source of truth for state the form already owns — and for the bounds it is not even possible: Angular refuses a [min] or [max] binding on a field-bound control outright (NG8022). The one thing this component adds is hidden(), which Angular's own docs say a template has to honour itself; a hidden field is drawn by nobody.
// State lives in the schema, never in the spec.
required(path.workEmail); // draws the * beside the label
disabled(path.seats, ({ valueOf }) => valueOf(path.role) === 'starter');
hidden(path.notes, ({ valueOf }) => !valueOf(path.agree));
min(path.seats, 1);
max(path.seats, 500);
// This does not compile — NG8022. The field owns the bound.
// <wr-input-number [formField]="f.seats" [min]="1" />A field with no spec is skipped
Silently, and on purpose: a schema may describe more than one screen renders, and inferring a control from a field's type would draw a switch for every boolean flag in the model.
That is also the escape hatch. Point [field] at a nested object field to draw one section, and write the fields that need real markup — a colour picker, a transfer list, anything with a layout of its own — by hand beside it. The two halves bind to the same form.
<!-- Draw most of the form, then write the one field by hand. -->
<wr-schema-form [field]="signup.address" [columns]="2" />
<wr-form-field label="Signature">
<wr-color-picker [formField]="signup.brandColor" />
</wr-form-field>A nested group is one form each
The field tree is walked one level deep, so an object-valued field is skipped like any other undescribed field — and its children go with it.
That is the default because a model nests for reasons a screen knows nothing about, and because a group has a presentation of its own — a heading, a fieldset, a column count that need not match its parent's — which the spec does not describe. So a group is drawn by pointing a second <wr-schema-form> at it, with whatever markup belongs around it.
<!-- `address` is a group: the parent draws none of it. -->
<wr-schema-form [field]="signup" [columns]="2" />
<h3>Address</h3>
<wr-schema-form [field]="signup.address" [columns]="2" />Describing the children of a group and then getting none of them is never what anyone meant, and it does not look like a mistake — nothing is missing from the page, so a half-drawn form reads as a finished one. That exact shape warns in development. A group nobody described inside stays silent, because it is a model detail this screen is not rendering.
API
| Name | Description | Type | Default |
|---|---|---|---|
fieldrequired | The form to draw — the value form() returned, or any object-valued field inside one. | FieldTree<T> | — |
columns | Grid columns. A field spans one of them unless its spec says otherwise. | number | 1 |
See also
- Componentwr-form-fieldWhat this component draws around every field. Reach for it directly when you write the markup yourself.
- GuideFormsHow Signal Forms binds to ngwr controls, and which states reach a control on their own.
- Componentwr-selectWhat `kind: 'select'` renders — including the search and multi modes a hand-written field can ask for.
CSS variables
Custom properties ngwr/schema-form publishes. Each default below is declared on the component's own selector, so a :root override is shadowed by it — set them on that selector, on a wrapper you scope yourself, or inline on the element. Unlike the BEM class names, these are the supported way to restyle the component.
| Variable | Default | Declared on |
|---|---|---|
--wr-schema-form-gap | 1rem | .wr-schema-form |