@erickxavier/no-js 1.6.0 → 1.8.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/README.md +14 -12
- package/dist/cjs/no.js +6 -6
- package/dist/cjs/no.js.map +3 -3
- package/dist/esm/no.js +6 -6
- package/dist/esm/no.js.map +3 -3
- package/dist/iife/no.js +5 -5
- package/dist/iife/no.js.map +3 -3
- package/package.json +1 -1
- package/src/directives/refs.js +71 -4
- package/src/directives/validation.js +401 -64
- package/src/dom.js +15 -1
- package/src/globals.js +1 -1
- package/src/index.js +38 -6
- package/src/router.js +103 -21
package/package.json
CHANGED
package/src/directives/refs.js
CHANGED
|
@@ -6,8 +6,12 @@ import {
|
|
|
6
6
|
_refs,
|
|
7
7
|
_stores,
|
|
8
8
|
_notifyStoreWatchers,
|
|
9
|
+
_emitEvent,
|
|
10
|
+
_routerInstance,
|
|
11
|
+
_warn,
|
|
9
12
|
_onDispose,
|
|
10
13
|
} from "../globals.js";
|
|
14
|
+
import { _devtoolsEmit } from "../devtools.js";
|
|
11
15
|
import { createContext } from "../context.js";
|
|
12
16
|
import { evaluate, _execStatement, _interpolate } from "../evaluate.js";
|
|
13
17
|
import { _doFetch } from "../fetch.js";
|
|
@@ -75,19 +79,41 @@ registerDirective("call", {
|
|
|
75
79
|
init(el, name, url) {
|
|
76
80
|
const ctx = findContext(el);
|
|
77
81
|
const method = el.getAttribute("method") || "get";
|
|
78
|
-
const asKey = el.getAttribute("as");
|
|
82
|
+
const asKey = el.getAttribute("as") || "data";
|
|
79
83
|
const intoStore = el.getAttribute("into");
|
|
80
84
|
const successTpl = el.getAttribute("success");
|
|
81
85
|
const errorTpl = el.getAttribute("error");
|
|
86
|
+
const loadingTpl = el.getAttribute("loading");
|
|
82
87
|
const thenExpr = el.getAttribute("then");
|
|
83
88
|
const confirmMsg = el.getAttribute("confirm");
|
|
84
89
|
const bodyAttr = el.getAttribute("body");
|
|
90
|
+
const redirectPath = el.getAttribute("redirect");
|
|
91
|
+
const headersAttr = el.getAttribute("headers");
|
|
92
|
+
|
|
93
|
+
const originalChildren = [...el.childNodes].map((n) => n.cloneNode(true));
|
|
94
|
+
let _activeAbort = null;
|
|
85
95
|
|
|
86
96
|
el.addEventListener("click", async (e) => {
|
|
87
97
|
e.preventDefault();
|
|
88
98
|
if (confirmMsg && !window.confirm(confirmMsg)) return;
|
|
89
99
|
|
|
100
|
+
// SwitchMap: abort previous in-flight request
|
|
101
|
+
if (_activeAbort) _activeAbort.abort();
|
|
102
|
+
_activeAbort = new AbortController();
|
|
103
|
+
|
|
90
104
|
const resolvedUrl = _interpolate(url, ctx);
|
|
105
|
+
|
|
106
|
+
// Show loading template
|
|
107
|
+
if (loadingTpl) {
|
|
108
|
+
const clone = _cloneTemplate(loadingTpl);
|
|
109
|
+
if (clone) {
|
|
110
|
+
el.innerHTML = "";
|
|
111
|
+
el.appendChild(clone);
|
|
112
|
+
processTree(el);
|
|
113
|
+
el.disabled = true;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
91
117
|
try {
|
|
92
118
|
let reqBody = null;
|
|
93
119
|
if (bodyAttr) {
|
|
@@ -98,9 +124,27 @@ registerDirective("call", {
|
|
|
98
124
|
reqBody = interpolated;
|
|
99
125
|
}
|
|
100
126
|
}
|
|
101
|
-
|
|
127
|
+
|
|
128
|
+
const extraHeaders = headersAttr ? JSON.parse(headersAttr) : {};
|
|
129
|
+
const data = await _doFetch(
|
|
130
|
+
resolvedUrl,
|
|
131
|
+
method,
|
|
132
|
+
reqBody,
|
|
133
|
+
extraHeaders,
|
|
134
|
+
el,
|
|
135
|
+
_activeAbort.signal,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// Restore original children
|
|
139
|
+
if (loadingTpl) {
|
|
140
|
+
el.innerHTML = "";
|
|
141
|
+
for (const child of originalChildren)
|
|
142
|
+
el.appendChild(child.cloneNode(true));
|
|
143
|
+
el.disabled = false;
|
|
144
|
+
}
|
|
145
|
+
|
|
102
146
|
if (asKey) ctx.$set(asKey, data);
|
|
103
|
-
if (
|
|
147
|
+
if (intoStore) {
|
|
104
148
|
if (!_stores[intoStore]) _stores[intoStore] = createContext({});
|
|
105
149
|
_stores[intoStore].$set(asKey, data);
|
|
106
150
|
_notifyStoreWatchers();
|
|
@@ -123,14 +167,37 @@ registerDirective("call", {
|
|
|
123
167
|
processTree(wrapper);
|
|
124
168
|
}
|
|
125
169
|
}
|
|
170
|
+
|
|
171
|
+
if (redirectPath && _routerInstance)
|
|
172
|
+
_routerInstance.push(redirectPath);
|
|
173
|
+
|
|
174
|
+
_emitEvent("fetch:success", { url: resolvedUrl, data });
|
|
175
|
+
_devtoolsEmit("fetch:success", { method, url: resolvedUrl });
|
|
126
176
|
} catch (err) {
|
|
177
|
+
// SwitchMap: silently ignore aborted requests
|
|
178
|
+
if (err.name === "AbortError") return;
|
|
179
|
+
|
|
180
|
+
_warn(`call ${method.toUpperCase()} ${resolvedUrl} failed:`, err.message);
|
|
181
|
+
|
|
182
|
+
// Restore original children
|
|
183
|
+
if (loadingTpl) {
|
|
184
|
+
el.innerHTML = "";
|
|
185
|
+
for (const child of originalChildren)
|
|
186
|
+
el.appendChild(child.cloneNode(true));
|
|
187
|
+
el.disabled = false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
_emitEvent("fetch:error", { url: resolvedUrl, error: err });
|
|
191
|
+
_emitEvent("error", { url: resolvedUrl, error: err });
|
|
192
|
+
_devtoolsEmit("fetch:error", { method, url: resolvedUrl, error: err.message });
|
|
193
|
+
|
|
127
194
|
if (errorTpl) {
|
|
128
195
|
const clone = _cloneTemplate(errorTpl);
|
|
129
196
|
if (clone) {
|
|
130
197
|
const tplEl = document.getElementById(errorTpl.replace("#", ""));
|
|
131
198
|
const vn = tplEl?.getAttribute("var") || "err";
|
|
132
199
|
const childCtx = createContext(
|
|
133
|
-
{ [vn]: { message: err.message, status: err.status } },
|
|
200
|
+
{ [vn]: { message: err.message, status: err.status, body: err.body } },
|
|
134
201
|
ctx,
|
|
135
202
|
);
|
|
136
203
|
const target = el.parentElement;
|
|
@@ -1,13 +1,189 @@
|
|
|
1
1
|
// ═══════════════════════════════════════════════════════════════════════
|
|
2
2
|
// DIRECTIVES: validate, error-boundary
|
|
3
|
-
// HELPER: _validateField
|
|
3
|
+
// HELPER: _validateField, _getValidityErrors, _resolveErrorMessage
|
|
4
4
|
// ═══════════════════════════════════════════════════════════════════════
|
|
5
5
|
|
|
6
6
|
import { _validators, _onDispose } from "../globals.js";
|
|
7
7
|
import { createContext } from "../context.js";
|
|
8
8
|
import { findContext, _cloneTemplate } from "../dom.js";
|
|
9
9
|
import { registerDirective, processTree } from "../registry.js";
|
|
10
|
+
import { evaluate } from "../evaluate.js";
|
|
10
11
|
|
|
12
|
+
// ── ValidityState → rule name mapping ────────────────────────────────
|
|
13
|
+
const _validityMap = [
|
|
14
|
+
["valueMissing", "required"],
|
|
15
|
+
["typeMismatch", null], // resolved to input type at runtime
|
|
16
|
+
["tooShort", "minlength"],
|
|
17
|
+
["tooLong", "maxlength"],
|
|
18
|
+
["patternMismatch", "pattern"],
|
|
19
|
+
["rangeUnderflow", "min"],
|
|
20
|
+
["rangeOverflow", "max"],
|
|
21
|
+
["stepMismatch", "step"],
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
// ── Priority order for error display ─────────────────────────────────
|
|
25
|
+
const _rulePriority = [
|
|
26
|
+
"required", "email", "url", "number", "date", "time",
|
|
27
|
+
"datetime-local", "month", "week", "tel",
|
|
28
|
+
"minlength", "maxlength", "pattern", "min", "max", "step",
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
function _getFieldType(field) {
|
|
32
|
+
return (field.getAttribute("type") || "text").toLowerCase();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Get all failing rules from ValidityState + NoJS validators ───────
|
|
36
|
+
function _getValidityErrors(field, allValues) {
|
|
37
|
+
const errors = [];
|
|
38
|
+
const seenRules = new Set();
|
|
39
|
+
|
|
40
|
+
// First, check NoJS-specific validators from validate="" attribute
|
|
41
|
+
// (these take priority over native ValidityState messages)
|
|
42
|
+
const rules = field.getAttribute("validate");
|
|
43
|
+
if (rules) {
|
|
44
|
+
const ruleList = rules.split("|").map((r) => r.trim());
|
|
45
|
+
for (const rule of ruleList) {
|
|
46
|
+
const [name, ...args] = rule.split(":");
|
|
47
|
+
const fn = _validators[name];
|
|
48
|
+
if (fn) {
|
|
49
|
+
const result = fn(field.value, ...args, allValues);
|
|
50
|
+
if (result !== true && result) {
|
|
51
|
+
errors.push({ rule: name, message: result });
|
|
52
|
+
seenRules.add(name);
|
|
53
|
+
}
|
|
54
|
+
} else {
|
|
55
|
+
// Built-in NoJS validators (backward compat + NoJS-only)
|
|
56
|
+
const value = field.value;
|
|
57
|
+
let msg = null;
|
|
58
|
+
switch (name) {
|
|
59
|
+
case "required":
|
|
60
|
+
if (value == null || String(value).trim() === "") msg = "Required";
|
|
61
|
+
break;
|
|
62
|
+
case "email":
|
|
63
|
+
if (value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) msg = "Invalid email";
|
|
64
|
+
break;
|
|
65
|
+
case "url":
|
|
66
|
+
try { new URL(value); } catch { msg = "Invalid URL"; }
|
|
67
|
+
break;
|
|
68
|
+
case "min":
|
|
69
|
+
if (Number(value) < Number(args[0])) msg = `Minimum value is ${args[0]}`;
|
|
70
|
+
break;
|
|
71
|
+
case "max":
|
|
72
|
+
if (Number(value) > Number(args[0])) msg = `Maximum value is ${args[0]}`;
|
|
73
|
+
break;
|
|
74
|
+
case "custom":
|
|
75
|
+
if (args[0] && _validators[args[0]]) {
|
|
76
|
+
const result = _validators[args[0]](value, allValues);
|
|
77
|
+
if (result !== true && result) msg = result;
|
|
78
|
+
}
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
if (msg) {
|
|
82
|
+
errors.push({ rule: name, message: msg });
|
|
83
|
+
seenRules.add(name);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Then, check native ValidityState (skip rules already covered by NoJS validators)
|
|
90
|
+
const validity = field.validity;
|
|
91
|
+
if (validity && !validity.valid) {
|
|
92
|
+
for (const [prop, ruleName] of _validityMap) {
|
|
93
|
+
if (validity[prop]) {
|
|
94
|
+
const name = ruleName || _getFieldType(field);
|
|
95
|
+
if (!seenRules.has(name)) {
|
|
96
|
+
errors.push({ rule: name, message: field.validationMessage });
|
|
97
|
+
seenRules.add(name);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return errors;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Resolve error message: error-{rule} attr → error attr → default ──
|
|
107
|
+
function _resolveErrorMessage(field, ruleName, defaultMsg) {
|
|
108
|
+
const perRule = field.getAttribute(`error-${ruleName}`);
|
|
109
|
+
if (perRule) return perRule;
|
|
110
|
+
const generic = field.getAttribute("error");
|
|
111
|
+
if (generic && !generic.startsWith("#")) return generic;
|
|
112
|
+
return defaultMsg;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Pick the highest-priority error from a list ──────────────────────
|
|
116
|
+
function _pickError(errors, field) {
|
|
117
|
+
if (!errors.length) return null;
|
|
118
|
+
|
|
119
|
+
// Sort by priority
|
|
120
|
+
const sorted = [...errors].sort((a, b) => {
|
|
121
|
+
const ai = _rulePriority.indexOf(a.rule);
|
|
122
|
+
const bi = _rulePriority.indexOf(b.rule);
|
|
123
|
+
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const top = sorted[0];
|
|
127
|
+
return {
|
|
128
|
+
rule: top.rule,
|
|
129
|
+
message: _resolveErrorMessage(field, top.rule, top.message),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── Render template reference for error ──────────────────────────────
|
|
134
|
+
function _renderErrorTemplate(selector, errorMsg, ruleName, anchorEl, ctx) {
|
|
135
|
+
// Clean up previous render
|
|
136
|
+
_clearErrorTemplate(anchorEl);
|
|
137
|
+
|
|
138
|
+
const tpl = document.querySelector(selector);
|
|
139
|
+
if (!tpl) return;
|
|
140
|
+
|
|
141
|
+
const clone = tpl.content.cloneNode(true);
|
|
142
|
+
const wrapper = document.createElement("div");
|
|
143
|
+
wrapper.style.display = "contents";
|
|
144
|
+
wrapper.__errorTemplateFor = anchorEl;
|
|
145
|
+
const childCtx = createContext({ $error: errorMsg, $rule: ruleName }, ctx);
|
|
146
|
+
wrapper.__ctx = childCtx;
|
|
147
|
+
wrapper.appendChild(clone);
|
|
148
|
+
|
|
149
|
+
// Insert after the template element (in-place rendering)
|
|
150
|
+
tpl.parentNode.insertBefore(wrapper, tpl.nextSibling);
|
|
151
|
+
processTree(wrapper);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function _clearErrorTemplate(anchorEl) {
|
|
155
|
+
// Find and remove any previously rendered error template for this field
|
|
156
|
+
const existing = document.querySelector(`div[style="display: contents;"]`);
|
|
157
|
+
// Use a more targeted approach
|
|
158
|
+
const all = document.querySelectorAll('div[style="display: contents;"]');
|
|
159
|
+
for (const el of all) {
|
|
160
|
+
if (el.__errorTemplateFor === anchorEl) {
|
|
161
|
+
el.remove();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Check if field should be validated (validate-if) ─────────────────
|
|
167
|
+
function _shouldValidate(field, ctx) {
|
|
168
|
+
const expr = field.getAttribute("validate-if");
|
|
169
|
+
if (!expr) return true;
|
|
170
|
+
try {
|
|
171
|
+
return !!evaluate(expr, ctx);
|
|
172
|
+
} catch {
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Get validate-on triggers ─────────────────────────────────────────
|
|
178
|
+
function _getValidationTriggers(field, form) {
|
|
179
|
+
const fieldAttr = field.getAttribute("validate-on");
|
|
180
|
+
if (fieldAttr) return fieldAttr.split(/\s+/);
|
|
181
|
+
const formAttr = form ? form.getAttribute("validate-on") : null;
|
|
182
|
+
if (formAttr) return formAttr.split(/\s+/);
|
|
183
|
+
return ["input", "focusout"];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── Backward-compat: old _validateField for field-level validation ───
|
|
11
187
|
function _validateField(value, rules, allValues) {
|
|
12
188
|
const ruleList = rules.split("|").map((r) => r.trim());
|
|
13
189
|
for (const rule of ruleList) {
|
|
@@ -17,55 +193,21 @@ function _validateField(value, rules, allValues) {
|
|
|
17
193
|
const result = fn(value, ...args, allValues);
|
|
18
194
|
if (result !== true && result) return result;
|
|
19
195
|
} else {
|
|
20
|
-
// Built-in validators
|
|
21
196
|
switch (name) {
|
|
22
197
|
case "required":
|
|
23
|
-
if (value == null || String(value).trim() === "")
|
|
24
|
-
return "Required";
|
|
198
|
+
if (value == null || String(value).trim() === "") return "Required";
|
|
25
199
|
break;
|
|
26
200
|
case "email":
|
|
27
|
-
if (value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))
|
|
28
|
-
return "Invalid email";
|
|
201
|
+
if (value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return "Invalid email";
|
|
29
202
|
break;
|
|
30
203
|
case "url":
|
|
31
|
-
try {
|
|
32
|
-
new URL(value);
|
|
33
|
-
} catch {
|
|
34
|
-
return "Invalid URL";
|
|
35
|
-
}
|
|
204
|
+
try { new URL(value); } catch { return "Invalid URL"; }
|
|
36
205
|
break;
|
|
37
206
|
case "min":
|
|
38
|
-
if (Number(value) < Number(args[0]))
|
|
39
|
-
return `Minimum value is ${args[0]}`;
|
|
207
|
+
if (Number(value) < Number(args[0])) return `Minimum value is ${args[0]}`;
|
|
40
208
|
break;
|
|
41
209
|
case "max":
|
|
42
|
-
if (Number(value) > Number(args[0]))
|
|
43
|
-
return `Maximum value is ${args[0]}`;
|
|
44
|
-
break;
|
|
45
|
-
case "between": {
|
|
46
|
-
const n = Number(value);
|
|
47
|
-
if (n < Number(args[0]) || n > Number(args[1]))
|
|
48
|
-
return `Must be between ${args[0]} and ${args[1]}`;
|
|
49
|
-
break;
|
|
50
|
-
}
|
|
51
|
-
case "match":
|
|
52
|
-
if (value !== allValues[args[0]]) return `Must match ${args[0]}`;
|
|
53
|
-
break;
|
|
54
|
-
case "phone":
|
|
55
|
-
if (!/^[\d\s\-+()]{7,15}$/.test(value))
|
|
56
|
-
return "Invalid phone number";
|
|
57
|
-
break;
|
|
58
|
-
case "cpf":
|
|
59
|
-
if (!/^\d{3}\.?\d{3}\.?\d{3}-?\d{2}$/.test(value))
|
|
60
|
-
return "Invalid CPF";
|
|
61
|
-
break;
|
|
62
|
-
case "cnpj":
|
|
63
|
-
if (!/^\d{2}\.?\d{3}\.?\d{3}\/?\d{4}-?\d{2}$/.test(value))
|
|
64
|
-
return "Invalid CNPJ";
|
|
65
|
-
break;
|
|
66
|
-
case "creditcard":
|
|
67
|
-
if (!/^\d{13,19}$/.test(value.replace(/\s|-/g, "")))
|
|
68
|
-
return "Invalid card number";
|
|
210
|
+
if (Number(value) > Number(args[0])) return `Maximum value is ${args[0]}`;
|
|
69
211
|
break;
|
|
70
212
|
case "custom":
|
|
71
213
|
if (args[0] && _validators[args[0]]) {
|
|
@@ -84,65 +226,259 @@ registerDirective("validate", {
|
|
|
84
226
|
init(el, name, rules) {
|
|
85
227
|
const ctx = findContext(el);
|
|
86
228
|
|
|
87
|
-
//
|
|
229
|
+
// ═════════════════════════════════════════════════════
|
|
230
|
+
// FORM-LEVEL VALIDATION
|
|
231
|
+
// ═════════════════════════════════════════════════════
|
|
88
232
|
if (el.tagName === "FORM") {
|
|
233
|
+
// Prevent native browser validation popups
|
|
234
|
+
el.setAttribute("novalidate", "");
|
|
235
|
+
|
|
236
|
+
const errorClassAttr = el.getAttribute("error-class");
|
|
237
|
+
const touchedFields = new Set();
|
|
238
|
+
const dirtyFields = new Set();
|
|
239
|
+
const pendingValidators = new Map();
|
|
240
|
+
|
|
89
241
|
const formCtx = {
|
|
90
242
|
valid: false,
|
|
91
243
|
dirty: false,
|
|
92
244
|
touched: false,
|
|
93
245
|
submitting: false,
|
|
246
|
+
pending: false,
|
|
94
247
|
errors: {},
|
|
95
248
|
values: {},
|
|
249
|
+
firstError: null,
|
|
250
|
+
errorCount: 0,
|
|
251
|
+
fields: {},
|
|
96
252
|
reset: () => {
|
|
97
253
|
formCtx.dirty = false;
|
|
98
254
|
formCtx.touched = false;
|
|
255
|
+
formCtx.pending = false;
|
|
256
|
+
touchedFields.clear();
|
|
257
|
+
dirtyFields.clear();
|
|
99
258
|
el.reset();
|
|
100
259
|
checkValidity();
|
|
101
260
|
},
|
|
102
261
|
};
|
|
103
262
|
ctx.$set("$form", formCtx);
|
|
104
263
|
|
|
264
|
+
// ── Collect all form fields ────────────────────────
|
|
265
|
+
function getFields() {
|
|
266
|
+
return el.querySelectorAll("input, textarea, select");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ── Main validation check ──────────────────────────
|
|
105
270
|
function checkValidity() {
|
|
106
271
|
const errors = {};
|
|
107
272
|
const values = {};
|
|
273
|
+
const fields = {};
|
|
108
274
|
let valid = true;
|
|
275
|
+
let firstError = null;
|
|
276
|
+
let errorCount = 0;
|
|
277
|
+
let hasPending = false;
|
|
109
278
|
|
|
110
|
-
for (const field of
|
|
279
|
+
for (const field of getFields()) {
|
|
111
280
|
if (!field.name) continue;
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if (
|
|
118
|
-
|
|
119
|
-
|
|
281
|
+
|
|
282
|
+
// Collect value
|
|
283
|
+
if (field.type === "checkbox") {
|
|
284
|
+
values[field.name] = field.checked;
|
|
285
|
+
} else if (field.type === "radio") {
|
|
286
|
+
if (field.checked) values[field.name] = field.value;
|
|
287
|
+
else if (!(field.name in values)) values[field.name] = "";
|
|
288
|
+
} else {
|
|
289
|
+
values[field.name] = field.value;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
for (const field of getFields()) {
|
|
294
|
+
if (!field.name) continue;
|
|
295
|
+
|
|
296
|
+
const fieldTouched = touchedFields.has(field.name);
|
|
297
|
+
const fieldDirty = dirtyFields.has(field.name);
|
|
298
|
+
|
|
299
|
+
// Check validate-if
|
|
300
|
+
if (!_shouldValidate(field, ctx)) {
|
|
301
|
+
// Field excluded from validation
|
|
302
|
+
fields[field.name] = {
|
|
303
|
+
valid: true, dirty: fieldDirty, touched: fieldTouched,
|
|
304
|
+
error: null, value: values[field.name],
|
|
305
|
+
};
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Get all errors for this field
|
|
310
|
+
const fieldErrors = _getValidityErrors(field, values);
|
|
311
|
+
const topError = _pickError(fieldErrors, field);
|
|
312
|
+
|
|
313
|
+
const fieldValid = !topError;
|
|
314
|
+
const fieldInteracted = fieldTouched || fieldDirty;
|
|
315
|
+
|
|
316
|
+
// $form.valid reflects real state (keeps submit disabled)
|
|
317
|
+
if (!fieldValid) valid = false;
|
|
318
|
+
|
|
319
|
+
// $form.errors only shows errors for interacted fields
|
|
320
|
+
if (!fieldValid && fieldInteracted) {
|
|
321
|
+
errors[field.name] = topError.message;
|
|
322
|
+
errorCount++;
|
|
323
|
+
if (!firstError) firstError = topError.message;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Per-field context ($field)
|
|
327
|
+
fields[field.name] = {
|
|
328
|
+
valid: fieldValid,
|
|
329
|
+
dirty: fieldDirty,
|
|
330
|
+
touched: fieldTouched,
|
|
331
|
+
error: topError ? topError.message : null,
|
|
332
|
+
value: values[field.name],
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
// error-class handling
|
|
336
|
+
const fieldErrorClass = field.getAttribute("error-class") || errorClassAttr;
|
|
337
|
+
if (fieldErrorClass) {
|
|
338
|
+
const classes = fieldErrorClass.split(/\s+/);
|
|
339
|
+
if (!fieldValid && fieldInteracted) {
|
|
340
|
+
field.classList.add(...classes);
|
|
341
|
+
} else {
|
|
342
|
+
field.classList.remove(...classes);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// error template references (error="#tpl" or error-{rule}="#tpl")
|
|
347
|
+
if (topError && fieldInteracted) {
|
|
348
|
+
const perRuleVal = field.getAttribute(`error-${topError.rule}`);
|
|
349
|
+
const genericVal = field.getAttribute("error");
|
|
350
|
+
const tplRef = (perRuleVal && perRuleVal.startsWith("#") ? perRuleVal : null) ||
|
|
351
|
+
(genericVal && genericVal.startsWith("#") ? genericVal : null);
|
|
352
|
+
if (tplRef) {
|
|
353
|
+
_renderErrorTemplate(tplRef, topError.message, topError.rule, field, ctx);
|
|
354
|
+
} else {
|
|
355
|
+
_clearErrorTemplate(field);
|
|
120
356
|
}
|
|
357
|
+
} else {
|
|
358
|
+
_clearErrorTemplate(field);
|
|
121
359
|
}
|
|
122
360
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
361
|
+
// $field via as="" attribute
|
|
362
|
+
const asAttr = field.getAttribute("as");
|
|
363
|
+
if (asAttr) {
|
|
364
|
+
ctx.$set(asAttr, fields[field.name]);
|
|
127
365
|
}
|
|
128
366
|
}
|
|
129
367
|
|
|
368
|
+
// Check for pending async validators
|
|
369
|
+
if (pendingValidators.size > 0) hasPending = true;
|
|
370
|
+
|
|
130
371
|
formCtx.valid = valid;
|
|
131
372
|
formCtx.errors = errors;
|
|
132
373
|
formCtx.values = values;
|
|
374
|
+
formCtx.fields = fields;
|
|
375
|
+
formCtx.firstError = firstError;
|
|
376
|
+
formCtx.errorCount = errorCount;
|
|
377
|
+
formCtx.pending = hasPending;
|
|
133
378
|
ctx.$set("$form", { ...formCtx });
|
|
379
|
+
|
|
380
|
+
// Auto-disable submit buttons
|
|
381
|
+
_updateSubmitButtons(el, valid && !hasPending);
|
|
134
382
|
}
|
|
135
383
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
384
|
+
// ── Auto-disable submit buttons ────────────────────
|
|
385
|
+
function _updateSubmitButtons(form, isValid) {
|
|
386
|
+
const buttons = form.querySelectorAll(
|
|
387
|
+
'button:not([type="button"]), input[type="submit"]'
|
|
388
|
+
);
|
|
389
|
+
for (const btn of buttons) {
|
|
390
|
+
// Skip if user has explicit disabled expression
|
|
391
|
+
if (btn.hasAttribute("disabled") && btn.getAttribute("disabled") !== "") {
|
|
392
|
+
const val = btn.getAttribute("disabled");
|
|
393
|
+
// Only skip if it's a custom expression (not empty or "disabled")
|
|
394
|
+
if (val !== "disabled" && val !== "true" && val !== "false") continue;
|
|
395
|
+
}
|
|
396
|
+
btn.disabled = !isValid;
|
|
397
|
+
btn.__autoDisabled = true;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ── Event binding with validate-on support ─────────
|
|
402
|
+
function bindFieldEvents(field) {
|
|
403
|
+
if (!field.name) return;
|
|
404
|
+
const triggers = _getValidationTriggers(field, el);
|
|
405
|
+
|
|
406
|
+
const handler = () => {
|
|
407
|
+
dirtyFields.add(field.name);
|
|
408
|
+
formCtx.dirty = true;
|
|
409
|
+
checkValidity();
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
const touchHandler = () => {
|
|
413
|
+
touchedFields.add(field.name);
|
|
414
|
+
formCtx.touched = true;
|
|
415
|
+
checkValidity();
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
if (triggers.includes("input")) {
|
|
419
|
+
field.addEventListener("input", handler);
|
|
420
|
+
} else {
|
|
421
|
+
// Always track dirty and re-validate on input for data accuracy
|
|
422
|
+
// (validate-on only affects visual feedback like error-class/templates)
|
|
423
|
+
field.addEventListener("input", () => {
|
|
424
|
+
dirtyFields.add(field.name);
|
|
425
|
+
formCtx.dirty = true;
|
|
426
|
+
checkValidity();
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
if (triggers.includes("blur") || triggers.includes("focusout")) {
|
|
430
|
+
field.addEventListener("focusout", (e) => {
|
|
431
|
+
touchHandler();
|
|
432
|
+
if (triggers.includes("blur")) handler();
|
|
433
|
+
});
|
|
434
|
+
} else {
|
|
435
|
+
// Always track touched on focusout
|
|
436
|
+
field.addEventListener("focusout", touchHandler);
|
|
437
|
+
}
|
|
438
|
+
if (triggers.includes("submit")) {
|
|
439
|
+
field.addEventListener("focusout", touchHandler);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Default events if no validate-on (backward compat)
|
|
444
|
+
const hasValidateOn = el.hasAttribute("validate-on");
|
|
445
|
+
const hasFieldValidateOn = [...getFields()].some(f => f.hasAttribute("validate-on"));
|
|
446
|
+
|
|
447
|
+
if (!hasValidateOn && !hasFieldValidateOn) {
|
|
448
|
+
// Legacy behavior: form-level input, change, and focusout
|
|
449
|
+
const inputHandler = (e) => {
|
|
450
|
+
const target = e.target;
|
|
451
|
+
if (target && target.name) {
|
|
452
|
+
dirtyFields.add(target.name);
|
|
453
|
+
}
|
|
454
|
+
formCtx.dirty = true;
|
|
455
|
+
checkValidity();
|
|
456
|
+
};
|
|
457
|
+
el.addEventListener("input", inputHandler);
|
|
458
|
+
el.addEventListener("change", inputHandler);
|
|
459
|
+
el.addEventListener("focusout", (e) => {
|
|
460
|
+
if (e.target && e.target.name) {
|
|
461
|
+
touchedFields.add(e.target.name);
|
|
462
|
+
}
|
|
463
|
+
formCtx.touched = true;
|
|
464
|
+
checkValidity();
|
|
465
|
+
});
|
|
466
|
+
} else {
|
|
467
|
+
// Per-field event binding with validate-on
|
|
468
|
+
for (const field of getFields()) {
|
|
469
|
+
bindFieldEvents(field);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
el.addEventListener("submit", (e) => {
|
|
474
|
+
// If validate-on="submit", run validation now
|
|
475
|
+
formCtx.submitting = true;
|
|
476
|
+
// Mark all fields as touched on submit
|
|
477
|
+
for (const field of getFields()) {
|
|
478
|
+
if (field.name) touchedFields.add(field.name);
|
|
479
|
+
}
|
|
141
480
|
formCtx.touched = true;
|
|
142
481
|
checkValidity();
|
|
143
|
-
});
|
|
144
|
-
el.addEventListener("submit", () => {
|
|
145
|
-
formCtx.submitting = true;
|
|
146
482
|
ctx.$set("$form", { ...formCtx });
|
|
147
483
|
requestAnimationFrame(() => {
|
|
148
484
|
formCtx.submitting = false;
|
|
@@ -155,7 +491,9 @@ registerDirective("validate", {
|
|
|
155
491
|
return;
|
|
156
492
|
}
|
|
157
493
|
|
|
158
|
-
//
|
|
494
|
+
// ═════════════════════════════════════════════════════
|
|
495
|
+
// FIELD-LEVEL VALIDATION (standalone, outside form)
|
|
496
|
+
// ═════════════════════════════════════════════════════
|
|
159
497
|
if (
|
|
160
498
|
rules &&
|
|
161
499
|
(el.tagName === "INPUT" ||
|
|
@@ -166,7 +504,6 @@ registerDirective("validate", {
|
|
|
166
504
|
el.addEventListener("input", () => {
|
|
167
505
|
const err = _validateField(el.value, rules, {});
|
|
168
506
|
if (err && errorTpl) {
|
|
169
|
-
// Show error
|
|
170
507
|
let errorEl = el.nextElementSibling?.__validationError
|
|
171
508
|
? el.nextElementSibling
|
|
172
509
|
: null;
|