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.
- package/AGENTS.md +868 -0
- package/CHANGELOG.md +255 -0
- package/CLAUDE.md +178 -0
- package/LICENSE +21 -0
- package/README.md +229 -0
- package/cli/art.js +127 -0
- package/cli/commands/a11y.js +61 -0
- package/cli/commands/audit.js +225 -0
- package/cli/commands/build.js +38 -0
- package/cli/commands/dev.js +18 -0
- package/cli/commands/doctor.js +197 -0
- package/cli/commands/figma-sync.js +48 -0
- package/cli/commands/figma-tokens.js +55 -0
- package/cli/commands/generate.js +26 -0
- package/cli/commands/init.js +116 -0
- package/cli/commands/lint.js +209 -0
- package/cli/commands/mcp.js +530 -0
- package/cli/commands/migrate.js +175 -0
- package/cli/commands/test.js +38 -0
- package/cli/commands/validate.js +354 -0
- package/cli/index.js +113 -0
- package/package.json +95 -0
- package/reference/atoms.md +517 -0
- package/reference/behaviors.md +384 -0
- package/reference/build-tooling.md +275 -0
- package/reference/color-guidelines.md +965 -0
- package/reference/component-lifecycle.md +137 -0
- package/reference/compound-spacing.md +95 -0
- package/reference/decantation-process.md +499 -0
- package/reference/dev-server-routes.md +93 -0
- package/reference/form-system.md +253 -0
- package/reference/i18n.md +336 -0
- package/reference/icons.md +576 -0
- package/reference/llm-primer.md +953 -0
- package/reference/plugins.md +252 -0
- package/reference/registry-consumption.md +76 -0
- package/reference/router.md +217 -0
- package/reference/shells.md +116 -0
- package/reference/spatial-guidelines.md +541 -0
- package/reference/ssr.md +234 -0
- package/reference/state-data.md +215 -0
- package/reference/state-patterns.md +166 -0
- package/reference/state.md +194 -0
- package/reference/style-system.md +110 -0
- package/reference/tokens.md +460 -0
- package/src/app.js +19 -0
- package/src/chart/_animate.js +266 -0
- package/src/chart/_base.js +109 -0
- package/src/chart/_data.js +209 -0
- package/src/chart/_format.js +106 -0
- package/src/chart/_interact.js +364 -0
- package/src/chart/_palette.js +105 -0
- package/src/chart/_renderer.js +52 -0
- package/src/chart/_scene.js +262 -0
- package/src/chart/_shared.js +371 -0
- package/src/chart/index.js +637 -0
- package/src/chart/layouts/_layout-base.js +328 -0
- package/src/chart/layouts/cartesian.js +148 -0
- package/src/chart/layouts/hierarchy.js +562 -0
- package/src/chart/layouts/polar.js +101 -0
- package/src/chart/renderers/canvas.js +179 -0
- package/src/chart/renderers/svg.js +256 -0
- package/src/chart/renderers/webgpu.js +715 -0
- package/src/chart/types/_type-base.js +26 -0
- package/src/chart/types/area.js +134 -0
- package/src/chart/types/bar.js +173 -0
- package/src/chart/types/box-plot.js +125 -0
- package/src/chart/types/bubble.js +63 -0
- package/src/chart/types/candlestick.js +115 -0
- package/src/chart/types/chord.js +85 -0
- package/src/chart/types/combination.js +108 -0
- package/src/chart/types/funnel.js +68 -0
- package/src/chart/types/gauge.js +163 -0
- package/src/chart/types/heatmap.js +98 -0
- package/src/chart/types/histogram.js +71 -0
- package/src/chart/types/line.js +111 -0
- package/src/chart/types/org-chart.js +93 -0
- package/src/chart/types/pie.js +81 -0
- package/src/chart/types/radar.js +96 -0
- package/src/chart/types/radial.js +68 -0
- package/src/chart/types/range-area.js +55 -0
- package/src/chart/types/range-bar.js +61 -0
- package/src/chart/types/sankey.js +73 -0
- package/src/chart/types/scatter.js +66 -0
- package/src/chart/types/sparkline.js +81 -0
- package/src/chart/types/sunburst.js +69 -0
- package/src/chart/types/swimlane.js +88 -0
- package/src/chart/types/treemap.js +62 -0
- package/src/chart/types/waterfall.js +100 -0
- package/src/components/_base.js +1658 -0
- package/src/components/_behaviors.js +1140 -0
- package/src/components/_primitives.js +534 -0
- package/src/components/_qr-encoder.js +539 -0
- package/src/components/accordion.js +207 -0
- package/src/components/affix.js +62 -0
- package/src/components/alert-dialog.js +75 -0
- package/src/components/alert.js +47 -0
- package/src/components/aspect-ratio.js +24 -0
- package/src/components/avatar-group.js +55 -0
- package/src/components/avatar.js +38 -0
- package/src/components/back-top.js +75 -0
- package/src/components/badge.js +74 -0
- package/src/components/banner.js +68 -0
- package/src/components/breadcrumb.js +162 -0
- package/src/components/button.js +115 -0
- package/src/components/calendar.js +131 -0
- package/src/components/card.js +192 -0
- package/src/components/carousel.js +98 -0
- package/src/components/cascader.js +261 -0
- package/src/components/checkbox.js +80 -0
- package/src/components/chip.js +81 -0
- package/src/components/code-block.js +82 -0
- package/src/components/collapsible.js +50 -0
- package/src/components/color-palette.js +438 -0
- package/src/components/color-picker.js +314 -0
- package/src/components/combobox.js +181 -0
- package/src/components/command.js +174 -0
- package/src/components/comment.js +206 -0
- package/src/components/context-menu.js +76 -0
- package/src/components/data-table.js +724 -0
- package/src/components/date-picker.js +217 -0
- package/src/components/date-range-picker.js +244 -0
- package/src/components/datetime-picker.js +271 -0
- package/src/components/descriptions.js +68 -0
- package/src/components/drawer.js +179 -0
- package/src/components/dropdown.js +88 -0
- package/src/components/empty.js +41 -0
- package/src/components/float-button.js +90 -0
- package/src/components/form.js +106 -0
- package/src/components/hover-card.js +49 -0
- package/src/components/icon.js +87 -0
- package/src/components/image.js +97 -0
- package/src/components/index.js +117 -0
- package/src/components/input-group.js +75 -0
- package/src/components/input-number.js +155 -0
- package/src/components/input-otp.js +178 -0
- package/src/components/input.js +91 -0
- package/src/components/kbd.js +36 -0
- package/src/components/label.js +25 -0
- package/src/components/list.js +118 -0
- package/src/components/masked-input.js +236 -0
- package/src/components/mentions.js +165 -0
- package/src/components/menu.js +259 -0
- package/src/components/message.js +80 -0
- package/src/components/modal.js +147 -0
- package/src/components/navigation-menu.js +166 -0
- package/src/components/notification.js +84 -0
- package/src/components/pagination.js +104 -0
- package/src/components/placeholder.js +132 -0
- package/src/components/popconfirm.js +70 -0
- package/src/components/popover.js +58 -0
- package/src/components/progress.js +61 -0
- package/src/components/qrcode.js +251 -0
- package/src/components/radiogroup.js +120 -0
- package/src/components/range-slider.js +176 -0
- package/src/components/rate.js +186 -0
- package/src/components/resizable.js +83 -0
- package/src/components/result.js +57 -0
- package/src/components/scroll-area.js +43 -0
- package/src/components/segmented.js +97 -0
- package/src/components/select.js +165 -0
- package/src/components/separator.js +31 -0
- package/src/components/shell.js +407 -0
- package/src/components/skeleton.js +39 -0
- package/src/components/slider.js +141 -0
- package/src/components/sortable-list.js +176 -0
- package/src/components/space.js +42 -0
- package/src/components/spinner.js +112 -0
- package/src/components/splitter.js +147 -0
- package/src/components/statistic.js +136 -0
- package/src/components/steps.js +99 -0
- package/src/components/switch.js +95 -0
- package/src/components/table.js +44 -0
- package/src/components/tabs.js +216 -0
- package/src/components/tag.js +115 -0
- package/src/components/textarea.js +82 -0
- package/src/components/time-picker.js +153 -0
- package/src/components/time-range-picker.js +170 -0
- package/src/components/timeline.js +226 -0
- package/src/components/toast.js +71 -0
- package/src/components/toggle.js +213 -0
- package/src/components/tooltip.js +57 -0
- package/src/components/tour.js +159 -0
- package/src/components/transfer.js +163 -0
- package/src/components/tree-select.js +274 -0
- package/src/components/tree.js +141 -0
- package/src/components/typography.js +136 -0
- package/src/components/upload.js +118 -0
- package/src/components/visually-hidden.js +20 -0
- package/src/components/watermark.js +124 -0
- package/src/core/index.js +539 -0
- package/src/core/lifecycle.js +69 -0
- package/src/css/atoms.js +651 -0
- package/src/css/components.js +940 -0
- package/src/css/derive.js +1296 -0
- package/src/css/index.js +265 -0
- package/src/css/runtime.js +268 -0
- package/src/css/styles/addons/bioluminescent.js +93 -0
- package/src/css/styles/addons/clay.js +70 -0
- package/src/css/styles/addons/clean.js +57 -0
- package/src/css/styles/addons/command-center.js +143 -0
- package/src/css/styles/addons/dopamine.js +83 -0
- package/src/css/styles/addons/editorial.js +80 -0
- package/src/css/styles/addons/glassmorphism.js +99 -0
- package/src/css/styles/addons/liquid-glass.js +105 -0
- package/src/css/styles/addons/prismatic.js +100 -0
- package/src/css/styles/addons/retro.js +63 -0
- package/src/css/styles/auradecantism.js +96 -0
- package/src/css/theme-registry.js +444 -0
- package/src/data/entity.js +281 -0
- package/src/data/index.js +13 -0
- package/src/data/persist.js +225 -0
- package/src/data/query.js +839 -0
- package/src/data/realtime.js +299 -0
- package/src/data/url.js +177 -0
- package/src/data/worker.js +134 -0
- package/src/explorer/archetypes.js +243 -0
- package/src/explorer/atoms.js +228 -0
- package/src/explorer/charts.js +497 -0
- package/src/explorer/components.js +129 -0
- package/src/explorer/foundations.js +949 -0
- package/src/explorer/icons.js +178 -0
- package/src/explorer/patterns.js +247 -0
- package/src/explorer/recipes.js +194 -0
- package/src/explorer/shared/pattern-examples.js +1337 -0
- package/src/explorer/shared/showcase-renderer.js +958 -0
- package/src/explorer/shared/spec-table.js +41 -0
- package/src/explorer/shared/usage-links.js +87 -0
- package/src/explorer/shell-config.js +10 -0
- package/src/explorer/shells.js +551 -0
- package/src/explorer/styles.js +161 -0
- package/src/explorer/tokens.js +262 -0
- package/src/explorer/tools.js +525 -0
- package/src/form/index.js +804 -0
- package/src/i18n/index.js +251 -0
- package/src/icons/essential.js +479 -0
- package/src/icons/index.js +53 -0
- package/src/plugins/index.js +282 -0
- package/src/registry/archetypes/content-site.json +71 -0
- package/src/registry/archetypes/docs-explorer.json +23 -0
- package/src/registry/archetypes/ecommerce.json +104 -0
- package/src/registry/archetypes/financial-dashboard.json +77 -0
- package/src/registry/archetypes/index.json +41 -0
- package/src/registry/archetypes/portfolio.json +82 -0
- package/src/registry/archetypes/recipe-community.json +159 -0
- package/src/registry/archetypes/saas-dashboard.json +86 -0
- package/src/registry/architect/cross-cutting.json +45 -0
- package/src/registry/architect/domains/ecommerce.json +294 -0
- package/src/registry/architect/domains/financial-services.json +302 -0
- package/src/registry/architect/index.json +26 -0
- package/src/registry/architect/traits.json +379 -0
- package/src/registry/atoms.json +16 -0
- package/src/registry/chart-showcase.json +160 -0
- package/src/registry/chart.json +136 -0
- package/src/registry/components.json +8616 -0
- package/src/registry/core.json +216 -0
- package/src/registry/css.json +319 -0
- package/src/registry/data.json +135 -0
- package/src/registry/foundations.json +11 -0
- package/src/registry/icons.json +463 -0
- package/src/registry/index.json +101 -0
- package/src/registry/patterns/activity-feed.json +37 -0
- package/src/registry/patterns/article-content.json +27 -0
- package/src/registry/patterns/auth-form.json +37 -0
- package/src/registry/patterns/author-card.json +20 -0
- package/src/registry/patterns/card-grid.json +127 -0
- package/src/registry/patterns/category-nav.json +26 -0
- package/src/registry/patterns/chart-grid.json +36 -0
- package/src/registry/patterns/chat-interface.json +37 -0
- package/src/registry/patterns/checklist-card.json +55 -0
- package/src/registry/patterns/comparison-panel.json +27 -0
- package/src/registry/patterns/component-showcase.json +24 -0
- package/src/registry/patterns/contact-form.json +31 -0
- package/src/registry/patterns/cta-section.json +20 -0
- package/src/registry/patterns/data-table.json +37 -0
- package/src/registry/patterns/detail-header.json +83 -0
- package/src/registry/patterns/detail-panel.json +27 -0
- package/src/registry/patterns/explorer-shell.json +22 -0
- package/src/registry/patterns/filter-bar.json +33 -0
- package/src/registry/patterns/filter-sidebar.json +27 -0
- package/src/registry/patterns/form-sections.json +110 -0
- package/src/registry/patterns/goal-tracker.json +27 -0
- package/src/registry/patterns/hero.json +107 -0
- package/src/registry/patterns/index.json +47 -0
- package/src/registry/patterns/kpi-grid.json +36 -0
- package/src/registry/patterns/media-gallery.json +20 -0
- package/src/registry/patterns/order-history.json +20 -0
- package/src/registry/patterns/pagination.json +19 -0
- package/src/registry/patterns/photo-to-recipe.json +36 -0
- package/src/registry/patterns/pipeline-tracker.json +28 -0
- package/src/registry/patterns/post-list.json +27 -0
- package/src/registry/patterns/pricing-table.json +32 -0
- package/src/registry/patterns/scorecard.json +28 -0
- package/src/registry/patterns/search-bar.json +20 -0
- package/src/registry/patterns/specimen-grid.json +19 -0
- package/src/registry/patterns/stat-card.json +55 -0
- package/src/registry/patterns/stats-bar.json +55 -0
- package/src/registry/patterns/steps-card.json +55 -0
- package/src/registry/patterns/table-of-contents.json +19 -0
- package/src/registry/patterns/testimonials.json +21 -0
- package/src/registry/patterns/timeline.json +27 -0
- package/src/registry/patterns/token-inspector.json +21 -0
- package/src/registry/patterns/wizard.json +27 -0
- package/src/registry/recipe-auradecantism.json +69 -0
- package/src/registry/recipe-clean.json +65 -0
- package/src/registry/recipe-command-center.json +78 -0
- package/src/registry/router.json +73 -0
- package/src/registry/schema/README.md +197 -0
- package/src/registry/skeletons.json +259 -0
- package/src/registry/state.json +137 -0
- package/src/registry/tokens.json +40 -0
- package/src/router/hash.js +17 -0
- package/src/router/history.js +18 -0
- package/src/router/index.js +598 -0
- package/src/ssr/index.js +922 -0
- package/src/state/arrays.js +181 -0
- package/src/state/devtools.js +647 -0
- package/src/state/index.js +498 -0
- package/src/state/middleware.js +288 -0
- package/src/state/scheduler.js +206 -0
- package/src/state/store.js +300 -0
- package/src/tags/index.js +19 -0
- package/src/tannins/auth.js +396 -0
- package/src/test/dom.js +352 -0
- package/src/test/index.js +62 -0
- package/src/test/state.js +306 -0
- package/tools/a11y-audit.js +487 -0
- package/tools/analyzer.js +315 -0
- package/tools/audit.js +706 -0
- package/tools/builder.js +1422 -0
- package/tools/css-extract.js +188 -0
- package/tools/dev-server.js +316 -0
- package/tools/dts-gen.js +1260 -0
- package/tools/figma-components.js +329 -0
- package/tools/figma-patterns.js +516 -0
- package/tools/figma-plugin/code.js +453 -0
- package/tools/figma-plugin/manifest.json +14 -0
- package/tools/figma-plugin/ui.html +268 -0
- package/tools/figma-render.js +293 -0
- package/tools/figma-tokens.js +712 -0
- package/tools/figma-upload.js +318 -0
- package/tools/generate.js +738 -0
- package/tools/icons.js +133 -0
- package/tools/init-templates.js +265 -0
- package/tools/install-hooks.sh +5 -0
- package/tools/migrations/0.5.0.js +53 -0
- package/tools/migrations/0.6.0.js +95 -0
- package/tools/minify.js +170 -0
- package/tools/pre-commit +4 -0
- package/tools/registry.js +662 -0
- package/tools/reset-playground.js +61 -0
- package/tools/starter-templates/content-site/app.js +49 -0
- package/tools/starter-templates/content-site/essence.js +19 -0
- package/tools/starter-templates/content-site/pages.js +31 -0
- package/tools/starter-templates/ecommerce/app.js +50 -0
- package/tools/starter-templates/ecommerce/essence.js +19 -0
- package/tools/starter-templates/ecommerce/pages.js +31 -0
- package/tools/starter-templates/landing-page/app.js +38 -0
- package/tools/starter-templates/landing-page/essence.js +18 -0
- package/tools/starter-templates/landing-page/pages.js +21 -0
- package/tools/starter-templates/portfolio/app.js +45 -0
- package/tools/starter-templates/portfolio/essence.js +19 -0
- package/tools/starter-templates/portfolio/pages.js +33 -0
- package/tools/starter-templates/saas-dashboard/app.js +70 -0
- package/tools/starter-templates/saas-dashboard/essence.js +19 -0
- package/tools/starter-templates/saas-dashboard/pages.js +31 -0
- package/tools/verify-pack.js +203 -0
- package/types/chart.d.ts +77 -0
- package/types/components.d.ts +587 -0
- package/types/core.d.ts +89 -0
- package/types/css.d.ts +149 -0
- package/types/data.d.ts +238 -0
- package/types/form.d.ts +164 -0
- package/types/i18n.d.ts +51 -0
- package/types/icons.d.ts +27 -0
- package/types/index.d.ts +13 -0
- package/types/router.d.ts +116 -0
- package/types/ssr.d.ts +102 -0
- package/types/state.d.ts +83 -0
- package/types/tags.d.ts +62 -0
- package/types/tannins.d.ts +63 -0
- 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
|
+
*/
|