ngx-signal-forms-renderer
v0.0.1-beta.0
Published
Render Angular Signal Forms from a declarative layout.
Maintainers
Readme
ngx-signal-forms-renderer
Render Angular Signal Forms from a declarative layout.
ngx-signal-forms-renderer is a UI-agnostic library that renders a form from a layout
description, powered by Angular Signal Forms (@angular/forms/signals). It separates form
concerns into two layers:
- Signal Forms — data model + validation + metadata (your domain, untouched)
- Layout — structure + which component renders each field (controls, groups, arrays)
You bring your own control components; the renderer wires them to the form.
Installation
npm install ngx-signal-forms-rendererRequires Angular 22+.
Quick Start
1. Register your control components
// app.config.ts
import { provideSignalFormsRenderer } from 'ngx-signal-forms-renderer';
export const appConfig: ApplicationConfig = {
providers: [
provideSignalFormsRenderer({
types: {
text: TextInputComponent,
select: SelectInputComponent,
textarea: TextareaInputComponent,
},
}),
],
};2. Define your form (signal forms)
import { signal } from '@angular/core';
import { form, required, email } from '@angular/forms/signals';
interface UserProfile {
name: string;
email: string;
role: string;
}
const model = signal<UserProfile>({ name: '', email: '', role: 'user' });
const userForm = form(model, (f) => {
required(f.name);
email(f.email);
});3. Describe the layout
Inputs to your components are passed as bindings using inputBinding from @angular/core
(reactive — they also support outputBinding and twoWayBinding).
import { inputBinding } from '@angular/core';
import { layout, control, group } from 'ngx-signal-forms-renderer';
const userLayout = layout(userForm, (f) => [
group(
{
name: control(f.name, { type: 'text', bindings: [inputBinding('label', () => 'Full name')] }),
email: control(f.email, {
type: 'text',
bindings: [inputBinding('label', () => 'Email'), inputBinding('inputType', () => 'email')],
}),
role: control(f.role, {
type: 'select',
bindings: [inputBinding('label', () => 'Role'), inputBinding('options', () => ['user', 'editor', 'admin'])],
}),
},
{ component: CardGroupComponent, bindings: [inputBinding('title', () => 'Personal info')] },
),
]);4. Render it
<sfr-form [form]="userForm" [layout]="userLayout" />Core Concepts
Two-layer architecture
Signal Form (data + validation) Layout (structure + components)
form(model, schema) layout(form, f => [...])
\ /
\ /
+------ <sfr-form> ----------------+
walks the layout tree
resolves each node's component
renders it dynamically with reactive bindingsThe layout never touches your data rules; signal forms never knows about rendering. A control
component you write gets the real FieldTree, so it keeps full access to signal forms features
(validation, hidden(), disabled(), metadata, …) — the renderer stays out of the way.
Layout node kinds
control— a form control. Its component receives thefield(and optionallystate), or implementsFormValueControl<T>.group— a keyed collection of nodes (Record<string, LayoutNode>), optionally wrapped in a component. Can be data-driven from a sub-object field.array— a list of nodes: static, renderer-owned, or library-iterated from an array field.
Component resolution (two-tier)
Every node resolves its component the same way:
- Direct component reference (
component) — highest priority - String
typeresolved via the registry fromprovideSignalFormsRenderer({ types })
control(f.bio, { component: RichTextEditor }); // direct
control(f.name, { type: 'text' }); // from the registryBindings & directives
Layout nodes carry bindings and directives instead of a plain props object — the same vocabulary
the renderer uses internally. inputBinding(name, value) takes a value accessor (() => value):
a signal already is one, so pass it directly; wrap plain/static values in () => value.
import { inputBinding, outputBinding } from '@angular/core';
control(f.email, {
type: 'text',
bindings: [
inputBinding('label', () => 'Email'), // static input value — wrap in an accessor
inputBinding('hint', hintSignal), // a signal is already () => value — pass it directly
outputBinding('focused', onFocus), // output listener — pass the handler directly
],
directives: [TooltipDirective], // host directives, conditionally if you like
});Consumer bindings/directives are merged after the renderer's own (the field/state inputs,
or the FormField directive for FormValueControl components) — don't re-bind those.
Visibility
- A node that maps to a field (any control, or a data-driven group/array) defers to that field's
native signal-forms
hidden(). - A layout-level
hiddensignal (on any node) takes precedence — use it for elements that don't map to a field, or to override.
// In the signal forms schema (semantic — also excluded from validation)
const userForm = form(model, (f) => {
hidden(f.bio, { when: ({ valueOf }) => valueOf(f.role) !== 'admin' });
});
// Layout-level override
group({ /* ... */ }, { hidden: computed(() => userForm.role().value() !== 'admin') });API Reference
You build layouts with the functions below — you don't construct node objects by hand. Every builder accepts the same options object:
| Option | Type | Purpose |
| ------------ | --------------------------------------------------- | ---------------------------------------------------- |
| component | Type<unknown> | Render with this component (takes precedence). |
| type | string | Component registered via provideSignalFormsRenderer. |
| bindings | Binding[] | Inputs/outputs (inputBinding/outputBinding/twoWayBinding). |
| directives | (Type<unknown> \| DirectiveWithBindings<unknown>)[] | Host directives to attach. |
| hidden | Signal<boolean> | Layout-level visibility override. |
The builders return LayoutNodes — the only type you'll reference directly, as the children input
on custom group/array wrapper components.
Builder functions
control(field, options?)
control(f.name);
control(f.name, { type: 'text', bindings: [inputBinding('label', () => 'Name')] });
control(f.name, { component: CustomInput });group(...)
A keyed collection of nodes, optionally wrapped in a component. Two forms:
Explicit children — a fixed set of keyed nodes. Use to group unrelated top-level fields under one wrapper (e.g. a card).
group({ name: control(f.name), email: control(f.email) });
group({ name: control(f.name) }, { component: CardComponent });Data-driven — bound to a sub-object field; the callback receives that object's sub-fields. Use for
a nested object (an address, etc.). The group also defers to the field's native hidden().
group(f.address, (g) => ({ street: control(g.street), city: control(g.city) }));
group(f.address, { component: AddressCard }, (g) => ({ street: control(g.street) }));array(...)
A list of nodes. Four forms, differing in who owns iteration:
Static — a fixed, ordered set of nodes (not driven by an array field). Use for things like wizard steps that just need ordering.
array([control(f.step1), control(f.step2)]);Library-iterated — the renderer iterates the array field and builds one node per item from your
itemLayout. Use for a plain repeating list. Item identity is tracked, so editing/adding/removing/
reordering doesn't recreate (or lose focus in) the other rows.
array(f.items, (item) => group(item, (g) => ({ name: control(g.name) })));Library-iterated + wrapper — same per-item iteration, but the per-item nodes are passed to a
wrapper component as its children. Use when the list needs UI around each item (reorder, remove,
"add"): the library builds each item, your component arranges them and adds the controls.
array(f.items, { component: SortableList }, (item) => group(item, (g) => ({ name: control(g.name) })));Renderer-owned — the component receives the array field itself and does its own iteration and
mutation. Use when you want full control over how the array renders and changes.
array(f.items, { component: ItemsListComponent });layout(form, fn)
Returns a Signal<LayoutNode[]>. Reading signals inside fn makes the layout reactive.
const myLayout = layout(userForm, (f) => [
group({ name: control(f.name, { type: 'text' }) }),
array(f.items, (item) => group(item, (g) => ({ name: control(g.name) }))),
]);Provider
provideSignalFormsRenderer({
types: { text: TextInputComponent, select: SelectInputComponent, card: CardGroupComponent },
});Components
<sfr-form>
| Input | Type | Description |
| -------- | ----------------------- | ------------------------------------------ |
| form | FieldTree<T> | Required. The signal form. |
| layout | Signal<LayoutNode[]> | Required. The signal from layout(). |
<sfr-form [form]="userForm" [layout]="userLayout" /><sfr-children>
Helper for custom group/array components to render their children. Renders no element of its own
(display: contents), so your component's element is the layout container.
@Component({
imports: [SfrChildrenComponent],
template: `
<mat-card>
<mat-card-header><mat-card-title>{{ title() }}</mat-card-title></mat-card-header>
<mat-card-content><sfr-children [children]="children()" /></mat-card-content>
</mat-card>
`,
})
export class CardGroupComponent {
readonly title = input('');
readonly children = input.required<Record<string, LayoutNode>>();
}Component contracts
Control components
The renderer binds field (the FieldTree) and state (the FieldState) only if the component
declares those inputs — so a control opts into exactly what it needs.
Pattern A — wrap an existing UI control (Material, PrimeNG, …) via [formField]:
@Component({
imports: [MatFormField, MatLabel, MatInput, MatError, FormField],
template: `
<mat-form-field>
<mat-label>{{ label() }}</mat-label>
<input matInput [formField]="field()" />
<mat-error>{{ state().errors()[0]?.message }}</mat-error>
</mat-form-field>
`,
})
export class TextInputComponent {
readonly field = input.required<FieldTree<string>>();
readonly state = input.required<FieldState<string>>(); // declare only if you read it
readonly label = input(''); // from bindings
}A control that only binds via [formField] and shows no errors can declare just field.
Pattern B — fully custom control implementing FormValueControl<T>. The renderer attaches the
FormField directive (binding value/touched/etc.), so it receives no field/state:
export class RatingComponent implements FormValueControl<number> {
readonly value = model(0); // required — two-way bound by FormField
readonly label = input(''); // from bindings
}Group / array components
Receive children (and any custom bindings). Render them with <sfr-children>, or iterate for
custom placement.
export class StepperComponent {
readonly children = input.required<LayoutNode[]>();
}Layout composability
Layouts are plain functions returning LayoutNode[], so composition is just TypeScript.
function addressFields(addr: FieldTree<Address>): Record<string, LayoutNode> {
return {
street: control(addr.street, { type: 'text', bindings: [inputBinding('label', () => 'Street')] }),
city: control(addr.city, { type: 'text', bindings: [inputBinding('label', () => 'City')] }),
};
}
const orderLayout = layout(orderForm, (f) => [
group(f.shippingAddress, { component: CardGroupComponent }, addressFields),
group(f.billingAddress, { component: CardGroupComponent }, addressFields),
]);Because the layout is just data + functions, you can also build it from your own schema/config — the demo renders a whole checkout form from a plain-data schema mapped onto these builders.
License
MIT
