decantr 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (382) hide show
  1. package/AGENTS.md +868 -0
  2. package/CHANGELOG.md +255 -0
  3. package/CLAUDE.md +178 -0
  4. package/LICENSE +21 -0
  5. package/README.md +229 -0
  6. package/cli/art.js +127 -0
  7. package/cli/commands/a11y.js +61 -0
  8. package/cli/commands/audit.js +225 -0
  9. package/cli/commands/build.js +38 -0
  10. package/cli/commands/dev.js +18 -0
  11. package/cli/commands/doctor.js +197 -0
  12. package/cli/commands/figma-sync.js +48 -0
  13. package/cli/commands/figma-tokens.js +55 -0
  14. package/cli/commands/generate.js +26 -0
  15. package/cli/commands/init.js +116 -0
  16. package/cli/commands/lint.js +209 -0
  17. package/cli/commands/mcp.js +530 -0
  18. package/cli/commands/migrate.js +175 -0
  19. package/cli/commands/test.js +38 -0
  20. package/cli/commands/validate.js +354 -0
  21. package/cli/index.js +113 -0
  22. package/package.json +95 -0
  23. package/reference/atoms.md +517 -0
  24. package/reference/behaviors.md +384 -0
  25. package/reference/build-tooling.md +275 -0
  26. package/reference/color-guidelines.md +965 -0
  27. package/reference/component-lifecycle.md +137 -0
  28. package/reference/compound-spacing.md +95 -0
  29. package/reference/decantation-process.md +499 -0
  30. package/reference/dev-server-routes.md +93 -0
  31. package/reference/form-system.md +253 -0
  32. package/reference/i18n.md +336 -0
  33. package/reference/icons.md +576 -0
  34. package/reference/llm-primer.md +953 -0
  35. package/reference/plugins.md +252 -0
  36. package/reference/registry-consumption.md +76 -0
  37. package/reference/router.md +217 -0
  38. package/reference/shells.md +116 -0
  39. package/reference/spatial-guidelines.md +541 -0
  40. package/reference/ssr.md +234 -0
  41. package/reference/state-data.md +215 -0
  42. package/reference/state-patterns.md +166 -0
  43. package/reference/state.md +194 -0
  44. package/reference/style-system.md +110 -0
  45. package/reference/tokens.md +460 -0
  46. package/src/app.js +19 -0
  47. package/src/chart/_animate.js +266 -0
  48. package/src/chart/_base.js +109 -0
  49. package/src/chart/_data.js +209 -0
  50. package/src/chart/_format.js +106 -0
  51. package/src/chart/_interact.js +364 -0
  52. package/src/chart/_palette.js +105 -0
  53. package/src/chart/_renderer.js +52 -0
  54. package/src/chart/_scene.js +262 -0
  55. package/src/chart/_shared.js +371 -0
  56. package/src/chart/index.js +637 -0
  57. package/src/chart/layouts/_layout-base.js +328 -0
  58. package/src/chart/layouts/cartesian.js +148 -0
  59. package/src/chart/layouts/hierarchy.js +562 -0
  60. package/src/chart/layouts/polar.js +101 -0
  61. package/src/chart/renderers/canvas.js +179 -0
  62. package/src/chart/renderers/svg.js +256 -0
  63. package/src/chart/renderers/webgpu.js +715 -0
  64. package/src/chart/types/_type-base.js +26 -0
  65. package/src/chart/types/area.js +134 -0
  66. package/src/chart/types/bar.js +173 -0
  67. package/src/chart/types/box-plot.js +125 -0
  68. package/src/chart/types/bubble.js +63 -0
  69. package/src/chart/types/candlestick.js +115 -0
  70. package/src/chart/types/chord.js +85 -0
  71. package/src/chart/types/combination.js +108 -0
  72. package/src/chart/types/funnel.js +68 -0
  73. package/src/chart/types/gauge.js +163 -0
  74. package/src/chart/types/heatmap.js +98 -0
  75. package/src/chart/types/histogram.js +71 -0
  76. package/src/chart/types/line.js +111 -0
  77. package/src/chart/types/org-chart.js +93 -0
  78. package/src/chart/types/pie.js +81 -0
  79. package/src/chart/types/radar.js +96 -0
  80. package/src/chart/types/radial.js +68 -0
  81. package/src/chart/types/range-area.js +55 -0
  82. package/src/chart/types/range-bar.js +61 -0
  83. package/src/chart/types/sankey.js +73 -0
  84. package/src/chart/types/scatter.js +66 -0
  85. package/src/chart/types/sparkline.js +81 -0
  86. package/src/chart/types/sunburst.js +69 -0
  87. package/src/chart/types/swimlane.js +88 -0
  88. package/src/chart/types/treemap.js +62 -0
  89. package/src/chart/types/waterfall.js +100 -0
  90. package/src/components/_base.js +1658 -0
  91. package/src/components/_behaviors.js +1140 -0
  92. package/src/components/_primitives.js +534 -0
  93. package/src/components/_qr-encoder.js +539 -0
  94. package/src/components/accordion.js +207 -0
  95. package/src/components/affix.js +62 -0
  96. package/src/components/alert-dialog.js +75 -0
  97. package/src/components/alert.js +47 -0
  98. package/src/components/aspect-ratio.js +24 -0
  99. package/src/components/avatar-group.js +55 -0
  100. package/src/components/avatar.js +38 -0
  101. package/src/components/back-top.js +75 -0
  102. package/src/components/badge.js +74 -0
  103. package/src/components/banner.js +68 -0
  104. package/src/components/breadcrumb.js +162 -0
  105. package/src/components/button.js +115 -0
  106. package/src/components/calendar.js +131 -0
  107. package/src/components/card.js +192 -0
  108. package/src/components/carousel.js +98 -0
  109. package/src/components/cascader.js +261 -0
  110. package/src/components/checkbox.js +80 -0
  111. package/src/components/chip.js +81 -0
  112. package/src/components/code-block.js +82 -0
  113. package/src/components/collapsible.js +50 -0
  114. package/src/components/color-palette.js +438 -0
  115. package/src/components/color-picker.js +314 -0
  116. package/src/components/combobox.js +181 -0
  117. package/src/components/command.js +174 -0
  118. package/src/components/comment.js +206 -0
  119. package/src/components/context-menu.js +76 -0
  120. package/src/components/data-table.js +724 -0
  121. package/src/components/date-picker.js +217 -0
  122. package/src/components/date-range-picker.js +244 -0
  123. package/src/components/datetime-picker.js +271 -0
  124. package/src/components/descriptions.js +68 -0
  125. package/src/components/drawer.js +179 -0
  126. package/src/components/dropdown.js +88 -0
  127. package/src/components/empty.js +41 -0
  128. package/src/components/float-button.js +90 -0
  129. package/src/components/form.js +106 -0
  130. package/src/components/hover-card.js +49 -0
  131. package/src/components/icon.js +87 -0
  132. package/src/components/image.js +97 -0
  133. package/src/components/index.js +117 -0
  134. package/src/components/input-group.js +75 -0
  135. package/src/components/input-number.js +155 -0
  136. package/src/components/input-otp.js +178 -0
  137. package/src/components/input.js +91 -0
  138. package/src/components/kbd.js +36 -0
  139. package/src/components/label.js +25 -0
  140. package/src/components/list.js +118 -0
  141. package/src/components/masked-input.js +236 -0
  142. package/src/components/mentions.js +165 -0
  143. package/src/components/menu.js +259 -0
  144. package/src/components/message.js +80 -0
  145. package/src/components/modal.js +147 -0
  146. package/src/components/navigation-menu.js +166 -0
  147. package/src/components/notification.js +84 -0
  148. package/src/components/pagination.js +104 -0
  149. package/src/components/placeholder.js +132 -0
  150. package/src/components/popconfirm.js +70 -0
  151. package/src/components/popover.js +58 -0
  152. package/src/components/progress.js +61 -0
  153. package/src/components/qrcode.js +251 -0
  154. package/src/components/radiogroup.js +120 -0
  155. package/src/components/range-slider.js +176 -0
  156. package/src/components/rate.js +186 -0
  157. package/src/components/resizable.js +83 -0
  158. package/src/components/result.js +57 -0
  159. package/src/components/scroll-area.js +43 -0
  160. package/src/components/segmented.js +97 -0
  161. package/src/components/select.js +165 -0
  162. package/src/components/separator.js +31 -0
  163. package/src/components/shell.js +407 -0
  164. package/src/components/skeleton.js +39 -0
  165. package/src/components/slider.js +141 -0
  166. package/src/components/sortable-list.js +176 -0
  167. package/src/components/space.js +42 -0
  168. package/src/components/spinner.js +112 -0
  169. package/src/components/splitter.js +147 -0
  170. package/src/components/statistic.js +136 -0
  171. package/src/components/steps.js +99 -0
  172. package/src/components/switch.js +95 -0
  173. package/src/components/table.js +44 -0
  174. package/src/components/tabs.js +216 -0
  175. package/src/components/tag.js +115 -0
  176. package/src/components/textarea.js +82 -0
  177. package/src/components/time-picker.js +153 -0
  178. package/src/components/time-range-picker.js +170 -0
  179. package/src/components/timeline.js +226 -0
  180. package/src/components/toast.js +71 -0
  181. package/src/components/toggle.js +213 -0
  182. package/src/components/tooltip.js +57 -0
  183. package/src/components/tour.js +159 -0
  184. package/src/components/transfer.js +163 -0
  185. package/src/components/tree-select.js +274 -0
  186. package/src/components/tree.js +141 -0
  187. package/src/components/typography.js +136 -0
  188. package/src/components/upload.js +118 -0
  189. package/src/components/visually-hidden.js +20 -0
  190. package/src/components/watermark.js +124 -0
  191. package/src/core/index.js +539 -0
  192. package/src/core/lifecycle.js +69 -0
  193. package/src/css/atoms.js +651 -0
  194. package/src/css/components.js +940 -0
  195. package/src/css/derive.js +1296 -0
  196. package/src/css/index.js +265 -0
  197. package/src/css/runtime.js +268 -0
  198. package/src/css/styles/addons/bioluminescent.js +93 -0
  199. package/src/css/styles/addons/clay.js +70 -0
  200. package/src/css/styles/addons/clean.js +57 -0
  201. package/src/css/styles/addons/command-center.js +143 -0
  202. package/src/css/styles/addons/dopamine.js +83 -0
  203. package/src/css/styles/addons/editorial.js +80 -0
  204. package/src/css/styles/addons/glassmorphism.js +99 -0
  205. package/src/css/styles/addons/liquid-glass.js +105 -0
  206. package/src/css/styles/addons/prismatic.js +100 -0
  207. package/src/css/styles/addons/retro.js +63 -0
  208. package/src/css/styles/auradecantism.js +96 -0
  209. package/src/css/theme-registry.js +444 -0
  210. package/src/data/entity.js +281 -0
  211. package/src/data/index.js +13 -0
  212. package/src/data/persist.js +225 -0
  213. package/src/data/query.js +839 -0
  214. package/src/data/realtime.js +299 -0
  215. package/src/data/url.js +177 -0
  216. package/src/data/worker.js +134 -0
  217. package/src/explorer/archetypes.js +243 -0
  218. package/src/explorer/atoms.js +228 -0
  219. package/src/explorer/charts.js +497 -0
  220. package/src/explorer/components.js +129 -0
  221. package/src/explorer/foundations.js +949 -0
  222. package/src/explorer/icons.js +178 -0
  223. package/src/explorer/patterns.js +247 -0
  224. package/src/explorer/recipes.js +194 -0
  225. package/src/explorer/shared/pattern-examples.js +1337 -0
  226. package/src/explorer/shared/showcase-renderer.js +958 -0
  227. package/src/explorer/shared/spec-table.js +41 -0
  228. package/src/explorer/shared/usage-links.js +87 -0
  229. package/src/explorer/shell-config.js +10 -0
  230. package/src/explorer/shells.js +551 -0
  231. package/src/explorer/styles.js +161 -0
  232. package/src/explorer/tokens.js +262 -0
  233. package/src/explorer/tools.js +525 -0
  234. package/src/form/index.js +804 -0
  235. package/src/i18n/index.js +251 -0
  236. package/src/icons/essential.js +479 -0
  237. package/src/icons/index.js +53 -0
  238. package/src/plugins/index.js +282 -0
  239. package/src/registry/archetypes/content-site.json +71 -0
  240. package/src/registry/archetypes/docs-explorer.json +23 -0
  241. package/src/registry/archetypes/ecommerce.json +104 -0
  242. package/src/registry/archetypes/financial-dashboard.json +77 -0
  243. package/src/registry/archetypes/index.json +41 -0
  244. package/src/registry/archetypes/portfolio.json +82 -0
  245. package/src/registry/archetypes/recipe-community.json +159 -0
  246. package/src/registry/archetypes/saas-dashboard.json +86 -0
  247. package/src/registry/architect/cross-cutting.json +45 -0
  248. package/src/registry/architect/domains/ecommerce.json +294 -0
  249. package/src/registry/architect/domains/financial-services.json +302 -0
  250. package/src/registry/architect/index.json +26 -0
  251. package/src/registry/architect/traits.json +379 -0
  252. package/src/registry/atoms.json +16 -0
  253. package/src/registry/chart-showcase.json +160 -0
  254. package/src/registry/chart.json +136 -0
  255. package/src/registry/components.json +8616 -0
  256. package/src/registry/core.json +216 -0
  257. package/src/registry/css.json +319 -0
  258. package/src/registry/data.json +135 -0
  259. package/src/registry/foundations.json +11 -0
  260. package/src/registry/icons.json +463 -0
  261. package/src/registry/index.json +101 -0
  262. package/src/registry/patterns/activity-feed.json +37 -0
  263. package/src/registry/patterns/article-content.json +27 -0
  264. package/src/registry/patterns/auth-form.json +37 -0
  265. package/src/registry/patterns/author-card.json +20 -0
  266. package/src/registry/patterns/card-grid.json +127 -0
  267. package/src/registry/patterns/category-nav.json +26 -0
  268. package/src/registry/patterns/chart-grid.json +36 -0
  269. package/src/registry/patterns/chat-interface.json +37 -0
  270. package/src/registry/patterns/checklist-card.json +55 -0
  271. package/src/registry/patterns/comparison-panel.json +27 -0
  272. package/src/registry/patterns/component-showcase.json +24 -0
  273. package/src/registry/patterns/contact-form.json +31 -0
  274. package/src/registry/patterns/cta-section.json +20 -0
  275. package/src/registry/patterns/data-table.json +37 -0
  276. package/src/registry/patterns/detail-header.json +83 -0
  277. package/src/registry/patterns/detail-panel.json +27 -0
  278. package/src/registry/patterns/explorer-shell.json +22 -0
  279. package/src/registry/patterns/filter-bar.json +33 -0
  280. package/src/registry/patterns/filter-sidebar.json +27 -0
  281. package/src/registry/patterns/form-sections.json +110 -0
  282. package/src/registry/patterns/goal-tracker.json +27 -0
  283. package/src/registry/patterns/hero.json +107 -0
  284. package/src/registry/patterns/index.json +47 -0
  285. package/src/registry/patterns/kpi-grid.json +36 -0
  286. package/src/registry/patterns/media-gallery.json +20 -0
  287. package/src/registry/patterns/order-history.json +20 -0
  288. package/src/registry/patterns/pagination.json +19 -0
  289. package/src/registry/patterns/photo-to-recipe.json +36 -0
  290. package/src/registry/patterns/pipeline-tracker.json +28 -0
  291. package/src/registry/patterns/post-list.json +27 -0
  292. package/src/registry/patterns/pricing-table.json +32 -0
  293. package/src/registry/patterns/scorecard.json +28 -0
  294. package/src/registry/patterns/search-bar.json +20 -0
  295. package/src/registry/patterns/specimen-grid.json +19 -0
  296. package/src/registry/patterns/stat-card.json +55 -0
  297. package/src/registry/patterns/stats-bar.json +55 -0
  298. package/src/registry/patterns/steps-card.json +55 -0
  299. package/src/registry/patterns/table-of-contents.json +19 -0
  300. package/src/registry/patterns/testimonials.json +21 -0
  301. package/src/registry/patterns/timeline.json +27 -0
  302. package/src/registry/patterns/token-inspector.json +21 -0
  303. package/src/registry/patterns/wizard.json +27 -0
  304. package/src/registry/recipe-auradecantism.json +69 -0
  305. package/src/registry/recipe-clean.json +65 -0
  306. package/src/registry/recipe-command-center.json +78 -0
  307. package/src/registry/router.json +73 -0
  308. package/src/registry/schema/README.md +197 -0
  309. package/src/registry/skeletons.json +259 -0
  310. package/src/registry/state.json +137 -0
  311. package/src/registry/tokens.json +40 -0
  312. package/src/router/hash.js +17 -0
  313. package/src/router/history.js +18 -0
  314. package/src/router/index.js +598 -0
  315. package/src/ssr/index.js +922 -0
  316. package/src/state/arrays.js +181 -0
  317. package/src/state/devtools.js +647 -0
  318. package/src/state/index.js +498 -0
  319. package/src/state/middleware.js +288 -0
  320. package/src/state/scheduler.js +206 -0
  321. package/src/state/store.js +300 -0
  322. package/src/tags/index.js +19 -0
  323. package/src/tannins/auth.js +396 -0
  324. package/src/test/dom.js +352 -0
  325. package/src/test/index.js +62 -0
  326. package/src/test/state.js +306 -0
  327. package/tools/a11y-audit.js +487 -0
  328. package/tools/analyzer.js +315 -0
  329. package/tools/audit.js +706 -0
  330. package/tools/builder.js +1422 -0
  331. package/tools/css-extract.js +188 -0
  332. package/tools/dev-server.js +316 -0
  333. package/tools/dts-gen.js +1260 -0
  334. package/tools/figma-components.js +329 -0
  335. package/tools/figma-patterns.js +516 -0
  336. package/tools/figma-plugin/code.js +453 -0
  337. package/tools/figma-plugin/manifest.json +14 -0
  338. package/tools/figma-plugin/ui.html +268 -0
  339. package/tools/figma-render.js +293 -0
  340. package/tools/figma-tokens.js +712 -0
  341. package/tools/figma-upload.js +318 -0
  342. package/tools/generate.js +738 -0
  343. package/tools/icons.js +133 -0
  344. package/tools/init-templates.js +265 -0
  345. package/tools/install-hooks.sh +5 -0
  346. package/tools/migrations/0.5.0.js +53 -0
  347. package/tools/migrations/0.6.0.js +95 -0
  348. package/tools/minify.js +170 -0
  349. package/tools/pre-commit +4 -0
  350. package/tools/registry.js +662 -0
  351. package/tools/reset-playground.js +61 -0
  352. package/tools/starter-templates/content-site/app.js +49 -0
  353. package/tools/starter-templates/content-site/essence.js +19 -0
  354. package/tools/starter-templates/content-site/pages.js +31 -0
  355. package/tools/starter-templates/ecommerce/app.js +50 -0
  356. package/tools/starter-templates/ecommerce/essence.js +19 -0
  357. package/tools/starter-templates/ecommerce/pages.js +31 -0
  358. package/tools/starter-templates/landing-page/app.js +38 -0
  359. package/tools/starter-templates/landing-page/essence.js +18 -0
  360. package/tools/starter-templates/landing-page/pages.js +21 -0
  361. package/tools/starter-templates/portfolio/app.js +45 -0
  362. package/tools/starter-templates/portfolio/essence.js +19 -0
  363. package/tools/starter-templates/portfolio/pages.js +33 -0
  364. package/tools/starter-templates/saas-dashboard/app.js +70 -0
  365. package/tools/starter-templates/saas-dashboard/essence.js +19 -0
  366. package/tools/starter-templates/saas-dashboard/pages.js +31 -0
  367. package/tools/verify-pack.js +203 -0
  368. package/types/chart.d.ts +77 -0
  369. package/types/components.d.ts +587 -0
  370. package/types/core.d.ts +89 -0
  371. package/types/css.d.ts +149 -0
  372. package/types/data.d.ts +238 -0
  373. package/types/form.d.ts +164 -0
  374. package/types/i18n.d.ts +51 -0
  375. package/types/icons.d.ts +27 -0
  376. package/types/index.d.ts +13 -0
  377. package/types/router.d.ts +116 -0
  378. package/types/ssr.d.ts +102 -0
  379. package/types/state.d.ts +83 -0
  380. package/types/tags.d.ts +62 -0
  381. package/types/tannins.d.ts +63 -0
  382. package/types/test.d.ts +48 -0
@@ -0,0 +1,804 @@
1
+ /**
2
+ * Decantr Form State Management
3
+ * Enterprise-grade reactive form handling built on Decantr signals.
4
+ *
5
+ * @module decantr/form
6
+ *
7
+ * Exports:
8
+ * createForm(config) — Create a reactive form instance
9
+ * validators — Built-in validator factories
10
+ * useFormField(form, name) — Hook for binding custom components
11
+ */
12
+ import { createSignal, createEffect, createMemo, batch } from '../state/index.js';
13
+
14
+ // ─── HELPERS ─────────────────────────────────────────────────────
15
+
16
+ /** @param {any} v @returns {boolean} */
17
+ function _empty(v) {
18
+ if (v == null) return true;
19
+ if (typeof v === 'string') return v.trim() === '';
20
+ if (Array.isArray(v)) return v.length === 0;
21
+ if (typeof v === 'number') return false;
22
+ if (typeof v === 'boolean') return false;
23
+ return false;
24
+ }
25
+
26
+ /** Shallow-clone plain object or return value as-is */
27
+ function _clone(v) {
28
+ if (v && typeof v === 'object' && !Array.isArray(v)) return { ...v };
29
+ if (Array.isArray(v)) return [...v];
30
+ return v;
31
+ }
32
+
33
+ /** Simple deep-equal for plain values/objects/arrays */
34
+ function _eq(a, b) {
35
+ if (Object.is(a, b)) return true;
36
+ if (a == null || b == null) return false;
37
+ if (typeof a !== typeof b) return false;
38
+ if (Array.isArray(a)) {
39
+ if (!Array.isArray(b) || a.length !== b.length) return false;
40
+ for (let i = 0; i < a.length; i++) { if (!_eq(a[i], b[i])) return false; }
41
+ return true;
42
+ }
43
+ if (typeof a === 'object') {
44
+ const ka = Object.keys(a), kb = Object.keys(b);
45
+ if (ka.length !== kb.length) return false;
46
+ for (const k of ka) { if (!_eq(a[k], b[k])) return false; }
47
+ return true;
48
+ }
49
+ return false;
50
+ }
51
+
52
+ /** Debounce a function by `ms` milliseconds. Returns wrapped fn + cancel. */
53
+ function _debounce(fn, ms) {
54
+ let id = null;
55
+ function debounced(...args) {
56
+ if (id !== null) clearTimeout(id);
57
+ id = setTimeout(() => { id = null; fn(...args); }, ms);
58
+ }
59
+ debounced.cancel = () => { if (id !== null) { clearTimeout(id); id = null; } };
60
+ return debounced;
61
+ }
62
+
63
+ // ─── VALIDATORS ──────────────────────────────────────────────────
64
+
65
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
66
+
67
+ /**
68
+ * Built-in validator factories. Each returns a validator function:
69
+ * `(value, allValues) => string|null` (sync) or `Promise<string|null>` (async).
70
+ *
71
+ * @namespace
72
+ */
73
+ export const validators = {
74
+ /**
75
+ * Value must be truthy (non-empty string, non-null, non-undefined).
76
+ * @param {string} [msg='Required']
77
+ * @returns {(value: any) => string|null}
78
+ */
79
+ required(msg) {
80
+ const m = msg || 'Required';
81
+ return (v) => _empty(v) ? m : null;
82
+ },
83
+
84
+ /**
85
+ * String length must be >= n.
86
+ * @param {number} n
87
+ * @param {string} [msg]
88
+ * @returns {(value: any) => string|null}
89
+ */
90
+ minLength(n, msg) {
91
+ const m = msg || `Must be at least ${n} characters`;
92
+ return (v) => (typeof v === 'string' && v.length < n) ? m : null;
93
+ },
94
+
95
+ /**
96
+ * String length must be <= n.
97
+ * @param {number} n
98
+ * @param {string} [msg]
99
+ * @returns {(value: any) => string|null}
100
+ */
101
+ maxLength(n, msg) {
102
+ const m = msg || `Must be at most ${n} characters`;
103
+ return (v) => (typeof v === 'string' && v.length > n) ? m : null;
104
+ },
105
+
106
+ /**
107
+ * Numeric value must be >= n.
108
+ * @param {number} n
109
+ * @param {string} [msg]
110
+ * @returns {(value: any) => string|null}
111
+ */
112
+ min(n, msg) {
113
+ const m = msg || `Must be at least ${n}`;
114
+ return (v) => (typeof v === 'number' && v < n) ? m : null;
115
+ },
116
+
117
+ /**
118
+ * Numeric value must be <= n.
119
+ * @param {number} n
120
+ * @param {string} [msg]
121
+ * @returns {(value: any) => string|null}
122
+ */
123
+ max(n, msg) {
124
+ const m = msg || `Must be at most ${n}`;
125
+ return (v) => (typeof v === 'number' && v > n) ? m : null;
126
+ },
127
+
128
+ /**
129
+ * Value must match regex pattern.
130
+ * @param {RegExp} regex
131
+ * @param {string} [msg]
132
+ * @returns {(value: any) => string|null}
133
+ */
134
+ pattern(regex, msg) {
135
+ const m = msg || `Invalid format`;
136
+ return (v) => (typeof v === 'string' && !regex.test(v)) ? m : null;
137
+ },
138
+
139
+ /**
140
+ * Must be a valid email address.
141
+ * @param {string} [msg]
142
+ * @returns {(value: any) => string|null}
143
+ */
144
+ email(msg) {
145
+ const m = msg || 'Invalid email address';
146
+ return (v) => (typeof v === 'string' && v.length > 0 && !EMAIL_RE.test(v)) ? m : null;
147
+ },
148
+
149
+ /**
150
+ * Value must equal another field's current value.
151
+ * @param {string} fieldName — name of the field to match
152
+ * @param {string} [msg]
153
+ * @returns {(value: any, allValues: Object) => string|null}
154
+ */
155
+ match(fieldName, msg) {
156
+ const m = msg || `Must match ${fieldName}`;
157
+ return (v, all) => (!_eq(v, all[fieldName])) ? m : null;
158
+ },
159
+
160
+ /**
161
+ * Custom synchronous validator.
162
+ * `fn` should return `true` if valid or an error string if invalid.
163
+ * @param {(value: any, allValues: Object) => true|string} fn
164
+ * @param {string} [msg] — fallback message if fn returns non-string falsy
165
+ * @returns {(value: any, allValues: Object) => string|null}
166
+ */
167
+ custom(fn, msg) {
168
+ return (v, all) => {
169
+ const result = fn(v, all);
170
+ if (result === true) return null;
171
+ if (typeof result === 'string') return result;
172
+ return msg || 'Invalid';
173
+ };
174
+ },
175
+
176
+ /**
177
+ * Async validator. `fn` should return a Promise resolving to `true` or error string.
178
+ * Automatically debounced (300ms) when used in a form.
179
+ * @param {(value: any, allValues: Object) => Promise<true|string>} fn
180
+ * @param {string} [msg]
181
+ * @returns {(value: any, allValues: Object) => Promise<string|null>}
182
+ */
183
+ async(fn, msg) {
184
+ const validator = async (v, all) => {
185
+ const result = await fn(v, all);
186
+ if (result === true) return null;
187
+ if (typeof result === 'string') return result;
188
+ return msg || 'Invalid';
189
+ };
190
+ /** @type {any} */ (validator)._async = true;
191
+ return validator;
192
+ },
193
+ };
194
+
195
+ // ─── FIELD INSTANCE ──────────────────────────────────────────────
196
+
197
+ /**
198
+ * @typedef {Object} FieldConfig
199
+ * @property {any} [value] — initial value
200
+ * @property {Array<Function>} [validators] — array of validator functions
201
+ * @property {Function} [transform] — (rawValue) => transformedValue, applied on setValue
202
+ */
203
+
204
+ /**
205
+ * Creates a single reactive field instance.
206
+ * @param {string} name
207
+ * @param {FieldConfig} config
208
+ * @param {Function} getFormValues — () => all form values (for cross-field validators)
209
+ * @param {'onChange'|'onBlur'|'onSubmit'} validateOn
210
+ * @returns {FieldInstance}
211
+ */
212
+ function _createField(name, config, getFormValues, validateOn) {
213
+ const initial = config.value !== undefined ? config.value : '';
214
+ const fieldValidators = config.validators || [];
215
+ const transform = config.transform || null;
216
+
217
+ const [value, _setValue] = createSignal(_clone(initial));
218
+ const [errors, setErrors] = createSignal(/** @type {string[]} */ ([]));
219
+ const [touched, setTouched] = createSignal(false);
220
+
221
+ /** @type {Function|null} */
222
+ let _asyncDebounced = null;
223
+
224
+ // Separate sync and async validators
225
+ const syncValidators = fieldValidators.filter(v => !/** @type {any} */ (v)._async);
226
+ const asyncValidators = fieldValidators.filter(v => /** @type {any} */ (v)._async);
227
+
228
+ /** Run sync validators, return error strings */
229
+ function _runSync(val, allValues) {
230
+ /** @type {string[]} */
231
+ const errs = [];
232
+ for (let i = 0; i < syncValidators.length; i++) {
233
+ const msg = syncValidators[i](val, allValues);
234
+ if (msg) errs.push(msg);
235
+ }
236
+ return errs;
237
+ }
238
+
239
+ /** Run all validators (sync + async), return error strings */
240
+ async function _runAll(val, allValues) {
241
+ const errs = _runSync(val, allValues);
242
+ // Only run async validators if sync passes (short-circuit)
243
+ if (errs.length === 0 && asyncValidators.length > 0) {
244
+ const results = await Promise.all(asyncValidators.map(v => v(val, allValues)));
245
+ for (const msg of results) {
246
+ if (msg) errs.push(msg);
247
+ }
248
+ }
249
+ return errs;
250
+ }
251
+
252
+ /** Trigger validation based on mode; called internally */
253
+ function _triggerValidation(mode) {
254
+ if (validateOn === 'onSubmit' && mode !== 'submit') return;
255
+ if (validateOn === 'onBlur' && mode === 'change') return;
256
+
257
+ const val = value();
258
+ const allValues = getFormValues();
259
+
260
+ // Sync validators run immediately
261
+ const syncErrs = _runSync(val, allValues);
262
+
263
+ if (asyncValidators.length > 0 && syncErrs.length === 0) {
264
+ // Debounce async validators
265
+ if (!_asyncDebounced) {
266
+ _asyncDebounced = _debounce(async () => {
267
+ const allErrs = await _runAll(value(), getFormValues());
268
+ setErrors(allErrs);
269
+ }, 300);
270
+ }
271
+ // Set sync errors first (empty), then kick off async
272
+ setErrors(syncErrs);
273
+ _asyncDebounced();
274
+ } else {
275
+ // Cancel any pending async validation
276
+ if (_asyncDebounced) _asyncDebounced.cancel();
277
+ setErrors(syncErrs);
278
+ }
279
+ }
280
+
281
+ function setValue(v) {
282
+ const raw = typeof v === 'function' ? v(value()) : v;
283
+ const transformed = transform ? transform(raw) : raw;
284
+ _setValue(transformed);
285
+ _triggerValidation('change');
286
+ }
287
+
288
+ function setTouchedFn() {
289
+ if (!touched()) setTouched(true);
290
+ _triggerValidation('blur');
291
+ }
292
+
293
+ function setError(msg) {
294
+ setErrors(msg ? [msg] : []);
295
+ }
296
+
297
+ function reset(val) {
298
+ const resetVal = val !== undefined ? val : _clone(initial);
299
+ batch(() => {
300
+ _setValue(resetVal);
301
+ setErrors([]);
302
+ setTouched(false);
303
+ });
304
+ if (_asyncDebounced) _asyncDebounced.cancel();
305
+ }
306
+
307
+ /** Validate field imperatively. Returns true if valid. */
308
+ async function validate() {
309
+ const allErrs = await _runAll(value(), getFormValues());
310
+ setErrors(allErrs);
311
+ return allErrs.length === 0;
312
+ }
313
+
314
+ // Derived signals
315
+ const error = createMemo(() => { const e = errors(); return e.length > 0 ? e[0] : null; });
316
+ const valid = createMemo(() => errors().length === 0);
317
+ const dirty = createMemo(() => !_eq(value(), initial));
318
+
319
+ /** @type {FieldInstance} */
320
+ const field = {
321
+ name,
322
+ value,
323
+ error,
324
+ errors,
325
+ touched,
326
+ dirty,
327
+ valid,
328
+ setValue,
329
+ setTouched: setTouchedFn,
330
+ setError,
331
+ reset,
332
+ validate,
333
+ /**
334
+ * Returns a props object for binding to Decantr form components.
335
+ * Compatible with Input, Select, Textarea, Checkbox, Switch, etc.
336
+ * @returns {{ value: Function, onchange: Function, onblur: Function, error: Function }}
337
+ */
338
+ bind() {
339
+ return {
340
+ value,
341
+ onchange: (v) => setValue(typeof v === 'object' && v !== null && v.target ? v.target.value : v),
342
+ onblur: () => setTouchedFn(),
343
+ error,
344
+ };
345
+ },
346
+ };
347
+
348
+ return field;
349
+ }
350
+
351
+ // ─── FIELD ARRAY ─────────────────────────────────────────────────
352
+
353
+ /**
354
+ * Creates a reactive field array for repeatable form sections.
355
+ * Each item in the array is a plain value (or object). The array itself
356
+ * is stored as a signal so consumers can react to structural changes.
357
+ *
358
+ * @param {string} name
359
+ * @param {any[]} initial
360
+ * @returns {FieldArrayInstance}
361
+ */
362
+ function _createFieldArray(name, initial) {
363
+ const [items, setItems] = createSignal(initial ? [...initial] : []);
364
+
365
+ return {
366
+ /** Signal getter for the array of items. */
367
+ items,
368
+
369
+ /** @returns {number} Current array length. */
370
+ length: createMemo(() => items().length),
371
+
372
+ /** Append a value to the end. @param {any} value */
373
+ append(value) { setItems(prev => [...prev, _clone(value)]); },
374
+
375
+ /** Prepend a value to the beginning. @param {any} value */
376
+ prepend(value) { setItems(prev => [_clone(value), ...prev]); },
377
+
378
+ /** Remove item at index. @param {number} index */
379
+ remove(index) {
380
+ setItems(prev => {
381
+ const next = [...prev];
382
+ next.splice(index, 1);
383
+ return next;
384
+ });
385
+ },
386
+
387
+ /** Move item from one index to another. @param {number} from @param {number} to */
388
+ move(from, to) {
389
+ setItems(prev => {
390
+ const next = [...prev];
391
+ const [item] = next.splice(from, 1);
392
+ next.splice(to, 0, item);
393
+ return next;
394
+ });
395
+ },
396
+
397
+ /** Swap two items by index. @param {number} a @param {number} b */
398
+ swap(a, b) {
399
+ setItems(prev => {
400
+ const next = [...prev];
401
+ const tmp = next[a];
402
+ next[a] = next[b];
403
+ next[b] = tmp;
404
+ return next;
405
+ });
406
+ },
407
+
408
+ /** Replace item at index with a new value. @param {number} index @param {any} value */
409
+ replace(index, value) {
410
+ setItems(prev => {
411
+ const next = [...prev];
412
+ next[index] = _clone(value);
413
+ return next;
414
+ });
415
+ },
416
+ };
417
+ }
418
+
419
+ // ─── CREATE FORM ─────────────────────────────────────────────────
420
+
421
+ /**
422
+ * Create a reactive form instance.
423
+ *
424
+ * @param {Object} config
425
+ * @param {Object} config.fields — `{ fieldName: { value?, validators?, transform? } }`
426
+ * @param {Function} [config.onSubmit] — `async (values, form) => void`
427
+ * @param {Function} [config.validate] — `(values) => errors` — cross-field validation returning `{ fieldName: string[] }`
428
+ * @param {'onChange'|'onBlur'|'onSubmit'} [config.validateOn='onBlur']
429
+ * @returns {FormInstance}
430
+ *
431
+ * @example
432
+ * const form = createForm({
433
+ * fields: {
434
+ * email: { value: '', validators: [validators.required(), validators.email()] },
435
+ * password: { value: '', validators: [validators.required(), validators.minLength(8)] },
436
+ * },
437
+ * validateOn: 'onBlur',
438
+ * async onSubmit(values) { await api.login(values); },
439
+ * });
440
+ *
441
+ * // Bind to Decantr components
442
+ * Input({ type: 'email', ...form.field('email').bind() })
443
+ * Input({ type: 'password', ...form.field('password').bind() })
444
+ * Button({ onclick: () => form.submit() }, 'Login')
445
+ */
446
+ export function createForm(config) {
447
+ const { fields: fieldConfigs = {}, onSubmit, validate: crossValidate, validateOn = 'onBlur' } = config;
448
+
449
+ const [isSubmitting, setIsSubmitting] = createSignal(false);
450
+ const [isSubmitted, setIsSubmitted] = createSignal(false);
451
+ const [submitCount, setSubmitCount] = createSignal(0);
452
+
453
+ /** @type {Map<string, FieldInstance>} */
454
+ const _fields = new Map();
455
+
456
+ /** @type {Map<string, FieldArrayInstance>} */
457
+ const _fieldArrays = new Map();
458
+
459
+ /** @type {Array<{name: string|null, cb: Function, dispose: Function|null}>} */
460
+ const _watchers = [];
461
+
462
+ // ── Values getter (reads all field signals) ──
463
+
464
+ function getValues() {
465
+ /** @type {Record<string, any>} */
466
+ const vals = {};
467
+ for (const [name, f] of _fields) {
468
+ vals[name] = f.value();
469
+ }
470
+ // Include field array values
471
+ for (const [name, fa] of _fieldArrays) {
472
+ vals[name] = fa.items();
473
+ }
474
+ return vals;
475
+ }
476
+
477
+ // ── Field creation / access ──
478
+
479
+ /**
480
+ * Get or create a FieldInstance by name.
481
+ * @param {string} name
482
+ * @returns {FieldInstance}
483
+ */
484
+ function field(name) {
485
+ let f = _fields.get(name);
486
+ if (!f) {
487
+ const cfg = fieldConfigs[name] || {};
488
+ f = _createField(name, cfg, getValues, validateOn);
489
+ _fields.set(name, f);
490
+ }
491
+ return f;
492
+ }
493
+
494
+ // Initialize all configured fields eagerly
495
+ for (const name of Object.keys(fieldConfigs)) {
496
+ field(name);
497
+ }
498
+
499
+ // ── Fields proxy ──
500
+ // Allows `form.fields.email.value()` syntax
501
+
502
+ const fields = new Proxy(/** @type {any} */ ({}), {
503
+ get(_, prop) {
504
+ if (typeof prop === 'string') return field(prop);
505
+ return undefined;
506
+ },
507
+ });
508
+
509
+ // ── Derived form-level signals ──
510
+
511
+ const errors = createMemo(() => {
512
+ /** @type {Record<string, string[]>} */
513
+ const errs = {};
514
+ for (const [name, f] of _fields) {
515
+ const fieldErrs = f.errors();
516
+ if (fieldErrs.length > 0) errs[name] = fieldErrs;
517
+ }
518
+ return errs;
519
+ });
520
+
521
+ const isValid = createMemo(() => {
522
+ for (const [, f] of _fields) {
523
+ if (!f.valid()) return false;
524
+ }
525
+ return true;
526
+ });
527
+
528
+ const isDirty = createMemo(() => {
529
+ for (const [, f] of _fields) {
530
+ if (f.dirty()) return true;
531
+ }
532
+ return false;
533
+ });
534
+
535
+ const values = createMemo(() => getValues());
536
+
537
+ // ── Actions ──
538
+
539
+ /**
540
+ * Run all field validators + cross-field validation.
541
+ * @returns {Promise<boolean>} true if form is valid
542
+ */
543
+ async function validate() {
544
+ const results = await Promise.all(
545
+ [..._fields.values()].map(f => f.validate())
546
+ );
547
+ const allValid = results.every(Boolean);
548
+
549
+ // Cross-field validation
550
+ if (crossValidate) {
551
+ const crossErrors = crossValidate(getValues());
552
+ if (crossErrors && typeof crossErrors === 'object') {
553
+ for (const [name, errs] of Object.entries(crossErrors)) {
554
+ const f = _fields.get(name);
555
+ if (f) {
556
+ const fieldErrs = Array.isArray(errs) ? errs : [errs];
557
+ if (fieldErrs.length > 0) {
558
+ // Merge with existing errors
559
+ f.setError(fieldErrs[0]);
560
+ return false;
561
+ }
562
+ }
563
+ }
564
+ }
565
+ }
566
+
567
+ return allValid;
568
+ }
569
+
570
+ /**
571
+ * Submit the form. Runs all validators, sets isSubmitting, calls onSubmit if valid.
572
+ * @returns {Promise<void>}
573
+ */
574
+ async function submit() {
575
+ setSubmitCount(c => c + 1);
576
+
577
+ // Touch all fields on submit
578
+ batch(() => {
579
+ for (const [, f] of _fields) f.setTouched();
580
+ });
581
+
582
+ const valid = await validate();
583
+ if (!valid) return;
584
+
585
+ if (!onSubmit) return;
586
+
587
+ setIsSubmitting(true);
588
+ try {
589
+ await onSubmit(getValues(), form);
590
+ setIsSubmitted(true);
591
+ } finally {
592
+ setIsSubmitting(false);
593
+ }
594
+ }
595
+
596
+ /**
597
+ * Reset all fields. Optionally provide new initial values.
598
+ * @param {Object} [newValues] — partial or full values to reset to
599
+ */
600
+ function reset(newValues) {
601
+ batch(() => {
602
+ for (const [name, f] of _fields) {
603
+ f.reset(newValues ? newValues[name] : undefined);
604
+ }
605
+ setIsSubmitted(false);
606
+ setSubmitCount(0);
607
+ });
608
+ }
609
+
610
+ /**
611
+ * Set multiple field values at once.
612
+ * @param {Object} partial — `{ fieldName: value }`
613
+ */
614
+ function setValues(partial) {
615
+ batch(() => {
616
+ for (const [name, val] of Object.entries(partial)) {
617
+ field(name).setValue(val);
618
+ }
619
+ });
620
+ }
621
+
622
+ /**
623
+ * Set errors on multiple fields at once.
624
+ * @param {Object} errs — `{ fieldName: string|string[] }`
625
+ */
626
+ function setErrors(errs) {
627
+ batch(() => {
628
+ for (const [name, msg] of Object.entries(errs)) {
629
+ const f = _fields.get(name);
630
+ if (f) f.setError(Array.isArray(msg) ? msg[0] : msg);
631
+ }
632
+ });
633
+ }
634
+
635
+ /**
636
+ * Get or create a FieldArrayInstance by name.
637
+ * @param {string} name
638
+ * @returns {FieldArrayInstance}
639
+ */
640
+ function fieldArray(name) {
641
+ let fa = _fieldArrays.get(name);
642
+ if (!fa) {
643
+ const cfg = fieldConfigs[name];
644
+ const initial = cfg && Array.isArray(cfg.value) ? cfg.value : [];
645
+ fa = _createFieldArray(name, initial);
646
+ _fieldArrays.set(name, fa);
647
+ }
648
+ return fa;
649
+ }
650
+
651
+ /**
652
+ * Watch a specific field for value changes.
653
+ * @param {string} fieldName
654
+ * @param {(value: any, prevValue: any) => void} callback
655
+ * @returns {Function} unsubscribe
656
+ */
657
+ function watch(fieldName, callback) {
658
+ const f = field(fieldName);
659
+ let prev = f.value();
660
+ const dispose = createEffect(() => {
661
+ const next = f.value();
662
+ if (!_eq(next, prev)) {
663
+ const old = prev;
664
+ prev = next;
665
+ callback(next, old);
666
+ }
667
+ });
668
+ const entry = { name: fieldName, cb: callback, dispose };
669
+ _watchers.push(entry);
670
+ return () => {
671
+ const idx = _watchers.indexOf(entry);
672
+ if (idx >= 0) _watchers.splice(idx, 1);
673
+ if (dispose) dispose();
674
+ };
675
+ }
676
+
677
+ /**
678
+ * Watch all fields for any value change.
679
+ * @param {(values: Object) => void} callback
680
+ * @returns {Function} unsubscribe
681
+ */
682
+ function watchAll(callback) {
683
+ const dispose = createEffect(() => {
684
+ const v = values();
685
+ callback(v);
686
+ });
687
+ const entry = { name: null, cb: callback, dispose };
688
+ _watchers.push(entry);
689
+ return () => {
690
+ const idx = _watchers.indexOf(entry);
691
+ if (idx >= 0) _watchers.splice(idx, 1);
692
+ if (dispose) dispose();
693
+ };
694
+ }
695
+
696
+ /** @type {FormInstance} */
697
+ const form = {
698
+ field,
699
+ fields,
700
+ values,
701
+ errors,
702
+ isValid,
703
+ isDirty,
704
+ isSubmitting,
705
+ isSubmitted,
706
+ submitCount,
707
+ submit,
708
+ reset,
709
+ setValues,
710
+ setErrors,
711
+ validate,
712
+ fieldArray,
713
+ watch,
714
+ watchAll,
715
+ };
716
+
717
+ return form;
718
+ }
719
+
720
+ // ─── USE FORM FIELD ──────────────────────────────────────────────
721
+
722
+ /**
723
+ * Hook for binding a form field to a custom component.
724
+ * Returns reactive getters and handlers for value, error, touched, and blur.
725
+ *
726
+ * @param {FormInstance} form
727
+ * @param {string} fieldName
728
+ * @returns {{ value: Function, setValue: Function, error: Function, touched: Function, onBlur: Function }}
729
+ *
730
+ * @example
731
+ * function MyCustomInput(form, name) {
732
+ * const { value, setValue, error, onBlur } = useFormField(form, name);
733
+ * const input = h('input', { value: value(), onblur: onBlur });
734
+ * createEffect(() => { input.value = value(); });
735
+ * input.addEventListener('input', (e) => setValue(e.target.value));
736
+ * return input;
737
+ * }
738
+ */
739
+ export function useFormField(form, fieldName) {
740
+ const f = form.field(fieldName);
741
+ return {
742
+ value: f.value,
743
+ setValue: f.setValue,
744
+ error: f.error,
745
+ errors: f.errors,
746
+ touched: f.touched,
747
+ dirty: f.dirty,
748
+ valid: f.valid,
749
+ onBlur: f.setTouched,
750
+ bind: f.bind,
751
+ };
752
+ }
753
+
754
+ // ─── TYPE DEFINITIONS (JSDoc) ────────────────────────────────────
755
+
756
+ /**
757
+ * @typedef {Object} FieldInstance
758
+ * @property {string} name
759
+ * @property {() => any} value — Signal getter for current value
760
+ * @property {() => string|null} error — First error or null
761
+ * @property {() => string[]} errors — All error messages
762
+ * @property {() => boolean} touched — Whether field has been blurred
763
+ * @property {() => boolean} dirty — Whether value differs from initial
764
+ * @property {() => boolean} valid — Whether field has no errors
765
+ * @property {(v: any) => void} setValue
766
+ * @property {() => void} setTouched
767
+ * @property {(msg: string) => void} setError
768
+ * @property {(val?: any) => void} reset
769
+ * @property {() => Promise<boolean>} validate
770
+ * @property {() => {value: Function, onchange: Function, onblur: Function, error: Function}} bind
771
+ */
772
+
773
+ /**
774
+ * @typedef {Object} FieldArrayInstance
775
+ * @property {() => any[]} items — Signal getter for array of items
776
+ * @property {() => number} length — Reactive item count
777
+ * @property {(value: any) => void} append
778
+ * @property {(value: any) => void} prepend
779
+ * @property {(index: number) => void} remove
780
+ * @property {(from: number, to: number) => void} move
781
+ * @property {(a: number, b: number) => void} swap
782
+ * @property {(index: number, value: any) => void} replace
783
+ */
784
+
785
+ /**
786
+ * @typedef {Object} FormInstance
787
+ * @property {(name: string) => FieldInstance} field
788
+ * @property {Object} fields — Proxy for field access via dot notation
789
+ * @property {() => Object} values — All current values
790
+ * @property {() => Object} errors — All errors `{ fieldName: string[] }`
791
+ * @property {() => boolean} isValid
792
+ * @property {() => boolean} isDirty
793
+ * @property {() => boolean} isSubmitting
794
+ * @property {() => boolean} isSubmitted
795
+ * @property {() => number} submitCount
796
+ * @property {() => Promise<void>} submit
797
+ * @property {(values?: Object) => void} reset
798
+ * @property {(partial: Object) => void} setValues
799
+ * @property {(errors: Object) => void} setErrors
800
+ * @property {() => Promise<boolean>} validate
801
+ * @property {(name: string) => FieldArrayInstance} fieldArray
802
+ * @property {(fieldName: string, callback: Function) => Function} watch
803
+ * @property {(callback: Function) => Function} watchAll
804
+ */