553 lines
17 KiB
JavaScript
553 lines
17 KiB
JavaScript
(function () {
|
|
function normalizeNumericInput(raw) {
|
|
if (raw == null) return '';
|
|
return String(raw).trim().replace(/\s+/g, '');
|
|
}
|
|
|
|
function parseLooseNumber(raw) {
|
|
const normalized = normalizeNumericInput(raw).replace(',', '.');
|
|
if (!normalized) return NaN;
|
|
const parsed = Number(normalized);
|
|
return Number.isFinite(parsed) ? parsed : NaN;
|
|
}
|
|
|
|
function formatFrenchAmount(value, digits) {
|
|
const number = Number(value);
|
|
if (!Number.isFinite(number)) return '0.00';
|
|
return number.toLocaleString('fr-FR', {
|
|
minimumFractionDigits: digits,
|
|
maximumFractionDigits: digits
|
|
});
|
|
}
|
|
|
|
function syncFloatingLabels(root) {
|
|
const scope = root && root.querySelectorAll ? root : document;
|
|
const labels = scope.querySelectorAll('.rc-field-label[for]');
|
|
labels.forEach(function (label) {
|
|
const fieldId = label.getAttribute('for');
|
|
if (!fieldId) return;
|
|
const field = document.getElementById(fieldId);
|
|
if (!field) return;
|
|
|
|
const inputField = label.closest('.input-field');
|
|
const container = inputField || label.parentElement;
|
|
if (container) {
|
|
container.classList.add('rc-has-floating-label');
|
|
}
|
|
|
|
label.classList.add('rc-floating-label');
|
|
if (label.dataset.rcFloatBound === 'true') {
|
|
// Déjà lié: on force juste un resync visuel.
|
|
const hasValue = String(field.value || '').trim() !== '';
|
|
label.classList.toggle('active', hasValue || document.activeElement === field);
|
|
return;
|
|
}
|
|
|
|
const hasPrefix = Boolean(inputField && inputField.querySelector('.prefix'));
|
|
label.style.left = hasPrefix ? '3rem' : '0.75rem';
|
|
|
|
const syncState = function () {
|
|
const hasValue = String(field.value || '').trim() !== '';
|
|
label.classList.toggle('active', hasValue || document.activeElement === field);
|
|
};
|
|
|
|
field.addEventListener('focus', syncState);
|
|
field.addEventListener('blur', syncState);
|
|
field.addEventListener('input', syncState);
|
|
field.addEventListener('change', syncState);
|
|
label.dataset.rcFloatBound = 'true';
|
|
syncState();
|
|
});
|
|
|
|
if (window.M && typeof window.M.updateTextFields === 'function') {
|
|
window.M.updateTextFields();
|
|
}
|
|
}
|
|
|
|
function ensureErrorSlot(field, customErrorId) {
|
|
if (!field) return null;
|
|
|
|
if (customErrorId) {
|
|
const found = document.getElementById(customErrorId);
|
|
if (found) return found;
|
|
}
|
|
|
|
if (field.id) {
|
|
const byConvention = document.getElementById(field.id + '-error');
|
|
if (byConvention) return byConvention;
|
|
}
|
|
|
|
if (field.dataset && field.dataset.errorTarget) {
|
|
const byData = document.getElementById(field.dataset.errorTarget);
|
|
if (byData) return byData;
|
|
}
|
|
|
|
let sibling = field.parentElement ? field.parentElement.querySelector('.rc-inline-error') : null;
|
|
if (sibling) return sibling;
|
|
|
|
sibling = document.createElement('span');
|
|
sibling.className = 'helper-text red-text rc-inline-error';
|
|
sibling.style.display = 'none';
|
|
field.insertAdjacentElement('afterend', sibling);
|
|
return sibling;
|
|
}
|
|
|
|
function pickFieldLabel(field, fallbackLabel) {
|
|
if (fallbackLabel) return fallbackLabel;
|
|
if (!field) return 'Champ';
|
|
|
|
if (field.dataset && field.dataset.rcLabel) return field.dataset.rcLabel;
|
|
|
|
const directLabel = field.id ? document.querySelector('label[for="' + field.id + '"]') : null;
|
|
if (directLabel && directLabel.textContent.trim()) return directLabel.textContent.trim();
|
|
|
|
const cardLabel = field.closest('.row, .input-field, td, .card-content')?.querySelector('.rc-field-label');
|
|
if (cardLabel && cardLabel.textContent.trim()) return cardLabel.textContent.trim();
|
|
|
|
const th = field.closest('td')?.parentElement?.querySelector('th');
|
|
if (th && th.textContent.trim()) return th.textContent.trim();
|
|
|
|
return field.name || field.id || 'Champ';
|
|
}
|
|
|
|
function setFieldError(field, errorSlot, message) {
|
|
if (!errorSlot) return;
|
|
errorSlot.textContent = message || '';
|
|
errorSlot.style.display = message ? 'block' : 'none';
|
|
|
|
if (field) {
|
|
if (message) {
|
|
field.classList.add('invalid');
|
|
} else {
|
|
field.classList.remove('invalid');
|
|
}
|
|
}
|
|
}
|
|
|
|
function hasForbiddenNumericChars(value) {
|
|
return /[A-Za-z€$£¥]/.test(value);
|
|
}
|
|
|
|
function validateNumeric(value, options) {
|
|
const raw = normalizeNumericInput(value);
|
|
const decimals = Number.isInteger(options.decimals) ? options.decimals : 2;
|
|
const label = options.label || 'Champ';
|
|
const required = Boolean(options.required);
|
|
|
|
if (!raw) {
|
|
if (required) {
|
|
return { valid: false, message: label + ' est obligatoire.' };
|
|
}
|
|
return { valid: true };
|
|
}
|
|
|
|
if (hasForbiddenNumericChars(raw)) {
|
|
return { valid: false, message: label + ' doit contenir uniquement des chiffres, avec virgule ou point décimal.' };
|
|
}
|
|
|
|
if (/[^0-9.,-]/.test(raw)) {
|
|
return { valid: false, message: label + ' contient des caractères interdits.' };
|
|
}
|
|
|
|
if ((raw.match(/,/g) || []).length > 1 || (raw.match(/\./g) || []).length > 1) {
|
|
return { valid: false, message: label + ' a un format invalide.' };
|
|
}
|
|
|
|
if (raw.includes('-') && raw.indexOf('-') !== 0) {
|
|
return { valid: false, message: label + ' a un format invalide.' };
|
|
}
|
|
|
|
const decimalRegex = new RegExp('^-?\\d+(?:[.,]\\d{1,' + decimals + '})?$');
|
|
if (!decimalRegex.test(raw)) {
|
|
return { valid: false, message: label + ' doit être un nombre valide (max ' + decimals + ' décimales).' };
|
|
}
|
|
|
|
const number = parseLooseNumber(raw);
|
|
if (!Number.isFinite(number)) {
|
|
return { valid: false, message: label + ' doit être un nombre valide.' };
|
|
}
|
|
|
|
if (typeof options.min === 'number' && number < options.min) {
|
|
return { valid: false, message: label + ' doit être supérieur ou égal à ' + options.min + '.' };
|
|
}
|
|
|
|
if (typeof options.max === 'number' && number > options.max) {
|
|
return { valid: false, message: label + ' doit être inférieur ou égal à ' + options.max + '.' };
|
|
}
|
|
|
|
if (options.positive && number <= 0) {
|
|
return { valid: false, message: label + ' doit être strictement supérieur à 0.' };
|
|
}
|
|
|
|
return { valid: true, normalized: raw.replace(',', '.') };
|
|
}
|
|
|
|
function validateInteger(value, options) {
|
|
const raw = normalizeNumericInput(value);
|
|
const label = options.label || 'Champ';
|
|
const required = Boolean(options.required);
|
|
|
|
if (!raw) {
|
|
if (required) {
|
|
return { valid: false, message: label + ' est obligatoire.' };
|
|
}
|
|
return { valid: true };
|
|
}
|
|
|
|
if (!/^\d+$/.test(raw)) {
|
|
return { valid: false, message: label + ' doit contenir uniquement des chiffres entiers.' };
|
|
}
|
|
|
|
const number = Number(raw);
|
|
if (typeof options.min === 'number' && number < options.min) {
|
|
return { valid: false, message: label + ' doit être supérieur ou égal à ' + options.min + '.' };
|
|
}
|
|
if (typeof options.max === 'number' && number > options.max) {
|
|
return { valid: false, message: label + ' doit être inférieur ou égal à ' + options.max + '.' };
|
|
}
|
|
|
|
return { valid: true };
|
|
}
|
|
|
|
function validateImmat(value, options) {
|
|
const raw = String(value || '').trim().toUpperCase();
|
|
const label = options.label || 'Immatriculation';
|
|
|
|
if (!raw) {
|
|
if (options.required) {
|
|
return { valid: false, message: label + ' est obligatoire.' };
|
|
}
|
|
return { valid: true, normalized: '' };
|
|
}
|
|
|
|
if (!/^[A-Z0-9-]+$/.test(raw)) {
|
|
return { valid: false, message: label + ' doit contenir uniquement des lettres majuscules, chiffres et tirets.' };
|
|
}
|
|
|
|
return { valid: true, normalized: raw };
|
|
}
|
|
|
|
function validateTextSafe(value, options) {
|
|
const raw = String(value || '').trim();
|
|
const label = options.label || 'Champ';
|
|
|
|
if (!raw) {
|
|
if (options.required) {
|
|
return { valid: false, message: label + ' est obligatoire.' };
|
|
}
|
|
return { valid: true };
|
|
}
|
|
|
|
if (/[<>;&"]/g.test(raw)) {
|
|
return { valid: false, message: label + ' contient des caractères interdits (<, >, &, ;, ").' };
|
|
}
|
|
|
|
const pattern = options.pattern || /^[A-Za-zÀ-ÖØ-öø-ÿ0-9\s'.,:/()\-_]+$/;
|
|
if (!pattern.test(raw)) {
|
|
return { valid: false, message: label + ' contient des caractères non autorisés.' };
|
|
}
|
|
|
|
return { valid: true };
|
|
}
|
|
|
|
function validateNumberOrConsult(raw, options) {
|
|
const value = String(raw || '').trim();
|
|
const lower = value.toLowerCase();
|
|
|
|
if (!value) {
|
|
if (options.required) {
|
|
return { valid: false, message: (options.label || 'Champ') + ' est obligatoire.' };
|
|
}
|
|
return { valid: true };
|
|
}
|
|
|
|
if (lower === 'nous consulter') {
|
|
return { valid: true };
|
|
}
|
|
|
|
return validateNumeric(value, options);
|
|
}
|
|
|
|
function evaluateRule(field, config) {
|
|
const profile = config.profile;
|
|
const label = pickFieldLabel(field, config.label);
|
|
const required = typeof config.requiredWhen === 'function' ? Boolean(config.requiredWhen(field)) : Boolean(config.required);
|
|
|
|
let result;
|
|
switch (profile) {
|
|
case 'numeric':
|
|
result = validateNumeric(field.value, {
|
|
label: label,
|
|
required: required,
|
|
decimals: config.decimals,
|
|
min: config.min,
|
|
max: config.max,
|
|
positive: config.positive
|
|
});
|
|
break;
|
|
case 'integer':
|
|
result = validateInteger(field.value, {
|
|
label: label,
|
|
required: required,
|
|
min: config.min,
|
|
max: config.max
|
|
});
|
|
break;
|
|
case 'immat':
|
|
result = validateImmat(field.value, {
|
|
label: label,
|
|
required: required
|
|
});
|
|
break;
|
|
case 'text':
|
|
result = validateTextSafe(field.value, {
|
|
label: label,
|
|
required: required,
|
|
pattern: config.pattern
|
|
});
|
|
break;
|
|
case 'number_or_consulter':
|
|
result = validateNumberOrConsult(field.value, {
|
|
label: label,
|
|
required: required,
|
|
decimals: config.decimals,
|
|
min: config.min,
|
|
max: config.max,
|
|
positive: config.positive
|
|
});
|
|
break;
|
|
default:
|
|
result = { valid: true };
|
|
}
|
|
|
|
if (result.normalized != null && config.normalize !== false && field.value !== result.normalized) {
|
|
field.value = result.normalized;
|
|
}
|
|
|
|
return {
|
|
valid: result.valid,
|
|
message: result.message || '',
|
|
label: label
|
|
};
|
|
}
|
|
|
|
function createSummaryElement(summaryId) {
|
|
if (!summaryId) return null;
|
|
return document.getElementById(summaryId);
|
|
}
|
|
|
|
function createGuard(options) {
|
|
const summary = createSummaryElement(options.summaryId);
|
|
const summaryTitle = String(options.summaryTitle || 'Impossible de continuer car :');
|
|
const state = {
|
|
fieldRules: new Map(),
|
|
fieldErrors: new Map(),
|
|
externalChecks: new Map(),
|
|
targetButtons: [],
|
|
onChange: typeof options.onChange === 'function' ? options.onChange : null
|
|
};
|
|
|
|
const targetSelectors = Array.isArray(options.blockTargets) ? options.blockTargets : [];
|
|
targetSelectors.forEach(function (selector) {
|
|
document.querySelectorAll(selector).forEach(function (button) {
|
|
if (!button.dataset.rcOriginalDisabled) {
|
|
button.dataset.rcOriginalDisabled = button.disabled ? 'true' : 'false';
|
|
}
|
|
state.targetButtons.push(button);
|
|
});
|
|
});
|
|
|
|
function applySummary(messages) {
|
|
if (!summary) return;
|
|
if (!messages.length) {
|
|
summary.style.display = 'none';
|
|
summary.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
const uniqueMessages = Array.from(new Set(messages));
|
|
const list = uniqueMessages.map(function (msg) {
|
|
return '<li>' + msg + '</li>';
|
|
}).join('');
|
|
|
|
summary.innerHTML = [
|
|
'<div class="rc-blocking-title">' + summaryTitle + '</div>',
|
|
'<ul class="rc-blocking-list">',
|
|
list,
|
|
'</ul>'
|
|
].join('');
|
|
summary.style.display = 'block';
|
|
}
|
|
|
|
function applyBlocking(hasErrors) {
|
|
state.targetButtons.forEach(function (button) {
|
|
if (!button) return;
|
|
if (hasErrors) {
|
|
button.disabled = true;
|
|
button.dataset.rcInvalid = 'true';
|
|
return;
|
|
}
|
|
|
|
if (button.dataset.rcInvalid === 'true') {
|
|
button.dataset.rcInvalid = 'false';
|
|
button.disabled = button.dataset.rcOriginalDisabled === 'true';
|
|
}
|
|
});
|
|
}
|
|
|
|
function recompute() {
|
|
const messages = [];
|
|
|
|
state.fieldErrors.forEach(function (error) {
|
|
if (error && error.message) {
|
|
messages.push(error.message);
|
|
}
|
|
});
|
|
|
|
state.externalChecks.forEach(function (entry, key) {
|
|
const checkResult = typeof entry.fn === 'function' ? entry.fn() : { valid: true };
|
|
if (!checkResult || checkResult.valid === false) {
|
|
const message = checkResult && checkResult.message ? checkResult.message : entry.message || key;
|
|
messages.push(message);
|
|
}
|
|
});
|
|
|
|
applySummary(messages);
|
|
applyBlocking(messages.length > 0);
|
|
|
|
if (state.onChange) {
|
|
state.onChange(messages);
|
|
}
|
|
|
|
return messages;
|
|
}
|
|
|
|
function validateField(field) {
|
|
const config = state.fieldRules.get(field);
|
|
if (!config) return;
|
|
|
|
if (!field || !field.isConnected || field.disabled) {
|
|
if (config.errorSlot) setFieldError(field, config.errorSlot, '');
|
|
state.fieldErrors.delete(config.key);
|
|
recompute();
|
|
return;
|
|
}
|
|
|
|
if (typeof config.activeWhen === 'function' && !config.activeWhen(field)) {
|
|
setFieldError(field, config.errorSlot, '');
|
|
state.fieldErrors.delete(config.key);
|
|
recompute();
|
|
return;
|
|
}
|
|
|
|
// Sécurité défaut: sans activeWhen explicite, on ignore un champ masqué.
|
|
if (typeof config.activeWhen !== 'function' && field.type !== 'hidden' && field.offsetParent === null) {
|
|
setFieldError(field, config.errorSlot, '');
|
|
state.fieldErrors.delete(config.key);
|
|
recompute();
|
|
return;
|
|
}
|
|
|
|
const result = evaluateRule(field, config);
|
|
setFieldError(field, config.errorSlot, result.valid ? '' : result.message);
|
|
|
|
if (result.valid) {
|
|
state.fieldErrors.delete(config.key);
|
|
} else {
|
|
state.fieldErrors.set(config.key, {
|
|
message: result.message
|
|
});
|
|
}
|
|
|
|
recompute();
|
|
}
|
|
|
|
function attachField(field, config) {
|
|
if (!field) return;
|
|
|
|
const key = config.key || (field.id ? field.id : (field.name ? field.name : ('field_' + state.fieldRules.size)));
|
|
if (field.dataset && field.dataset.rcGuardAttached === 'true' && state.fieldRules.has(field)) return;
|
|
|
|
const fieldConfig = Object.assign({}, config, {
|
|
key: key,
|
|
errorSlot: ensureErrorSlot(field, config.errorId)
|
|
});
|
|
|
|
state.fieldRules.set(field, fieldConfig);
|
|
if (field.dataset) {
|
|
field.dataset.rcGuardAttached = 'true';
|
|
}
|
|
|
|
const handler = function () {
|
|
validateField(field);
|
|
};
|
|
|
|
field.addEventListener('input', handler);
|
|
field.addEventListener('change', handler);
|
|
field.addEventListener('blur', handler);
|
|
|
|
validateField(field);
|
|
}
|
|
|
|
function registerField(selectorOrElement, config) {
|
|
if (!selectorOrElement || !config || !config.profile) return;
|
|
if (typeof selectorOrElement === 'string') {
|
|
document.querySelectorAll(selectorOrElement).forEach(function (element) {
|
|
attachField(element, config);
|
|
});
|
|
return;
|
|
}
|
|
attachField(selectorOrElement, config);
|
|
}
|
|
|
|
function observe(selector, config) {
|
|
if (!selector || !config || !config.profile) return;
|
|
|
|
registerField(selector, config);
|
|
|
|
const observer = new MutationObserver(function () {
|
|
registerField(selector, config);
|
|
});
|
|
|
|
observer.observe(document.body, {
|
|
childList: true,
|
|
subtree: true
|
|
});
|
|
|
|
return observer;
|
|
}
|
|
|
|
function registerExternal(key, fn, fallbackMessage) {
|
|
if (!key || typeof fn !== 'function') return;
|
|
state.externalChecks.set(key, {
|
|
fn: fn,
|
|
message: fallbackMessage || ''
|
|
});
|
|
recompute();
|
|
}
|
|
|
|
function refresh() {
|
|
state.fieldRules.forEach(function (_cfg, field) {
|
|
validateField(field);
|
|
});
|
|
return recompute();
|
|
}
|
|
|
|
return {
|
|
registerField: registerField,
|
|
observe: observe,
|
|
registerExternal: registerExternal,
|
|
refresh: refresh,
|
|
formatFrenchAmount: formatFrenchAmount,
|
|
parseLooseNumber: parseLooseNumber
|
|
};
|
|
}
|
|
|
|
window.RCValidationUtils = {
|
|
createGuard: createGuard,
|
|
parseLooseNumber: parseLooseNumber,
|
|
formatFrenchAmount: formatFrenchAmount,
|
|
syncFloatingLabels: syncFloatingLabels
|
|
};
|
|
})();
|