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, '&') 75 .replace(/</g, '<') 76 .replace(/>/g, '>') 77 .replace(/"/g, '"'); 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.