nette-forms
Version:
Client side script for Nette Forms Component
564 lines (556 loc) • 17 kB
JavaScript
/*!
* NetteForms - simple form validation.
*
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.Nette?.noInit ? (global.Nette = factory()) : (global.Nette = factory()).initOnLoad());
})(this, (function () { 'use strict';
class Validators {
filled(elem, arg, val) {
return val !== '' && val !== false && val !== null
&& (!Array.isArray(val) || val.length > 0)
&& (!(val instanceof FileList) || val.length > 0);
}
blank(elem, arg, val) {
return !this.filled(elem, arg, val);
}
valid(elem, arg) {
return arg.validateControl(elem, undefined, true);
}
equal(elem, arg, val) {
if (arg === undefined) {
return null;
}
let toString = (val) => {
if (typeof val === 'number' || typeof val === 'string') {
return '' + val;
}
else {
return val === true ? '1' : '';
}
};
let vals = Array.isArray(val) ? val : [val];
let args = Array.isArray(arg) ? arg : [arg];
loop: for (let a of vals) {
for (let b of args) {
if (toString(a) === toString(b)) {
continue loop;
}
}
return false;
}
return vals.length > 0;
}
notEqual(elem, arg, val) {
return arg === undefined ? null : !this.equal(elem, arg, val);
}
minLength(elem, arg, val) {
val = typeof val === 'number' ? val.toString() : val;
return val.length >= arg;
}
maxLength(elem, arg, val) {
val = typeof val === 'number' ? val.toString() : val;
return val.length <= arg;
}
length(elem, arg, val) {
val = typeof val === 'number' ? val.toString() : val;
arg = Array.isArray(arg) ? arg : [arg, arg];
return ((arg[0] === null || val.length >= arg[0])
&& (arg[1] === null || val.length <= arg[1]));
}
email(elem, arg, val) {
return (/^("([ !#-[\]-~]|\\[ -~])+"|[-a-z0-9!#$%&'*+/=?^_`{|}~]+(\.[-a-z0-9!#$%&'*+/=?^_`{|}~]+)*)@([0-9a-z\u00C0-\u02FF\u0370-\u1EFF]([-0-9a-z\u00C0-\u02FF\u0370-\u1EFF]{0,61}[0-9a-z\u00C0-\u02FF\u0370-\u1EFF])?\.)+[a-z\u00C0-\u02FF\u0370-\u1EFF]([-0-9a-z\u00C0-\u02FF\u0370-\u1EFF]{0,17}[a-z\u00C0-\u02FF\u0370-\u1EFF])?$/i).test(val);
}
url(elem, arg, val, newValue) {
if (!(/^[a-z\d+.-]+:/).test(val)) {
val = 'https://' + val;
}
if ((/^https?:\/\/((([-_0-9a-z\u00C0-\u02FF\u0370-\u1EFF]+\.)*[0-9a-z\u00C0-\u02FF\u0370-\u1EFF]([-0-9a-z\u00C0-\u02FF\u0370-\u1EFF]{0,61}[0-9a-z\u00C0-\u02FF\u0370-\u1EFF])?\.)?[a-z\u00C0-\u02FF\u0370-\u1EFF]([-0-9a-z\u00C0-\u02FF\u0370-\u1EFF]{0,17}[a-z\u00C0-\u02FF\u0370-\u1EFF])?|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|\[[0-9a-f:]{3,39}\])(:\d{1,5})?(\/\S*)?$/i).test(val)) {
newValue.value = val;
return true;
}
return false;
}
regexp(elem, arg, val) {
let parts = typeof arg === 'string' ? arg.match(/^\/(.*)\/([imu]*)$/) : false;
try {
return parts && (new RegExp(parts[1], parts[2].replace('u', ''))).test(val);
}
catch {
return null;
}
}
pattern(elem, arg, val, newValue, caseInsensitive) {
if (typeof arg !== 'string') {
return null;
}
try {
let regExp;
try {
regExp = new RegExp('^(?:' + arg + ')$', caseInsensitive ? 'ui' : 'u');
}
catch {
regExp = new RegExp('^(?:' + arg + ')$', caseInsensitive ? 'i' : '');
}
return val instanceof FileList
? Array.from(val).every((file) => regExp.test(file.name))
: regExp.test(val);
}
catch {
return null;
}
}
patternCaseInsensitive(elem, arg, val) {
return this.pattern(elem, arg, val, null, true);
}
numeric(elem, arg, val) {
return (/^[0-9]+$/).test(val);
}
integer(elem, arg, val, newValue) {
if ((/^-?[0-9]+$/).test(val)) {
newValue.value = parseFloat(val);
return true;
}
return false;
}
float(elem, arg, val, newValue) {
val = val.replace(/ +/g, '').replace(/,/g, '.');
if ((/^-?[0-9]*\.?[0-9]+$/).test(val)) {
newValue.value = parseFloat(val);
return true;
}
return false;
}
min(elem, arg, val) {
if (Number.isFinite(arg)) {
val = parseFloat(val);
}
return val >= arg;
}
max(elem, arg, val) {
if (Number.isFinite(arg)) {
val = parseFloat(val);
}
return val <= arg;
}
range(elem, arg, val) {
if (!Array.isArray(arg)) {
return null;
}
else if (elem.type === 'time' && arg[0] > arg[1]) {
return val >= arg[0] || val <= arg[1];
}
return (arg[0] === null || this.min(elem, arg[0], val))
&& (arg[1] === null || this.max(elem, arg[1], val));
}
submitted(elem) {
return elem.form['nette-submittedBy'] === elem;
}
fileSize(elem, arg, val) {
return Array.from(val).every((file) => file.size <= arg);
}
mimeType(elem, args, val) {
let parts = [];
args = Array.isArray(args) ? args : [args];
args.forEach((arg) => parts.push('^' + arg.replace(/([^\w])/g, '\\$1').replace('\\*', '.*') + '$'));
let re = new RegExp(parts.join('|'));
return Array.from(val).every((file) => !file.type || re.test(file.type));
}
image(elem, arg, val) {
return this.mimeType(elem, arg ?? ['image/gif', 'image/png', 'image/jpeg', 'image/webp'], val);
}
static(elem, arg) {
return arg;
}
}
class FormValidator {
formErrors = [];
validators = new Validators;
#preventFiltering = {};
#formToggles = {};
#toggleListeners = new WeakMap;
#getFormElement(form, name) {
let res = form.elements.namedItem(name);
return (res instanceof RadioNodeList ? res[0] : res);
}
#expandRadioElement(elem) {
let res = elem.form.elements.namedItem(elem.name);
return (res instanceof RadioNodeList ? Array.from(res) : [res]);
}
/**
* Function to execute when the DOM is fully loaded.
*/
#onDocumentReady(callback) {
if (document.readyState !== 'loading') {
callback.call(this);
}
else {
document.addEventListener('DOMContentLoaded', callback);
}
}
/**
* Returns the value of form element.
*/
getValue(elem) {
if (elem instanceof HTMLInputElement) {
if (elem.type === 'radio') {
return this.#expandRadioElement(elem)
.find((input) => input.checked)
?.value ?? null;
}
else if (elem.type === 'file') {
return elem.files;
}
else if (elem.type === 'checkbox') {
return elem.name.endsWith('[]') // checkbox list
? this.#expandRadioElement(elem)
.filter((input) => input.checked)
.map((input) => input.value)
: elem.checked;
}
else {
return elem.value.trim();
}
}
else if (elem instanceof HTMLSelectElement) {
return elem.multiple
? Array.from(elem.selectedOptions, (option) => option.value)
: elem.selectedOptions[0]?.value ?? null;
}
else if (elem instanceof HTMLTextAreaElement) {
return elem.value;
}
else if (elem instanceof RadioNodeList) {
return this.getValue(elem[0]);
}
else {
return null;
}
}
/**
* Returns the effective value of form element.
*/
getEffectiveValue(elem, filter = false) {
let val = this.getValue(elem);
if (val === elem.getAttribute('data-nette-empty-value')) {
val = '';
}
if (filter && this.#preventFiltering[elem.name] === undefined) {
this.#preventFiltering[elem.name] = true;
let ref = { value: val };
this.validateControl(elem, undefined, true, ref);
val = ref.value;
delete this.#preventFiltering[elem.name];
}
return val;
}
/**
* Validates form element against given rules.
*/
validateControl(elem, rules, onlyCheck = false, value, emptyOptional) {
rules ??= JSON.parse(elem.getAttribute('data-nette-rules') ?? '[]');
value ??= { value: this.getEffectiveValue(elem) };
emptyOptional ??= !this.validateRule(elem, ':filled', null, value);
for (let rule of rules) {
let op = rule.op.match(/(~)?([^?]+)/), curElem = rule.control ? this.#getFormElement(elem.form, rule.control) : elem;
rule.neg = !!op[1];
rule.op = op[2];
rule.condition = !!rule.rules;
if (!curElem) {
continue;
}
else if (emptyOptional && !rule.condition && rule.op !== ':filled') {
continue;
}
let success = this.validateRule(curElem, rule.op, rule.arg, elem === curElem ? value : undefined);
if (success === null) {
continue;
}
else if (rule.neg) {
success = !success;
}
if (rule.condition && success) {
if (!this.validateControl(elem, rule.rules, onlyCheck, value, rule.op === ':blank' ? false : emptyOptional)) {
return false;
}
}
else if (!rule.condition && !success) {
if (this.isDisabled(curElem)) {
continue;
}
if (!onlyCheck) {
let arr = Array.isArray(rule.arg) ? rule.arg : [rule.arg], message = rule.msg.replace(/%(value|\d+)/g, (foo, m) => this.getValue(m === 'value' ? curElem : elem.form.elements.namedItem(arr[m].control)));
this.addError(curElem, message);
}
return false;
}
}
return true;
}
/**
* Validates whole form.
*/
validateForm(sender, onlyCheck = false) {
let form = sender.form ?? sender, scope;
this.formErrors = [];
if (sender.getAttribute('formnovalidate') !== null) {
let scopeArr = JSON.parse(sender.getAttribute('data-nette-validation-scope') ?? '[]');
if (scopeArr.length) {
scope = new RegExp('^(' + scopeArr.join('-|') + '-)');
}
else {
this.showFormErrors(form, []);
return true;
}
}
for (let elem of form.elements) {
if (elem.willValidate && elem.validity.badInput) {
elem.reportValidity();
return false;
}
}
for (let elem of form.elements) {
if (elem.getAttribute('data-nette-rules')
&& (!scope || elem.name.replace(/]\[|\[|]|$/g, '-').match(scope))
&& !this.isDisabled(elem)
&& !this.validateControl(elem, undefined, onlyCheck)
&& !this.formErrors.length) {
return false;
}
}
let success = !this.formErrors.length;
this.showFormErrors(form, this.formErrors);
return success;
}
/**
* Check if input is disabled.
*/
isDisabled(elem) {
if (elem.type === 'radio') {
return this.#expandRadioElement(elem)
.every((input) => input.disabled);
}
return elem.disabled;
}
/**
* Adds error message to the queue.
*/
addError(elem, message) {
this.formErrors.push({
element: elem,
message: message,
});
}
/**
* Display error messages.
*/
showFormErrors(form, errors) {
let messages = [], focusElem;
for (let error of errors) {
if (messages.indexOf(error.message) < 0) {
messages.push(error.message);
focusElem ??= error.element;
}
}
if (messages.length) {
this.showModal(messages.join('\n'), () => {
focusElem?.focus();
});
}
}
/**
* Display modal window.
*/
showModal(message, onclose) {
let dialog = document.createElement('dialog');
if (!dialog.showModal) {
alert(message);
onclose();
return;
}
let style = document.createElement('style');
style.innerText = '.netteFormsModal { text-align: center; margin: auto; border: 2px solid black; padding: 1rem } .netteFormsModal button { padding: .1em 2em }';
let button = document.createElement('button');
button.innerText = 'OK';
button.onclick = () => {
dialog.remove();
onclose();
};
dialog.setAttribute('class', 'netteFormsModal');
dialog.innerText = message + '\n\n';
dialog.append(style, button);
document.body.append(dialog);
dialog.showModal();
}
/**
* Validates single rule.
*/
validateRule(elem, op, arg, value) {
if (elem.validity.badInput) {
return op === ':filled';
}
value ??= { value: this.getEffectiveValue(elem, true) };
let method = op.charAt(0) === ':' ? op.substring(1) : op;
method = method.replace('::', '_').replaceAll('\\', '');
let args = Array.isArray(arg) ? arg : [arg];
args = args.map((arg) => {
if (arg?.control) {
let control = this.#getFormElement(elem.form, arg.control);
return control === elem ? value.value : this.getEffectiveValue(control, true);
}
return arg;
});
if (method === 'valid') {
args[0] = this; // todo
}
return this.validators[method]
? this.validators[method](elem, Array.isArray(arg) ? args : args[0], value.value, value)
: null;
}
/**
* Process all toggles in form.
*/
toggleForm(form, event) {
this.#formToggles = {};
for (let elem of Array.from(form.elements)) {
if (elem.getAttribute('data-nette-rules')) {
this.toggleControl(elem, undefined, null, !event);
}
}
for (let i in this.#formToggles) {
this.toggle(i, this.#formToggles[i].state, this.#formToggles[i].elem, event);
}
}
/**
* Process toggles on form element.
*/
toggleControl(elem, rules, success = null, firsttime = false, value, emptyOptional) {
rules ??= JSON.parse(elem.getAttribute('data-nette-rules') ?? '[]');
value ??= { value: this.getEffectiveValue(elem) };
emptyOptional ??= !this.validateRule(elem, ':filled', null, value);
let has = false, curSuccess;
for (let rule of rules) {
let op = rule.op.match(/(~)?([^?]+)/), curElem = rule.control ? this.#getFormElement(elem.form, rule.control) : elem;
rule.neg = !!op[1];
rule.op = op[2];
rule.condition = !!rule.rules;
if (!curElem) {
continue;
}
else if (emptyOptional && !rule.condition && rule.op !== ':filled') {
continue;
}
curSuccess = success;
if (success !== false) {
curSuccess = this.validateRule(curElem, rule.op, rule.arg, elem === curElem ? value : undefined);
if (curSuccess === null) {
continue;
}
else if (rule.neg) {
curSuccess = !curSuccess;
}
if (!rule.condition) {
success = curSuccess;
}
}
if ((rule.condition && this.toggleControl(elem, rule.rules, curSuccess, firsttime, value, rule.op === ':blank' ? false : emptyOptional)) || rule.toggle) {
has = true;
if (firsttime) {
this.#expandRadioElement(curElem)
.filter((el) => !this.#toggleListeners.has(el))
.forEach((el) => {
el.addEventListener('change', (e) => this.toggleForm(elem.form, e));
this.#toggleListeners.set(el, null);
});
}
for (let id in rule.toggle ?? {}) {
this.#formToggles[id] ??= { elem: elem, state: false };
this.#formToggles[id].state ||= rule.toggle[id] ? !!curSuccess : !curSuccess;
}
}
}
return has;
}
/**
* Displays or hides HTML element.
*/
toggle(selector, visible, srcElement, event) {
if (/^\w[\w.:-]*$/.test(selector)) { // id
selector = '#' + selector;
}
Array.from(document.querySelectorAll(selector))
.forEach((elem) => elem.hidden = !visible);
}
/**
* Compact checkboxes
*/
compactCheckboxes(form, formData) {
let values = {};
for (let elem of form.elements) {
if (elem instanceof HTMLInputElement && elem.type === 'checkbox' && elem.name.endsWith('[]') && elem.checked && !elem.disabled) {
formData.delete(elem.name);
values[elem.name] ??= [];
values[elem.name].push(elem.value);
}
}
for (let name in values) {
formData.set(name.substring(0, name.length - 2), values[name].join(','));
}
}
/**
* Setup handlers.
*/
initForm(form) {
if (form.method === 'get' && form.hasAttribute('data-nette-compact')) {
form.addEventListener('formdata', (e) => this.compactCheckboxes(form, e.formData));
}
if (!Array.from(form.elements).some((elem) => elem.getAttribute('data-nette-rules'))) {
return;
}
this.toggleForm(form);
if (form.noValidate) {
return;
}
form.noValidate = true;
form.addEventListener('submit', (e) => {
if (!this.validateForm((e.submitter || form))) {
e.stopImmediatePropagation();
e.preventDefault();
}
});
form.addEventListener('reset', () => {
setTimeout(() => this.toggleForm(form));
});
}
initOnLoad() {
this.#onDocumentReady(() => {
Array.from(document.forms)
.forEach((form) => this.initForm(form));
});
}
}
let webalizeTable = { \u00e1: 'a', \u00e4: 'a', \u010d: 'c', \u010f: 'd', \u00e9: 'e', \u011b: 'e', \u00ed: 'i', \u013e: 'l', \u0148: 'n', \u00f3: 'o', \u00f4: 'o', \u0159: 'r', \u0161: 's', \u0165: 't', \u00fa: 'u', \u016f: 'u', \u00fd: 'y', \u017e: 'z' };
/**
* Converts string to web safe characters [a-z0-9-] text.
* @param {string} s
* @return {string}
*/
function webalize(s) {
s = s.toLowerCase();
let res = '';
for (let i = 0; i < s.length; i++) {
let ch = webalizeTable[s.charAt(i)];
res += ch ? ch : s.charAt(i);
}
return res.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
var version = "3.5.3";
let nette = new FormValidator;
nette.version = version;
nette.webalize = webalize;
return nette;
}));