@faqir-ui/core 1.0.0 → 1.1.1
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 +45 -5
- package/cdn.json +38 -22
- package/dist/faqir-core.js +121 -57
- package/dist/faqir-core.min.js +3 -3
- package/dist/faqir-core.min.js.map +3 -3
- package/dist/faqir-core.mjs +75 -59
- package/dist/faqir.aurora.css +1209 -507
- package/dist/faqir.brutalist.css +1184 -489
- package/dist/faqir.candy.css +14152 -0
- package/dist/faqir.clinical.css +14152 -0
- package/dist/faqir.contrast.css +1198 -526
- package/dist/faqir.default.css +1261 -588
- package/dist/faqir.document-serif.css +1211 -491
- package/dist/faqir.document.css +1193 -490
- package/dist/faqir.editorial-document.css +14116 -0
- package/dist/faqir.editorial.css +14152 -0
- package/dist/faqir.fintech.css +14152 -0
- package/dist/faqir.glass.css +1214 -517
- package/dist/faqir.ink-document.css +14116 -0
- package/dist/faqir.ink.css +14127 -0
- package/dist/faqir.luxe.css +14211 -0
- package/dist/faqir.midnight.css +1229 -507
- package/dist/faqir.neo.css +14153 -0
- package/dist/faqir.neumorph.css +14151 -0
- package/dist/faqir.nordic.css +14151 -0
- package/dist/faqir.organic.css +14152 -0
- package/dist/faqir.paper.css +1200 -508
- package/dist/faqir.slate.css +1296 -564
- package/dist/faqir.soft.css +1180 -494
- package/dist/faqir.sunset.css +14153 -0
- package/dist/faqir.swiss-document.css +14116 -0
- package/dist/faqir.swiss.css +14153 -0
- package/dist/faqir.terminal.css +1187 -491
- package/dist/plugins/faqir-collapse.js +1 -1
- package/dist/plugins/faqir-intersect.js +2 -0
- package/dist/plugins/faqir-mask.js +27 -17
- package/dist/plugins/faqir-rules.js +63 -0
- package/dist/plugins/faqir-validate.js +471 -58
- package/dist/sri.json +36 -20
- package/faqir-core.d.ts +119 -6
- 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
|
|
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
|
-
*
|
|
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.
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
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 ||
|
|
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 ||
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
628
|
+
var G =
|
|
216
629
|
(typeof globalThis !== "undefined" && globalThis.Faqir) ||
|
|
217
630
|
(typeof window !== "undefined" && window.Faqir);
|
|
218
|
-
if (
|
|
219
|
-
|
|
631
|
+
if (G && typeof G.plugin === "function") {
|
|
632
|
+
G.plugin(install);
|
|
220
633
|
}
|
|
221
634
|
})();
|