# ng-forge Dynamic Forms: Full Documentation > Generated from 63 documentation files. > Source: https://ng-forge.com/dynamic-forms/ --- addons/custom-types --- The shipped types are the universal `text`, `template`, and `component` types plus the per-adapter icon and button types (`mat-icon` / `mat-button` for Material, `bs-icon` / `bs-button` for Bootstrap, `prime-icon` / `prime-button` for PrimeNG, `ion-icon` / `ion-button` for Ionic). When they don't cover your case (a rating widget in the prefix, a status pill in the suffix, a copy-to-clipboard component with bespoke styling), register a custom type. Two independent steps: a runtime registration (`withCustomAddon`) and an optional type-level augmentation. ## 1. Define the addon shape A custom type extends `BaseAddon` and pins `type` to a unique string literal: ```typescript name="rating-addon.ts" import type { BaseAddon } from '@ng-forge/dynamic-forms'; export interface RatingAddon extends BaseAddon { readonly type: 'rating'; readonly value: number; readonly max?: number; } ``` `BaseAddon` carries the universal axes (`slot`, optional `hidden`, optional `className`, optional `disabled`) so you only declare the type-specific fields. ## 2. Build the type component ```typescript name="rating-addon.component.ts" import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; import type { RatingAddon } from './rating-addon'; @Component({ selector: 'app-rating-addon', template: ` @for (i of stars(); track $index) { } `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class RatingAddonComponent { readonly addon = input.required(); protected readonly stars = computed(() => Array.from({ length: this.addon().max ?? 5 }, (_, i) => i)); } ``` The contract: declare `addon: input.required()`. The dispatcher (``) wires the addon object via `[addon]` and forwards the `slot` HTML attribute on the host element. ARIA defaults are owned by your component: decorative types typically set `aria-hidden="true"`; interactive types handle their own labelling. ## 3. Register at the provider level Define the `AddonTypeDefinition` next to the addon shape: ```typescript name="rating-addon.ts" import { DynamicFormError, type AddonTypeDefinition } from '@ng-forge/dynamic-forms'; import type { RatingAddon } from './rating-addon'; export const RATING_KIND: AddonTypeDefinition = { type: 'rating', loadComponent: () => import('./rating-addon.component').then((m) => m.RatingAddonComponent), validate: (addon, fieldKey) => { if (typeof addon.value !== 'number' || addon.value < 0) { throw new DynamicFormError(`Addon 'rating' on field '${fieldKey}' requires a non-negative 'value'.`); } }, }; ``` Then pass it through `withCustomAddon(...)` to `provideDynamicForm` alongside your adapter's field bundle: `loadComponent` returns a Promise; the type component is loaded lazily on first render and cached. `validate` is optional; when provided, the runtime addon validator calls it at config init. Throwing `DynamicFormError` drops the addon with an actionable warning and the form keeps rendering; `validate` is a sanitisation hook, not a hard fail. ## 4. Type-level augmentation (optional but recommended) To make `type: 'rating'` autocomplete inside the field's `addons` array, augment the active adapter's addon-extension seam (`MatAddonExtensions`, `BsAddonExtensions`, `PrimeAddonExtensions`, or `IonAddonExtensions`): The runtime registration and the type-level augmentation are independent; use either or both. Without augmentation, custom types still work at runtime; you lose IDE narrowing on the `addons` array. ## When _not_ to use a custom type - **Static decoration** (pure CSS or text): `type: 'text'` covers it. - **An entirely new field control** (file picker, rich-text editor, color picker): register a custom **field type**, not an addon type. Addons decorate; field types render the primary control. See [Adding custom fields](/recipes/custom-fields). - **One-off behavior** for a specific button: use `action` (code-only) or `actionRef` (registered handler) on a built-in button type. ## Verification checklist When you ship a custom type: 1. The type component declares `addon: input.required()`. 2. `withCustomAddon(...)` is passed to `provideDynamicForm` after the field-type bundle. 3. The runtime `validate` function rejects malformed configs with `DynamicFormError`. 4. The type-level augmentation lives in the same file as the runtime registration so the two stay in sync. 5. ARIA defaults are explicit: `aria-hidden="true"` for decorative types; `aria-label` for types that convey meaning. ## Where to next - **[Building an Adapter](/building-an-adapter)**: adapter authors registering bundled types. - **[Custom field types](/recipes/custom-fields)**: when the "addon" you're building is really a new field control. --- addons/overview --- Addons decorate a field with inline content (a search icon, a clear button, a currency symbol, a password-visibility toggle) placed in the field's `prefix` or `suffix` slot. They're typed, JSON-safe, and ship as a first-class feature of every UI adapter. > **Prerequisites.** Addons attach to a field, not the form. You need a working ng-forge setup first: `provideDynamicForm(...with{Adapter}Fields())` at the application config and a `[dynamic-form]`-bearing form using a field type that supports addons (today: every adapter's `input`, plus Material's `select`, `textarea`, and `datepicker`). See [Getting Started](/getting-started) if you're new to ng-forge. ## Quickstart ## Live example A universal addon (works the same in every adapter, no per-type branching): ## Available types ## How addons render The wrapper is dropped entirely when every addon is reactively hidden: no empty group element. ## Provider setup ## Reactive `hidden` and `disabled` Both axes accept `DynamicValue`: any of `boolean`, `Signal`, or `Observable`. Four equivalent shapes for the same toggle: ```typescript { ..., hidden: true } // Static — JSON-safe. { ..., hidden: signal(false) } // Signal — code-only. { ..., hidden: computed(() => !hasValue()) } // Derived — code-only. { ..., hidden: visibility$ } // Observable — code-only. ``` The classic "show clear button only when input has value" pattern, in your active adapter: When `hidden` resolves to `true` the addon is filtered out of the rendered slot entirely, and it reappears reactively as soon as `hidden` turns falsy again. Authoring forms in JSON? Reactive values can't survive serialization; see [Reactive addons from JSON](/addons/presets-and-actions#reactive-addons-from-json). ## Accessibility Icon types emit `aria-hidden="true"` by default. When the icon conveys meaning (status, action), set `ariaLabel`. Icon-only button types (no `label`) require `ariaLabel`. TypeScript flags it at compile time; at runtime an icon-only addon missing `ariaLabel` is dropped with a `[Dynamic Forms]` warning rather than rendered, and the rest of the form is unaffected. ## Troubleshooting - **Addon doesn't render at all.** Check the console for `[Dynamic Forms]` warnings. Common causes: the active adapter's `with*Fields()` helper isn't in `provideDynamicForm`; the `type` string belongs to a different adapter (e.g. `prime-button` in a Material form); the host field type doesn't support addons. - **Icon-only button has no `ariaLabel`.** TypeScript and the runtime validator both refuse this; set `ariaLabel`. For genuinely decorative icons, prefer `type: 'text'` or the adapter's `*-icon` type. - **`actionRef` warning at click time.** The handler name isn't registered. Did you call `withAddonActions({ runSearch: ... })` for that name? - **Inline function silently dropped.** Configs with `source: 'json'` strip functions on `action`, `hidden`, `disabled`, `loading` at validation time; they can't round-trip through JSON. See [Reactive addons from JSON](/addons/presets-and-actions#reactive-addons-from-json). - **Material `MatFormFieldControl` ContentChild missing.** Caused by wrapping the input in a template that breaks Material's content-projection query. Render `` directly inside ``. ## Where to next 1. **[Presets and Actions](/addons/presets-and-actions)**: built-in click presets (`clear`, `reset`, `paste`, `copy`, `toggle-password-visibility`), `actionRef` for registered handlers, and inline `action` for code-only behaviour. 2. **[Custom Types](/addons/custom-types)**: register your own addon type (rating widget, status pill, anything) with `withCustomAddon(...)` and augment the type-level extensions seam. --- addons/presets-and-actions --- Button addons accept exactly one click variant: `preset`, `actionRef`, or `action`. The variants are mutually exclusive at the type level; setting two is a compile error. ## The three variants Pick the leftmost variant that covers your case. Most addon buttons map to a preset. ## Built-in presets Five presets ship with the library. Adapter input fields provide the built-in preset handler. Material `select`, `textarea`, and `datepicker` support addon rendering, but use `actionRef` or `action` for click behavior: | Preset | Behaviour | | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `'clear'` | Empties the field value. Writes `''` for string fields and `undefined` for non-string fields (numeric, date, object) so the field's declared type is preserved. | | `'reset'` | Restores the field's configured default value from the form's `defaultValues` map (resolved at click time). Falls back to `''` / `undefined` (matching `'clear'`) when no default is reachable. | | `'paste'` | Reads from the system clipboard (`navigator.clipboard.readText()`) and writes the result to the field. | | `'copy'` | Writes the field's current value to the system clipboard (`navigator.clipboard.writeText`). | | `'toggle-password-visibility'` | Flips the host input's `type` between `'password'` and `'text'`. No-op (warning logged) when used outside an input-style field that exposes a type-override token. | All presets are JSON-safe. For form submission, use the dedicated `'submit'` field type; it is intentionally not exposed as a preset. ### Password toggle live demo ### Per-adapter wiring ## Registered handlers (`actionRef`) When you need behavior that goes beyond the presets but want to keep the config JSON-safe, register named handlers at the application root and reference them by string. ```typescript name="app-actions.ts" import { withAddonActions } from '@ng-forge/dynamic-forms'; export const appActions = withAddonActions({ submitDraft: (ctx) => myDraftService.save(ctx.field.key, ctx.value), openPreview: (ctx) => myDialog.open(PreviewDialog, { data: ctx.value }), }); ``` Wire the feature into `provideDynamicForm`: The backend can now ship configs like: ```json { "slot": "suffix", "type": "", "icon": "send", "ariaLabel": "Send", "actionRef": "submitDraft" } ``` ### Type narrowing `withAddonActions(...)` returns a feature whose `__handlerKeys` phantom field captures the registered names; derive the global `DynamicFormActionRegistry` augmentation from it in one line so `actionRef` autocompletes everywhere: ```typescript export const appActions = withAddonActions({ runSearch: (ctx) => { /* … */ }, submitDraft: (ctx) => { /* … */ }, }); declare module '@ng-forge/dynamic-forms' { interface DynamicFormActionRegistry extends Record, true> {} } ``` Each handler receives a discriminated `AddonActionContext`: ```typescript type AddonActionContext = | FieldBoundAddonActionContext // form: ReadonlyFieldTree; setValue: required | OrphanAddonActionContext; // form: null; setValue: absent interface FieldBoundAddonActionContext { readonly field: { readonly key: string; readonly type: string }; readonly form: ReadonlyFieldTree; readonly value: TValue | undefined; readonly setValue: (next: TValue) => void; // <- non-optional once narrowed } ``` Narrow with the `isFieldBoundContext` guard so write-back handlers don't need `ctx.setValue?.(…)` everywhere: ```typescript import { isFieldBoundContext, withAddonActions } from '@ng-forge/dynamic-forms'; withAddonActions({ submit: (ctx) => { if (!isFieldBoundContext(ctx)) return; // orphan — nothing to write to myService.send(ctx.field.key, ctx.value, ctx.setValue); }, }); ``` For broader field state, use the form-tree projection ng-forge already supplies to wrappers; `field.key` is intentionally the only stable identity surface across the addon contract. ## JSON-safety quick reference | Click variant | JSON-safe? | Use when | | ------------- | ---------- | ------------------------------------------------------------------------------- | | `preset` | yes | Behaviour matches one of the five built-ins. | | `actionRef` | yes | Custom behaviour registered once via `withAddonActions`. | | `action` | code-only | Prototypes / scenarios where the config is hand-authored and never round-trips. | Reactive axes are similarly tiered: `boolean` values are JSON-safe. On JSON-source configs the validator strips any function value (signals are functions, so they are stripped too) with a warning; Observables cannot appear in parsed JSON in the first place. See [Reactive addons from JSON](#reactive-addons-from-json) below. ## Inline `action` (code-only) For prototypes or scenarios where the handler can't live in JSON, pass a function directly: ```typescript { slot: 'suffix', type: '', icon: 'add', ariaLabel: 'Append marker', action: (ctx) => { if (!isFieldBoundContext(ctx)) return; // orphan — nothing to write to const current = typeof ctx.value === 'string' ? ctx.value : ''; ctx.setValue(`${current}+`); // narrowed: no optional chain needed }, } ``` The validator drops `action` from JSON-source configs (it can't serialise a function), so reach for `actionRef` if the config might round-trip through a backend. ## Reactive `loading` and `disabled` Button types expose both: - `loading?: DynamicValue`: when truthy, the button shows the adapter's spinner state. Implies disabled. - `disabled?: DynamicValue`: independent of loading; click is a no-op. ```typescript const submitting = signal(false); { slot: 'suffix', type: '', icon: 'send', ariaLabel: 'Send', actionRef: 'submitDraft', loading: submitting, disabled: computed(() => !canSubmit()), } ``` ## Multi-set rule Exactly one of `preset` / `actionRef` / `action` may be set. The TypeScript types enforce this via an XOR union; when an addon smuggles multiple variants past the type checker, the runtime validator keeps the highest-precedence one (`preset`, then `actionRef`, then `action`), strips the rest, and logs a warning. Decorative buttons that simply look like buttons but do nothing are valid; omit all three. ## Reactive addons from JSON JSON cannot carry `Signal` / `Observable` / function values, so two questions come up when you ship configs from a backend: 1. **How do I express `hidden`/`disabled`/`loading` reactivity?** 2. **What survives the round-trip?** Three patterns, in order of preference: 1. **Pre-process the JSON in app code.** Before passing the parsed config to `DynamicForm`, walk the parsed tree and replace reactive axes with `computed(...)` against your app's signals. This keeps the wire format JSON-safe and the runtime reactive; your bridge code is the only place that needs to know app state. ```typescript const config = JSON.parse(jsonFromApi) as FormConfig; // Locate the target addon and overwrite its `hidden` axis with a Signal/Observable. const search = config.fields?.find((f) => f.key === 'search'); const clearAddon = search?.addons?.find((a) => a.slot === 'suffix'); if (clearAddon) { (clearAddon as { hidden: unknown }).hidden = computed(() => !hasValue()); } ``` 2. **Express the gate as a form-level derivation or condition.** When the reactive axis depends on form values rather than out-of-band app state, model it as a derivation on the host field's `logic` block. The condition lives in JSON (it's a string-expression DSL) and the addon stays static. 3. **Skip reactivity at the addon layer.** If the addon's visibility is purely a function of static form metadata, render it unconditionally and let the field's own validation/state hide the value semantically. Reach for this when (1) and (2) feel heavy. Functions on `hidden` / `disabled` / `loading` / `action` are stripped from JSON-source configs at validation time with a logged warning; you'll see them in the console if a config carries an inline function. `preset` and `actionRef` are the JSON-safe escape hatches for behaviour; `computed`/`Observable` are the code-side escape hatches for reactivity. ## Where to next - **[Custom Types](/addons/custom-types)**: when none of the built-in types fit, register your own type component and augment the type registry. - **[Migrating from ngx-formly](/migrating-from-ngx-formly#addons-prefix-suffix-slots)**: concept mapping for users coming from formly's per-adapter addon shapes. --- ai-integration/mcp-server --- # IDE Usage (MCP) The `@ng-forge/dynamic-form-mcp` package provides a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that enables AI assistants to generate, validate, and work with ng-forge dynamic form configurations. This page covers authoring time: an assistant in your editor writing form configs for you. For runtime, where an agent in the browser fills and submits a form your users are looking at, see [WebMCP](/ai-integration/webmcp). ## Available Tools The MCP server provides 5 focused tools: | Tool | Description | Read-only | | ------------------ | ----------------------------------------------------------- | --------- | | `ngforge_lookup` | Get documentation about field types, concepts, and patterns | ✅ | | `ngforge_examples` | Get working code examples for common form patterns | ✅ | | `ngforge_validate` | Validate FormConfig and get detailed error feedback | ✅ | | `ngforge_scaffold` | Generate valid FormConfig skeletons | ✅ | | `ngforge_search` | Keyword search across all documentation topics and examples | ✅ | ## Get Started ### Cursor Add to your Cursor MCP settings: ```json { "ng-forge": { "command": "npx", "args": ["-y", "@ng-forge/dynamic-form-mcp"] } } ``` ### VS Code with Copilot Create `.vscode/mcp.json` in your project: ```json { "servers": { "ng-forge": { "command": "npx", "args": ["-y", "@ng-forge/dynamic-form-mcp"] } } } ``` ### Claude Desktop Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS): ```json { "mcpServers": { "ng-forge": { "command": "npx", "args": ["-y", "@ng-forge/dynamic-form-mcp"] } } } ``` ### JetBrains IDEs Go to **Settings > Tools > AI Assistant > Model Context Protocol (MCP)** and add: | Field | Value | | --------- | ----------------------------- | | Name | ng-forge | | Command | npx | | Arguments | -y @ng-forge/dynamic-form-mcp | --- ## Tool Reference ### ngforge_lookup Get documentation about any ng-forge topic. | Parameter | Type | Default | Description | | --------------- | --------------------------------------------------------- | ---------- | ----------------------- | | `topic` | string | (required) | Topic to look up | | `depth` | `"brief"` \| `"full"` \| `"schema"` | `"full"` | Level of detail | | `uiIntegration` | `"material"` \| `"bootstrap"` \| `"primeng"` \| `"ionic"` | - | Filter UI-specific info | **Available Topics:** | Category | Topics | | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Field Types | `input`, `select`, `slider`, `radio`, `checkbox`, `multi-checkbox`, `textarea`, `datepicker`, `toggle`, `text`, `hidden`, `button`, `submit`, `next`, `previous`, `add-array-item`, `prepend-array-item`, `insert-array-item`, `remove-array-item`, `pop-array-item`, `shift-array-item` | | Containers | `group`, `row`, `array`, `simplified-array`, `page` | | Wrappers | `wrappers`, `css`, `arraySection`, `section` | | Addons | `addons`, `template`, `component`, `mat-icon`, `mat-button`, `bs-icon`, `bs-button`, `prime-icon`, `prime-button`, `ion-icon`, `ion-button` | | Concepts | `validation`, `validation-messages`, `conditional`, `derivation`, `property-derivation`, `options-format`, `expression-variables`, `async-validators`, `buttons`, `external-data` | | Patterns | `golden-path`, `multi-page-gotchas`, `pitfalls`, `field-placement`, `logic-matrix`, `context-api`, `containers`, `array-buttons`, `custom-validators`, `conditions`, `common-expressions`, `type-narrowing`, `workflow` | Wrapper and addon topics are read from the registry at runtime, so use `topic="list"` to get the complete, current set with descriptions. **Examples:** ``` ngforge_lookup topic="hidden" depth="brief" ngforge_lookup topic="conditional" depth="full" ngforge_lookup topic="input" depth="schema" uiIntegration="material" ``` --- ### ngforge_examples Get working code examples for common patterns. | Parameter | Type | Default | Description | | --------- | ----------------------------------------------------- | ---------- | ------------------------------------------------------------- | | `pattern` | string | (optional) | Pattern name; omit it (or pass `"list"`) to list all patterns | | `depth` | `"minimal"` \| `"brief"` \| `"full"` \| `"explained"` | `"full"` | Level of detail | **Available Patterns:** | Pattern | Description | Lines | | -------------------------- | -------------------------------------------------- | ----- | | `minimal-multipage` | Simplest 2-page wizard form | ~50 | | `minimal-array` | Array with add/remove buttons | ~30 | | `minimal-conditional` | Show/hide field based on condition | ~25 | | `minimal-validation` | Password confirmation validation | ~20 | | `minimal-hidden` | Hidden fields in multi-page form | ~15 | | `minimal-simplified-array` | Simplified array with template and value | - | | `derivation` | Value derivation (computed fields) | - | | `property-derivation` | Dynamic field properties (label, options, min/max) | - | | `conditional` | Conditional visibility patterns | - | | `multi-page` | Multi-step wizard forms | - | | `validation` | Form validation patterns | - | | `complete` | Full form with all major features | - | | `mega` | Kitchen sink demonstrating every feature | - | **Examples:** ``` ngforge_examples pattern="minimal-multipage" depth="minimal" ngforge_examples pattern="conditional" depth="explained" ``` --- ### ngforge_validate Validate FormConfig and get detailed error feedback. | Parameter | Type | Default | Description | | --------------- | --------------------------------------------------------- | ------------ | ------------------------------ | | `config` | string \| object | (required) | File path or JSON config | | `uiIntegration` | `"material"` \| `"bootstrap"` \| `"primeng"` \| `"ionic"` | `"material"` | UI library to validate against | **Input Detection:** | Input | Treated As | | ------------------------ | ------------------ | | Ends with `.ts` or `.js` | File path | | Starts with `{` or `[` | JSON string | | Object | Validated directly | **Example Errors:** - "Hidden field missing REQUIRED value property" - "options MUST be at FIELD level, NOT inside props" - "Containers (group, row, array) only support 'hidden' logic type" **Examples:** ``` ngforge_validate config="/path/to/form.config.ts" ngforge_validate config='{"fields":[...]}' uiIntegration="bootstrap" ``` --- ### ngforge_scaffold Generate valid FormConfig skeletons. | Parameter | Type | Default | Description | | --------------- | -------- | ------------ | ------------------------------------- | | `pages` | number | `0` | Number of pages (0 = single-page) | | `arrays` | string[] | `[]` | Array field names | | `groups` | string[] | `[]` | Group field names | | `hidden` | string[] | `[]` | Hidden fields as `"name:value"` pairs | | `fields` | string[] | `[]` | Fields as `"name:type"` pairs | | `uiIntegration` | enum | `"material"` | UI library | **Supported Types for `fields`:** `input`, `select`, `radio`, `checkbox`, `textarea`, `datepicker`, `slider`, `toggle` **Examples:** ``` ngforge_scaffold pages=0 fields=["name:input","email:input"] ngforge_scaffold pages=3 arrays=["contacts"] groups=["address"] ngforge_scaffold hidden=["userId:abc123","source:web"] ``` --- ## MCP Resources In addition to tools, the server exposes resources that AI can read: | Resource URI | Description | | ---------------------------------- | -------------------------------------------------------------- | | `ng-forge://instructions` | Best practices guide for generating FormConfig | | `ng-forge://schemas` | Schema reference (UI integrations, field types, tool overview) | | `ng-forge://examples` | Curated FormConfig examples | | `ng-forge://examples/{id}` | Specific example by ID | | `ng-forge://field-types` | Field type reference | | `ng-forge://field-types/{type}` | Details for a specific field type | | `ng-forge://validators` | Validator reference | | `ng-forge://validators/{type}` | Details for a specific validator | | `ng-forge://wrappers` | Wrapper reference | | `ng-forge://wrappers/{type}` | Details for a specific wrapper | | `ng-forge://ui-adapters` | UI library configurations | | `ng-forge://ui-adapters/{library}` | Details for a specific UI library | | `ng-forge://docs` | Full documentation index | | `ng-forge://docs/{topic}` | Documentation for a specific topic | Category-filtered URIs also exist for field types, validators, and wrappers (`ng-forge://field-types/category/{category}`, and the equivalents for validators and wrappers). --- ## Feedback Found an issue or have a suggestion? [Open an issue on GitHub](https://github.com/ng-forge/ng-forge/issues). --- ai-integration/skills --- # Agent Skill An [agent skill](https://code.claude.com/docs/en/skills) is a set of instructions your coding assistant loads when it recognises the task. The ng-forge skill teaches an assistant how to write FormConfig objects, and tells it to check its own work with a real validator. It is plain markdown. No process runs, no port opens, and nothing needs approval beyond letting files into the repository. That makes it the route for teams who cannot run an MCP server, whether because security review has not cleared it, the IDE is locked down, or the policy is simply no. ## Install Two skills: the core one, and one for the UI adapter your project uses. ```bash npx skills add ng-forge/ng-forge --skill ng-forge-dynamic-forms npx skills add ng-forge/ng-forge --skill ng-forge-dynamic-forms-material ``` Swap `material` for `bootstrap`, `primeng` or `ionic`. The split follows the packages you already depend on: field types, validation and the authoring rules are identical across adapters and live in the core skill, while each adapter skill carries only the `props` that adapter adds. Installing all four would describe three sets of properties your project does not have. The [installer](https://github.com/vercel-labs/skills) supports Claude Code, Cursor, Codex, OpenCode and others, and can install per project or globally. There is no registry involved: it reads this repository directly over git. If you would rather not use the installer, copy `skills/dynamic-forms/` and `skills/dynamic-forms-/` out of the repository by hand. ## What it contains `SKILL.md` is deliberately short. The detail sits in reference files the assistant reads only when it needs them. | File | Contents | | --------------------------- | ---------------------------------------------------------------------- | | `SKILL.md` | The rules that get broken most often, and the write-then-validate loop | | `references/rules.md` | The full authoring contract, including expression syntax and i18n | | `references/field-types.md` | Every field type, its props, and where it may be nested | | `references/patterns.md` | Working configurations to adapt | | `references/pitfalls.md` | The error-to-fix table | All of it is generated from the same registries that back the [MCP server](/ai-integration/mcp-server), so the two cannot drift apart. Every configuration in `patterns.md` is checked against the real schema by the test suite. ## The validation loop An assistant's confidence in its own output is not evidence. The skill's central instruction is to run a validator, which ships as a command: ```bash npx --yes @ng-forge/dynamic-forms-cli@next "src/**/*.form.ts" --ui material ``` Requires Node 24 or newer. `--yes` keeps npx from pausing on its first-install prompt, which matters when an agent runs the command. `@next` is where the executable is published; `latest` still points at a placeholder release that ships no binary. It finds every FormConfig in the matched files, validates it against the schema for your adapter, and reports the exact property that is wrong along with the fix. This is the same validation the MCP server performs, because both call the same package. ``` # Validation Report **File:** src/app/checkout/checkout.form.ts **UI Integration:** material ## Found 1 FormConfig(s) ### 2 Error(s) Found #### checkoutForm (line 8): Invalid - **fields[3].props.options:** "options" MUST be at FIELD level, NOT inside props! - **Fix:** Move `options` from `props: { options: [...] }` to field level: `{ key, type, options: [...] }` - **fields[7].value:** Hidden field "token" is MISSING REQUIRED "value" property. - **Fix:** Hidden fields REQUIRE a `value` property. Add: `value: "your-value-here"` ``` ### Options | Flag | Default | Description | | ------------------------ | ---------- | ----------------------------------------------------------------------------- | | `-u, --ui ` | `material` | One of `material`, `bootstrap`, `primeng`, `ionic` | | `--json` | off | Emit machine-readable JSON instead of the report | | `-q, --quiet` | off | Only print failures | | `--require-config` | off | Fail when the matched files contain no FormConfig | | `--tsconfig ` | discovered | tsconfig used to resolve types; found from the working directory when omitted | | `-v, --version` | — | Print this CLI's version | ### Exit codes | Code | Meaning | | ---- | ------------------------------------------------------------ | | `0` | Every config valid, or no configs found in the matched files | | `1` | At least one config failed validation | | `2` | Unknown UI integration, or no files matched | Code `2` is separate from `1` so a typo in a glob does not read as a passing run. One case to know about before relying on this as a gate: if files match but none of them contains a FormConfig the extractor recognises, the command reports that on stderr and still exits `0`. Pass `--require-config` to make that a failure, which is what the CI snippet below does. Without it, a refactor that moves configs somewhere the extractor cannot see them turns the gate quiet rather than red. ```yaml - name: Validate form configs run: npx --yes @ng-forge/dynamic-forms-cli@next "src/**/*.form.ts" --ui material --quiet --require-config ``` ## What the compiler already covers For configs written in TypeScript, `as const satisfies FormConfig` plus a typecheck catches a large share of mistakes before any of this runs. Use both. The CLI exists for what the compiler cannot see: structural rules such as containers accepting only `hidden` logic, hidden fields requiring a `value`, and configs that are not TypeScript at all. What it does not currently catch is a property nobody declared. The schemas pass unknown keys through, so an invented field option, or a validator whose value is the wrong type, is reported as valid. That matters most for AI-generated configs, where inventing a plausible-looking property is a common failure. Treat a clean run as "the structure is right", not as "every key here exists". ## Configs that arrive at runtime If your forms come from an API or a CMS, neither the compiler nor the CLI can reach them. Validate them where they land: ```typescript import { validateFormConfig } from '@ng-forge/dynamic-forms-cli/validate'; const result = validateFormConfig('material', configFromApi); if (!result.valid) { console.error(result.errorSummary); } ``` The same package generates JSON Schema, which is useful for constraining an LLM's structured output to configs that will actually render: ```typescript import { getFormConfigJsonSchema } from '@ng-forge/dynamic-forms-cli/validate'; const schema = getFormConfigJsonSchema('material'); ``` ## Reading the docs directly The whole documentation site is published in a form assistants can read without any integration: | File | Contents | | ------------------------------------------------------------------- | ----------------------- | | [`llms.txt`](https://ng-forge.com/dynamic-forms/llms.txt) | Index of every page | | [`llms-full.txt`](https://ng-forge.com/dynamic-forms/llms-full.txt) | Full text of every page | ## Compared to the MCP server | MCP tool | Equivalent with the skill | | ------------------ | ---------------------------------------------------------------------------- | | `ngforge_validate` | `npx --yes @ng-forge/dynamic-forms-cli@next`, or its `/validate` entry point | | `ngforge_lookup` | `references/rules.md` and `references/field-types.md`, or `llms-full.txt` | | `ngforge_examples` | `references/patterns.md` | | `ngforge_scaffold` | The patterns, adapted by the assistant | | `ngforge_search` | No direct equivalent. Use the docs site search | The skill loses interactive lookup: the assistant reads whole reference files rather than querying for one topic. In exchange it needs no server, works across assistants, and its validation runs in CI. ## Versioning `npx skills add` installs a snapshot, and nothing links it to the version of ng-forge you have installed. The skill states which version it documents and tells the assistant to check the installed version first, so a mismatch surfaces as a warning rather than as confidently wrong output. Re-run the install command after upgrading. --- ai-integration/webmcp --- # WebMCP [WebMCP](https://developer.chrome.com/docs/ai/webmcp) lets a web page offer structured tools to an AI agent running in the browser. Instead of the agent guessing at your DOM and simulating clicks, it calls a function you declared, with arguments described by a schema. ng-forge can generate those tools from a form config. Because the config already carries labels, option lists, and validators, the schema an agent receives is far richer than one inferred from the form's runtime values. > [!WARNING] > This is experimental in the strongest sense. WebMCP is a proposed standard, not a shipped one, and the browser surface underneath this feature can still change. The API is named `withExperimentalWebMcp()` so that is visible at the call site. Expect breaking changes outside a major version. ## Browser support and page requirements | Requirement | Why | | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | Chrome 149 or later | The API is in origin trial. Earlier versions expose no model context at all. Verified against Chrome 150. | | Origin trial token, or `chrome://flags/#enable-webmcp-testing` locally | Without one, `document.modelContext` is undefined. | | A secure context | `https`, or `localhost` during development. Cross-origin isolation is not required, so no `COOP`/`COEP` headers are needed. | | The `tools` Permissions Policy | Allowed on the top-level document by default. An iframe needs `allow="tools"`. | Where any of these is missing, nothing is registered and nothing breaks. The form renders and behaves exactly as it would without the feature, and `webMcpStatus()` on the component reports `unsupported`. For exploratory testing, the [Model Context Tool Inspector](https://github.com/beaufortfrancois/model-context-tool-inspector) extension lists a page's tools and calls them. ## Setup Add the feature to your providers, then opt individual forms in. ```typescript import { provideDynamicForm, withExperimentalWebMcp } from '@ng-forge/dynamic-forms'; import { withMaterialFields } from '@ng-forge/dynamic-forms-material'; export const appConfig: ApplicationConfig = { providers: [provideDynamicForm(...withMaterialFields(), withExperimentalWebMcp())], }; ``` ```typescript const config = { options: { webMcp: { name: 'signup', description: 'Sign a new user up with a username, plan and newsletter preference.', }, }, fields: [ { key: 'username', type: 'input', label: 'Username', required: true, placeholder: 'Letters and numbers' }, { key: 'plan', type: 'select', label: 'Plan', options: [ { label: 'Free', value: 'free' }, { label: 'Pro, billed yearly', value: 'pro' }, ], }, ], } as const satisfies FormConfig; ``` `webMcp` is part of `FormOptions`, so it can also come from the `[formOptions]` input instead of the config. Forms without `options.webMcp` register nothing. The registrar module is loaded on demand, so a form that never opts in never pulls it. The feature, its token, and the small form-scoped hook that decides whether to load are part of the main bundle. ## The tools | Tool | Registered | What it does | | --------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `fill_{name}` | Always | Applies any subset of fields to the form and reports what it set, which fields apply, which are still empty, and any validation errors. Never submits. | | `submit_{name}` | Only with `allowSubmit` | Applies any fields given, submits, and waits for the result. | Tool names must satisfy the WebMCP draft's own rule: 1 to 128 characters of `A-Z`, `a-z`, `0-9`, `_`, `-` or `.`. Keep `webMcp.name` short, since Chrome's guidance is that agents scan tool names within roughly a 30-character budget. ### fill `fill` writes to the real form, so the user watching the page sees the agent's work land in the fields. It accepts a partial patch: - A **scalar** is replaced. - A **group** is merged key by key, all the way down. Sending `{ person: { first: 'Grace' } }` changes `person.first` and leaves `person.last` where it was. - A **list** is replaced whole. There is no positional patch an agent could express unambiguously, since index 1 of a five-item list means nothing once the list is reordered. Replacing a list whole has a consequence worth stating: anything in an item that the agent is not allowed to write cannot survive the rewrite. The agent never sees those fields in the item schema, so it cannot send them back, and pinning them to their old positions would attach a server id to whichever item happened to land there. So a list whose items already hold such a value is refused rather than rewritten, and the response names the fields at stake. A list that is empty, or whose protected fields are all still unset, has nothing to lose and is written normally, which keeps the usual "add the first few items" case working. To let an agent edit a list like that, mark the field it needs to preserve `webMcp: { writable: true }` so it can send it back, or keep the identifying data outside the array. Calling it with no fields changes nothing and reports the current state, which is the natural way for an agent to orient itself before it starts. Because it applies to the live form, everything it reports back is the genuine answer. Cross-field validators, conditional visibility, and derivations all evaluate exactly as they would for a human typing. ### submit Submission is off by default. To allow it: ```typescript options: { webMcp: { name: 'signup', description: 'Sign a new user up.', allowSubmit: true, }, } ``` Without `allowSubmit`, no submit tool is registered at all and the agent simply cannot submit the form. It fills the fields and a human presses the button. > [!WARNING] > Leave `allowSubmit` off for anything that spends money, sends a message, or cannot be undone. Every registered tool is callable by any agent that reaches the page, including one following instructions injected somewhere else entirely. See [Chrome's agent security guidance](https://developer.chrome.com/docs/agents/security). This mirrors the platform's own posture. WebMCP's declarative forms API also defaults to manual submission and requires an explicit `toolautosubmit` to let an agent submit. The submit tool is also annotated `consequentialHint: true`. Treat that as a marker for later rather than a protection you have today: Chrome 150 keeps only `readOnly` and `untrustedContent` on a registered tool and drops every other hint, so no browser currently turns it into a confirmation prompt. So `allowSubmit` is the gate. It decides whether a submit tool exists at all, and nothing downstream of it asks the person to confirm the call. `submit` waits for the submission to finish before answering, and reports what actually happened: | Result | When | | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | | Submitted successfully | `submission.action` ran and resolved cleanly. | | Submitted, came back with errors | The action returned server-side validation errors. | | Not submitted, validation failed | The form is invalid. The values sent are still in the form for correction. | | Not submitted, validation had not finished | Async validators were still resolving. | | Not submitted, the submission failed | The action threw or rejected. | | Not submitted, already submitting | Another submission was in flight and this one was dropped. | | Submitted, the page handled it | No `submission.action` is configured, so the `(submitted)` output received the value and there is no result to report. | ## Validating what the agent sends Every argument is checked in code before anything reaches the form. The input schema is advisory: Angular's own WebMCP documentation warns that agent input may not be validated against it, and Chrome's guidance is to validate strictly in code. The parser enforces known properties only, runtime types, `null` only where a field is nullable, enum membership, object and array structure, and each field's write policy. It also refuses a field the form has disabled or made read-only at the moment of the call. A call that fails any of these is rejected whole. Nothing is half-applied, and the response says plainly that the form is unchanged and lists every problem at once, so one round trip is enough to fix them all. Value constraints such as `minLength` or `pattern` are deliberately left to the form's own validators, which report them with the message you wrote. ## Controlling what an agent can see and change Every field carries a policy, derived from the field itself unless you say otherwise: | Field | Readable | Writable | | ----------------------------------- | -------- | -------- | | `type: 'hidden'` | no | no | | `props.type === 'password'` | no | yes | | `readonly: true`, or a `derivation` | yes | no | | anything else | yes | yes | Override either axis per field, or hide a field from agents entirely: ```typescript { key: 'accountNumber', type: 'input', webMcp: { readable: false } }, { key: 'internalRef', type: 'input', webMcp: false }, ``` A field that is not readable still appears in the report by name, along with whether it currently holds a value. Only the value itself is withheld. ### Readback By default a tool response returns only the values the call itself set, plus which fields apply, which are still empty, and any validation errors. That is enough for an agent to orient itself and correct its own work. ```typescript options: { webMcp: { name: 'signup', description: '...', readback: 'all' } } ``` `readback: 'all'` returns the whole model instead, minus any field whose `webMcp.readable` is off. > [!WARNING] > Choose `readback: 'all'` deliberately. It hands an agent every value the form is holding, including data a user entered before the agent arrived. Chrome's guidance is explicit that even a read-only tool can reveal user information. See [WebMCP tool security](https://developer.chrome.com/docs/ai/webmcp/secure-tools). Both tools are flagged `untrustedContentHint`, because the values they echo back are user content and a well-behaved agent should not treat them as instructions. That annotation is a prompt-injection hint. It does not keep user data out of the response, which is what the readback and per-field policies are for. ## What the agent sees in the schema The tool schema is generated from your config, not from the form's current values. That means: - `label` becomes the property title, and `placeholder` (or `props.placeholder`, or `props.hint`) becomes its description - A select's `options` become an `enum`, plus an `anyOf` of `const` values carrying each option's label, so an agent choosing between opaque values such as country codes can tell what they mean - Disabled options are left out, since an agent cannot select them - Static validators become constraints. Both the shorthand form (`required`, `email`, `min`, `max`, `minLength`, `maxLength`, `pattern` declared on the field) and the advanced `validators` array are read - `nullable: true` widens the property to accept `null` - Fields an agent may not write are left out entirely The schema describes a patch, so it carries no `required` list at any level. A `required` property would tell an agent it has to send that field on every call, which is the opposite of the contract. What is required right now comes back from `fill`, live. Conditional validators (those with a `when` clause) and expression-driven ones are also left out. They depend on live form state, so freezing them into a schema the agent may have cached would misreport what the form accepts. Validation errors come back using the messages you already wrote: a field's `validationMessages` first, then the form's `defaultValidationMessages`, with parameters interpolated. ## Following the config Tools track the form's effective options. Change the config, rename the tools, remove `options.webMcp`, or turn `allowSubmit` off, and the previous tools are unregistered before the new ones are registered. This matters most for `allowSubmit`: turning it off has to actually revoke the agent's submission authority, not merely stop advertising it. `webMcpStatus()` on the `DynamicForm` component reports where that stands: `disabled`, `idle`, `registering`, `active`, `unsupported`, or `failed`. ## Paged forms A paged form is a single flat model, since pages affect layout rather than value shape. One set of tools covers every page, and an agent can fill fields from any page in one call. Navigation is not exposed as a tool. ## Known limitations - **Arrays must be homogeneous.** ng-forge builds a list's schema from its item template, so an array declared with `value: []` is still fully described. An array whose item definitions differ by position is left out with a console warning. JSON Schema 2020-12 can express that shape through `prefixItems`, but the inference layer WebMCP vendors ignores the keyword, so emitting it would imply support agents do not get. - **Tool names must be unique across the page.** Two forms sharing a `webMcp.name` collide, and the second registration is rejected. Give each form its own name, in the same way you would give it its own `idPrefix`. - **Dynamic messages fall back to the error kind.** A `validationMessages` entry that is an Observable or Signal resolves per field at render time, which is not available when building a tool response. The same applies to a dynamic `label` or `placeholder`. - **Async validation has a deadline.** A tool call waits up to five seconds for pending validators. If they are still running it says so rather than reporting a clean form. ## Testing The browser contract is `document.modelContext`, which fakes cleanly. A useful fake enforces what the real one enforces: registration is asynchronous, a duplicate or malformed name rejects, and an aborted signal unregisters the tool. A recording spy that accepts everything will pass against code that is quietly broken. ```typescript await page.addInitScript(() => { const tools = new Map(); (window as any).__mcp = { getTools: () => [...tools.values()], executeTool: (name: string, args: unknown) => tools.get(name).execute(args, {}), }; (document as any).modelContext = { async registerTool(tool: any, options?: { signal?: AbortSignal }) { if (tools.has(tool.name)) throw new DOMException('duplicate', 'InvalidStateError'); tools.set(tool.name, tool); options?.signal?.addEventListener('abort', () => tools.delete(tool.name)); }, }; }); ``` That gives deterministic coverage of the whole round trip. It does not cover whether a real agent picks the right tool or understands the schema, which is probabilistic and worth checking separately, as [Chrome recommends](https://developer.chrome.com/docs/ai/webmcp/best-practices). ng-forge keeps a small eval set for that second question, covering discovery, partial completion, correction after a validation error, conditional fields, opaque select values, and a negative control for a form that offers no submit tool. It lives in the repository under `packages/dynamic-forms/src/lib/core/web-mcp/eval`, with deterministic graders and a README describing how to run it. It has no CI target on purpose, since it needs an agent driving a browser. --- api-driven-forms --- Build dynamic forms from JSON returned by your backend or CMS. When your form configuration is fetched at runtime instead of defined statically, you don't need `as const satisfies FormConfig`: the plain `FormConfig` type works out of the box. This is the recommended pattern for server-driven forms, JSON-based form builders, and any scenario where the form structure isn't known at compile time. ## Basic Pattern Create a form component that accepts a `FormConfig` input: ```typescript @Component({ imports: [DynamicForm], template: `
`, changeDetection: ChangeDetectionStrategy.OnPush, }) export class DynamicFormComponent { config = input.required(); } ``` Then fetch your config from an API and pass it in: ```typescript @Component({ imports: [DynamicFormComponent], template: ` @if (formConfig(); as config) { } @else {

Loading form...

} `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class MyPageComponent { private http = inject(HttpClient); formConfig = toSignal(this.http.get('/api/forms/registration')); } ``` No `as const`, no `satisfies`: the `FormConfig` interface accepts any valid field configuration at its default generic parameters. ## What's Serializable Not every `FormConfig` property can come from a JSON API. Some require runtime code: | Property | Serializable | Notes | | --------------------------- | ------------ | -------------------------------------------------------------------------- | | `fields` | Yes | Core form structure: field types, keys, values, options, validators, logic | | `options` | Yes | Disabled state, button behavior, value exclusion | | `schemas` | Yes | Reusable validation schemas | | `defaultValidationMessages` | Yes | Fallback error messages | | `defaultProps` | Yes | Default UI props (appearance, sizing) | | `defaultWrappers` | Yes | Form-wide default wrapper configs | | `submission` | No | Requires an `action` function | | `customFnConfig` | No | Custom validators, derivation functions, condition functions | | `externalData` | No | Angular `Signal` instances | | `schema` | No | Standard Schema objects (Zod, Valibot, etc.) | The serializable properties cover the vast majority of form configuration. The non-serializable ones are for advanced features that inherently require client-side code. Fields accept a [`nullable: true`](/configuration#nullable-values) flag to preserve `null` through defaults and submission, useful when mirroring OpenAPI schemas that distinguish null from empty. ## Hydrating Runtime Features When you need both API-driven structure and client-side behavior, merge them: ```typescript @Component({...}) export class OrderFormPage { private http = inject(HttpClient); private authService = inject(AuthService); private orderService = inject(OrderService); private apiConfig = toSignal( this.http.get('/api/forms/order') ); // Merge API config with client-side code formConfig = computed(() => { const api = this.apiConfig(); if (!api) return undefined; return { ...api, submission: { action: async (form) => { await this.orderService.submit(form().value()); return undefined; }, }, externalData: { userRole: computed(() => this.authService.currentRole()), }, customFnConfig: { validators: { checkStock: (ctx) => { const qty = ctx.value() as number; return qty > 100 ? { kind: 'maxStock', message: 'Max 100 items' } : null; }, }, }, } satisfies FormConfig; }); } ``` This pattern keeps your form structure server-driven while attaching client-side behavior where needed. ## Typing Form Values With API-driven configs, TypeScript can't infer the form value shape at compile time (since the config isn't a constant). You have two options: ### Manual Interface Define the expected shape yourself: ```typescript interface RegistrationForm { email: string; password: string; name?: string; } function onSubmit(value: unknown) { const data = value as RegistrationForm; console.log(data.email); } ``` ### Runtime Validation For stronger guarantees, validate at runtime with a schema library: ```typescript import { z } from 'zod'; const registrationSchema = z.object({ email: z.string().email(), password: z.string().min(8), name: z.string().optional(), }); function onSubmit(value: unknown) { const result = registrationSchema.safeParse(value); if (!result.success) { console.error(result.error); return; } // result.data is fully typed console.log(result.data.email); } ``` > For static configs where compile-time inference is possible, see [Type Safety](/recipes/type-safety). ## Related - **[Configuration](/configuration)**: Global form setup and provider options - **[Type Safety](/recipes/type-safety)**: Compile-time type inference with `as const satisfies` - **[Form Submission](/dynamic-behavior/submission)**: Submission handlers and server error mapping --- building-an-adapter --- Build a custom integration so ng-forge field types render with your own component library or design system. > **Just need one extra field on top of an existing adapter** (Material/Bootstrap/PrimeNG/Ionic)? See the shorter [Custom Fields](/recipes/custom-fields) recipe: same primitive, scoped to a single field type. ## Overview An ng-forge adapter provides: 1. A **field component** for each field type your adapter supports (input, select, checkbox, etc.). 2. A **provider function** (`withMyAdapterFields()`) that registers all those types with `provideDynamicForm()`. 3. Optional **adapter-level configuration** that cascades into individual fields (size, appearance, theme color). Every field component composes the `NgForgeField` directive via `hostDirectives`. That directive owns the standard contract: the nine forwarded inputs every field accepts, eight derived signals (errors, ARIA helpers, ID derivation) on top of the re-exported `key` and `className`, and five universal host bindings. You only write the parts that are actually adapter-specific: the template and any UI-library quirks. Package entrypoints you'll import from: | Entrypoint | Purpose | | ------------------------------------- | ------------------------------------------------------------- | | `@ng-forge/dynamic-forms` | Core types, `provideDynamicForm`, `FormConfig`, etc. | | `@ng-forge/dynamic-forms/integration` | Field type definitions, mappers, the `NgForgeField` primitive | ## The directive primitives ng-forge ships three layered directives + two **wrapper** directives that bundle them for the two common shapes. In practice you'll compose just the wrapper. **Layers:** - **`NgForgeFieldShell`**: the universal base. Owns the `key` + `className` inputs and the identity host bindings (`[id]`, `[attr.data-testid]`, `[class]`). Every ng-forge component uses this. - **`NgForgeField`**: the **value** add-on. Injects Shell. Owns `field`/`label`/`placeholder`/`tabIndex`/`props`/`meta`/`validationMessages`, the error/aria derived signals, meta-tracking, and the `[attr.hidden]`/`[attr.aria-disabled]` host bindings driven by `field()()`. - **`NgForgeAction`**: the **action** add-on. Injects Shell. Owns `label`/`disabled`/`hidden`/`tabIndex`/`event`/`eventArgs`/`eventContext`/`props`, the `[attr.hidden]`/`[attr.aria-disabled]` host bindings driven by its own inputs, and a `dispatch()` method that resolves event-arg tokens and dispatches through `EventBus`. **Wrappers:** - **`NgForgeFieldHost`**: composes `NgForgeFieldShell` + `NgForgeField`. Use for value-bearing components. - **`NgForgeActionHost`**: composes `NgForgeFieldShell` + `NgForgeAction`. Use for button / action components. The wrappers exist because Angular's library partial-compilation can't resolve cross-package const references inside `hostDirectives:`. A wrapper directive's own `hostDirectives` IS resolvable at the integration package's compile time, so consumers compose a single class instead of writing the two-entry literal in every component. **Forwarded inputs** (per directive): | Directive | Input names array (re-export) | | ------------------- | ------------------------------------------------------------------------------------------------------------------- | | `NgForgeFieldShell` | `NG_FORGE_FIELD_SHELL_INPUTS` (`key`, `className`) | | `NgForgeField` | `NG_FORGE_VALUE_FIELD_INPUTS` (`field`, `label`, `placeholder`, `tabIndex`, `props`, `meta`, `validationMessages`) | | `NgForgeAction` | `NG_FORGE_ACTION_INPUTS` (`label`, `disabled`, `hidden`, `tabIndex`, `event`, `eventArgs`, `eventContext`, `props`) | **Derived signals available via `injectNgForgeField()`:** | Signal | Type | Source | | ----------------- | ------------------------- | ----------------------------------------------------------------------- | | `key` | `Signal` | re-exported from the injected `NgForgeFieldShell` | | `className` | `Signal` | re-exported from the injected `NgForgeFieldShell` | | `errors` | `Signal` | resolved against `validationMessages` + `DEFAULT_VALIDATION_MESSAGES` | | `showErrors` | `Signal` | `field` is invalid AND touched | | `errorsToDisplay` | `Signal` | `errors()` if `showErrors()` else `[]` | | `errorId` | `Signal` | `${key()}-error` | | `hintId` | `Signal` | `${key()}-hint` | | `ariaInvalid` | `Signal` | `field()().invalid() && field()().touched()` | | `ariaRequired` | `Signal` | `true` when the field has a required validator, otherwise `null` | | `ariaDescribedBy` | `Signal` | links to `errorId` when erroring, `hintId` when `props.hint` is present | **`NgForgeAction` exposes** `key`, `className` (re-exports from Shell), the value-input signals (`label`, `disabled`, `hidden`, `tabIndex`, `event`, `eventArgs`, `eventContext`, `props`), and a `dispatch()` method that components call from their click handler. **Universal host bindings** (applied to your component's host element automatically): - From `NgForgeFieldShell` (all field types): `[id]="key()"`, `[attr.data-testid]="key()"`, `[class]="className()"` - From `NgForgeField` (value fields): `[attr.hidden]="field()().hidden() || null"`, `[attr.aria-disabled]="field()().disabled() || null"` - From `NgForgeAction` (actions): `[attr.hidden]="hidden() || null"`, `[attr.aria-disabled]="disabled() || null"` ## Anatomy of a field component The canonical shape, using a custom Bootstrap-style input as the example. Every value-bearing field component in every adapter follows this pattern: ```typescript // custom-input.component.ts import { ChangeDetectionStrategy, Component, input } from '@angular/core'; import { AsyncPipe } from '@angular/common'; import { FormField } from '@angular/forms/signals'; import { DynamicTextPipe, injectNgForgeField, NgForgeControl, NgForgeFieldHost } from '@ng-forge/dynamic-forms/integration'; import { CustomInputProps } from './custom-input.type'; @Component({ selector: 'custom-input', imports: [FormField, DynamicTextPipe, AsyncPipe, NgForgeControl], hostDirectives: [NgForgeFieldHost], template: ` @let f = ngf.field(); @let inputId = ngf.key() + '-input'; @if (ngf.label()) { } @if (ngf.errorsToDisplay()[0]; as error) {
{{ error.message }}
} @else if (props()?.hint; as hint) {
{{ hint | dynamicText | async }}
} `, changeDetection: ChangeDetectionStrategy.OnPush, }) export default class CustomInputComponent { protected readonly ngf = injectNgForgeField(); readonly props = input(); } ``` What the component does **not** declare: - Standard inputs (`field`, `key`, `label`, etc.): those come from `NgForgeField` via `hostDirectives`. The component reads them through `ngf.X()`. - Host bindings for `id`/`data-testid`/`class`/`hidden`: `NgForgeField` owns those. - Error / ARIA / hint plumbing: derived signals come from the directive. What the component **does** declare: - A typed `injectNgForgeField()` so `ngf.field()` is a `Signal>` rather than `FieldTree`. - The `props` input (typed to your adapter's per-field props interface). - The template, including `[ngForgeControl]` on the canonical control element so meta attributes (`data-*`, `aria-*`, `autocomplete`) reach the right place. - Any adapter-specific computeds (e.g. `size`, `appearance`) that resolve `props().X ?? adapterConfig?.X ?? defaultX`. ### Typed access via injectNgForgeField `injectNgForgeField()` returns the `NgForgeField` instance with `field` narrowed to `Signal>`. The cast is unchecked (the runtime contract is that the field-type registration matches the value type), but it lets `[formField]="ngf.field()"` type-check inside templates that need a strict generic. For boolean fields you'd write `injectNgForgeField()`, for `Date | null` datepickers `injectNgForgeField()`, and so on. ## Anatomy of an action component Buttons, submits, navigation buttons, and array-mutation buttons all compose `NgForgeFieldShell` + `NgForgeAction` instead of `NgForgeField`. The Action directive owns event dispatch: your component's click handler calls `action.dispatch()` and the directive resolves any `eventArgs` tokens via the ambient `ARRAY_CONTEXT` and dispatches through `EventBus`. ```typescript // custom-button.component.ts import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; import { FormEvent } from '@ng-forge/dynamic-forms'; import { injectNgForgeAction, NgForgeActionHost } from '@ng-forge/dynamic-forms/integration'; import { CustomButtonProps } from './custom-button.type'; @Component({ selector: 'custom-button', hostDirectives: [NgForgeActionHost], template: ` `, changeDetection: ChangeDetectionStrategy.OnPush, }) export default class CustomButtonComponent { protected readonly action = injectNgForgeAction(); readonly props = input(); protected readonly buttonType = computed(() => this.props()?.type ?? 'button'); onClick(): void { // Native form submit buttons let the form handle submission; everything else dispatches. if (this.buttonType() === 'submit') return; this.action.dispatch(); } } ``` The corresponding `FieldTypeDefinition` opts out of value handling and explicit render-readiness: ```typescript { name: 'button', loadComponent: () => import('./custom-button.component'), mapper: buttonFieldMapper, valueHandling: 'exclude', renderReadyWhen: [], } ``` `buttonFieldMapper` (or `submitButtonFieldMapper` / `nextButtonFieldMapper` / `addArrayItemButtonMapper` / …) emits exactly the keys `NgForgeFieldShell` + `NgForgeAction` accept, the same lockstep guarantee as value fields. ## Meta forwarding Field meta (the `meta` input on every field) carries native HTML attributes (`data-*`, `autocomplete`, `inputmode`, etc.). Markers also forward the directive's derived aria signals (`aria-invalid`, `aria-required`, `aria-describedby`) onto the same target, so authors don't bind those manually. ng-forge ships two marker directives plus an ambient injection path for sub-components. ### NgForgeControl: the common case A template attribute directive. Place it on the canonical control element in your template: ```html ``` `NgForgeControl` injects the parent `NgForgeField`, reads `meta()` and the aria signals, and applies the resulting attributes to its own host element. For wrapped controls where the canonical native input is rendered as a descendant inside the wrapper, pass a CSS selector through the input alias and the directive queries the host subtree: ```html {{ ngf.label() }} ``` For dynamic option lists (radio buttons, multi-checkbox), put `ngForgeControl` inside the `@for`: ```html @for (option of options(); track option.value) { } ``` Each iteration spawns its own directive instance. Adding/removing options via Angular's structural lifecycle creates and destroys those instances naturally: no manual subscription, no `dependents` array. ### NgForgeHostControl: for shadow-DOM wrappers Some component libraries (Ionic web components, certain PrimeNG controls) wrap a native input inside shadow DOM that you can't reach with a template selector. In those cases the wrapper element itself is the canonical control from the user's perspective. Add `NgForgeHostControl` to your component's `hostDirectives` so meta + aria land on the host: ```typescript import { Component } from '@angular/core'; import { injectNgForgeField, NgForgeFieldHost, NgForgeHostControl } from '@ng-forge/dynamic-forms/integration'; @Component({ selector: 'df-ionic-toggle', hostDirectives: [ // Order matters — NgForgeFieldHost must come first. // NgForgeHostControl's constructor injects the parent NgForgeField, // and Angular instantiates hostDirectives in array order on the same // element injector. List Shell+Field (via NgForgeFieldHost) BEFORE // NgForgeHostControl so the parent exists when the marker constructs. NgForgeFieldHost, NgForgeHostControl, ], template: `{{ ngf.label() | dynamicText | async }}`, }) export default class IonicToggleField { protected readonly ngf = injectNgForgeField(); } ``` `NgForgeHostControl` is selectorless: it's only used via `hostDirectives`, never as a template attribute. Reversing the order (`[NgForgeHostControl, NgForgeFieldHost]`) causes `inject(NgForgeField)` inside the marker's constructor to fail with NG0203 because the parent isn't on the element injector yet. ### Quick decision rule - The control element is rendered in **your template** (an `input`, `select`, or any custom element): use `[ngForgeControl]` on that element. - The control element is the **component's host** (no inner element to mark, e.g. shadow-DOM wrapper): use `NgForgeHostControl` in `hostDirectives`. - Meta should not be applied at all: omit both. If you set `meta()` on a field but no marker / ambient consumer claims it, ng-forge logs a dev-mode warning so the wiring gap surfaces immediately instead of failing silently. ### Forwarding to a sub-component If your field component delegates rendering to a sub-component (e.g. `df-bs-radio-group` inside `df-bs-radio`), put `ngForgeControl` on the canonical control element in the sub-component's template. The marker walks the element-injector tree to find the parent's `NgForgeField` and absorbs meta + aria automatically. For per-iteration shapes (radio buttons, multi-checkbox options), one marker instance per `@for` iteration: ```typescript @Component({ selector: 'df-bs-radio-group', imports: [NgForgeControl], template: ` @for (option of options(); track option.value; let i = $index) { } `, }) export class BsRadioGroupComponent { /* FormValueControl props omitted */ } ``` No `[meta]="ngf.meta()"` binding on the parent side is needed and no `setupMetaTracking` call inside the sub-component: each marker instance claims the ambient field on construction. The dev-mode unclaimed-meta warning fires if `meta()` is non-empty and no marker / ambient consumer registered. > **Warning-race note.** In a normal template-driven render, sub-components construct during the parent's template instantiation (so `markClaimed()` runs before `NgForgeField`'s `afterRenderEffect.write` fires). For programmatic late mounts (Storybook stories, mid-tree manual instantiation) the warning can fire once before the late claim lands; the latch ensures it doesn't repeat. ## Mappers A mapper translates a field definition (`FieldDef<...>`) into the inputs that flow into your component. It's a function called inside an injection context: ```typescript type MapperFn> = (input: T) => Signal>; ``` The signal emits a record mapping input names to values. The form engine reads each entry and calls `ref.setInput(name, value)` on the rendered component. ng-forge ships mappers for the standard field categories. You'll register field types against these, not write your own most of the time: | Mapper | For | What it emits | | ----------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `valueFieldMapper` | input, textarea, datepicker, slider, … | `field`, `key`, `label`, `placeholder`, `className`, `tabIndex`, `props`, `meta`, `validationMessages`, plus `addons` when the field declares addons. Note: the mapper no longer emits `defaultValidationMessages` as a per-component input; `NgForgeField` reads `DEFAULT_VALIDATION_MESSAGES` from DI directly. The form-level `defaultValidationMessages` config option in `provideDynamicForm` / `FormConfig` is unaffected: it still flows in through the DI token. | | `checkboxFieldMapper` | checkbox, toggle | same as `valueFieldMapper` | | `optionsFieldMapper` | select, radio, multi-checkbox | adds `options` | | `datepickerFieldMapper` | datepicker | adds `minDate`, `maxDate`, `startAt` (string to Date conversion) | | `buttonFieldMapper` | plain buttons | `key`, `label`, `disabled`, `event`, `props`, `className` | | Array button mappers | `addArrayItem`, `removeArrayItem`, etc. | event + event-args wiring for array mutations | ### Mapper-as-contract Every key a mapper emits must match a declared input on the component or one of its host directives. If your component exposes the standard nine inputs (via `NgForgeField` `hostDirectives`) and accepts `props`, every key the built-in mappers emit lines up automatically. `ComponentRef.setInput` (used by the field outlet to push mapper output onto the rendered component) is **lenient** on unknown input names in Angular 22: extra keys are silently dropped rather than throwing NG0303. So if a custom mapper emits a key the component doesn't declare, the input is lost without a runtime error. Composing `NgForgeFieldHost` registers all the standard input names on the component (Shell's `key`/`className` + Field's `field`/`label`/`placeholder`/`tabIndex`/`props`/`meta`/`validationMessages`) so built-in mapper output always lines up. That's the recommended authoring shape for third-party adapters. ### Writing a custom mapper Most adapter authors never need this: the built-in mappers handle every standard category. You'd write a custom mapper when your field type doesn't fit any standard category (e.g. a multi-select with grouped options, a tree-picker with a custom data shape). Example: a hypothetical "weighted choice" field where each option has an associated number: ```typescript import { computed, inject, Signal } from '@angular/core'; import { FieldDef } from '@ng-forge/dynamic-forms'; import { DEFAULT_PROPS } from '@ng-forge/dynamic-forms/integration'; import { buildValueFieldInputs, resolveValueFieldContext } from '@ng-forge/dynamic-forms/integration'; import type { WeightedChoiceField } from './weighted-choice.types'; export function weightedChoiceFieldMapper(fieldDef: WeightedChoiceField): Signal> { const ctx = resolveValueFieldContext(); const defaultProps = inject(DEFAULT_PROPS); return computed(() => { const base = buildValueFieldInputs(fieldDef, ctx, defaultProps()); return { ...base, // Adapter-specific keys — every one must match a declared input on the // component or its host directives. choices: fieldDef.choices, totalWeight: fieldDef.choices.reduce((sum, c) => sum + c.weight, 0), }; }); } ``` Reuse `buildValueFieldInputs` (exported from `/integration`) to get the standard value-field keys without rewriting them, then layer your extra keys on top. ### Writing a custom action / button mapper Action fields (buttons, submits, array-mutation buttons) compose `NgForgeActionHost` instead of `NgForgeFieldHost`. Their mapper emits a different key set: `key`, `className`, `label`, `disabled`, `hidden`, `tabIndex`, `event`, `eventArgs`, `eventContext`, `props`. `NgForgeAction.dispatch()` reads `event` + `eventArgs` and resolves any tokens (`$key`, `$index`, `$arrayKey`, `formValue`) against the ambient `ARRAY_CONTEXT` injection token, falling back to the static `eventContext` input. The built-in `buttonFieldMapper` covers the generic case. For preconfigured events (submit, next/previous-page, array mutations) ng-forge ships `submitButtonFieldMapper`, `nextButtonFieldMapper`, `previousButtonFieldMapper`, `addArrayItemButtonMapper`, `prependArrayItemButtonMapper`, `insertArrayItemButtonMapper`, `removeArrayItemButtonMapper`, `popArrayItemButtonMapper`, `shiftArrayItemButtonMapper`. Each one wires the `event` class internally so the field definition doesn't have to. You'd write a custom action mapper for a button that dispatches a custom `FormEvent` subclass with a non-standard payload shape, or for a button whose event-arg resolution differs from the built-in tokens. Example: a "save draft" button that dispatches a custom `SaveDraftEvent` with the current form value snapshot: ```typescript import { computed, inject, Signal } from '@angular/core'; import { FieldDef } from '@ng-forge/dynamic-forms'; import { ARRAY_CONTEXT, FIELD_SIGNAL_CONTEXT, buildBaseInputs, DEFAULT_PROPS } from '@ng-forge/dynamic-forms/integration'; import type { ButtonField } from '@ng-forge/dynamic-forms/integration'; import { SaveDraftEvent } from './events/save-draft.event'; export function saveDraftButtonMapper(fieldDef: ButtonField): Signal> { const defaultProps = inject(DEFAULT_PROPS); const ctx = inject(FIELD_SIGNAL_CONTEXT); // ARRAY_CONTEXT is optional — present when the button is rendered inside an array item. const arrayContext = inject(ARRAY_CONTEXT, { optional: true }); return computed(() => { const base = buildBaseInputs(fieldDef, defaultProps()); return { ...base, // The button-event surface — NgForgeAction reads these inputs verbatim. event: SaveDraftEvent, eventArgs: fieldDef.eventArgs, // Provide a fallback context when the button is rendered outside any array. // NgForgeAction prefers ARRAY_CONTEXT when present; eventContext is only // consulted when ARRAY_CONTEXT is absent. eventContext: arrayContext ? undefined : { key: fieldDef.key, formValue: ctx.value() }, }; }); } ``` Three contracts the mapper must honor: - **The `event` input must be a class reference** (a `FormEventConstructor`), not an instance. `NgForgeAction.dispatch()` calls `new event(...args)` internally via `EventBus.dispatch`. - **`eventArgs` carries tokens, not resolved values.** Token resolution happens inside `dispatch()` at click time, using the current `ARRAY_CONTEXT.index()` signal so dispatched indices are always live. Resolving args at mapper time would freeze the index at the moment the mapper ran. - **`eventContext` is the fallback path.** When `ARRAY_CONTEXT` is provided (button is inside an array), it wins. When absent (button at form root), `NgForgeAction` falls back to `eventContext()`, then to `{ key: this.key() }`. Register with `valueHandling: 'exclude'` and `renderReadyWhen: []` since action fields don't carry a value or wait for `field` to bind: ```typescript { name: 'saveDraft', loadComponent: () => import('./save-draft-button.component'), mapper: saveDraftButtonMapper, valueHandling: 'exclude', renderReadyWhen: [], } ``` ## Required-input forwarding & renderReadyWhen `NgForgeField` declares `field` as `input.required()`, and `NgForgeFieldShell` declares `key` the same way. The form engine guarantees both are bound before the component renders, but the contract is enforced via the `renderReadyWhen` mechanism on the `FieldTypeDefinition`. The renderer resolves the effective `renderReadyWhen` per registration in this order: 1. `FieldTypeDefinition.renderReadyWhen`: explicit on the registration. Always wins (escape hatch). 2. `valueHandling: 'exclude'`: short-circuits to `[]`. Display / action / layout fields don't bind to a form value, so they never wait. 3. Default `['field']`. In dev mode the renderer also emits a one-time warning via `DynamicFormLogger` so adapter authors learn to declare the contract explicitly. Every registration in your adapter should declare `renderReadyWhen` (directly or via a shared base constant) so the contract is visible at the registration site. The convention the built-in adapters follow: ```typescript const VALUE_FIELD_TYPES_BASE = { renderReadyWhen: ['field'], } as const; const BUTTON_FIELD_TYPES_BASE = { renderReadyWhen: [], valueHandling: 'exclude', } as const; export const ADAPTER_FIELD_TYPES: FieldTypeDefinition[] = [ { name: 'input', loadComponent: () => import('./input/input.component'), mapper: valueFieldMapper, ...VALUE_FIELD_TYPES_BASE, }, { name: 'submit', loadComponent: () => import('./buttons/submit-button.component'), mapper: submitButtonFieldMapper, ...BUTTON_FIELD_TYPES_BASE, }, ]; ``` For custom mappers that emit _other_ required inputs your component depends on, list them explicitly: ```typescript { name: 'image-picker', loadComponent: () => import('./image-picker.component'), mapper: imagePickerFieldMapper, renderReadyWhen: ['field', 'allowedTypes'], } ``` ## Provider function & module augmentation Wrap your `FieldTypeDefinition` array in an exported provider function so consumers register everything in one call: ```typescript // my-adapter-providers.ts import type { Provider } from '@angular/core'; import type { FieldTypeDefinition } from '@ng-forge/dynamic-forms/integration'; import { MY_ADAPTER_FIELD_TYPES } from './my-adapter-field-config'; import type { MyAdapterConfig } from './my-adapter-config'; import { MY_ADAPTER_CONFIG } from './my-adapter-config.token'; export type MyAdapterFieldTypes = FieldTypeDefinition[]; type MyAdapterConfigFeature = { ɵkind: 'my-adapter-config'; ɵproviders: Provider[]; }; type MyAdapterFieldsWithConfig = [...MyAdapterFieldTypes, MyAdapterConfigFeature]; export function withMyAdapterFields(): MyAdapterFieldTypes; export function withMyAdapterFields(config: MyAdapterConfig): MyAdapterFieldsWithConfig; export function withMyAdapterFields(config?: MyAdapterConfig): MyAdapterFieldTypes | MyAdapterFieldsWithConfig { if (!config) return MY_ADAPTER_FIELD_TYPES; return [ ...MY_ADAPTER_FIELD_TYPES, { ɵkind: 'my-adapter-config', ɵproviders: [{ provide: MY_ADAPTER_CONFIG, useValue: config }], } satisfies MyAdapterConfigFeature, ]; } ``` Consumers register the adapter just like the in-tree ones: ```typescript // app.config.ts import { ApplicationConfig } from '@angular/core'; import { provideDynamicForm } from '@ng-forge/dynamic-forms'; import { withMyAdapterFields } from '@my-org/ng-forge-my-adapter'; export const appConfig: ApplicationConfig = { providers: [provideDynamicForm(...withMyAdapterFields({ size: 'lg', theme: 'dark' }))], }; ``` ### Module augmentation for type safety Register your typed field definitions with TypeScript so `FormConfig` autocompletes against the union of registered field types: ```typescript // my-adapter-types.ts import type { MyAdapterInputField, MyAdapterSelectField, MyAdapterCheckboxField } from './fields'; declare module '@ng-forge/dynamic-forms' { interface FieldRegistryLeaves { input: MyAdapterInputField; select: MyAdapterSelectField; checkbox: MyAdapterCheckboxField; // ... one entry per field type } } ``` After this declaration, IDE autocomplete on `FormConfig.fields[].type` resolves to your adapter's field types, and per-type props get full IntelliSense. ## Adapter-level configuration Most design systems have settings that should cascade across every field: appearance variant, size, theme color. The pattern is: 1. Define an injection token with the config shape. 2. Make the config optional in your provider function. 3. Each component injects the token (optional) and resolves the value through a `computed` that falls back to the token, then a hard-coded default. ```typescript // my-adapter-config.ts export interface MyAdapterConfig { size?: 'sm' | 'md' | 'lg'; theme?: 'light' | 'dark'; } ``` ```typescript // my-adapter-config.token.ts import { InjectionToken } from '@angular/core'; import type { MyAdapterConfig } from './my-adapter-config'; export const MY_ADAPTER_CONFIG = new InjectionToken('MY_ADAPTER_CONFIG'); ``` In each field component, layer the lookups: per-field `props` win, adapter config falls in next, hard-coded default last. ```typescript @Component({/* ... */}) export default class MyInputComponent { private readonly config = inject(MY_ADAPTER_CONFIG, { optional: true }); protected readonly ngf = injectNgForgeField(); readonly props = input(); readonly size = computed(() => this.props()?.size ?? this.config?.size ?? 'md'); } ``` Templates bind the resolved computeds rather than reading `props` directly: ```html ``` ### propsToMeta Some "props" are actually native HTML attributes: `type` on inputs, `rows`/`cols` on textareas, `autocomplete`. Listing them in `propsToMeta` on the field type definition causes the form engine to merge those values into `meta` before passing them to your component, which means they flow through `[ngForgeControl]` onto the actual control element automatically. ```typescript { name: 'input', loadComponent: () => import('./fields/input/my-input.component'), mapper: valueFieldMapper, propsToMeta: ['type'], // reaches the DOM via meta } ``` If `meta` and `props` both carry the same key, `meta` wins. ## Custom wrappers Wrappers are "chrome" around a field: sections, accordions, tooltips, badges. ng-forge ships a wrapper-chain registry separate from the field-type registry, so adapters can register custom wrappers alongside field types from the same provider entry point. The complete wrapper-authoring guide lives in **[Writing a Wrapper](/wrappers/writing-a-wrapper)** (component shape, slot semantics, `WrapperFieldInputs`, error patterns) and **[Registering and Applying](/wrappers/registering-and-applying)** (the `createWrappers` bundle + module augmentation). For adapter library packaging, import the wrapper-authoring API from `@ng-forge/dynamic-forms/integration` rather than the root entry point. This keeps a single import path across your field components and wrapper components: ```typescript import { createWrappers, type FieldWrapper, type InferWrapperRegistry, type WrapperFieldInputs, wrapperProps, } from '@ng-forge/dynamic-forms/integration'; ``` Functionally identical to the root entry: same symbols, same `declare module '@ng-forge/dynamic-forms'` augmentation. The integration re-export exists so adapter packages have one import path. ## Reference adapters The four in-tree adapters are the canonical reference implementations. Each ships ~10 field components, all built on `NgForgeField`. Read them as full working examples: - [`packages/dynamic-forms-bootstrap`](https://github.com/ng-forge/ng-forge/tree/main/packages/dynamic-forms-bootstrap): the smallest surface, often the easiest to copy from. - [`packages/dynamic-forms-material`](https://github.com/ng-forge/ng-forge/tree/main/packages/dynamic-forms-material): wraps Angular Material's existing form-field primitives. - [`packages/dynamic-forms-primeng`](https://github.com/ng-forge/ng-forge/tree/main/packages/dynamic-forms-primeng): examples of inner control components for opaque PrimeNG widgets. - [`packages/dynamic-forms-ionic`](https://github.com/ng-forge/ng-forge/tree/main/packages/dynamic-forms-ionic): shadow-DOM wrappers using `NgForgeHostControl`. ## Related - **[Custom Fields](/recipes/custom-fields)**: single-field recipe alongside an existing adapter. - **[Field Types](/field-types/text-inputs)**: what the standard field types provide. - **[Type Safety](/recipes/type-safety)**: module augmentation patterns. - **[Validation](/validation/basics)**: how validation surfaces through `ngf.errorsToDisplay()`. --- configuration --- > [!TIP] > **Coming from ngx-formly?** The [migration guide](/migrating-from-ngx-formly) maps `defaultProps`, `extends`, and the formly `extensions` API to their ng-forge equivalents. Configure global defaults for all forms at provider level, or per-form via `defaultProps`. ## The Cascade ng-forge applies props in priority order: more specific always wins. --- --- ## Large-form rendering Paged and flat forms use separate rendering controls. For a paged form, `options.pagePreloadWindow` controls how many neighbouring pages are mounted and preloaded. The default is `1`. Set it to `0` when only the active page should load, or increase it when fast jump navigation matters more than initial work. For a large flat form, `options.fieldWindowing` can defer leaf fields until they approach the viewport: ```typescript const config = { options: { fieldWindowing: { eager: 20, placeholderHeight: '4rem', park: { margin: '100%' }, }, }, fields: [ // Flat field definitions ], } as const satisfies FormConfig; ``` - `eager` mounts the leading leaf fields immediately. - `placeholderHeight` reserves layout space before a deferred field mounts. - `park` leaves mounted offscreen DOM in place while removing its view from routine change detection. Disabled, readonly, required, and validation state stays current. Other model-to-DOM updates catch up when the field returns. - `park.margin` follows `IntersectionObserver.rootMargin`: use one to four `px` or `%` values. Unsupported units fall back to the inherited margin. - `fieldWindowing: false` disables inherited deferred mounting for this form. - A park-only object changes parking without changing the inherited mounting mode. Use `{ park: false }` to opt out or `{ park: true }` to opt in. Use `withFieldWindowing()` at provider level when the same defaults should apply to every form. Per-form options take precedence. --- ## Field-level Props Each field type also accepts its own adapter-specific `props`. See [Field Types](/field-types/text-inputs) for the full per-field reference. ### Nullable values Value fields accept an optional `nullable?: boolean` flag. When `true`: - `value` accepts `null` in addition to the field's normal type (e.g. `string | null`). - An omitted `value` resolves to `null` instead of the type-specific empty default (`''`, `NaN`, `[]`, …). - `nullable` stays orthogonal to `required`: they describe different layers. `nullable` declares that the **model** accepts `null` (data shape). `required` is a **validation** constraint. ng-forge maps `{ type: 'required' }` to the Signal Forms `required()` validator, which treats `null` as invalid, so a field that is both `nullable` and `required` will fail required-validation when the value is `null`. The flags are independent OpenAPI concepts; combine them if that matches your schema, but understand the runtime interaction. ```typescript { key: 'middleName', type: 'input', label: 'Middle Name', nullable: true, value: null, // allowed; also the resolved default when omitted } ``` **Read-side caveat.** A user clearing a text input reads back as `""`, not `null`. This is a DOM/Web IDL contract, identical to classic Reactive Forms. `nullable` is a contract for _accepted_ values, not a guarantee of _emitted_ ones. If your backend distinguishes null from empty string, handle the coercion at submission. ## Multiple forms on one page Each field renders a DOM `id` derived from its key (`id="email"`, `id="email-input"`), and that id is reused for the `