@faqir-ui/core 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +45 -5
  2. package/cdn.json +38 -22
  3. package/dist/faqir-core.js +89 -52
  4. package/dist/faqir-core.min.js +3 -3
  5. package/dist/faqir-core.min.js.map +3 -3
  6. package/dist/faqir-core.mjs +61 -54
  7. package/dist/faqir.aurora.css +1209 -507
  8. package/dist/faqir.brutalist.css +1184 -489
  9. package/dist/faqir.candy.css +14152 -0
  10. package/dist/faqir.clinical.css +14152 -0
  11. package/dist/faqir.contrast.css +1198 -526
  12. package/dist/faqir.default.css +1261 -588
  13. package/dist/faqir.document-serif.css +1211 -491
  14. package/dist/faqir.document.css +1193 -490
  15. package/dist/faqir.editorial-document.css +14116 -0
  16. package/dist/faqir.editorial.css +14152 -0
  17. package/dist/faqir.fintech.css +14152 -0
  18. package/dist/faqir.glass.css +1214 -517
  19. package/dist/faqir.ink-document.css +14116 -0
  20. package/dist/faqir.ink.css +14127 -0
  21. package/dist/faqir.luxe.css +14211 -0
  22. package/dist/faqir.midnight.css +1229 -507
  23. package/dist/faqir.neo.css +14153 -0
  24. package/dist/faqir.neumorph.css +14151 -0
  25. package/dist/faqir.nordic.css +14151 -0
  26. package/dist/faqir.organic.css +14152 -0
  27. package/dist/faqir.paper.css +1200 -508
  28. package/dist/faqir.slate.css +1296 -564
  29. package/dist/faqir.soft.css +1180 -494
  30. package/dist/faqir.sunset.css +14153 -0
  31. package/dist/faqir.swiss-document.css +14116 -0
  32. package/dist/faqir.swiss.css +14153 -0
  33. package/dist/faqir.terminal.css +1187 -491
  34. package/dist/plugins/faqir-collapse.js +1 -1
  35. package/dist/plugins/faqir-intersect.js +2 -0
  36. package/dist/plugins/faqir-mask.js +27 -17
  37. package/dist/plugins/faqir-rules.js +63 -0
  38. package/dist/plugins/faqir-validate.js +471 -58
  39. package/dist/sri.json +36 -20
  40. package/faqir-core.d.ts +111 -5
  41. package/package.json +1 -1
@@ -1,14 +1,18 @@
1
1
  // @ui:plugin faqir-validate
2
2
  // @ui:provides l-validate
3
+ // @ui:modifier l-validate .async | On `l-validate:<name>`, marks a validator whose expression answers with a promise: the field-group sits in `data-state="validating"` while it is out, runs are debounced 250ms and the newest wins, and a rejection is a failure rather than a silent pass.
3
4
  /**
4
- * faqir-validate — declarative form validation for the field-group contract. [0.6-02 · §7.1, §A5]
5
+ * faqir-validate — declarative + programmatic form validation. [0.6-02 · 1.1B-03 · §7.1, §A5, §8.3]
6
+ * Everything the field-group contract needs, driven from markup or from JS.
5
7
  *
6
8
  * <form l-validate>
7
9
  * <div data-ui="field-group">
8
10
  * <label data-part="label" for="email">Work email</label>
9
11
  * <input data-part="input" id="email" name="email" type="email" required
10
12
  * l-validate:company="isCompanyEmail(value)"
11
- * data-error-company="Use your company address.">
13
+ * l-validate:taken.async="isFree(value)"
14
+ * data-error-company="Use your company address."
15
+ * data-error-taken="That address is already registered.">
12
16
  * <p data-part="error"></p>
13
17
  * </div>
14
18
  * </form>
@@ -29,6 +33,10 @@
29
33
  * live — on `blur` (error surfaces when you leave it) and on `input` (error
30
34
  * clears as you fix it). Before that first attempt, typing is never
31
35
  * interrupted with errors. (`blur` doesn't bubble, so both are captured.)
36
+ * • A submit that has to wait on an async check is blocked, not abandoned:
37
+ * when every check has answered it either focuses the first offender or
38
+ * completes — the on-valid hook if the form declares one, otherwise a
39
+ * native `form.submit()`, which fires no submit event and cannot re-enter.
32
40
  *
33
41
  * ── Custom validators ───────────────────────────────────────────────────────
34
42
  * `l-validate:<name>="expr"` on a control runs only after native constraints
@@ -37,6 +45,30 @@
37
45
  * `data-error-<name>`, then `data-error`, then a default. Multiple named
38
46
  * validators may coexist on one control; the first to fail wins.
39
47
  *
48
+ * `l-validate:<name>.async="expr"` is the same thing for an expression that
49
+ * answers with a promise. The field-group enters `data-state="validating"`
50
+ * while it is out, the run is debounced 250 ms as you type and the newest run
51
+ * wins, and a rejected promise is a failure with a built-in message — never a
52
+ * silent pass. (An `.async`-less validator that returns a promise is awaited
53
+ * too, because a promise is truthy and would otherwise pass everything; the
54
+ * dev build says so.)
55
+ *
56
+ * ── Programmatic validators ─────────────────────────────────────────────────
57
+ * `Faqir.validate.register(form, field, name, fn, message?)` adds a validator
58
+ * to the control(s) named `field`, and returns a function that removes it
59
+ * again (as does `unregister(form, field, name?)` — no `name` drops the
60
+ * field's lot). `fn(value, { el, form, data })` answers `true`, `false`, a
61
+ * message string, or a promise of those; `data` is the form's `l-data` scope.
62
+ * Registered validators run after native constraints and after the attribute
63
+ * validators, in registration order, first failure wins. Their message chain
64
+ * is: the string `fn` returned → the `message` argument → `data-error-<name>`
65
+ * → `data-error` → a default.
66
+ *
67
+ * `Faqir.validate.run(form)` validates the whole form and resolves to whether
68
+ * it is clean. It flushes any pending debounce, waits for every async check,
69
+ * and — like a submit attempt — turns live revalidation on, so the errors it
70
+ * surfaces can be cleared by typing. Both take a form element or a selector.
71
+ *
40
72
  * ── Message resolution (when a field is invalid) ────────────────────────────
41
73
  * custom-validator message → `data-error-<constraint>` → `data-error`
42
74
  * → the browser's native `validationMessage` → a built-in default.
@@ -44,10 +76,12 @@
44
76
  * Escape hatch: `data-validate-ignore` on a control opts it out entirely.
45
77
  *
46
78
  * Self-registers via `Faqir.plugin` when a global `Faqir` is present (load it
47
- * after faqir-core) and is also exported for bundlers/tests. Zero dependencies,
48
- * ≤ 2 KB gzip. The only attributes it owns are `data-state="invalid"` on the
49
- * group and `aria-invalid`/`aria-describedby` on the control — the frozen
50
- * five-attribute protocol is untouched.
79
+ * after faqir-core) and is also exported for bundlers/tests. It installs the
80
+ * optional `Faqir.validate` member — plugin-installed surface, declared in
81
+ * `faqir-core.d.ts` as optional for exactly that reason. Zero dependencies,
82
+ * ≤ 3 KB gzip. The only attributes it owns are `data-state="invalid"` and
83
+ * `data-state="validating"` on the group and `aria-invalid`/`aria-describedby`
84
+ * on the control — the frozen five-attribute protocol is untouched.
51
85
  */
52
86
  (function () {
53
87
  "use strict";
@@ -55,6 +89,10 @@
55
89
  // Non-field / non-validatable input types — skipped when scanning a form.
56
90
  var SKIP_TYPES = { submit: 1, button: 1, reset: 1, image: 1, hidden: 1 };
57
91
 
92
+ // The two `data-state` values this plugin owns. Any other state on the group
93
+ // is the author's (or a controller's) and is never cleared by a verdict.
94
+ var OWNED = { invalid: 1, validating: 1 };
95
+
58
96
  // ValidityState flag → (author-message attribute suffix, built-in fallback).
59
97
  // Checked in order; the first failing flag decides the message.
60
98
  var CONSTRAINTS = [
@@ -69,6 +107,19 @@
69
107
  ["badInput", "input", "Please enter a valid value."]
70
108
  ];
71
109
 
110
+ var ATTR = "l-validate:";
111
+ var ASYNC = ".async";
112
+ var DEBOUNCE_MS = 250;
113
+ var INVALID = "This field is invalid.";
114
+ // A check that throws or rejects never answered, so it is not the author's
115
+ // message — and it is certainly not a pass.
116
+ var CHECK_FAILED = "Could not check this field. Please try again.";
117
+
118
+ var F = null; // the engine, from install()
119
+ var contexts = new WeakMap(); // form → { scope, submitted }
120
+ var registered = new WeakMap(); // form → Map(field name → [{ name, fn, message }])
121
+ var runs = new WeakMap(); // control → { seq, timer, async }
122
+
72
123
  function isFormField(el) {
73
124
  var tag = el.tagName;
74
125
  if (tag !== "INPUT" && tag !== "SELECT" && tag !== "TEXTAREA") return false;
@@ -85,20 +136,25 @@
85
136
  return out;
86
137
  }
87
138
 
88
- // Suffixes of the custom `l-validate:<name>` validators declared on a control.
89
- function customNames(el) {
90
- var names = [];
91
- for (var i = 0; i < el.attributes.length; i++) {
92
- var n = el.attributes[i].name;
93
- if (n.indexOf("l-validate:") === 0) names.push(n.slice(11));
139
+ // Per-control bookkeeping: `seq` is the last-wins token, `timer` the pending
140
+ // debounce, `async` whether this control has ever been seen to go async.
141
+ function slot(el) {
142
+ var s = runs.get(el);
143
+ if (!s) {
144
+ s = { seq: 0, timer: 0, async: false };
145
+ runs.set(el, s);
94
146
  }
95
- return names;
147
+ return s;
148
+ }
149
+
150
+ function valueOf(el) {
151
+ return "value" in el ? el.value : null;
96
152
  }
97
153
 
98
154
  // Message shown once a control is invalid — see header for the priority order.
99
155
  function messageFor(el) {
100
156
  var v = el.validity;
101
- if (v.customError) return el.validationMessage || "This field is invalid.";
157
+ if (v.customError) return el.validationMessage || INVALID;
102
158
  for (var i = 0; i < CONSTRAINTS.length; i++) {
103
159
  if (v[CONSTRAINTS[i][0]]) {
104
160
  return (
@@ -109,22 +165,36 @@
109
165
  );
110
166
  }
111
167
  }
112
- return el.validationMessage || "This field is invalid.";
168
+ return el.validationMessage || INVALID;
113
169
  }
114
170
 
115
171
  function group(el) {
116
172
  return el.closest('[data-ui="field-group"]');
117
173
  }
118
174
 
175
+ // Dev-build diagnostic. The production engine's `report` is an empty function,
176
+ // so this costs one call and says nothing in a shipped page.
177
+ function report(message, el) {
178
+ if (F && F.devtools && F.devtools.report) F.devtools.report(message, el);
179
+ }
180
+
181
+ // Set (or clear) the group state this plugin owns. An author's "disabled"
182
+ // survives a clear; a verdict still overwrites it, as it always has.
183
+ function setGroupState(el, next) {
184
+ var g = group(el);
185
+ if (!g) return;
186
+ if (next) g.setAttribute("data-state", next);
187
+ else if (OWNED[g.getAttribute("data-state")]) g.removeAttribute("data-state");
188
+ }
189
+
119
190
  // Reflect a control's verdict onto itself + its field-group. The plugin owns
120
- // the group's `[data-part="error"]` text and toggles `data-state` only between
121
- // "invalid" and cleared (an author's "disabled"/"validating" state is left be).
191
+ // the group's `[data-part="error"]` text.
122
192
  function applyState(el, valid, message) {
123
193
  var g = group(el);
124
194
  var err = g && g.querySelector('[data-part="error"]');
125
195
  if (valid) {
126
196
  el.removeAttribute("aria-invalid");
127
- if (g && g.getAttribute("data-state") === "invalid") g.removeAttribute("data-state");
197
+ setGroupState(el, null);
128
198
  if (err) {
129
199
  err.textContent = "";
130
200
  if (err.id && el.getAttribute("aria-describedby") === err.id)
@@ -132,7 +202,7 @@
132
202
  }
133
203
  } else {
134
204
  el.setAttribute("aria-invalid", "true");
135
- if (g) g.setAttribute("data-state", "invalid");
205
+ setGroupState(el, "invalid");
136
206
  if (err) {
137
207
  err.textContent = message;
138
208
  if (err.id && !el.getAttribute("aria-describedby"))
@@ -141,57 +211,398 @@
141
211
  }
142
212
  }
143
213
 
144
- function install(Faqir) {
145
- Faqir.directive("validate", function (form, dir, scope) {
146
- var submitted = false;
147
-
148
- // Run a control's custom validators (native constraints already passed).
149
- // `value` and `$el` are exposed so `l-validate:x="fn(value)"` resolves; the
150
- // first falsy verdict sets a custom validity message and stops.
151
- function runCustom(el) {
152
- var names = customNames(el);
153
- if (!names.length) return;
154
- var local = Object.create(scope);
155
- local.value = "value" in el ? el.value : null;
156
- local.$el = el;
157
- for (var i = 0; i < names.length; i++) {
158
- var ok = !!Faqir.evaluate(el.getAttribute("l-validate:" + names[i]), local, el);
159
- if (!ok) {
160
- el.setCustomValidity(
161
- el.getAttribute("data-error-" + names[i]) ||
162
- el.getAttribute("data-error") ||
163
- "This field is invalid."
164
- );
165
- return;
214
+ // Waiting on an async check: the previous verdict is gone and the new one has
215
+ // not arrived. `validating` is the state the field-group contract already
216
+ // declares for exactly this moment.
217
+ function markValidating(el) {
218
+ applyState(el, true, "");
219
+ setGroupState(el, "validating");
220
+ }
221
+
222
+ // ── Checks ────────────────────────────────────────────────────────────────
223
+ // A check is a thunk returning true / false / a message / a promise of those,
224
+ // carrying the message to show when it answers falsy.
225
+
226
+ function check(el, name, run, message) {
227
+ run.msg =
228
+ message ||
229
+ el.getAttribute("data-error-" + name) ||
230
+ el.getAttribute("data-error") ||
231
+ INVALID;
232
+ return run;
233
+ }
234
+
235
+ // The custom validators declared on a control, in attribute order. With no
236
+ // `l-validate` context (a bare `Faqir.validate.run`) there is no scope for
237
+ // their expressions to read, so they are skipped rather than run into a
238
+ // ReferenceError that would mark every field invalid.
239
+ function attrChecks(el, ctx) {
240
+ var out = [];
241
+ if (!ctx) return out;
242
+ for (var i = 0; i < el.attributes.length; i++) {
243
+ var attr = el.attributes[i];
244
+ if (attr.name.indexOf(ATTR) !== 0) continue;
245
+ var name = attr.name.slice(ATTR.length);
246
+ var declared = name.length > ASYNC.length && name.slice(-ASYNC.length) === ASYNC;
247
+ if (declared) name = name.slice(0, -ASYNC.length);
248
+ out.push(check(el, name, expressionCheck(el, attr.value, ctx, declared), null));
249
+ }
250
+ return out;
251
+ }
252
+
253
+ // `Object.create(scope)` inherits from the engine's reactive PROXY, and a
254
+ // plain assignment on a child object still runs that proxy's `set` trap
255
+ // (`receiver` is the child, the trap is the prototype's): `local.value = …`
256
+ // would write into the page's data, and `local.$el = …` is refused outright —
257
+ // a TypeError under "use strict" in a real browser, which is where this was
258
+ // found. happy-dom never showed it. `defineProperty` writes the own property
259
+ // directly and consults no trap. [1.1B-03]
260
+ function define(obj, key, value) {
261
+ Object.defineProperty(obj, key, {
262
+ value: value,
263
+ enumerable: true,
264
+ configurable: true,
265
+ writable: true
266
+ });
267
+ }
268
+
269
+ function expressionCheck(el, expression, ctx, declared) {
270
+ return function () {
271
+ var local = Object.create((ctx && ctx.scope) || Object.prototype);
272
+ define(local, "value", valueOf(el));
273
+ define(local, "$el", el);
274
+ var answer = F.evaluate(expression, local, el);
275
+ if (!declared && thenable(answer)) {
276
+ report(
277
+ 'l-validate:… ="' + expression + '" returned a promise. A promise is ' +
278
+ "truthy, so the field would always pass — write it as " +
279
+ "l-validate:<name>.async so the verdict is awaited.",
280
+ el
281
+ );
282
+ }
283
+ return answer;
284
+ };
285
+ }
286
+
287
+ // The validators registered for this control's `name`, in registration order.
288
+ function registeredChecks(form, el, ctx) {
289
+ var byField = registered.get(form);
290
+ var list = byField && el.name ? byField.get(el.name) : null;
291
+ var out = [];
292
+ for (var i = 0; list && i < list.length; i++) {
293
+ out.push(registeredCheck(form, el, list[i], ctx));
294
+ }
295
+ return out;
296
+ }
297
+
298
+ function registeredCheck(form, el, entry, ctx) {
299
+ var c = check(
300
+ el,
301
+ entry.name,
302
+ function () {
303
+ return entry.fn(valueOf(el), {
304
+ el: el,
305
+ form: form,
306
+ data: (ctx && ctx.scope) || null
307
+ });
308
+ },
309
+ entry.message
310
+ );
311
+ c.speaks = true; // a string answer is its message, not a truthy pass
312
+ return c;
313
+ }
314
+
315
+ function thenable(value) {
316
+ return !!value && typeof value.then === "function";
317
+ }
318
+
319
+ // null when the check passed, otherwise the message to show. Only a
320
+ // registered validator may answer with its own sentence; an attribute
321
+ // expression keeps 1.0's reading, where any truthy value — a non-empty string
322
+ // included, as in l-validate:nonblank="value.trim()" — is a pass.
323
+ function verdict(c, value) {
324
+ if (value === true) return null;
325
+ if (c.speaks && typeof value === "string") return value || c.msg;
326
+ return value ? null : c.msg;
327
+ }
328
+
329
+ // Run checks in order, first failure wins. Returns null (all passed) or a
330
+ // message — or a promise of either, the moment a check answers with one.
331
+ function runChecks(checks, i) {
332
+ for (; i < checks.length; i++) {
333
+ var c = checks[i];
334
+ var value;
335
+ try {
336
+ value = c();
337
+ } catch (e) {
338
+ return CHECK_FAILED;
339
+ }
340
+ if (thenable(value)) {
341
+ var next = i + 1;
342
+ return value.then(
343
+ function (settled) {
344
+ var m = verdict(c, settled);
345
+ return m === null ? runChecks(checks, next) : m;
346
+ },
347
+ function () {
348
+ return CHECK_FAILED;
166
349
  }
167
- }
350
+ );
351
+ }
352
+ var message = verdict(c, value);
353
+ if (message !== null) return message;
354
+ }
355
+ return null;
356
+ }
357
+
358
+ // A custom check has answered. The message also goes onto the control as its
359
+ // custom validity, so `el.validity.valid` and `form.checkValidity()` agree
360
+ // with what the field-group is showing.
361
+ function settle(el, message) {
362
+ if (message !== null) el.setCustomValidity(message);
363
+ applyState(el, message === null, message || "");
364
+ }
365
+
366
+ // Validate one control and reflect the verdict. Returns true/false, or a
367
+ // Promise<boolean> when a check went async. Cancels any pending debounce on
368
+ // this control, so a submit flushes what typing had deferred.
369
+ function validateField(form, el, ctx) {
370
+ var s = slot(el);
371
+ if (s.timer) {
372
+ clearTimeout(s.timer);
373
+ s.timer = 0;
374
+ }
375
+ var token = ++s.seq;
376
+
377
+ el.setCustomValidity(""); // clear any prior custom verdict
378
+ if (!el.validity.valid) {
379
+ applyState(el, false, messageFor(el));
380
+ return false;
381
+ }
382
+
383
+ var out = runChecks(attrChecks(el, ctx).concat(registeredChecks(form, el, ctx)), 0);
384
+ if (!thenable(out)) {
385
+ settle(el, out);
386
+ return out === null;
387
+ }
388
+
389
+ s.async = true; // from now on this control's live runs are debounced
390
+ markValidating(el);
391
+ return out.then(function (message) {
392
+ // A newer run owns the DOM — answer, but touch nothing.
393
+ if (s.seq !== token) return message === null;
394
+ settle(el, message);
395
+ return message === null;
396
+ });
397
+ }
398
+
399
+ // True when this control is known to take time: it declares an `.async`
400
+ // validator, or a previous run of it went async (a registered `fn` only
401
+ // reveals that by returning a promise).
402
+ function isSlow(el) {
403
+ var s = slot(el);
404
+ if (s.async) return true;
405
+ for (var i = 0; i < el.attributes.length; i++) {
406
+ var n = el.attributes[i].name;
407
+ if (n.indexOf(ATTR) === 0 && n.slice(-ASYNC.length) === ASYNC) {
408
+ s.async = true;
409
+ return true;
410
+ }
411
+ }
412
+ return false;
413
+ }
414
+
415
+ // Typing into a slow control: hold the run for 250 ms and let the newest one
416
+ // win, rather than firing a request per keystroke. Sync-only controls still
417
+ // answer on the keystroke — that is the "error clears as you fix it" half of
418
+ // the revalidation policy.
419
+ function schedule(form, el, ctx) {
420
+ var s = slot(el);
421
+ if (s.timer) clearTimeout(s.timer);
422
+ markValidating(el);
423
+ s.timer = setTimeout(function () {
424
+ s.timer = 0;
425
+ validateField(form, el, ctx);
426
+ }, DEBOUNCE_MS);
427
+ }
428
+
429
+ function firstInvalid(list, verdicts) {
430
+ for (var i = 0; i < list.length; i++) if (!verdicts[i]) return list[i];
431
+ return null;
432
+ }
433
+
434
+ function focusField(el) {
435
+ if (typeof el.focus === "function") el.focus();
436
+ }
437
+
438
+ function hookExpression(dir) {
439
+ return dir.expression && dir.expression.trim() ? dir.expression : null;
440
+ }
441
+
442
+ // Every check has answered after a submit that had to wait. Either focus the
443
+ // first offender, or finish the submit the wait interrupted — unless the
444
+ // page's own submit handler cancelled it (`@submit.prevent` on an SPA form):
445
+ // that submit was never going to navigate, and a native one now would.
446
+ function finishSubmit(form, list, verdicts, ctx, dir, cancelled) {
447
+ var bad = firstInvalid(list, verdicts);
448
+ if (bad) {
449
+ focusField(bad);
450
+ return;
451
+ }
452
+ if (hookExpression(dir)) {
453
+ F.evaluateAssignment(dir.expression, ctx.scope, form);
454
+ return;
455
+ }
456
+ if (cancelled()) return;
457
+ // Native submit: fires no submit event, so the listener below cannot
458
+ // re-enter and the async pass cannot loop.
459
+ if (typeof form.submit === "function") form.submit();
460
+ }
461
+
462
+ // Validate every control of a form, whoever asked. Returns a Promise<boolean>
463
+ // so a caller that does not care whether any check was async can just wait.
464
+ function validateAll(form, ctx) {
465
+ var list = fields(form);
466
+ var results = [];
467
+ for (var i = 0; i < list.length; i++) results.push(validateField(form, list[i], ctx));
468
+ return Promise.all(results).then(function (verdicts) {
469
+ return firstInvalid(list, verdicts) === null;
470
+ });
471
+ }
472
+
473
+ // ── The programmatic surface, installed as `Faqir.validate` ───────────────
474
+
475
+ function host(target, what) {
476
+ var el = typeof target === "string" ? document.querySelector(target) : target;
477
+ if (!el || !el.querySelectorAll) {
478
+ throw new TypeError(
479
+ "Faqir.validate." + what + ": no form element matched " + JSON.stringify(target)
480
+ );
481
+ }
482
+ return el;
483
+ }
484
+
485
+ function indexOfName(list, name) {
486
+ for (var i = 0; i < list.length; i++) if (list[i].name === name) return i;
487
+ return -1;
488
+ }
489
+
490
+ var api = {
491
+ register: function (form, field, name, fn, message) {
492
+ var el = host(form, "register");
493
+ if (typeof fn !== "function") {
494
+ throw new TypeError("Faqir.validate.register: `fn` must be a function");
495
+ }
496
+ var byField = registered.get(el);
497
+ if (!byField) {
498
+ byField = new Map();
499
+ registered.set(el, byField);
168
500
  }
501
+ var list = byField.get(field);
502
+ if (!list) {
503
+ list = [];
504
+ byField.set(field, list);
505
+ }
506
+ var entry = { name: name, fn: fn, message: message || null };
507
+ var at = indexOfName(list, name);
508
+ // Re-registering a name replaces it in place, so registration ORDER is
509
+ // the order the names were first seen — not the order they were last set.
510
+ if (at === -1) list.push(entry);
511
+ else list[at] = entry;
169
512
 
170
- function validateField(el) {
171
- el.setCustomValidity(""); // clear any prior custom verdict
172
- if (el.validity.valid) runCustom(el); // native ok → try custom
173
- var valid = el.validity.valid;
174
- applyState(el, valid, valid ? "" : messageFor(el));
175
- return valid;
513
+ if (!el.querySelector('[name="' + field + '"]')) {
514
+ report(
515
+ 'Faqir.validate.register("' + field + '", "' + name + '") matches no ' +
516
+ "control in this form — it will never run.",
517
+ el
518
+ );
176
519
  }
520
+ return function () {
521
+ api.unregister(el, field, name);
522
+ };
523
+ },
524
+
525
+ unregister: function (form, field, name) {
526
+ var el = host(form, "unregister");
527
+ var byField = registered.get(el);
528
+ if (!byField) return;
529
+ if (name === undefined) {
530
+ byField.delete(field);
531
+ return;
532
+ }
533
+ var list = byField.get(field);
534
+ var at = list ? indexOfName(list, name) : -1;
535
+ if (at !== -1) list.splice(at, 1);
536
+ },
537
+
538
+ run: function (form) {
539
+ var el = host(form, "run");
540
+ var ctx = contexts.get(el) || null;
541
+ if (ctx) ctx.submitted = true; // the errors are on screen — let typing clear them
542
+ else {
543
+ report(
544
+ "Faqir.validate.run() on a form with no l-validate. Native constraints " +
545
+ "and registered validators still run, but its l-validate:<name> " +
546
+ "expressions have no scope to read and are skipped.",
547
+ el
548
+ );
549
+ }
550
+ return validateAll(el, ctx);
551
+ }
552
+ };
553
+
554
+ function install(Faqir) {
555
+ F = Faqir;
556
+ // Plugin-installed surface. Declared optional in faqir-core.d.ts because a
557
+ // page that never loads this file does not have it.
558
+ if (!Faqir.validate) Faqir.validate = api;
559
+
560
+ Faqir.directive("validate", function (form, dir, scope) {
561
+ var ctx = { scope: scope, submitted: false };
562
+ contexts.set(form, ctx);
177
563
 
178
564
  // Submit: validate everything; block + focus the first offender when dirty.
179
565
  form.addEventListener("submit", function (e) {
180
- submitted = true;
181
- var list = fields(form),
182
- firstBad = null;
566
+ ctx.submitted = true;
567
+ var list = fields(form);
568
+ var results = [];
569
+ var waiting = false;
183
570
  for (var i = 0; i < list.length; i++) {
184
- if (!validateField(list[i]) && !firstBad) firstBad = list[i];
571
+ var r = validateField(form, list[i], ctx);
572
+ results.push(r);
573
+ if (thenable(r)) waiting = true;
574
+ }
575
+
576
+ // A check is still out: this turn cannot decide the submit, so block it
577
+ // and resume once every answer is in. Blocking sets `defaultPrevented`
578
+ // for everyone, so whether ANOTHER handler cancelled it — before this
579
+ // one, or after — is kept by watching this event's preventDefault.
580
+ if (waiting) {
581
+ var cancelledByPage = e.defaultPrevented;
582
+ var prevent = e.preventDefault;
583
+ e.preventDefault = function () {
584
+ cancelledByPage = true;
585
+ return prevent.call(e);
586
+ };
587
+ prevent.call(e);
588
+ Promise.all(results).then(function (verdicts) {
589
+ finishSubmit(form, list, verdicts, ctx, dir, function () {
590
+ return cancelledByPage;
591
+ });
592
+ });
593
+ return;
185
594
  }
595
+
596
+ var firstBad = firstInvalid(list, results);
186
597
  if (firstBad) {
187
598
  e.preventDefault();
188
- if (typeof firstBad.focus === "function") firstBad.focus();
599
+ focusField(firstBad);
189
600
  return;
190
601
  }
191
602
  // Clean. A non-empty `l-validate` expression is an on-valid hook (SPA
192
603
  // submit): run it and keep the browser from navigating. Otherwise let
193
604
  // the native submit proceed.
194
- if (dir.expression && dir.expression.trim()) {
605
+ if (hookExpression(dir)) {
195
606
  e.preventDefault();
196
607
  Faqir.evaluateAssignment(dir.expression, scope, form);
197
608
  }
@@ -199,7 +610,9 @@
199
610
 
200
611
  // After the first submit attempt, revalidate the touched field live.
201
612
  function live(e) {
202
- if (submitted && isFormField(e.target)) validateField(e.target);
613
+ if (!ctx.submitted || !isFormField(e.target)) return;
614
+ if (e.type === "input" && isSlow(e.target)) schedule(form, e.target, ctx);
615
+ else validateField(form, e.target, ctx);
203
616
  }
204
617
  form.addEventListener("blur", live, true);
205
618
  form.addEventListener("input", live, true);
@@ -212,10 +625,10 @@
212
625
  }
213
626
 
214
627
  // Browser self-registration: attach as soon as a global Faqir exists.
215
- var F =
628
+ var G =
216
629
  (typeof globalThis !== "undefined" && globalThis.Faqir) ||
217
630
  (typeof window !== "undefined" && window.Faqir);
218
- if (F && typeof F.plugin === "function") {
219
- F.plugin(install);
631
+ if (G && typeof G.plugin === "function") {
632
+ G.plugin(install);
220
633
  }
221
634
  })();