@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erickxavier/no-js",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "The HTML-first reactive framework — build dynamic web apps with just HTML attributes, no JavaScript required",
5
5
  "main": "dist/cjs/no.js",
6
6
  "module": "dist/esm/no.js",
@@ -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
- const data = await _doFetch(resolvedUrl, method, reqBody, {}, el);
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 (asKey && intoStore) {
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
- // If on a form, set up form-level validation
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 el.querySelectorAll("input, textarea, select")) {
279
+ for (const field of getFields()) {
111
280
  if (!field.name) continue;
112
- values[field.name] = field.value;
113
-
114
- const fieldRules = field.getAttribute("validate");
115
- if (fieldRules) {
116
- const err = _validateField(field.value, fieldRules, values);
117
- if (err) {
118
- errors[field.name] = err;
119
- valid = false;
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
- if (!field.checkValidity()) {
124
- errors[field.name] =
125
- errors[field.name] || field.validationMessage;
126
- valid = false;
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
- el.addEventListener("input", () => {
137
- formCtx.dirty = true;
138
- checkValidity();
139
- });
140
- el.addEventListener("focusout", () => {
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
- // Field-level validation
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;