{"_id":"@dmytromykhailiuk/preact-signal-formly","_rev":"5-0c4ecf5dda9508d75c593db2d73427ea","name":"@dmytromykhailiuk/preact-signal-formly","dist-tags":{"latest":"1.0.1"},"versions":{"0.1.0":{"name":"@dmytromykhailiuk/preact-signal-formly","version":"0.1.0","keywords":["preact","preact-signals","signals","forms","form","formly","dynamic-forms","json-forms","form-builder","form-validation","validation","field-array","reactive","zero-rerender","typescript","typed"],"author":{"name":"Dmytro Mykhailiuk","email":"dimamykhayluk@gmail.com"},"license":"MIT","_id":"@dmytromykhailiuk/preact-signal-formly@0.1.0","maintainers":[{"name":"dmytromykhailiuk","email":"dimamykhayluk@gmail.com"}],"homepage":"https://github.com/dmytromykhailiuk/preact-signal-formly#readme","bugs":{"url":"https://github.com/dmytromykhailiuk/preact-signal-formly/issues"},"dist":{"shasum":"4a70b0243aa35a423492d885e141c54d95e06276","tarball":"https://registry.npmjs.org/@dmytromykhailiuk/preact-signal-formly/-/preact-signal-formly-0.1.0.tgz","fileCount":9,"integrity":"sha512-CJxuTCwVTGQ/7Z6XHl23zbpbiiROpVzcFUE8jkOV0bgtPvifffzfABZESg2HG8gBjCacjpttR35jG7D2K7lzCQ==","signatures":[{"sig":"MEYCIQCNszLPadYvpVuiIum1dRDr+MlMuc+M5ZsMfukXcEkM+AIhAI+RKa2Hrk73NYsf51I8FFhlK+Wf8FonG7Ts/3pgmLci","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":417878},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","engines":{"node":">=18"},"exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}},"./package.json":"./package.json"},"gitHead":"61e641efaccbdfce8b132da5eddce10cb8140567","scripts":{"dev":"tsup --watch","lint":"biome check .","test":"vitest run","build":"tsup","format":"biome format --write .","lint:fix":"biome check --write .","typecheck":"tsc --noEmit","playground":"vite --config vite.playground.config.ts","test:watch":"vitest","prepublishOnly":"npm run build"},"_npmUser":{"name":"dmytromykhailiuk","email":"dimamykhayluk@gmail.com"},"repository":{"url":"git+https://github.com/dmytromykhailiuk/preact-signal-formly.git","type":"git"},"_npmVersion":"11.6.2","description":"Dynamic, config-driven forms for Preact — a Formly analogue built entirely on @preact/signals and preact-signal-hook-forms. Signal-first, zero re-render.","directories":{},"sideEffects":false,"_nodeVersion":"24.12.0","_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.3.5","vite":"^5.4.11","jsdom":"^25.0.1","preact":"^10.25.4","vitest":"^2.1.8","typescript":"^5.7.3","@types/node":"^22.10.5","@biomejs/biome":"^1.9.4","@preact/signals":"^2.0.1","@preact/preset-vite":"^2.10.1","@testing-library/preact":"^3.2.4","@dmytromykhailiuk/preact-signal-hook-forms":"file:../preact-signal-hook-forms"},"peerDependencies":{"preact":">=10.25.0","@preact/signals":"^2.0.0","@dmytromykhailiuk/preact-signal-hook-forms":">=0.1.0"},"_npmOperationalInternal":{"tmp":"tmp/preact-signal-formly_0.1.0_1784580093142_0.5693811681257812","host":"s3://npm-registry-packages-npm-production"}},"0.1.1":{"name":"@dmytromykhailiuk/preact-signal-formly","version":"0.1.1","keywords":["preact","preact-signals","signals","forms","form","formly","dynamic-forms","json-forms","form-builder","form-validation","validation","field-array","reactive","zero-rerender","typescript","typed"],"author":{"name":"Dmytro Mykhailiuk","email":"dimamykhayluk@gmail.com"},"license":"MIT","_id":"@dmytromykhailiuk/preact-signal-formly@0.1.1","maintainers":[{"name":"dmytromykhailiuk","email":"dimamykhayluk@gmail.com"}],"homepage":"https://github.com/dmytromykhailiuk/preact-signal-formly#readme","bugs":{"url":"https://github.com/dmytromykhailiuk/preact-signal-formly/issues"},"dist":{"shasum":"96f16d8326af9165b3f3dcb925b759b10e4aa32a","tarball":"https://registry.npmjs.org/@dmytromykhailiuk/preact-signal-formly/-/preact-signal-formly-0.1.1.tgz","fileCount":9,"integrity":"sha512-1bOeClRxZgFRACM3FSDIgcQic+8l4/wZvBjMmp8RqXBXNFvkFY/qxHsW2qDWAPLLRxB79hV62IX/wuedMXuKAQ==","signatures":[{"sig":"MEQCIGVbsWIvhH1mmVe/qEMQ+y5myo7Jpc7z8pMP1E/3MLWLAiBqU28Ifr5cvgBYCa5cxzo8rEaGTmMasEBWQVp0MmjnxQ==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":417869},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","engines":{"node":">=18"},"exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}},"./package.json":"./package.json"},"gitHead":"b839b8d4ee420246ffcb25e35385594fa728d430","scripts":{"dev":"tsup --watch","lint":"biome check .","test":"vitest run","build":"tsup","format":"biome format --write .","lint:fix":"biome check --write .","typecheck":"tsc --noEmit","playground":"vite --config vite.playground.config.ts","test:watch":"vitest","prepublishOnly":"npm run build"},"_npmUser":{"name":"dmytromykhailiuk","email":"dimamykhayluk@gmail.com"},"repository":{"url":"git+https://github.com/dmytromykhailiuk/preact-signal-formly.git","type":"git"},"_npmVersion":"11.6.2","description":"Dynamic, config-driven forms for Preact — a Formly analogue built entirely on @preact/signals and preact-signal-hook-forms. Signal-first, zero re-render.","directories":{},"sideEffects":false,"_nodeVersion":"24.12.0","_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.3.5","vite":"^5.4.11","jsdom":"^25.0.1","preact":"^10.25.4","vitest":"^2.1.8","typescript":"^5.7.3","@types/node":"^22.10.5","@biomejs/biome":"^1.9.4","@preact/signals":"^2.0.1","@preact/preset-vite":"^2.10.1","@testing-library/preact":"^3.2.4","@dmytromykhailiuk/preact-signal-hook-forms":"file:../preact-signal-hook-forms"},"peerDependencies":{"preact":">=10.25.0","@preact/signals":"^2.0.0","@dmytromykhailiuk/preact-signal-hook-forms":">=0.1.0"},"_npmOperationalInternal":{"tmp":"tmp/preact-signal-formly_0.1.1_1784580599753_0.7895669272113348","host":"s3://npm-registry-packages-npm-production"}},"0.1.2":{"name":"@dmytromykhailiuk/preact-signal-formly","version":"0.1.2","keywords":["preact","preact-signals","signals","forms","form","formly","dynamic-forms","json-forms","form-builder","form-validation","validation","field-array","reactive","zero-rerender","typescript","typed"],"author":{"name":"Dmytro Mykhailiuk","email":"dimamykhayluk@gmail.com"},"license":"MIT","_id":"@dmytromykhailiuk/preact-signal-formly@0.1.2","maintainers":[{"name":"dmytromykhailiuk","email":"dimamykhayluk@gmail.com"}],"homepage":"https://github.com/dmytromykhailiuk/preact-signal-formly#readme","bugs":{"url":"https://github.com/dmytromykhailiuk/preact-signal-formly/issues"},"dist":{"shasum":"f1919bd6c08c6049b4a4231438e94d51145bae2a","tarball":"https://registry.npmjs.org/@dmytromykhailiuk/preact-signal-formly/-/preact-signal-formly-0.1.2.tgz","fileCount":9,"integrity":"sha512-iSNSlURtMIBTKWHPlm/s6gC2A0olFYco/J6yyd7Hs1w6qcet7hYQ7opHfcCUdJzHwG4eGHWmvntFQqCKyZDDkw==","signatures":[{"sig":"MEQCIClTjEaPP+9FkSq9B0vsKP8gsCVe1qGJlRYsKfI0CL0xAiBed8pBypyxZTsXnlfeUIsI27SVGkuzfny+Agi1uFjnYw==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":417869},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","engines":{"node":">=18"},"exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}},"./package.json":"./package.json"},"gitHead":"18ff2110e4bcc64bc5603260214e68ac669c728f","scripts":{"dev":"tsup --watch","lint":"biome check .","test":"vitest run","build":"tsup","format":"biome format --write .","lint:fix":"biome check --write .","typecheck":"tsc --noEmit","playground":"vite --config vite.playground.config.ts","test:watch":"vitest","prepublishOnly":"npm run build"},"_npmUser":{"name":"dmytromykhailiuk","email":"dimamykhayluk@gmail.com"},"repository":{"url":"git+https://github.com/dmytromykhailiuk/preact-signal-formly.git","type":"git"},"_npmVersion":"11.6.2","description":"Dynamic, config-driven forms for Preact — a Formly analogue built entirely on @preact/signals and preact-signal-hook-forms. Signal-first, zero re-render.","directories":{},"sideEffects":false,"_nodeVersion":"24.12.0","_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.3.5","vite":"^5.4.11","jsdom":"^25.0.1","preact":"^10.25.4","vitest":"^2.1.8","typescript":"^5.7.3","@types/node":"^22.10.5","@biomejs/biome":"^1.9.4","@preact/signals":"^2.0.1","@preact/preset-vite":"^2.10.1","@testing-library/preact":"^3.2.4","@dmytromykhailiuk/preact-signal-hook-forms":"file:../preact-signal-hook-forms"},"peerDependencies":{"preact":">=10.25.0","@preact/signals":"^2.0.0","@dmytromykhailiuk/preact-signal-hook-forms":">=0.1.0"},"_npmOperationalInternal":{"tmp":"tmp/preact-signal-formly_0.1.2_1784582267420_0.20905468878527267","host":"s3://npm-registry-packages-npm-production"}},"1.0.0":{"name":"@dmytromykhailiuk/preact-signal-formly","version":"1.0.0","keywords":["preact","preact-signals","signals","forms","form","formly","dynamic-forms","json-forms","form-builder","form-validation","validation","field-array","reactive","zero-rerender","typescript","typed"],"author":{"name":"Dmytro Mykhailiuk","email":"dimamykhayluk@gmail.com"},"license":"MIT","_id":"@dmytromykhailiuk/preact-signal-formly@1.0.0","maintainers":[{"name":"dmytromykhailiuk","email":"dimamykhayluk@gmail.com"}],"homepage":"https://github.com/dmytromykhailiuk/preact-signal-formly#readme","bugs":{"url":"https://github.com/dmytromykhailiuk/preact-signal-formly/issues"},"dist":{"shasum":"fc05aed3676cd192ebbfdc71d1fea88c7417ad2c","tarball":"https://registry.npmjs.org/@dmytromykhailiuk/preact-signal-formly/-/preact-signal-formly-1.0.0.tgz","fileCount":9,"integrity":"sha512-uClA6JC7MpVg5ucXA3ghgRFjuVMInf6AOK7z2fuCy/kcK6KtGphN6asCtao74e6W52I6CeSfGaL5bm7g/LAz5Q==","signatures":[{"sig":"MEQCIHWakQay8wKepHqoR2bhnFOFXTpcv51QqnPcoV1VJ+HZAiBWh+WkeVqhGHG7rQQBMt+s5YoyfSFcz0ojS1jbfe5/fg==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":457974},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","engines":{"node":">=18"},"exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}},"./package.json":"./package.json"},"gitHead":"edcdda8178a8694a686e6e09378c18a8848fde20","scripts":{"dev":"tsup --watch","lint":"biome check .","test":"vitest run","build":"tsup","format":"biome format --write .","lint:fix":"biome check --write .","typecheck":"tsc --noEmit","playground":"vite --config vite.playground.config.ts","test:watch":"vitest","prepublishOnly":"npm run build"},"_npmUser":{"name":"dmytromykhailiuk","email":"dimamykhayluk@gmail.com"},"repository":{"url":"git+https://github.com/dmytromykhailiuk/preact-signal-formly.git","type":"git"},"_npmVersion":"11.6.2","description":"Dynamic, config-driven forms for Preact — a Formly analogue built entirely on @preact/signals and preact-signal-hook-forms. Signal-first, zero re-render.","directories":{},"sideEffects":false,"_nodeVersion":"24.12.0","_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.3.5","vite":"^5.4.11","jsdom":"^25.0.1","preact":"^10.25.4","vitest":"^2.1.8","typescript":"^5.7.3","@types/node":"^22.10.5","@biomejs/biome":"^1.9.4","@preact/signals":"^2.0.1","@preact/preset-vite":"^2.10.1","@testing-library/preact":"^3.2.4","@dmytromykhailiuk/preact-signal-hook-forms":"file:../preact-signal-hook-forms"},"peerDependencies":{"preact":">=10.25.0","@preact/signals":"^2.0.0","@dmytromykhailiuk/preact-signal-hook-forms":">=0.1.0"},"_npmOperationalInternal":{"tmp":"tmp/preact-signal-formly_1.0.0_1785326722074_0.5736142274075173","host":"s3://npm-registry-packages-npm-production"}},"1.0.1":{"name":"@dmytromykhailiuk/preact-signal-formly","version":"1.0.1","description":"Dynamic, config-driven forms for Preact — a Formly analogue built entirely on @preact/signals and preact-signal-hook-forms. Signal-first, zero re-render.","type":"module","sideEffects":false,"author":{"name":"Dmytro Mykhailiuk","email":"dimamykhayluk@gmail.com"},"license":"MIT","keywords":["preact","preact-signals","signals","forms","form","formly","dynamic-forms","json-forms","form-builder","form-validation","validation","field-array","reactive","zero-rerender","typescript","typed"],"main":"./dist/index.cjs","module":"./dist/index.js","types":"./dist/index.d.ts","exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}},"./package.json":"./package.json"},"scripts":{"build":"tsup","dev":"tsup --watch","playground":"vite --config vite.playground.config.ts","typecheck":"tsc --noEmit","test":"vitest run","test:watch":"vitest","lint":"biome check .","lint:fix":"biome check --write .","format":"biome format --write .","prepublishOnly":"npm run build"},"engines":{"node":">=18"},"peerDependencies":{"@dmytromykhailiuk/preact-signal-hook-forms":">=0.1.0","@preact/signals":"^2.0.0","preact":">=10.25.0"},"devDependencies":{"@biomejs/biome":"^1.9.4","@dmytromykhailiuk/preact-signal-hook-forms":"file:../preact-signal-hook-forms","@preact/preset-vite":"^2.10.1","@preact/signals":"^2.0.1","@testing-library/preact":"^3.2.4","@types/node":"^22.10.5","jsdom":"^25.0.1","preact":"^10.25.4","tsup":"^8.3.5","typescript":"^5.7.3","vite":"^5.4.11","vitest":"^2.1.8"},"repository":{"type":"git","url":"git+https://github.com/dmytromykhailiuk/preact-signal-formly.git"},"bugs":{"url":"https://github.com/dmytromykhailiuk/preact-signal-formly/issues"},"homepage":"https://dmytromykhailiuk.github.io/preact-signal-formly/","gitHead":"3d20695c6c351bb661507004d9aca1abb36e57bc","_id":"@dmytromykhailiuk/preact-signal-formly@1.0.1","_nodeVersion":"24.12.0","_npmVersion":"11.6.2","dist":{"integrity":"sha512-p5vVZrhWSvFZB6vdPu0S9A2g1ofnU7/XvoL+LeEyxz3Yk+WGADJ5FRZ96poOU1FyKKjHD4f30LWmrsJct/ZDQQ==","shasum":"f882fd4ac46c06ff04c0c850b0fa21e0cbc1e135","tarball":"https://registry.npmjs.org/@dmytromykhailiuk/preact-signal-formly/-/preact-signal-formly-1.0.1.tgz","fileCount":9,"unpackedSize":457967,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQC7MihTkb62XM8Qlq17a3jfTS5Qyc1vD98PRVwSfGG3eQIhAJR8kMijp7dnDR+nl3wY5NBkiSkZejJPXO9Ex2pZjHdu"}]},"_npmUser":{"name":"dmytromykhailiuk","email":"dimamykhayluk@gmail.com"},"directories":{},"maintainers":[{"name":"dmytromykhailiuk","email":"dimamykhayluk@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/preact-signal-formly_1.0.1_1786638971290_0.3344966184362894"},"_hasShrinkwrap":false}},"time":{"created":"2026-07-20T20:41:32.892Z","modified":"2026-08-13T16:36:11.976Z","0.1.0":"2026-07-20T20:41:33.389Z","0.1.1":"2026-07-20T20:49:59.905Z","0.1.2":"2026-07-20T21:17:47.556Z","1.0.0":"2026-07-29T12:05:22.237Z","1.0.1":"2026-08-13T16:36:11.429Z"},"bugs":{"url":"https://github.com/dmytromykhailiuk/preact-signal-formly/issues"},"author":{"name":"Dmytro Mykhailiuk","email":"dimamykhayluk@gmail.com"},"license":"MIT","homepage":"https://dmytromykhailiuk.github.io/preact-signal-formly/","keywords":["preact","preact-signals","signals","forms","form","formly","dynamic-forms","json-forms","form-builder","form-validation","validation","field-array","reactive","zero-rerender","typescript","typed"],"repository":{"type":"git","url":"git+https://github.com/dmytromykhailiuk/preact-signal-formly.git"},"description":"Dynamic, config-driven forms for Preact — a Formly analogue built entirely on @preact/signals and preact-signal-hook-forms. Signal-first, zero re-render.","maintainers":[{"name":"dmytromykhailiuk","email":"dimamykhayluk@gmail.com"}],"readme":"# @dmytromykhailiuk/preact-signal-formly\n\nDynamic, config-driven forms for **Preact** — a [Formly](https://formly.dev/) analogue built\nentirely on [@preact/signals](https://github.com/preactjs/signals) and\n[@dmytromykhailiuk/preact-signal-hook-forms](https://www.npmjs.com/package/@dmytromykhailiuk/preact-signal-hook-forms/).\n\n> **Full documentation:** open [Docs](https://dmytromykhailiuk.github.io/preact-signal-formly/) in a browser — every option, with\n> examples, a table of contents and cross-links. This README is the short form.\n\n**Signal-first, zero re-render.** Field components mount once; every update — values, dynamic\nprops, validation messages, visibility — flows through signals bound directly to DOM attributes\nand text content. Iteration and conditional display use `For`/`Show` from `@preact/signals/utils`\ninstead of re-rendering conditions and loops.\n\n## Install\n\n```sh\nnpm i @dmytromykhailiuk/preact-signal-formly @dmytromykhailiuk/preact-signal-hook-forms @preact/signals preact\n```\n\n> Requires `@preact/signals` **^2.0.0** (the `For`/`Show` utilities live in the `/utils`\n> subpath, which is a signals v2 feature).\n\n## Quick start\n\n```tsx\nimport { signal } from \"@preact/signals\";\nimport {\n  createFormlyFormBuilder,\n  defineFields,\n} from \"@dmytromykhailiuk/preact-signal-formly\";\n\n// 1. Build once (module scope) — register anything you need, then build().\nconst FormlyForm = createFormlyFormBuilder().build<{\n  email: string;\n  bio: string;\n}>();\n\n// 2. Three writable signals: model, config, formState.\nconst model = signal({ email: \"\", bio: \"\" });\nconst formState = signal(undefined);\nconst config = signal(\n  defineFields([\n    { key: \"email\", type: \"input\", props: { label: \"Email\", required: true } },\n    { key: \"bio\", type: \"textarea\", props: { label: \"Bio\" } },\n  ])\n);\n\nexport function App() {\n  return (\n    <FormlyForm\n      model={model}\n      config={config}\n      formState={formState}\n      onSubmit={console.log}\n    >\n      <button type=\"submit\">Send</button>\n    </FormlyForm>\n  );\n}\n```\n\n- `model` is two-way synced with the form values: typing updates `model.value`; writing a\n  new object to `model.value` updates the inputs. Treat the value as immutable — always write a\n  new object. A deep-equal write is a no-op; a different value goes through `setValue`, which\n  **dirties** the form (use `controlRef` + `control.reset()` to set a new pristine baseline).\n- `formState` is two-way synced with `form.formState.shared` from the base library — a\n  writable scratch signal for cross-field/app state (both sides always hold the same reference).\n- `config` is read reactively. Replace it with a new array and mounted fields update in\n  place through signals (matched by `key`), without remounting.\n- `defaultValue` on a field config applies when the model holds no value at that path (the\n  model always wins). It is registered as the control's default for that path, so the field\n  starts pristine and `control.reset()` restores it — including for fields that appear in the\n  config later. It works on every kind of field: a scalar on a leaf, a whole object on a group,\n  the initial rows on an array. Values are deep-cloned, so the form can never mutate your\n  config object. An array field whose `defaultValue` is not an array throws, naming the path.\n- `formOptions` is forwarded to the base library's `useForm`, read once at mount — most\n  notably `mode`/`reValidateMode`, which any field can override. See\n  [When validation runs](#when-validation-runs).\n\n## The signal rules\n\nThe whole API hands you signals and callbacks — never changing plain values:\n\n1. Never read `signal.value` in a component body — that subscribes the component and causes\n   re-renders. Unwrap only inside `computed` / `useComputed` / `effect` / `useSignalEffect`.\n2. Pass signals directly to DOM attributes and text content: `disabled={disabledComputed}`,\n   `<span>{label}</span>`.\n3. Use `<For each={signal}>` for lists and `<Show when={signal}>` for conditionals\n   (both from `@preact/signals/utils`).\n\n## Field config\n\n```ts\ninterface FormlyFieldConfig {\n  key?: string | number;        // path segment relative to the parent\n  type?: string;                // registered type name\n  props?: { label, placeholder, description, disabled, required,\n            min, max, minLength, maxLength, pattern, options, type, ... };\n  defaultValue?: any;           // applied when the model has no value at this path\n  className?: string;\n  wrappers?: string[];          // overrides the type's default wrappers; [0] is outermost\n  hide?: boolean | Signal<boolean> | ((ctx) => boolean) | string;  // string = expression\n  expressions?: {               // dynamic overrides, evaluated in computeds\n    \"hide\"?: ((ctx) => boolean) | string,\n    \"className\"?: value | Signal | ((ctx) => value) | { $expr: string },\n    \"props.<name>\"?: value | Signal | ((ctx) => value) | { $expr: string },\n  };\n  validators?: {\n    validation?: string[];      // names registered via registerValidator\n    [name: string]: fn | { expression: fn; message?: string | fn };\n  };\n  validation?: {\n    messages?: Record<string, string | ((error, field) => string)>;\n    mode?: ValidationMode;      // overrides formOptions.mode for this field\n    reValidateMode?: ReValidateMode;\n  };\n  fieldGroup?: FormlyFieldConfig[];      // nested group (keyless = transparent)\n  fieldGroupClassName?: string;\n  fieldArray?: FormlyFieldConfig | ((index: number) => FormlyFieldConfig);\n  hooks?: { onInit?, onDestroy? };\n}\n```\n\nExpression callbacks receive `{ model, formState, field, control, namePath }` — `model` and\n`formState` are signals; reading them inside the expression makes it reactive (expressions are\nevaluated inside computeds, so no component re-renders).\n\nThe three keys are `\"hide\"`, `\"className\"` and `\"props.<name>\"`. Anything else is a mistake:\nit is reported once through `console.error`, naming the field, rather than being silently\nignored. TypeScript rejects a wrong _prefix_ (`\"prop.disabled\"`) outright; a wrong prop _name_\n(`\"props.disbaled\"`) can only be caught at runtime, and is — which matters most for configs\nparsed from JSON, where there are no types at all.\n\n`expressions[\"className\"]` overrides `config.className`. Field types and wrappers read the\nresolved value as `ctx.className` (a signal), not from `config.className`.\n\n### String expressions (JSON configs)\n\nA config that arrives from a backend is JSON, and JSON cannot carry a callback. So an\nexpression may also be written as a string:\n\n```json\n{\n  \"key\": \"city\",\n  \"type\": \"input\",\n  \"hide\": \"!model.value.address.country\",\n  \"expressions\": {\n    \"props.disabled\": { \"$expr\": \"!model.value.address.country\" },\n    \"props.placeholder\": {\n      \"$expr\": \"model.value.address.country ? 'Enter a city' : 'Pick a country'\"\n    }\n  }\n}\n```\n\n`hide` accepts a **bare string** — its target is a boolean, so a string there could never have\nbeen a static value. Everywhere else an expression must be wrapped in `{ \"$expr\": \"...\" }`, so\nthat a plain string stays a plain string (`\"props.label\": \"Name\"` keeps working).\n\nThe string is **parsed and interpreted, never compiled** — no `eval`, no `new Function`. This\nis not only a CSP question: a config from a backend is untrusted input, and compiling it would\nbe remote code execution in the user's browser. The grammar is the security boundary:\n\n- read from `model`, `formState`, `field`, `namePath` — and nothing else; there is no way to\n  name `window`, `fetch` or `constructor`;\n- member access and indexing: `model.value.items[0].id` (a missing branch yields `undefined`\n  rather than throwing, so `?.` is never required);\n- literals, `!`, `- + * / %`, `=== !== == != < > <= >=`, `&& || ??`, and `a ? b : c`;\n- calls to a fixed list of pure, non-mutating methods — `includes`, `startsWith`, `endsWith`,\n  `indexOf`, `lastIndexOf`, `slice`, `toLowerCase`, `toUpperCase`, `trim`, `charAt`, `at`,\n  `split` on strings; `includes`, `indexOf`, `lastIndexOf`, `slice`, `join`, `at` on arrays.\n  Anything that can amplify its input (`repeat`, `padStart`) is deliberately excluded.\n\nThere is no assignment, no `new`, and no way to call an arbitrary function — including one\nthat happens to live in your own model. A malformed expression throws, naming the source;\nparsed expressions are cached, so a string is parsed once however often it is evaluated.\n\n`props.required/min/max/minLength/maxLength/pattern` map to the base library's built-in rules.\n\n## Builder API\n\n```ts\nconst builder = createFormlyFormBuilder({ builtIns: true })\n  .registerType(name, component, { wrappers, defaultProps, extends })\n  .registerArrayType(name, component)\n  .registerWrapper(name, component)\n  .registerLazyType(name, loader, { wrappers, defaultProps, extends, errorFallback })\n  .registerLazyArrayType(name, loader, { errorFallback })\n  .registerLazyWrapper(name, loader, { errorFallback })\n  .registerValidator(name, fn, defaultMessage?)\n  .registerValidationMessage(errorType, message)\n  .registerExtension(name, { prePopulate, onPopulate, postPopulate });\n\nconst FormlyForm = builder.build<Model>();\n```\n\n- `build()` snapshots the registry — registrations made afterwards don't affect already-built\n  components. Call `build()` again to pick them up.\n- Re-registering a name overrides it (including built-ins). `{ builtIns: false }` starts headless.\n- Built-ins: types `input`, `textarea`, `select`, `checkbox`, `radio`; wrapper `\"field\"`\n  (label + description + error); default messages for the built-in rules.\n- `extends` inherits `wrappers` and `defaultProps` from another registered type. The component\n  is **not** inherited — `registerType` always takes its own. A circular chain throws.\n- Extensions mutate the config draft during resolution, in the order\n  `prePopulate` → type defaults merge → `onPopulate` → `postPopulate`. The draft is a private\n  clone, so the config object you passed is never touched.\n- `hooks.onInit` runs once after a field mounts and may return a cleanup function;\n  `hooks.onDestroy` runs on unmount, including when the field is removed from the config.\n- The `registerLazy*` methods take a loader instead of a component — see below.\n\n## Lazy types & wrappers\n\nA registry usually outlives any single form: a rich text editor, a date picker, a map picker\nare all registered up front, and most forms use none of them. The `registerLazy*` methods take\na **loader** — a function returning a dynamic `import()` — so the component travels in its own\nchunk, fetched only when a field that uses it first renders.\n\n```tsx\nconst builder = createFormlyFormBuilder()\n  .registerLazyType(\"rating\", () => import(\"./types/Rating\"), {\n    wrappers: [\"field\"],\n    defaultProps: { max: 5 },\n  })\n  .registerLazyArrayType(\"phones\", () => import(\"./types/PhonesArray\"))\n  .registerLazyWrapper(\"card\", () => import(\"./wrappers/Card\"));\n```\n\nEverything else is unchanged: the name is used in configs exactly as an eagerly registered one\n(`{ key: \"score\", type: \"rating\" }`), and re-registering a name still overrides it — including\nreplacing an eager registration with a lazy one, or a built-in with a lazy override.\n\n**The loader** may resolve to the component itself or to a module whose `default` export is the\ncomponent, so `() => import(\"./Rating\")` works as is. For a named export, map it in the loader:\n\n```ts\n.registerLazyType(\"stars\", () => import(\"./types/Stars\").then((m) => m.StarsType))\n```\n\nIt is called **at most once per registration**, however many fields, array rows or built forms\nuse the name — the result is memoised and shared.\n\n**While the chunk is in flight, the slot renders nothing.** There is no fallback or spinner:\nthe field itself is already live. Its `defaultValue` is seeded and its validation rules are\nattached when the *field* mounts, not when the component arrives, so the model is correct and\nthe form validates while the import is still on the wire.\n\n**Registration options are eager — only the component is lazy.** `wrappers`, `defaultProps` and\n`extends` are plain data read while the config resolves, so they behave exactly as on\n`registerType`. One visible consequence: a lazy type's wrappers render immediately, so the\nbuilt-in `\"field\"` wrapper shows its label and error message around the still-empty slot.\n\nA lazy **wrapper** owns its children, so a field wrapped in a wrapper that is still loading\nrenders nothing until it lands. Wrappers nest as always — `wrappers[0]` stays outermost whether\nthe wrappers are eager, lazy or a mix.\n\n**If a chunk fails to load** — a network blip, a stale hashed filename after a deploy — the form\ndoes not crash. The failure is reported through `console.error`, the optional `errorFallback`\nrenders in the slot (without it the slot just stays empty), and the import is **retried the next\ntime a field using that name mounts**:\n\n```tsx\n.registerLazyWrapper(\"card\", () => import(\"./wrappers/Card\"), {\n  errorFallback: (error) => <p class=\"load-error\">Could not load this field: {String(error)}</p>,\n})\n```\n\n**Zero re-render still holds.** A chunk arriving is a signal flip consumed by `<Show>` — not\ncomponent state, not `Suspense`. The loaded component mounts once and never re-renders, and\nnothing around it re-renders either: not the form, not the field, not its wrappers, not the\nsibling fields.\n\n**Typing.** `registerLazyType` widens the builder's type map just like `registerType`, so\n`defineFields<BuilderTypes<typeof builder>>` keeps narrowing `props` by type name. Where props\ncannot be inferred through the loader's promise, name them:\n\n```ts\n.registerLazyType<\"rating\", RatingProps>(\"rating\", () => import(\"./types/Rating\"))\n```\n\n## Writing a custom type\n\nA type gets `control` and `namePath` and binds its input itself, exactly as it would with\n`@dmytromykhailiuk/preact-signal-hook-forms` outside of formly.\n\n**Uncontrolled** — spread `control.register(namePath)` onto the element:\n\n```tsx\nimport { useComputed } from \"@preact/signals\";\nimport { createFieldType } from \"@dmytromykhailiuk/preact-signal-formly\";\n\nconst Text = createFieldType<{ placeholder?: string }>(\n  ({ control, namePath, props, errorMessage, id }) => {\n    // Renders exactly once. All dynamism = signals.\n    const placeholder = useComputed(() => props.value.placeholder ?? \"\");\n    return (\n      <div>\n        <input\n          {...control.register(namePath)}\n          id={id}\n          placeholder={placeholder}\n        />\n        <span role=\"alert\">{errorMessage}</span>\n      </div>\n    );\n  }\n);\n```\n\n**Controlled** — for widgets with no DOM input of their own, use `<Controller>` or\n`useController` from the base library:\n\n```tsx\nimport { Controller } from \"@dmytromykhailiuk/preact-signal-hook-forms\";\n\nconst Rating = createFieldType<{ max?: number }>(\n  ({ control, namePath, props, id }) => {\n    const max = useComputed(() => props.value.max ?? 5);\n    return (\n      <Controller\n        control={control}\n        name={namePath}\n        // field.value is a signal; field.onChange/onBlur are callbacks\n        render={({ field }) => (\n          <div id={id}>\n            <span>{field.value}</span> / <span>{max}</span>\n            <button\n              type=\"button\"\n              onClick={() => field.onChange((field.value.peek() ?? 0) + 1)}\n            >\n              +\n            </button>\n          </div>\n        )}\n      />\n    );\n  }\n);\n\nbuilder.registerType(\"rating\", Rating, { wrappers: [\"field\"] });\n```\n\n`control.register(namePath)` carries the rules derived from the field config (`required`,\n`pattern`, `validators`, …); options you pass explicitly win over them. `<Controller>` and\n`useController` leave the rules alone, so they keep working too — and a type that binds\nnothing at all still validates, because the rules live on the field node.\n\nThe context contains: `control` (the `FormControl`, scoped to this field), `namePath`\n(the field's dot-path, e.g. `\"items.0.name\"` — the field's `name`), `formState` (the shared\nsignal), `config` (readonly signal of the resolved field config), plus `props`, `className`\n(both signals, with their `expressions` applied), `fieldState`, `errorMessage`, `id`. Wrappers\nget the same plus `children` (`createWrapper`). Array types get `array`\n(append/remove/move/…) and `renderItems()` (`createArrayType`).\n\n## Validation\n\nThree layers, all reported per field as signals with resolved messages:\n\n1. **Built-in rules** from `props`: `required`, `min`, `max`, `minLength`, `maxLength`, `pattern`.\n2. **Registered validators**: `registerValidator(\"email\", fn, \"Invalid email\")`, used via\n   `validators: { validation: [\"email\"] }`.\n3. **Inline validators**: `validators: { myCheck: (value, model, field) => boolean | string }`\n   or `{ expression, message }`. Return `true`/`undefined` = valid, `false` = invalid (message\n   resolved from the registry), a string = inline error message. Async validators are supported\n   and receive an `AbortSignal`.\n\nMessage precedence: field `validation.messages` → inline entry `message` → registry message →\nvalidator default → validator's returned string → error type. Messages may be functions\n`(error, field) => string`.\n\n### When validation runs\n\n`mode` decides the behaviour before the first submit; `reValidateMode` takes over once the form\nhas been submitted **or** while the field is showing an error.\n\n| `mode`              | before the first submit              |\n| ------------------- | ------------------------------------ |\n| `\"all\"` _(default)_ | on change and on blur                |\n| `\"onChange\"`        | on every change                      |\n| `\"onBlur\"`          | on blur                              |\n| `\"onTouched\"`       | first on blur, then on every change  |\n| `\"onSubmit\"`        | never — only on submit / `trigger()` |\n\n`reValidateMode` is `\"onChange\"` (default), `\"onBlur\"` or `\"onSubmit\"`.\n\nThe default is `\"all\"` — a config-driven form is usually a long one, and telling someone at\nsubmit time about a field they filled in ten fields ago is the worse default. Note that this\n**differs from the base library**, which defaults to `\"onSubmit\"`. To get that behaviour back:\n\n```tsx\n<FormlyForm … formOptions={{ mode: \"onSubmit\" }} />\n```\n\nSet it for the whole form through `formOptions`, and override it per field through\n`validation` in the field config — a field may be stricter _or_ looser than its form:\n\n```tsx\n<FormlyForm\n  model={model}\n  config={config}\n  formState={formState}\n  formOptions={{ mode: \"onBlur\", reValidateMode: \"onChange\" }}\n/>\n```\n\n```ts\ndefineFields([\n  // validates on every keystroke, though the form is \"onBlur\"\n  { key: \"slug\", type: \"input\", validation: { mode: \"onChange\" } },\n  // stays quiet until submit, though the form is not\n  { key: \"notes\", type: \"textarea\", validation: { mode: \"onSubmit\" } },\n]);\n```\n\n`formOptions` is forwarded to the base library's `useForm` and read once at mount, so it also\ncarries `resolver` (zod/yup schemas), `criteriaMode`, `delayError` and `shouldFocusError`.\n`defaultValues` is not accepted — the `model` prop and each field's `defaultValue` own that.\n\nExplicit validation — `trigger()`, `handleSubmit()`, `setValue({ shouldValidate: true })` — is\nunaffected by any of this and always runs.\n\n## Groups & arrays\n\n- **Groups**: `fieldGroup` nests fields; a `key` prefixes child paths (`address.city`), a\n  keyless group is purely visual.\n- **Arrays**: `fieldArray` is the item template (or a factory receiving the index). Item paths\n  are indexed automatically (`phones.0.number`). Register an array type to own layout and\n  add/remove buttons; without one, items render bare. Rows remount on structural changes\n  (append/remove/move) by design — the base library re-creates the child field nodes.\n\n## Escape hatch\n\n```tsx\n<FormlyForm controlRef={(control) => { /* reset, trigger, handleSubmit, getValues, … */ }} … />\n```\n\n## TypeScript\n\n`defineFields` narrows `props` by the registered type name. Pick up custom registrations with\n`BuilderTypes`:\n\n```ts\nconst builder = createFormlyFormBuilder().registerType(\"rating\", Rating);\nconst fields = defineFields<BuilderTypes<typeof builder>>([\n  { key: \"score\", type: \"rating\", props: { max: 10 } }, // ✓ typed\n]);\n```\n\n## License\n\nMIT\n","readmeFilename":"README.md"}