PageSourceSearch

https://www.thefigstore.com/js/component/fieldValidator.js?v=6

js thefigstore.com collected 2026-09-27 07:59:56 UTC 31,682 bytes, 755 lines download raw bytes

1/* ------------------------------------------------------------------
2 * fieldValidator — vanilla replacement for Core\Validate (jQuery Validate).
3 * Replace-as-you-go; do not add another validation library.
4 *
5 * Skill (migration recipes, wiring paths): .claude/skills/field-validator/SKILL.md
6 *
7 * API:
8 *   fieldValidator.bind(form, { requiredFields, onValidSubmit })
9 *   fieldValidator.validateForm(form, { showErrors, focusFirst })  → Promise<boolean>
10 *   fieldValidator.validateField(el, requiredSet, opts)
11 *   fieldValidator.unbind(form)
12 *
13 * Wired automatically by:
14 *   - Core\Validate::displayFieldValidator() / ->scriptFieldValidator()  (migrated pages)
15 *   - Core\Dialog modal open                   (idomains/lib/Dialog.php)
16 *   - slideoutForm.js after htmx swap
17 *
18 * Markup (legacy jQuery Validate class names still work):
19 *   .required .email .number .url .date .digits .creditcard
20 *   .telephone .bankSort .noNumeric .containAlpha .containNumeric
21 *   .multiEmail .is-host   (+ attribute telephone="true")
22 *   data-msg-{rule}   minlength / maxlength   min / max / step   data-equal-to="#other"
23 *   HTML5 input types honoured: type=number / email / url (no class needed)
24 *   data-validate-url + data-validate-field     (async; replaces remote: rules)
25 *   data-validate-extra="p_code"                (extra POST fields from form)
26 *   data-validate-send-idx="{idx}"              (static POST params)
27 *
28 * Errors: tippy popper (default); .inline-errors → .input-error on .input-container
29 *
30 * NOT YET PORTED from jQuery Validate (use data-validate-url or server-only):
31 *   depends:/conditional rules, $.validator.addMethod (hasAddress, gtw, …),
32 *   dynamic .rules('add'), non-EN message catalogs
33 * ------------------------------------------------------------------ */
34(function () {
35    'use strict';
36
37    var THEME = 'field-validator-error';
38
39    var FIELD_SELECTORS = [
40        '.required', '.email', '.number', '.url', '.date', '.digits', '.creditcard',
41        '.telephone', '.bankSort', '.noNumeric', '.containAlpha', '.containNumeric',
42        '.multiEmail', '.is-host',
43        '[data-validate-url]', '[telephone="true"]',
44        // HTML5 typed/attribute fields — the form is novalidate, so native checks
45        // are off and we must enforce these ourselves (jQuery Validate parity).
46        'input[type="number"]', 'input[type="email"]', 'input[type="url"]', 'input[step]',
47    ].join(', ');
48
49    var DEFAULT_MSG = {
50        required: 'Please fill in this field.',
51        email: 'Please enter a valid email address ([email protected]).',
52        number: 'Please enter a valid number.',
53        digits: 'Please enter only digits.',
54        url: 'Please enter a valid URL.',
55        date: 'Please enter a valid date.',
56        creditcard: 'Please enter a valid credit card number.',
57        telephone: 'Must be a valid telephone number.',
58        bankSort: 'Not a valid bank sort code.',
59        noNumeric: 'Letters only.',
60        containAlpha: 'Password must contain letters.',
61        containNumeric: 'Password must contain at least 1 number.',
62        multiEmail: 'Invalid email format.',
63        'is-host': 'Please enter a valid hostname or URL.',
64        minlength: 'Please enter at least {0} characters.',
65        maxlength: 'Please enter no more than {0} characters.',
66        min: 'Please enter a value greater than or equal to {0}.',
67        max: 'Please enter a value less than or equal to {0}.',
68        step: 'Please enter a multiple of {0}.',
69        equalTo: 'Please enter the same value again.',
70    };
71
72    function escapeHtml(s) {
73        return String(s)
74            .replace(/&/g, '&amp;')
75            .replace(/</g, '&lt;')
76            .replace(/>/g, '&gt;')
77            .replace(/"/g, '&quot;');
78    }
79
80    function formatMsg(template, n) {
81        return String(template).replace(/\{0\}/g, String(n));
82    }
83
84    function msg(el, rule, fallback) {
85        var custom = el.getAttribute('data-msg-' + rule);
86        return (custom && custom.trim()) ? custom.trim() : fallback;
87    }
88
89    function tipAnchor(el) {
90        var row = el.closest('.form-group');
91        if (row && row.contains(el)) return row;
92        return el;
93    }
94
95    function isVisible(el) {
96        return !el.disabled && el.type !== 'hidden' && el.offsetParent !== null;
97    }
98
99    function isRequired(el, requiredSet) {
100        if (!requiredSet) return el.classList.contains('required');
101        return el.classList.contains('required') || (el.name && requiredSet.has(el.name));
102    }
103
104    function isEmailList(val) {
105        var parts = val.split(/[,;]/).map(function (t) { return t.trim(); }
105).filter(Boolean);
106        if (!parts.length) return true;
107        return parts.every(function (s) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s); });
108    }
109
110    function isValidNumber(val) {
111        return /^-?(?:\d+)(?:\.\d+)?$/.test(val);
112    }
113
114    function isValidUrl(val) {
115        return /^(https?:\/\/|ftp:\/\/)/i.test(val)
116            || /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+/i.test(val)
117            || /^\/[^\s]*$/.test(val);
118    }
119
120    function isValidDate(val) {
121        if (!/^\d{1,4}[\/\-.]\d{1,2}[\/\-.]\d{1,4}$/.test(val)) return false;
122        var d = new Date(val);
123        return !isNaN(d.getTime());
124    }
125
126    function isValidCreditcard(val) {
127        if (/[^0-9 \-]+/.test(val)) return false;
128        var digits = val.replace(/\D/g, '');
129        if (digits.length < 13 || digits.length > 19) return false;
130        var nCheck = 0;
131        var bEven = false;
132        for (var n = digits.length - 1; n >= 0; n--) {
133            var nDigit = parseInt(digits.charAt(n), 10);
134            if (bEven) {
135                nDigit *= 2;
136                if (nDigit > 9) nDigit -= 9;
137            }
138            nCheck += nDigit;
139            bEven = !bEven;
140        }
141        return (nCheck % 10) === 0;
142    }
143
144    // Accept a bare hostname / IPv4 / IPv6 literal, or a full URL. Parsing with the
145    // native URL constructor is linear-time, so it sidesteps the catastrophic
146    // backtracking of the old jQuery URL regex (CodeQL ReDoS). A scheme-less value
147    // gets a temporary scheme so host-only input ("example.com/path") still parses;
148    // each label regex below is bounded and non-nesting, so it can't backtrack.
149    function isValidHost(val) {
150        val = (val || '').trim();
151        if (!val) return false;
152        var candidate = /^[a-z][a-z\d+.\-]*:\/\//i.test(val) ? val : 'http://' + val;
153        var host;
154        try { host = new URL(candidate).hostname; } catch (e) { return false; }
155        if (!host || host.length > 253) return false;
156        // IPv6 literals arrive bracketed and are already validated by URL parsing.
157        if (host.charAt(0) === '[') return true;
158        // Otherwise require dotted labels (domain or IPv4): each 1\u201363 chars of
159        // alnum/hyphen, not starting or ending with a hyphen. IDNs are punycode by
160        // now (URL normalises them), so they pass the ASCII label test too.
161        var labels = host.split('.');
162        if (labels.length < 2) return false;
163        return labels.every(function (label) {
164            return /^[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?$/i.test(label);
165        });
166    }
167
168    function hasRuleClass(el, name) {
169        return el.classList.contains(name);
170    }
171
172    function hasTelephoneRule(el) {
173        return hasRuleClass(el, 'telephone') || el.getAttribute('telephone') === 'true';
174    }
175
176    // Type-or-class rule detection — HTML5 input types imply the matching rule
177    // (jQuery Validate read both the type attribute and the legacy class names).
178    function hasEmailRule(el) {
179        return hasRuleClass(el, 'email') || hasRuleClass(el, 'multiEmail') || el.type === 'email';
180    }
181
182    function hasNumberRule(el) {
183        return hasRuleClass(el, 'number') || el.type === 'number';
184    }
185
186    function hasUrlRule(el) {
187        return hasRuleClass(el, 'url') || el.type === 'url';
188    }
189
190    function syncRequiredSet(requiredFields) {
191        var set = new Set();
192        (requiredFields || []).forEach(function (n) { if (n) set.add(n); });
193        return set;
194    }
195
196    // Only real form controls carry a value. Rule classes (.required, .number, …)
197    // sometimes bleed onto non-control elements — e.g. Core\Form::getLabel() copies the
198    // input's full class onto the radio/checkbox <label> — and those would otherwise be
199    // collected as fields that can never be satisfied (a <label> has no .value, so a
200    // required check on it stays "empty" forever, blocking submit). jQuery Validate only
201    // ever validated named controls; restrict to the same set for parity.
202    var FORM_CONTROL_TAGS = { INPUT: 1, SELECT: 1, TEXTAREA: 1 };
203
204    function collectFields(form, requiredSet) {
205        var seen = new Set();
206        var out = [];
207        function add(el) {
208            if (!el || seen.has(el) || !FORM_CONTROL_TAGS[el.tagName]) return;
209            seen.add(el);
210            out.push(el);
211        }
212        form.querySelectorAll(FIELD_SELECTORS).forEach(add);
213        requiredSet.forEach(function (name) {
214            form.querySelectorAll('[name="' + name + '"]').forEach(add);
215        });
216        return out;
217    }
218
219    function usesInlineErrors(el) {
220        if (!el.closest('.inline-errors')) return null;
221        var wrapper = el.closest('.input-container') || el.parentElement;
222        return wrapper || null;
223    }
224
225    function hideInlineError(el) {
226        var wrapper = usesInlineErrors(el);
227        if (!wrapper) return;
228        wrapper.classList.remove('error');
229        var err = wrapper.querySelector('.input-error');
230        if (err) err.remove();
231    }
232
233    function showInlineError(el, message) {
234        var wrapper = usesInlineErrors(el);
235        if (!wrapper) return false;
236        wrapper.classList.add('error');
237        var err = wrapper.querySelector('.input-error');
238        if (err) {
239            err.textContent = message;
240        } else if (wrapper.classList.contains('row-checkbox') || wrapper.classList.contains('row-radio')) {
241            var after = el.nextElementSibling;
242            var div = document.createElement('div');
243            div.className = 'input-error';
244            div.textContent = message;
245            if (after) after.insertAdjacentElement('afterend', div);
246            else el.insertAdjacentElement('afterend', div);
247        } else {
248            var div2 = document.createElement('div');
249            div2.className = 'input-error';
250            div2.textContent = message;
251            el.insertAdjacentElement('afterend', div2);
252        }
253        return true;
254    }
255
256    // Tips live in <body> (appendTo), so tippy cannot see its reference field
257    // being torn out of the tree — a dialog closing, an admin-nav .admin-center
258    // swap, an htmx re-render — and the popper is left floating over the page.
259    // Track every live tip and destroy the ones whose field has detached.
260    var activeTips = new Set();
261    var detachObserver = null;
262    var sweepQueued = false;
263
264    function pruneDetachedTips() {
265        sweepQueued = false;
266        activeTips.forEach(function (el) {
267            if (!el.isConnected) hideTip(el);
268        });
269    }
270
271    // Drop every live tip (optionally only those inside `root`) without touching
272    // the form's binding — for hosts that hide their content instead of removing
273    // it, which the detach observer cannot see.
274    function clearErrors(root) {
275        activeTips.forEach(function (el) {
276            if (!root || root.contains(el)) hideTip(el);
277        });
278    }
279
280    function watchForDetach() {
281        if (detachObserver || typeof MutationObserver !== 'function') return;
282        detachObserver = new MutationObserver(function () {
283            if (sweepQueued) return;
284            sweepQueued = true;   // one sweep per task, not per mutation batch
285            setTimeout(pruneDetachedTips, 0);
286        });
287        detachObserver.observe(document.documentElement, { childList: true, subtree: true });
288    }
289
290    function stopWatchingIfIdle() {
291        if (activeTips.size || !detachObserver) return;
292        detachObserver.disconnect();
293        detachObserver = null;
294    }
295
296    function hideTip(el) {
297        activeTips.delete(el);
298        stopWatchingIfIdle();
299        if (el._fvTip) {
300            try { el._fvTip.hide(); el._fvTip.destroy(); } catch (e) { /* ignore */ }
301            el._fvTip = null;
302        }
303        hideInlineError(el);
304        el.classList.remove('error');
305        el.removeAttribute('aria-invalid');
306    }
307
308    function showTip(el, message) {
309        hideTip(el);
310        el.classList.add('error');
311        el.setAttribute('aria-invalid', 'true');
312        if (showInlineError(el, message)) return;
313        var tipFn = window.tippy;
314        if (typeof tipFn !== 'function') return;
315        var anchor = tipAnchor(el);
316        var html = '<div class="field-validator-popover" role="alert">'
317            + '<span class="field-validator-popover__msg">' + escapeHtml(message) + '</span>'
318            + '</div>';
319        try {
320            el._fvTip = tipFn(anchor, {
321                content: html,
322                allowHTML: true,
323                trigger: 'manual',
324                showOnCreate: true,
325                appendTo: function () { return document.body; },
326                placement: 'right',
327                theme: THEME,
328                maxWidth: 280,
329                hideOnClick: false,
330                interactive: false,
331                zIndex: 2100,
332                offset: [0, 10],
333                popperOptions: {
334                    modifiers: [
335                        {
336                            name: 'flip',
337                            options: {
338                                fallbackPlacements: ['left', 'top-end', 'bottom-end'],
339                            },
340                        },
341                        { name: 'preventOverflow', options: { padding: 12 } },
342                    ],
343                },
344            });
345            activeTips.add(el);
346            watchForDetach();
347        } catch (e) { /* ignore */ }
348    }
349
350    function checkClassRules(el, val) {
351        if (hasEmailRule(el)) {
352            if (val && !isEmailList(val)) {
353                return { ok: false, msg: msg(el, 'email', DEFAULT_MSG.email) };
354            }
355        }
356        if (hasNumberRule(el) && val && !isValidNumber(val)) {
357            return { ok: false, msg: msg(el, 'number', DEFAULT_MSG.number) };
358        }
359        if (hasRuleClass(el, 'digits') && val && !/^\d+$/.test(val)) {
360            return { ok: false, msg: msg(el, 'digits', DEFAULT_MSG.digits) };
361        }
362        if (hasUrlRule(el) && val && !isValidUrl(val)) {
363            return { ok: false, msg: msg(el, 'url', DEFAULT_MSG.url) };
364        }
365        if (hasRuleClass(el, 'date') && val && !isValidDate(val)) {
366            return { ok: false, msg: msg(el, 'date', DEFAULT_MSG.date) };
367        }
368        if (hasRuleClass(el, 'creditcard') && val && !isValidCreditcard(val)) {
369            return { ok: false, msg: msg(el, 'creditcard', DEFAULT_MSG.creditcard) };
370        }
371        if (hasTelephoneRule(el) && val && !/^[0-9\-+()'\s]+$/i.test(val)) {
372            return { ok: false, msg: msg(el, 'telephone', DEFAULT_MSG.telephone) };
373        }
374        if (hasRuleClass(el, 'bankSort') && val && !/^['\s]*[0-9]{2}[-'\s]?[0-9]{2}[-'\s]?[0-9]{2}['\s]*$/i.test(val)) {
375            return { ok: false, msg: msg(el, 'bankSort', DEFAULT_MSG.bankSort) };
376        }
377        if (hasRuleClass(el, 'noNumeric') && val && !/^[a-z\-'\s]+$/i.test(val)) {
378            return { ok: false, msg: msg(el, 'noNumeric', DEFAULT_MSG.noNumeric) };
379        }
380        if (hasRuleClass(el, 'containAlpha') && val && !/[a-z]+/i.test(val)) {
381            return { ok: false, msg: msg(el, 'containAlpha', DEFAULT_MSG.containAlpha) };
382        }
383        if (hasRuleClass(el, 'containNumeric') && val && !/[0-9]+/.test(val)) {
384            return { ok: false, msg: msg(el, 'containNumeric', DEFAULT_MSG.containNumeric) };
385        }
386        if (hasRuleClass(el, 'is-host') && val && !isValidHost(val)) {
387            return { ok: false, msg: msg(el, 'is-host', DEFAULT_MSG['is-host']) };
388        }
389        return { ok: true };
390    }
391
392    function checkLengthRules(el, val) {
393        var minAttr = el.getAttribute('minlength');
394        var maxAttr = el.getAttribute('maxlength');
395        if (minAttr && val.length < parseInt(minAttr, 10)) {
396            return {
397                ok: false,
398                msg: msg(el, 'minlength', formatMsg(DEFAULT_MSG.minlength, minAttr)),
399            };
400        }
401        if (maxAttr && val.length > parseInt(maxAttr, 10)) {
402            return {
403                ok: false,
404                msg: msg(el, 'maxlength', formatMsg(DEFAULT_MSG.maxlength, maxAttr)),
405            };
406        }
407        return { ok: true };
408    }
409
410    function checkRangeRules(el, val) {
411        // HTML5 numeric min/max — jQuery Validate read these attributes for any field.
412        // Skip date fields (their min/max are date strings, not numbers).
413        if (!val || el.type === 'date' || hasRuleClass(el, 'date')) return { ok: true };
414        var num = parseFloat(val);
415        if (isNaN(num)) return { ok: true };
416        var minAttr = el.getAttribute('min');
417        var maxAttr = el.getAttribute('max');
418        if (minAttr !== null && minAttr !== '' && !isNaN(parseFloat(minAttr)) && num < parseFloat(minAttr)) {
419            return { ok: false, msg: msg(el, 'min', formatMsg(DEFAULT_MSG.min, minAttr)) };
420        }
421        if (maxAttr !== null && maxAttr !== '' && !isNaN(parseFloat(maxAttr)) && num > parseFloat(maxAttr)) {
422            return { ok: false, msg: msg(el, 'max', formatMsg(DEFAULT_MSG.max, maxAttr)) };
423        }
424        return { ok: true };
425    }
426
427    function decimalPlaces(num) {
428        var match = ('' + num).match(/(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/);
429        if (!match) return 0;
430        return Math.max(0, (match[1] ? match[1].length : 0) - (match[2] ? +match[2] : 0));
431    }
432
433    function checkStepRules(el, val) {
434        var stepAttr = el.getAttribute('step');
435        if (!stepAttr || stepAttr === 'any' || !val) return { ok: true };
436        var step = parseFloat(stepAttr);
437        if (isNaN(step) || step <= 0) return { ok: true };
438        var num = parseFloat(val);
439        if (isNaN(num)) return { ok: true };
440        // jQuery Validate's algorithm: scale to integers by the step's decimals.
441        var decimals = decimalPlaces(step);
442        var scale = Math.pow(10, decimals);
443        var toInt = function (n) { return Math.round(n * scale); };
444        if (decimalPlaces(num) > decimals || (toInt(num) % toInt(step)) !== 0) {
445            return { ok: false, msg: msg(el, 'step', formatMsg(DEFAULT_MSG.step, stepAttr)) };
446        }
447        return { ok: true };
448    }
449
450    function checkEqualTo(el, form) {
451        var target = el.getAttribute('data-equal-to');
452        if (!target) return { ok: true };
453        var other = form.querySelector(target);
454        if (!other) return { ok: true };
455        if (String(el.value || '') !== String(other.value || '')) {
456            return { ok: false, msg: msg(el, 'equalTo', DEFAULT_MSG.equalTo) };
457        }
458        return { ok: true };
459    }
460
461    function radioGroupChecked(el, form) {
462        var scope = form || el.closest('form');
463        if (!scope || !el.name) return el.checked;
464        var radios = scope.querySelectorAll('input[type="radio"]');
465        for (var i = 0; i < radios.length; i++) {
466            if (radios[i].name === el.name && radios[i].checked) return true;
467        }
468        return false;
469    }
470
471    function validateLocal(el, requiredSet, form) {
472        if (!isVisible(el)) return { ok: true };
473        var val = String(el.value || '').trim();
474        var rawVal = String(el.value || '');
475        if (isRequired(el, requiredSet)) {
476            var empty;
477            if (el.type === 'radio') {
478                empty = !radioGroupChecked(el, form);
479            } else if (el.type === 'checkbox') {
480                empty = !el.checked;
481            } else {
482                empty = !val;
483            }
484            if (empty) {
485                return { ok: false, msg: msg(el, 'required', DEFAULT_MSG.required) };
486            }
487        }
488        if (!val && el.type !== 'checkbox' && el.type !== 'radio') return { ok: true };
489
490        var classCheck = checkClassRules(el, val);
491        if (!classCheck.ok) return classCheck;
492
493        var lenCheck = checkLengthRules(el, rawVal);
494        if (!lenCheck.ok) return lenCheck;
495
496        var rangeCheck = checkRangeRules(el, val);
497        if (!rangeCheck.ok) return rangeCheck;
498
499        var stepCheck = checkStepRules(el, val);
500        if (!stepCheck.ok) return stepCheck;
501
502        if (form) {
503            var eqCheck = checkEqualTo(el, form);
504            if (!eqCheck.ok) return eqCheck;
505        }
506
507        return { ok: true };
508    }
509
510    function appendRemoteExtras(body, el, form) {
511        var extra = el.getAttribute('data-validate-extra');
512        if (extra) {
513            extra.split(',').map(function (s) { return s.trim(); }).filter(Boolean).forEach(function (name) {
514                var escId = (typeof CSS !== 'undefined' && CSS.escape)
515                    ? CSS.escape(name) : name.replace(/([^\w-])/g, '\\$1');
516                var field = form.querySelector('[name="' + name + '"]')
517                    || form.querySelector('#' + escId);
518                if (field) body.set(name, field.value || '');
519            });
520        }
521        Array.prototype.forEach.call(el.attributes, function (attr) {
522            if (attr.name.indexOf('data-validate-send-') === 0) {
523                var key = attr.name.slice('data-validate-send-'.length);
524                if (key) body.set(key, attr.value || '');
525            }
526        });
527    }
528
529    function validateRemote(el, form) {
530        var url = el.getAttribute('data-validate-url');
531        if (!url) return Promise.resolve({ ok: true });
532        var fieldName = el.getAttribute('data-validate-field') || el.name || el.id;
533        if (!fieldName) return Promise.resolve({ ok: true });
534        var body = new URLSearchParams();
535        body.set(fieldName, el.value || '');
536        appendRemoteExtras(body, el, form);
537        return fetch(url, {
538            method: 'POST',
539            headers: {
540                'HX-Request': 'true',
541                'Content-Type': 'application/x-www-form-urlencoded',
542            },
543            body: body.toString(),
544        }).then(function (r) { return r.text(); })
545            .then(function (text) {
546                var t = String(text || '').trim();
547                if (t === 'true') return { ok: true };
548                var div = document.createElement('div');
549                div.innerHTML = t;
550                var parsed = (div.textContent || t || 'Invalid value.').trim();
551                return { ok: false, msg: parsed || 'Invalid value.' };
552            })
553            .catch(function () { return { ok: true }; });
554    }
555
556    function validateField(el, requiredSet, opts) {
557        if (!requiredSet || typeof requiredSet.has !== 'function') {
558            requiredSet = syncRequiredSet(null);
559        }
560        var silent = opts && opts.silent;
561        var form = (opts && opts.form) || el.closest('form');
562        var local = validateLocal(el, requiredSet, form);
563        if (!local.ok) {
564            if (!silent) showTip(el, local.msg);
565            return Promise.resolve(false);
566        }
567        if (!el.hasAttribute('data-validate-url')) {
568            hideTip(el);
569            return Promise.resolve(true);
570        }
571        return validateRemote(el, form).then(function (remote) {
572            if (!remote.ok) {
573                if (!silent) showTip(el, remote.msg);
574                return false;
575            }
576            hideTip(el);
577            return true;
578        });
579    }
580
581    function validateForm(form, options) {
582        if (!form) return Promise.resolve(true);
583        var requiredSet = form._fvRequiredSet || syncRequiredSet(options && options.requiredFields);
584        var fields = collectFields(form, requiredSet);
585        var chain = Promise.resolve();
586        var allOk = true;
587        var firstBad = null;
588        fields.forEach(function (el) {
589            chain = chain.then(function () {
590                return validateField(el, requiredSet, { silent: true, form: form }).then(function (ok) {
591                    if (!ok) {
592                        allOk = false;
593                        if (!firstBad) firstBad = el;
594                    }
595                });
596            });
597        });
598        return chain.then(function () {
599            if (firstBad && options && options.showErrors) {
600                return validateField(firstBad, requiredSet, { form: form }).then(function () {
601                    try { firstBad.focus(); } catch (e) { /* ignore */ }
602                    return allOk;
603                });
604            }
605            if (firstBad && options && options.focusFirst) {
606                try { firstBad.focus(); } catch (e) { /* ignore */ }
607            }
608            return allOk;
609        });
610    }
611
612    // jQuery Validate mirrored the clicked submit button into the form as a hidden
613    // field before running submitHandler, so AJAX serialisers (ne
613w FormData(form))
614    // carried its name/value to the server. FormData omits submit-button values by
615    // spec, and server handlers (e.g. Core\Dialog) detect submission via that name
616    // (submit_btn). Re-create the mirror so those handlers keep working.
617    function injectSubmitter(form, submitter) {
618        if (!submitter || !submitter.name) return null;
619        var hidden = document.createElement('input');
620        hidden.type = 'hidden';
621        hidden.name = submitter.name;
622        hidden.value = submitter.value || '';
623        hidden.setAttribute('data-fv-submitter', '');
624        form.appendChild(hidden);
625        return hidden;
626    }
627
628    function bind(form, options) {
629        if (!form || form._fvBound) return;
630        form._fvBound = true;
631        var requiredSet = syncRequiredSet(options && options.requiredFields);
632        form._fvRequiredSet = requiredSet;
633        form._fvOnValidSubmit = options && options.onValidSubmit;
634
635        form.setAttribute('novalidate', 'novalidate');
636        form.querySelectorAll('[required]').forEach(function (el) {
637            if (!isRequired(el, requiredSet)) el.removeAttribute('required');
638        });
639        var fields = collectFields(form, requiredSet);
640
641        fields.forEach(function (el) {
642            el.addEventListener('blur', function () {
643                validateField(el, requiredSet, { form: form });
644            });
645            el.addEventListener('input', function () {
646                if (el.classList.contains('error') || el._fvTip) {
647                    validateField(el, requiredSet, { form: form });
648                } else {
649                    hideTip(el);
650                }
651            });
652            // Selecting any radio satisfies the group — clear every member's tip,
653            // not just the one that received the event.
654            if (el.type === 'radio') {
655                el.addEventListener('change', function () {
656                    if (!radioGroupChecked(el, form)) return;
657                    var radios = form.querySelectorAll('input[type="radio"]');
658                    for (var i = 0; i < radios.length; i++) {
659                        if (radios[i].name === el.name) hideTip(radios[i]);
660                    }
661                });
662            }
663        });
664
665        // Track the clicked submit button as a fallback for browsers without
666        // SubmitEvent.submitter (Safari < 15.4), mirroring jQuery Validate.
667        form.addEventListener('click', function (ev) {
668            var t = ev.target && ev.target.closest
669                ? ev.target.closest('button, input[type="submit"], input[type="image"]')
670                : null;
671            if (t && form.contains(t) && t.name && t.type !== 'reset' && t.type !== 'button') {
672                form._fvSubmitter = t;
673            }
674        }, true);
675
676        var runValidSubmit = function (submitter) {
677            // onValidSubmit callers (e.g. AJAX submitters) handle the submission themselves.
678            if (typeof form._fvOnValidSubmit === 'function') {
679                // Mirror the submit button so a synchronous new FormData(form) inside the
680                // callback still carries submit_btn to the server.
681                var mirror = injectSubmitter(form, submitter);
682                try {
683                    form._fvOnValidSubmit();
684                } finally {
685                    if (mirror) setTimeout(function () {
686                        if (mirror.parentNode) mirror.parentNode.removeChild(mirror);
687                    }, 0);
688                }
689                return;
690            }
691            form._fvAllowSubmit = true;
692            var btn = form._slideoutSubmitBtn || submitter || null;
693            if (btn && form.contains(btn) && form.requestSubmit) form.requestSubmit(btn);
694            else if (form.requestSubmit) form.requestSubmit();
695            else if (btn) btn.click();
696            else form.submit();
697        };
698
699        form.addEventListener('submit', function (ev) {
700            if (form._fvAllowSubmit) {
701                form._fvAllowSubmit = false;
702                return;
703            }
704            var submitter = ev.submitter || form._fvSubmitter || null;
705            var requiredSet = form._fvRequiredSet || syncRequiredSet(null);
706            var flds = collectFields(form, requiredSet);
707            var hasRemote = false, syncBad = null;
708            for (var i = 0; i < flds.length; i++) {
709                if (flds[i].hasAttribute('data-validate-url')) hasRemote = true;
710                if (!syncBad && !validateLocal(flds[i], requiredSet, form).ok) syncBad = flds[i];
711            }
712            // Fast path: synchronously valid and nothing needs an async (remote) check — let
713            // this (user-trusted) submit proceed natively. We must NOT cancel a submit we
714            // already know is valid: re-dispatching a programmatic submit (requestSubmit /
715            // .click / dispatchEvent) silently no-ops in Firefox when the submit button has
716            // been moved outside the form (e.g. into the slideout footer), which is what
717            // caused the "click twice to save" bug. Only onValidSubmit callers need the
718            // event cancelled here (they own the submission).
719            if (!syncBad && !hasRemote) {
720                if (typeof form._fvOnValidSubmit === 'function') {
721                    ev.preventDefault();
722                    runValidSubmit(submitter);
723                }
724                return;
725            }
726            // Errors to show, or async validation needed — cancel, validate fully, then submit.
727            ev.preventDefault();
728            ev.stopPropagation();
729            validateForm(form, { showErrors: true }).then(function (ok) {
730                if (ok) runValidSubmit(submitter);
731            });
732        }, true);
733    }
734
735    function unbind(form) {
736        if (!form) return;
737        form._fvBound = false;
738        form._fvRequiredSet = null;
739        form._fvOnValidSubmit = null;
740        collectFields(form, new Set()).forEach(hideTip);
741    }
742
743    window.fieldValidator = {
744        bind: bind,
745        unbind: unbind,
746        validateField: validateField,
747        validateForm: validateForm,
748        /** Programmatic error tip (cross-field / conditional rules). */
749        showError: showTip,
750        /** Clear a tip shown by showError or validateField. */
751        clearError: hideTip,
752        /** Clear every live tip, or just those inside `root`. Leaves binding intact. */
753        clearErrors: clearErrors,
754    };
755})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.