tova 0.4.6 → 0.4.7
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/package.json +14 -2
- package/src/analyzer/analyzer.js +10 -5
- package/src/analyzer/type-registry.js +22 -3
- package/src/codegen/base-codegen.js +15 -4
- package/src/codegen/client-codegen.js +9 -6
- package/src/codegen/codegen.js +3 -2
- package/src/codegen/server-codegen.js +526 -81
- package/src/lsp/server.js +44 -25
- package/src/parser/server-ast.js +2 -1
- package/src/parser/server-parser.js +12 -1
- package/src/runtime/embedded.js +3 -3
- package/src/runtime/reactivity.js +405 -23
- package/src/runtime/router.js +215 -25
- package/src/runtime/rpc.js +152 -17
- package/src/runtime/ssr.js +66 -10
- package/src/runtime/testing.js +241 -0
- package/src/version.js +1 -1
|
@@ -25,6 +25,9 @@ let pendingEffects = new Set();
|
|
|
25
25
|
let batchDepth = 0;
|
|
26
26
|
let flushing = false;
|
|
27
27
|
|
|
28
|
+
// Reusable array for flush cycle — avoids allocation on every flush
|
|
29
|
+
let _flushBuf = [];
|
|
30
|
+
|
|
28
31
|
function flush() {
|
|
29
32
|
if (flushing) return; // prevent re-entrant flush
|
|
30
33
|
flushing = true;
|
|
@@ -52,14 +55,17 @@ function flush() {
|
|
|
52
55
|
const toRun = pendingEffects;
|
|
53
56
|
pendingEffects = new Set();
|
|
54
57
|
// Sort by depth (parents first) to avoid redundant child re-runs
|
|
58
|
+
// Reuse buffer to reduce GC pressure
|
|
55
59
|
if (toRun.size > 1) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
_flushBuf.length = 0;
|
|
61
|
+
for (const effect of toRun) _flushBuf.push(effect);
|
|
62
|
+
_flushBuf.sort((a, b) => (a._depth || 0) - (b._depth || 0));
|
|
63
|
+
for (let i = 0; i < _flushBuf.length; i++) {
|
|
64
|
+
if (!_flushBuf[i]._disposed) {
|
|
65
|
+
_flushBuf[i]();
|
|
61
66
|
}
|
|
62
67
|
}
|
|
68
|
+
_flushBuf.length = 0;
|
|
63
69
|
} else {
|
|
64
70
|
for (const effect of toRun) {
|
|
65
71
|
if (!effect._disposed) {
|
|
@@ -626,6 +632,7 @@ export function Dynamic({ component, ...rest }) {
|
|
|
626
632
|
// ─── Portal ─────────────────────────────────────────────
|
|
627
633
|
// Renders children into a different DOM target.
|
|
628
634
|
// Usage: Portal({ target: "#modal-root", children })
|
|
635
|
+
// Cleans up children from the target when the component unmounts.
|
|
629
636
|
|
|
630
637
|
export function Portal({ target, children }) {
|
|
631
638
|
return {
|
|
@@ -633,6 +640,7 @@ export function Portal({ target, children }) {
|
|
|
633
640
|
tag: '__portal',
|
|
634
641
|
props: { target },
|
|
635
642
|
children: children || [],
|
|
643
|
+
_portalCleanup: true, // Signal to render() to register cleanup
|
|
636
644
|
};
|
|
637
645
|
}
|
|
638
646
|
|
|
@@ -678,6 +686,8 @@ export function lazy(loader) {
|
|
|
678
686
|
let resolved = null;
|
|
679
687
|
let loadError = null;
|
|
680
688
|
let promise = null;
|
|
689
|
+
// Signal is shared across all renders of this lazy component (not per-call)
|
|
690
|
+
const [tick, setTick] = createSignal(0);
|
|
681
691
|
|
|
682
692
|
return function LazyWrapper(props) {
|
|
683
693
|
if (resolved) {
|
|
@@ -693,18 +703,15 @@ export function lazy(loader) {
|
|
|
693
703
|
.then(mod => {
|
|
694
704
|
resolved = mod.default || mod;
|
|
695
705
|
if (suspense) suspense.resolve();
|
|
706
|
+
setTick(1);
|
|
696
707
|
})
|
|
697
708
|
.catch(e => {
|
|
698
709
|
loadError = e;
|
|
699
710
|
if (suspense) suspense.resolve();
|
|
711
|
+
setTick(1);
|
|
700
712
|
});
|
|
701
713
|
}
|
|
702
714
|
|
|
703
|
-
const [tick, setTick] = createSignal(0);
|
|
704
|
-
|
|
705
|
-
// Trigger re-render when promise settles
|
|
706
|
-
promise.then(() => setTick(1)).catch(() => setTick(1));
|
|
707
|
-
|
|
708
715
|
return {
|
|
709
716
|
__tova: true,
|
|
710
717
|
tag: '__dynamic',
|
|
@@ -748,17 +755,209 @@ export function inject(context) {
|
|
|
748
755
|
return context._default;
|
|
749
756
|
}
|
|
750
757
|
|
|
758
|
+
// ─── Head Component ──────────────────────────────────────
|
|
759
|
+
// Declarative document head management.
|
|
760
|
+
// Usage: Head({ children: [tova_el('title', {}, ['My Page']), tova_el('meta', {name: 'description', content: '...'})] })
|
|
761
|
+
// Components can render <Head> to set title, meta, link, and script tags in <head>.
|
|
762
|
+
// When the component unmounts, its head contributions are removed.
|
|
763
|
+
|
|
764
|
+
const __tovaHeadTags = [];
|
|
765
|
+
|
|
766
|
+
export function Head({ children }) {
|
|
767
|
+
if (typeof document === 'undefined') return null;
|
|
768
|
+
|
|
769
|
+
const addedElements = [];
|
|
770
|
+
const childList = Array.isArray(children) ? children : [children];
|
|
771
|
+
|
|
772
|
+
for (const child of childList) {
|
|
773
|
+
if (!child || !child.__tova) continue;
|
|
774
|
+
const tag = child.tag;
|
|
775
|
+
const props = child.props || {};
|
|
776
|
+
const text = child.children && child.children.length > 0 ? child.children.join('') : null;
|
|
777
|
+
|
|
778
|
+
if (tag === 'title') {
|
|
779
|
+
// Special case: update document.title directly
|
|
780
|
+
const prevTitle = document.title;
|
|
781
|
+
document.title = text || '';
|
|
782
|
+
addedElements.push({ type: 'title', prev: prevTitle });
|
|
783
|
+
} else {
|
|
784
|
+
const el = document.createElement(tag);
|
|
785
|
+
for (const [key, val] of Object.entries(props)) {
|
|
786
|
+
if (key.startsWith('on') || key === 'key' || key === 'ref') continue;
|
|
787
|
+
const attrName = key === 'className' ? 'class' : key;
|
|
788
|
+
const attrVal = typeof val === 'function' ? val() : val;
|
|
789
|
+
if (attrVal !== false && attrVal != null) {
|
|
790
|
+
el.setAttribute(attrName, String(attrVal));
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
if (text) el.textContent = text;
|
|
794
|
+
document.head.appendChild(el);
|
|
795
|
+
addedElements.push({ type: 'element', el });
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Register cleanup: remove added elements when component unmounts
|
|
800
|
+
if (currentOwner) {
|
|
801
|
+
const cleanup = () => {
|
|
802
|
+
for (const item of addedElements) {
|
|
803
|
+
if (item.type === 'element') {
|
|
804
|
+
if (typeof item.el.remove === 'function') {
|
|
805
|
+
item.el.remove();
|
|
806
|
+
} else if (item.el.parentNode && typeof item.el.parentNode.removeChild === 'function') {
|
|
807
|
+
item.el.parentNode.removeChild(item.el);
|
|
808
|
+
}
|
|
809
|
+
} else if (item.type === 'title') {
|
|
810
|
+
document.title = item.prev;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
};
|
|
814
|
+
if (!currentOwner._cleanups) currentOwner._cleanups = [];
|
|
815
|
+
currentOwner._cleanups.push(cleanup);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
return null; // Head renders nothing in the component tree
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// ─── createResource ──────────────────────────────────────
|
|
822
|
+
// Async data fetching primitive integrated with signals.
|
|
823
|
+
// Usage: const [data, { loading, error, refetch }] = createResource(fetcher)
|
|
824
|
+
// Usage with source: const [data, { loading, error, refetch }] = createResource(sourceSignal, fetcher)
|
|
825
|
+
// When source changes, fetcher is re-invoked automatically.
|
|
826
|
+
|
|
827
|
+
export function createResource(sourceOrFetcher, maybeFetcher) {
|
|
828
|
+
let source, fetcher;
|
|
829
|
+
if (typeof maybeFetcher === 'function') {
|
|
830
|
+
source = sourceOrFetcher;
|
|
831
|
+
fetcher = maybeFetcher;
|
|
832
|
+
} else {
|
|
833
|
+
source = null;
|
|
834
|
+
fetcher = sourceOrFetcher;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const [data, setData] = createSignal(undefined);
|
|
838
|
+
const [loading, setLoading] = createSignal(false);
|
|
839
|
+
const [error, setError] = createSignal(undefined);
|
|
840
|
+
let version = 0; // Guards against stale responses
|
|
841
|
+
|
|
842
|
+
function doFetch(sourceVal) {
|
|
843
|
+
const currentVersion = ++version;
|
|
844
|
+
setLoading(true);
|
|
845
|
+
setError(undefined);
|
|
846
|
+
try {
|
|
847
|
+
const result = source ? fetcher(sourceVal) : fetcher();
|
|
848
|
+
if (result && typeof result.then === 'function') {
|
|
849
|
+
result.then(
|
|
850
|
+
(val) => {
|
|
851
|
+
if (currentVersion === version) {
|
|
852
|
+
setData(() => val);
|
|
853
|
+
setLoading(false);
|
|
854
|
+
}
|
|
855
|
+
},
|
|
856
|
+
(err) => {
|
|
857
|
+
if (currentVersion === version) {
|
|
858
|
+
setError(() => err);
|
|
859
|
+
setLoading(false);
|
|
860
|
+
}
|
|
861
|
+
},
|
|
862
|
+
);
|
|
863
|
+
} else {
|
|
864
|
+
// Synchronous fetcher
|
|
865
|
+
if (currentVersion === version) {
|
|
866
|
+
setData(() => result);
|
|
867
|
+
setLoading(false);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
} catch (err) {
|
|
871
|
+
if (currentVersion === version) {
|
|
872
|
+
setError(() => err);
|
|
873
|
+
setLoading(false);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function refetch() {
|
|
879
|
+
const sourceVal = source ? (typeof source === 'function' ? source() : source) : undefined;
|
|
880
|
+
doFetch(sourceVal);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// If source is provided, track it reactively
|
|
884
|
+
if (source) {
|
|
885
|
+
createEffect(() => {
|
|
886
|
+
const sourceVal = typeof source === 'function' ? source() : source;
|
|
887
|
+
if (sourceVal !== undefined && sourceVal !== null && sourceVal !== false) {
|
|
888
|
+
doFetch(sourceVal);
|
|
889
|
+
}
|
|
890
|
+
});
|
|
891
|
+
} else {
|
|
892
|
+
// Fetch immediately
|
|
893
|
+
doFetch();
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
return [data, { loading, error, refetch, mutate: setData }];
|
|
897
|
+
}
|
|
898
|
+
|
|
751
899
|
// ─── DOM Rendering ────────────────────────────────────────
|
|
752
900
|
|
|
753
|
-
//
|
|
754
|
-
|
|
901
|
+
// CSP nonce — set via configureCSP({ nonce: '...' }) or auto-detected from
|
|
902
|
+
// <meta name="csp-nonce" content="...">. Used for style tags to comply with
|
|
903
|
+
// Content-Security-Policy headers.
|
|
904
|
+
let __cspNonce = null;
|
|
905
|
+
|
|
906
|
+
export function configureCSP(options) {
|
|
907
|
+
if (options && options.nonce) __cspNonce = options.nonce;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function getCSPNonce() {
|
|
911
|
+
if (__cspNonce) return __cspNonce;
|
|
912
|
+
if (typeof document !== 'undefined' && typeof document.querySelector === 'function') {
|
|
913
|
+
const meta = document.querySelector('meta[name="csp-nonce"]');
|
|
914
|
+
if (meta) {
|
|
915
|
+
__cspNonce = meta.getAttribute('content');
|
|
916
|
+
return __cspNonce;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
return null;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Inject scoped CSS into the page with reference counting.
|
|
923
|
+
// Style tags are created on first use and removed when no component instances reference them.
|
|
924
|
+
// Supports CSP nonce for Content-Security-Policy compliance.
|
|
925
|
+
const __tovaStyleRefs = new Map(); // id → { el, count }
|
|
755
926
|
export function tova_inject_css(id, css) {
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
927
|
+
const ref = __tovaStyleRefs.get(id);
|
|
928
|
+
if (ref) {
|
|
929
|
+
ref.count++;
|
|
930
|
+
} else {
|
|
931
|
+
const style = document.createElement('style');
|
|
932
|
+
style.setAttribute('data-tova-style', id);
|
|
933
|
+
const nonce = getCSPNonce();
|
|
934
|
+
if (nonce) style.setAttribute('nonce', nonce);
|
|
935
|
+
style.textContent = css;
|
|
936
|
+
document.head.appendChild(style);
|
|
937
|
+
__tovaStyleRefs.set(id, { el: style, count: 1 });
|
|
938
|
+
}
|
|
939
|
+
// Register cleanup on the current owner so unmount decrements the ref count
|
|
940
|
+
if (currentOwner) {
|
|
941
|
+
let cleaned = false;
|
|
942
|
+
const cleanup = () => {
|
|
943
|
+
if (cleaned) return;
|
|
944
|
+
cleaned = true;
|
|
945
|
+
const r = __tovaStyleRefs.get(id);
|
|
946
|
+
if (r) {
|
|
947
|
+
r.count--;
|
|
948
|
+
if (r.count <= 0) {
|
|
949
|
+
if (typeof r.el.remove === 'function') {
|
|
950
|
+
r.el.remove();
|
|
951
|
+
} else if (r.el.parentNode && typeof r.el.parentNode.removeChild === 'function') {
|
|
952
|
+
r.el.parentNode.removeChild(r.el);
|
|
953
|
+
}
|
|
954
|
+
__tovaStyleRefs.delete(id);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
};
|
|
958
|
+
if (!currentOwner._cleanups) currentOwner._cleanups = [];
|
|
959
|
+
currentOwner._cleanups.push(cleanup);
|
|
960
|
+
}
|
|
762
961
|
}
|
|
763
962
|
|
|
764
963
|
export function tova_el(tag, props = {}, children = []) {
|
|
@@ -842,6 +1041,31 @@ export function tova_transition(vnode, nameOrConfig, config = {}) {
|
|
|
842
1041
|
return vnode;
|
|
843
1042
|
}
|
|
844
1043
|
|
|
1044
|
+
// ─── TransitionGroup ──────────────────────────────────────
|
|
1045
|
+
// Animates enter, leave, and move for keyed list items.
|
|
1046
|
+
// Usage: TransitionGroup({ name: "fade", tag: "ul", children: items.map(i => ...) })
|
|
1047
|
+
// Each child MUST have a `key` prop.
|
|
1048
|
+
// Supports FLIP-based move animations when items reorder.
|
|
1049
|
+
|
|
1050
|
+
export function TransitionGroup({ name = 'fade', tag = 'div', config = {}, children, ...rest }) {
|
|
1051
|
+
const transName = name;
|
|
1052
|
+
const transConfig = config;
|
|
1053
|
+
const childList = Array.isArray(children) ? children : (children ? [children] : []);
|
|
1054
|
+
|
|
1055
|
+
// Annotate each child vnode with the transition
|
|
1056
|
+
const annotated = childList.map(child => {
|
|
1057
|
+
if (child && child.__tova && !child._transition) {
|
|
1058
|
+
child._transition = { name: transName, config: transConfig };
|
|
1059
|
+
}
|
|
1060
|
+
return child;
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
// Wrap in a container element (default <div>)
|
|
1064
|
+
const wrapper = tova_el(tag, { ...rest, 'data-tova-transition-group': '' }, annotated);
|
|
1065
|
+
wrapper._transitionGroup = { name: transName, config: transConfig };
|
|
1066
|
+
return wrapper;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
845
1069
|
// ─── Actions ──────────────────────────────────────────────
|
|
846
1070
|
// use: directive support. Calls actionFn(el, param) after render.
|
|
847
1071
|
// Returns the wrapped vnode. The action lifecycle (update/destroy) is managed.
|
|
@@ -1202,16 +1426,30 @@ export function render(vnode) {
|
|
|
1202
1426
|
if (vnode.tag === '__portal') {
|
|
1203
1427
|
const placeholder = document.createComment('portal');
|
|
1204
1428
|
const targetSelector = vnode.props.target;
|
|
1429
|
+
const portalNodes = [];
|
|
1205
1430
|
queueMicrotask(() => {
|
|
1206
1431
|
const targetEl = typeof targetSelector === 'string'
|
|
1207
1432
|
? document.querySelector(targetSelector)
|
|
1208
1433
|
: targetSelector;
|
|
1209
1434
|
if (targetEl) {
|
|
1210
1435
|
for (const child of flattenVNodes(vnode.children)) {
|
|
1211
|
-
|
|
1436
|
+
const rendered = render(child);
|
|
1437
|
+
targetEl.appendChild(rendered);
|
|
1438
|
+
portalNodes.push(rendered);
|
|
1212
1439
|
}
|
|
1213
1440
|
}
|
|
1214
1441
|
});
|
|
1442
|
+
// Register cleanup: remove portal children when component unmounts
|
|
1443
|
+
if (currentOwner && !currentOwner._disposed) {
|
|
1444
|
+
if (!currentOwner._cleanups) currentOwner._cleanups = [];
|
|
1445
|
+
currentOwner._cleanups.push(() => {
|
|
1446
|
+
for (const node of portalNodes) {
|
|
1447
|
+
disposeNode(node);
|
|
1448
|
+
if (node.parentNode) node.parentNode.removeChild(node);
|
|
1449
|
+
}
|
|
1450
|
+
portalNodes.length = 0;
|
|
1451
|
+
});
|
|
1452
|
+
}
|
|
1215
1453
|
return placeholder;
|
|
1216
1454
|
}
|
|
1217
1455
|
|
|
@@ -1306,12 +1544,18 @@ function applyReactiveProps(el, props) {
|
|
|
1306
1544
|
function applyPropValue(el, key, val) {
|
|
1307
1545
|
if (key === 'className') {
|
|
1308
1546
|
if (el.className !== val) el.className = val || '';
|
|
1309
|
-
} else if (key === '
|
|
1310
|
-
|
|
1547
|
+
} else if (key === 'dangerouslySetInnerHTML') {
|
|
1548
|
+
// Explicit unsafe HTML injection — requires {__html: "..."} format
|
|
1549
|
+
const html = typeof val === 'object' && val !== null ? val.__html || '' : '';
|
|
1311
1550
|
if (__DEV__ && html) {
|
|
1312
|
-
console.warn('Tova:
|
|
1551
|
+
console.warn('Tova: dangerouslySetInnerHTML bypasses XSS protection. Ensure content is sanitized.');
|
|
1313
1552
|
}
|
|
1314
1553
|
if (el.innerHTML !== html) el.innerHTML = html;
|
|
1554
|
+
} else if (key === 'innerHTML') {
|
|
1555
|
+
// Blocked: use dangerouslySetInnerHTML instead
|
|
1556
|
+
if (__DEV__) {
|
|
1557
|
+
console.error('Tova: innerHTML is not allowed. Use dangerouslySetInnerHTML={{__html: value}} to acknowledge XSS risk.');
|
|
1558
|
+
}
|
|
1315
1559
|
} else if (key === 'value') {
|
|
1316
1560
|
if (el !== document.activeElement && el.value !== val) {
|
|
1317
1561
|
el.value = val;
|
|
@@ -2011,7 +2255,11 @@ export function mount(component, container) {
|
|
|
2011
2255
|
|
|
2012
2256
|
const result = createRoot((dispose) => {
|
|
2013
2257
|
const vnode = typeof component === 'function' ? component() : component;
|
|
2014
|
-
container.
|
|
2258
|
+
if (typeof container.replaceChildren === 'function') {
|
|
2259
|
+
container.replaceChildren();
|
|
2260
|
+
} else {
|
|
2261
|
+
while (container.firstChild) container.removeChild(container.firstChild);
|
|
2262
|
+
}
|
|
2015
2263
|
container.appendChild(render(vnode));
|
|
2016
2264
|
return dispose;
|
|
2017
2265
|
});
|
|
@@ -2054,3 +2302,137 @@ export function hydrateWhenVisible(component, domNode, options = {}) {
|
|
|
2054
2302
|
observer.disconnect();
|
|
2055
2303
|
};
|
|
2056
2304
|
}
|
|
2305
|
+
|
|
2306
|
+
// ─── Form Handling ──────────────────────────────────────────
|
|
2307
|
+
// Reactive form primitives with field-level validation.
|
|
2308
|
+
// Usage:
|
|
2309
|
+
// const form = createForm({
|
|
2310
|
+
// fields: { email: { initial: '', validate: (v) => v.includes('@') ? null : 'Invalid email' } },
|
|
2311
|
+
// onSubmit: async (values) => { await server.register(values); }
|
|
2312
|
+
// });
|
|
2313
|
+
// <input bind:value={form.field('email').value} />
|
|
2314
|
+
// {form.field('email').error()}
|
|
2315
|
+
// <button on:click={form.submit} disabled={form.submitting()}>Submit</button>
|
|
2316
|
+
|
|
2317
|
+
export function createForm({ fields = {}, onSubmit, validateOnChange = true, validateOnBlur = true }) {
|
|
2318
|
+
const fieldSignals = {};
|
|
2319
|
+
const errorSignals = {};
|
|
2320
|
+
const touchedSignals = {};
|
|
2321
|
+
const [submitting, setSubmitting] = createSignal(false);
|
|
2322
|
+
const [submitError, setSubmitError] = createSignal(null);
|
|
2323
|
+
const [submitCount, setSubmitCount] = createSignal(0);
|
|
2324
|
+
|
|
2325
|
+
// Initialize field signals
|
|
2326
|
+
for (const [name, config] of Object.entries(fields)) {
|
|
2327
|
+
const initial = config.initial !== undefined ? config.initial : '';
|
|
2328
|
+
const [value, setValue] = createSignal(initial);
|
|
2329
|
+
const [error, setError] = createSignal(null);
|
|
2330
|
+
const [touched, setTouched] = createSignal(false);
|
|
2331
|
+
fieldSignals[name] = { value, setValue, validate: config.validate || null, initial };
|
|
2332
|
+
errorSignals[name] = { error, setError };
|
|
2333
|
+
touchedSignals[name] = { touched, setTouched };
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
function validateField(name) {
|
|
2337
|
+
const f = fieldSignals[name];
|
|
2338
|
+
const e = errorSignals[name];
|
|
2339
|
+
if (!f || !e || !f.validate) return null;
|
|
2340
|
+
const err = f.validate(f.value());
|
|
2341
|
+
e.setError(err);
|
|
2342
|
+
return err;
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
function validateAll() {
|
|
2346
|
+
let hasErrors = false;
|
|
2347
|
+
for (const name of Object.keys(fieldSignals)) {
|
|
2348
|
+
const err = validateField(name);
|
|
2349
|
+
if (err) hasErrors = true;
|
|
2350
|
+
}
|
|
2351
|
+
return !hasErrors;
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
function field(name) {
|
|
2355
|
+
const f = fieldSignals[name];
|
|
2356
|
+
const e = errorSignals[name];
|
|
2357
|
+
const t = touchedSignals[name];
|
|
2358
|
+
if (!f) throw new Error(`Tova form: unknown field "${name}"`);
|
|
2359
|
+
return {
|
|
2360
|
+
value: f.value,
|
|
2361
|
+
error: e.error,
|
|
2362
|
+
touched: t.touched,
|
|
2363
|
+
set(val) {
|
|
2364
|
+
f.setValue(val);
|
|
2365
|
+
if (validateOnChange && t.touched()) validateField(name);
|
|
2366
|
+
},
|
|
2367
|
+
blur() {
|
|
2368
|
+
t.setTouched(true);
|
|
2369
|
+
if (validateOnBlur) validateField(name);
|
|
2370
|
+
},
|
|
2371
|
+
validate() { return validateField(name); },
|
|
2372
|
+
};
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
function values() {
|
|
2376
|
+
const result = {};
|
|
2377
|
+
for (const [name, f] of Object.entries(fieldSignals)) {
|
|
2378
|
+
result[name] = f.value();
|
|
2379
|
+
}
|
|
2380
|
+
return result;
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
function reset() {
|
|
2384
|
+
for (const [name, f] of Object.entries(fieldSignals)) {
|
|
2385
|
+
f.setValue(f.initial);
|
|
2386
|
+
errorSignals[name].setError(null);
|
|
2387
|
+
touchedSignals[name].setTouched(false);
|
|
2388
|
+
}
|
|
2389
|
+
setSubmitError(null);
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
async function submit(e) {
|
|
2393
|
+
if (e && typeof e.preventDefault === 'function') e.preventDefault();
|
|
2394
|
+
// Touch all fields
|
|
2395
|
+
for (const name of Object.keys(touchedSignals)) {
|
|
2396
|
+
touchedSignals[name].setTouched(true);
|
|
2397
|
+
}
|
|
2398
|
+
if (!validateAll()) return;
|
|
2399
|
+
if (!onSubmit) return;
|
|
2400
|
+
setSubmitting(true);
|
|
2401
|
+
setSubmitError(null);
|
|
2402
|
+
setSubmitCount(c => c + 1);
|
|
2403
|
+
try {
|
|
2404
|
+
await onSubmit(values());
|
|
2405
|
+
} catch (err) {
|
|
2406
|
+
setSubmitError(err);
|
|
2407
|
+
} finally {
|
|
2408
|
+
setSubmitting(false);
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
const isValid = createComputed(() => {
|
|
2413
|
+
for (const name of Object.keys(errorSignals)) {
|
|
2414
|
+
if (errorSignals[name].error()) return false;
|
|
2415
|
+
}
|
|
2416
|
+
return true;
|
|
2417
|
+
});
|
|
2418
|
+
|
|
2419
|
+
const isDirty = createComputed(() => {
|
|
2420
|
+
for (const [name, f] of Object.entries(fieldSignals)) {
|
|
2421
|
+
if (f.value() !== f.initial) return true;
|
|
2422
|
+
}
|
|
2423
|
+
return false;
|
|
2424
|
+
});
|
|
2425
|
+
|
|
2426
|
+
return {
|
|
2427
|
+
field,
|
|
2428
|
+
values,
|
|
2429
|
+
reset,
|
|
2430
|
+
submit,
|
|
2431
|
+
submitting,
|
|
2432
|
+
submitError,
|
|
2433
|
+
submitCount,
|
|
2434
|
+
isValid,
|
|
2435
|
+
isDirty,
|
|
2436
|
+
validate: validateAll,
|
|
2437
|
+
};
|
|
2438
|
+
}
|