import { isSelectorValid } from "@web3r/flowerkit/css"; import { removeChildNodes } from "@web3r/flowerkit/dom"; import { bubble } from "@web3r/flowerkit/evt"; import { wait } from "@web3r/flowerkit/fn"; import { getJSONFromStr } from "@web3r/flowerkit/json"; import { getMapFromObj, isObjEmpty } from "@web3r/flowerkit/obj"; import { getDocument } from "ssr-window"; import { string, number, boolean } from "yup"; import { constants } from "../../../constants"; import { getUserControlledFields, getAttr, getInputForm, } from "../../../utils"; import { _isReactNode } from "../../../utils/_isReactNode"; import { Localisation } from "../../localisation"; import { selectors } from "../constants/defaultCfg"; import type { IFormExtendedCfg } from "../utils/_getFormExtendedCfg"; import { _getFormExtendedCfg } from "../utils/_getFormExtendedCfg"; /** * Событие валидации поля формы. */ export type TValidationEvent = CustomEvent; /** * Детали события валидации поля. */ export type TValidationEventDetail = { updateValidationUI: () => void | Promise; container: HTMLElement | null; reason: TValidationReason; isValid: boolean; msgCodes: TValidationMsgCodes; msgBlock: HTMLElement | null; validationType: string; input: TValidationInput; msg: Promise; }; /** * Допустимые типы полей для валидации. */ export type TValidationInput = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; /** * Коды сообщений по причинам валидации. */ export type TValidationMsgCodes = Record; /** * Причина результата валидации. */ export type TValidationReason = "valid" | "invalid" | "empty"; /** * Результат проверки поля в детализированном виде. */ export type TValidationResult = { isValid: boolean; reason: TValidationReason; }; /** * Описание правила валидации. */ export type TValidationRule = { fn: (input: TValidationInput) => boolean; errorKeys: TValidationMsgCodes; }; type TValidationOptions = { rules?: Record; patterns?: Record; ns?: string; }; type TValidateCfg = { inputs?: TValidationInput[]; isIncludeDisabled?: boolean; isBubble?: boolean; isSilent?: boolean; isScroll?: boolean; ns?: string; }; type TValidateInputCfg = { isBubble?: boolean; isSilent?: boolean; isDetailResult?: boolean; getCustomValidityFn?: (input: TValidationInput) => TValidationResult; ns?: string; container?: HTMLElement | null; }; type TLocalisationReadyDetail = { instance?: { getLocale: (key: string, options?: Record) => string; }; }; const isReadonlyField = (input: TValidationInput): boolean => { return "readOnly" in input ? !!input.readOnly : false; }; const isDisabledOrReadonly = (input: TValidationInput): boolean => { return !!input.disabled || isReadonlyField(input); }; const isLocalisationReadyEvent = (event: Event): event is CustomEvent => { return event instanceof CustomEvent && typeof event.detail === "object" && event.detail !== null; }; /** * Singleton. Плагин для валидации форм. Автоматически валидирует форму с атрибутом `data-js-form` и триггерит всплывающее событие. Работает как для `React` (триггер события), так и для статичной верстки (ручные манипуляции с DOM). Все исходные слушатели вешаются на документ. * @module FormValidation * @param options{Object=} параметры плагина * @param options.rules{Object=} объект дополнительных правил валидации * @param options.patterns{Object=} объект дополнительных паттернов валидации * @param options.ns{String=} неймспейс локалей * @example * //
* // * // * //
*/ export class FormValidation { static #instance: FormValidation | null = null; /** * Причины результата валидации */ static reasons: Record = { valid: "valid", invalid: "invalid", empty: "empty", }; /** * Паттерны валидации */ static patterns = { // eslint-disable-next-line security/detect-unsafe-regex email: /^(([^<>()\]\\.,;:\s@"]+(\.[^<>()\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, // eslint-disable-next-line security/detect-unsafe-regex phone: /^(\+{0,})(\d{0,})([(]{1}\d{1,3}[)]{0,}){0,}(\s?\d+|\+\d{2,3}\s{1}\d+|\d+){1}[\s|-]?\d+([\s|-]?\d+){1,2}(\s){0,}$/, }; /** * Карта валидаторов (для расширения исп. appendValidationRules) * @type {MapConstructor} */ static rules = getMapFromObj({ "dateRange": { fn: (input: TValidationInput) => FormValidation.isValidDateRange(input as HTMLInputElement), errorKeys: constants.defaultErrorsKeys, }, "date": { fn: (input: TValidationInput) => FormValidation.isValidDate(input as HTMLInputElement), errorKeys: constants.defaultErrorsKeys, }, "email": { fn: (input: TValidationInput) => FormValidation.isValidEmail(input as HTMLInputElement), errorKeys: { ...constants.defaultErrorsKeys, invalid: "invalidEmail" }, }, "phone": { fn: (input: TValidationInput) => FormValidation.isValidPhone(input as HTMLInputElement), errorKeys: constants.defaultErrorsKeys, }, "checkbox": { fn: (input: TValidationInput) => FormValidation.isValidCheckbox(input as HTMLInputElement), errorKeys: { empty: false, valid: false, invalid: "invalidCheckbox" }, }, "radio": { fn: (input: TValidationInput) => FormValidation.isValidRadio(input as HTMLInputElement), errorKeys: { empty: false, valid: false, invalid: "invalidRadio" }, }, "file": { fn: (input: TValidationInput) => FormValidation.isValidFile(input as HTMLInputElement), errorKeys: { empty: false, invalid: "invalidFile", valid: false }, }, "greCaptcha": { fn: (input: TValidationInput) => FormValidation.isValidGreCaptcha(input as HTMLTextAreaElement), errorKeys: { empty: false, invalid: false, valid: false }, }, "select": { fn: (input: TValidationInput) => FormValidation.isValidSelect(input as HTMLSelectElement), errorKeys: constants.defaultErrorsKeys, }, "": { fn: (input: TValidationInput) => FormValidation.isValidText(input as HTMLInputElement | HTMLTextAreaElement), errorKeys: constants.defaultErrorsKeys, }, }); /** * Добавляет новые правила валидации * @param rules{Object} */ static appendValidationRules(rules: Record = {}) { Object.entries(rules).forEach(([ name = "", rule ]) => { if (typeof name === "string" && typeof rule === "object" && rule && typeof rule.fn === "function" && typeof rule.errorKeys === "object") { FormValidation.rules.set(name, rule); } else { console.error(`[FormValidation] Can't append new validation rule "${name}". Use this format: { rule: { fn, errorKeys } } `); } }); } /** * Добавляет новый паттерн для масок * @param patterns{Object=} паттерны */ static appendPatterns(patterns: Record) { Object.entries(patterns).forEach(([ name, regExp ]) => { (FormValidation.patterns as Record)[name] = regExp; }); } /** * Неймспейс для локалей * @private */ static #ns = "forms."; /** * Селектор обычных полей, которые являются невалидными, но стали необязательными * @type {string} */ static #prevInvalidInputSelector = FormValidation.#getModifySelector(selectors.inputs, (selector) => `${selector}.${constants.stateClasses.invalid}:not(${selectors.formInput}, required)`); /** * Селектор специальных полей (выпадающие списки и календари), которые являются невалидными, но стали необязательными * @type {string} */ static #prevInvalidSpecialSelector = FormValidation.#getModifySelector(selectors.controlledInput, (selector) => `${selector}:not(${selectors.formInput}, required)`); constructor(options: TValidationOptions = {}) { const { rules = {}, patterns = {}, ns = FormValidation.#ns } = options; FormValidation.#ns = ns; FormValidation.appendValidationRules(rules); if (patterns && Object.keys(patterns).length > 0) { FormValidation.appendPatterns(patterns); } if (FormValidation.#instance) { return FormValidation.#instance; } this.#bindEvents(); FormValidation.#instance = this; } #bindEvents() { getDocument().addEventListener("reset", (e: Event) => this.#handleReset(e)); getDocument().addEventListener("submit", (e: Event) => this.#handleSubmit(e)); getDocument().addEventListener("change", (e: Event) => this.#handleChange(e), true); getDocument().addEventListener("blur", (e: Event) => this.#handleInputBlur(e), true); getDocument().addEventListener("keydown", (e: Event) => this.#handleKeyDown(e), true); } static #isInputNeedBubble(input: TValidationInput) { const attr = "data-js-input-validation-bubble"; return input.hasAttribute(attr) ? input.getAttribute(attr) === "true" : false; } #handleChange(e: Event) { const { target, detail } = e as CustomEvent<{ container: HTMLElement | null; }>; if ( target instanceof HTMLInputElement && target.matches(selectors.formInput) && (target.type === "radio" || target.type === "checkbox") ) { if (FormValidation.isInputRequired(target)) { // если поле имеет валидацию this.#liveValidate(target, { container: detail?.container ?? null, }); } } } #handleKeyDown(e: Event) { const { target, key } = e as KeyboardEvent & { target: Element; }; const form: IFormExtendedCfg["form"] | null = target.closest(selectors.form); if (key === "Enter" && !!form && !target.matches("textarea")) { const cfg = _getFormExtendedCfg(form); if (cfg.isPreventSubmitByEnter) { e.preventDefault(); } } } #handleReset(e: Event) { const { target } = e; if (!!target && (target as Element).matches(selectors.form)) { getUserControlledFields(target as HTMLFormElement) .forEach((node) => { bubble(node, "reset", { form: target, }, { bubbles: false }); // Если поле обязательно, то очистка результата валидации const isRequired = FormValidation.isInputRequired(node as TValidationInput); if (isRequired) { FormValidation.isInputValid(node as TValidationInput, { isBubble: _isReactNode(target as Element) || FormValidation.#isInputNeedBubble(node as TValidationInput), getCustomValidityFn: () => ({ isValid: true, reason: FormValidation.reasons.valid, }), }); } }); } } #handleSubmit(e: Event) { const { target } = e; if (!!target && (target as Element).matches(selectors.form)) { e.preventDefault(); const { isIgnoreValidation } = _getFormExtendedCfg(target as IFormExtendedCfg["form"]); if (!isIgnoreValidation) { FormValidation.isValidForm(target as HTMLFormElement); } return false; } } #liveValidate(input: TValidationInput, params: Partial = {}) { const form = getInputForm(input); if (form && form.matches(selectors.form) && _getFormExtendedCfg(form as IFormExtendedCfg["form"]).isLiveValidation) { FormValidation.isInputValid(input, { isBubble: _isReactNode(form), ...params, }); } } /** * Обработчик события `blur` на документе * @private */ #handleInputBlur(e: Event) { const { target } = e as FocusEvent & { target: Element; detail?: { container?: HTMLElement; }; }; const detail = (e as FocusEvent & { detail?: { container?: HTMLElement; }; }).detail; if (target.nodeType === 1 && target.matches(selectors.inputs)) { // только поля const container = detail?.container ?? null; const parentContainer = target.parentElement; if (!parentContainer) { return; } const nearbyInput = parentContainer.querySelector(selectors.inputs); // поле с валидацией может быть рядом if (FormValidation.isInputRequired(target as TValidationInput)) { // если поле имеет валидацию this.#liveValidate(target as TValidationInput, { container, }); } else if (!!nearbyInput && FormValidation.isInputRequired(nearbyInput)) { // если поле рядом имеет валидацию this.#liveValidate(nearbyInput, { container: container || parentContainer, }); } else if (target.matches(FormValidation.#prevInvalidInputSelector) || target.matches(FormValidation.#prevInvalidSpecialSelector)) { // если поле имело валидацию this.#liveValidate(target as TValidationInput, { container, getCustomValidityFn: () => ({ isValid: true, reason: FormValidation.reasons.valid, }), }); } else if (!!nearbyInput && (nearbyInput.matches(FormValidation.#prevInvalidInputSelector) || nearbyInput.matches(FormValidation.#prevInvalidSpecialSelector)) ) { // если поле рядом имело валидацию this.#liveValidate(nearbyInput, { container: container || parentContainer, getCustomValidityFn: () => ({ isValid: true, reason: FormValidation.reasons.valid, }), }); } } } /** * Получает модифицированный селектор элементов * @private * @param selector{String=} * @param getSelector{Function=} * @returns {string} */ static #getModifySelector(selector = "", getSelector = (str: string) => str) { return selector.split(",") .map((str) => { const selector = str.trim(); return getSelector(selector); }) .join(", "); } /** * Прокручивает к блоку указанного поля * @param input{HTMLInputElement|HTMLTextAreaElement|HTMLSelectElement} */ static scrollToField(input: TValidationInput) { if (input) { const block = input.closest(selectors.formBlock); if (block) { wait(300) .finally(() => { block.scrollIntoView({ block: "start", behavior: "smooth", }); }); } } } /** * Проверят, валидна ли форма * @param form{HTMLFormElement|Element|HTMLElement|Node} форма либо иной HTML-элемент * @param params{Object=} дополнительные настройки * @param params.inputs{NodeList|Array|HTMLCollection=} собственный список полей для валидации * @param params.isIncludeDisabled{Boolean=} включение в валидацию полей с атрибутами disabled и readonly * @param params.isBubble{Boolean=} всплытие события о валидации * @param params.isSilent{Boolean=} включение "тихой" валидации без визуальных изменений * @param params.ns{String=} namespace для локали * @returns {boolean} возвращает результат валидации */ static isValidForm(form: HTMLFormElement, params: TValidateCfg = {}) { const { inputs, isIncludeDisabled = false, isBubble = true, isSilent = false, isScroll = _getFormExtendedCfg(form as IFormExtendedCfg["form"]).isScrollToFirstInvalidInput, ns = FormValidation.#ns, } = params; // Пользователь передал собственные поля const isCustomInputs = Array.isArray(inputs); // Получение пользовательских полей const getCustomInputs = () => isCustomInputs ? (inputs || []).filter((input) => isIncludeDisabled || !isDisabledOrReadonly(input)) : []; // Массив полей для валидации const inputsToValidate: TValidationInput[] = isCustomInputs ? getCustomInputs() : FormValidation.getInputsToValidate(form, isIncludeDisabled); if (!isSilent) { // Дополнительные поля, которые являются невалидными, но стали необязательными const prevInvalidInputs: TValidationInput[] = isCustomInputs ? [] : FormValidation.getInputsToValidate(form, isIncludeDisabled, `${FormValidation.#prevInvalidInputSelector}, ${FormValidation.#prevInvalidSpecialSelector}`); if (prevInvalidInputs.length) { console.debug(`[Forms] Found inputs with previously required state:`, prevInvalidInputs); } [ ...inputsToValidate, ...prevInvalidInputs ] .forEach((input) => FormValidation.isInputValid(input, { ns, getCustomValidityFn: prevInvalidInputs.includes(input) ? () => ({ isValid: true, reason: FormValidation.reasons.valid, }) : undefined, isBubble: FormValidation.#isInputNeedBubble(input) || _isReactNode(form), })); } // Валидна ли форма? const isValid = inputsToValidate.every((input) => FormValidation.isFieldValid(input)); // Информация о полях const data = inputsToValidate .map((input) => ({ input, isValid: FormValidation.isFieldValid(input), type: FormValidation.#getInputValidationType(input) || "", })); // Скролл к некорректным полям if (isScroll && !isValid) { const firstInvalidData = data.find(({ isValid }) => !isValid); if (firstInvalidData) { FormValidation.scrollToField(firstInvalidData.input); } } console.debug(`[Forms] Validation result: "${isValid}" `, form, data); if (isBubble) { const { formValid, formInvalid } = constants.formBubbles; bubble(form, isValid ? formValid : formInvalid, { isValid, inputs: inputsToValidate }); } return isValid; } /** * Получает список полей для валидации внутри формы * @param form{HTMLFormElement|HTMLElement|Node} форма * @param isIncludeDisabled{Boolean=} включать в проверку поля с disabled и readonly * @param selector{String=} селектор выбора полей * @returns {Array} возвращает массив полей */ static getInputsToValidate(form: HTMLFormElement | HTMLElement, isIncludeDisabled = false, selector = `${selectors.formInput}, ${selectors.greCaptcha}`): TValidationInput[] { const { validateOnlySelector, id } = _getFormExtendedCfg(form as IFormExtendedCfg["form"]); // Контекст валидации const getValidationCtxEl = () => { if (!!validateOnlySelector && typeof validateOnlySelector === "string") { if (isSelectorValid(validateOnlySelector)) { const el = form.querySelector(validateOnlySelector); if (el) { return el; } else { console.warn(`"[FormValidation] Selector of "validateOnlySelector" ("${validateOnlySelector}") can't find this node, use form instead`); return form; } } else { console.warn(`"[FormValidation] Selector of "validateOnlySelector" ("${validateOnlySelector}") is invalid, use form instead`); return form; } } else { return form; } }; const validateEl = getValidationCtxEl(); // Поля внутри контекста валидации const formInputs = [ ...validateEl.querySelectorAll(selector) ]; // Селектор для полей вне формы const outFormInputsSelector = FormValidation.#getModifySelector(selector, (selector) => `${selector}[form="${id}"]`); // Поля вне формы с ID данной формы const outFormInputs: TValidationInput[] = id ? [ ...getDocument().querySelectorAll(outFormInputsSelector) ] : []; // Фильтрация полученных полей return formInputs .concat(outFormInputs) .filter((input) => isIncludeDisabled || !isDisabledOrReadonly(input)); } /** * Получает статус поля как обязательного * @param input{HTMLInputElement|HTMLTextAreaElement} поле * @returns {boolean} */ static isInputRequired(input: TValidationInput) { return input.required || input.matches(selectors.formInput) && input.getAttribute(getAttr(selectors.formInput)) !== "false"; } /** * Получает тип валидации поля * @private * @param input{HTMLInputElement|HTMLSelectElement|HTMLTextAreaElement} поле ввода * @returns {String} */ static #getInputValidationType(input: TValidationInput) { // Тип валидации по умолчанию const defaultType = ""; // Название атрибута валидации const attrName = getAttr(selectors.formInput); // Заданный пользователем атрибут const attr = input.hasAttribute(attrName) ? input.getAttribute(getAttr(selectors.formInput)) || defaultType : defaultType; // Если атрибут не указан const isEmptyAttr = attr.trim() === defaultType || attr === "true"; switch (true) { // Google Captcha, особый тип валидации case input.matches(selectors.greCaptcha): return "greCaptcha"; // Не указан пользовательский тип, но поле обязательно и специфично case isEmptyAttr && [ "checkbox", "radio", "file" ].includes(input.type): return input.type; // Явно пользовательский тип валидации case !isEmptyAttr: return attr; // Валидация по умолчанию на пустоту строки default: return defaultType; } } /** * Получает метод валидации для поля * @private * @param validationType{String=} * @returns {Object} */ static #getValidationRuleForType(validationType = "") { const defaultRule = FormValidation.rules.get(""); if (FormValidation.rules.has(validationType)) { return FormValidation.rules.get(validationType) || defaultRule || { fn: () => true, errorKeys: constants.defaultErrorsKeys, }; } return defaultRule || { fn: () => true, errorKeys: constants.defaultErrorsKeys, }; } /** * Возвращает результат проверки поля без изменений в интерфейсе * @param input{HTMLInputElement|HTMLSelectElement|HTMLTextAreaElement} поле ввода * @param customType{String=} желаемый тип валидации * @param isDetailResult{Boolean=} детальный отчет о валидации * @returns {{reason: (string), isValid: *}|*} */ static isFieldValid(input: TValidationInput, customType = "", isDetailResult = false) { // Тип валидации поля const validationType = customType || FormValidation.#getInputValidationType(input) || ""; // Правило валидации поля const validationRule = FormValidation.#getValidationRuleForType(validationType); // Результат валидации поля const isValid = validationRule.fn(input); if (isDetailResult) { const getReason = () => { switch (true) { // поле валидно case isValid: return FormValidation.reasons.valid; // особые типы валидации (у элементов с "checked" не может быть состояния empty) case (validationType === "checkbox" || validationType === "radio") && !isValid: return FormValidation.reasons.invalid; // проверка на пустоту поля case !FormValidation.isValidText(input): return FormValidation.reasons.empty; // поле заполнено неверно default: return FormValidation.reasons.invalid; } }; const reason = getReason(); return { isValid, reason, }; } else { return isValid; } } /** * Валидация Google Captcha * @param textarea{HTMLTextAreaElement} скрытое textarea-поле GreCaptcha * @returns {boolean} */ static isValidGreCaptcha(textarea: HTMLTextAreaElement) { return process.env.NODE_ENV === "development" ? true : string().min(1).isValidSync(textarea.value); } /** * Валидация email * @param input{HTMLInputElement} поле * @returns {boolean} */ static isValidEmail(input: HTMLInputElement) { const { value } = input; return string().matches(FormValidation.patterns.email).isValidSync(value); } /** * Валидация номера телефона * @param input{HTMLInputElement} поле * @returns {boolean} */ static isValidPhone(input: HTMLInputElement) { const cleanPhone = input.value.replaceAll(/\s/g, ""); return string() .matches(FormValidation.patterns.phone) .test("phone-length", "", (value = "") => value.length === 16 || value.length === 11) .isValidSync(cleanPhone); } /** * Валидация radio input * @param input{HTMLInputElement} поле типа "radio" * @returns {boolean} */ static isValidRadio(input: HTMLInputElement) { const { name } = input; return name ? [ ...getDocument().querySelectorAll(`[name="${name}"]`) as NodeListOf ].some(({ checked }) => !!checked) : false; } /** * Проверяет, валидный ли файл * @param input{HTMLInputElement} поле типа "file" * @returns {boolean} */ static isValidFile(input: HTMLInputElement) { return number().required().moreThan(0).isValidSync(input.files?.length); } /** * @private * @param value{String|Number} * @returns {boolean} */ static #isDateInstance(value: string | number) { return !!value && new Date(value) instanceof Date && !isNaN(new Date(value).getTime()); } /** * Проверяет, валидный ли дата * @param input{HTMLInputElement} поле типа "text" * @returns {boolean} */ static isValidDate(input: HTMLInputElement) { const { value } = input; if (!!value) { // не пустое const num = Number(value); if (isNaN(num)) { // возможно, это строка с датой return FormValidation.#isDateInstance(value); } else { // возможно, это штамп из getTime return FormValidation.#isDateInstance(num); } } else { return false; } } /** * Проверяет, валиден ли промежуток дат * @param input{HTMLInputElement} поле типа "text" * @returns {boolean} */ static isValidDateRange(input: HTMLInputElement) { const from = input.getAttribute("data-js-datepicker-from"); const to = input.getAttribute("data-js-datepicker-to"); return [ from, to ].every((value) => { const num = Number(value); if (isNaN(num)) { // возможно, это строка с датой return FormValidation.#isDateInstance(value || ""); } else { // возможно, это штамп из getTime return FormValidation.#isDateInstance(num); } }); } /** * Проверяет, валидный ли checkbox * @param input{HTMLInputElement} поле типа "checkbox" * @returns {boolean} */ static isValidCheckbox(input: HTMLInputElement) { return boolean().oneOf([ true ]).isValidSync(input.checked); } /** * Проверяет, заполнено ли текстовое поле * @param input{HTMLInputElement|HTMLSelectElement|HTMLTextAreaElement} поле ввода * @returns {boolean} */ static isValidText(input: TValidationInput) { const { value } = input; return string().trim().min(1).isValidSync(value); } /** * Проверяет, выбрана ли опция в селекте * @param input{HTMLSelectElement} поле селекта * @returns {boolean} */ static isValidSelect(input: HTMLSelectElement) { const { value } = input; return string().trim().min(1).isValidSync(value); } /** * Получает текст ошибки/успеха для поля, ожидая плагин локализации * @private * @param input{HTMLInputElement|HTMLSelectElement|HTMLTextAreaElement} поле ввода * @param msgCodes{Object=} объект с сообщениями об ошибке или успехе * @param reason{String=} тип ошибки или успешного сообщения * @param ns{String=} неймспейс локали * @returns {Promise} */ static async #getInputMsg(input: TValidationInput, msgCodes: TValidationMsgCodes = {}, reason: TValidationReason = FormValidation.reasons.valid, ns = FormValidation.#ns) { return new Promise((resolve) => { if (reason in msgCodes && msgCodes[reason] !== false) { const key = `${ns}${msgCodes[reason]}`; if (Localisation.instance && Localisation.isReady) { resolve(String(Localisation.instance.getLocale(key, {}))); } else { console.debug(`[Forms] Validation waiting for locales for key "${key}"...`); getDocument().addEventListener(constants.localisationBubbles.localisationReady, (event: Event) => { if (!isLocalisationReadyEvent(event)) { return; } const { instance } = event.detail; resolve(String(instance?.getLocale(key, {}) || "")); }, { once: true }); } } else { resolve(""); } }); } /** * Управление блоком ошибки/успеха у поля ввода * @private * @param input{HTMLInputElement|HTMLSelectElement|HTMLTextAreaElement} поле ввода * @param msgCodes{Object=} объект с сообщениями об ошибке или успехе * @param reason{String} тип ошибки или успешного сообщения */ static async #setInputMsg(input: TValidationInput, msgCodes: TValidationMsgCodes = {}, reason: TValidationReason = FormValidation.reasons.valid) { const msgBlock = FormValidation.#getInputMsgBlock(input); if (msgBlock) { const form = getInputForm(input); if (form && reason === FormValidation.reasons.valid) { return; } msgBlock.classList.add(constants.stateClasses.loading); msgBlock.innerHTML = ``; await FormValidation.#getInputMsg(input, msgCodes, reason) .then((errText) => { msgBlock.setAttribute("data-css-reason", reason); const msgText = getDocument().createElement("span"); msgText.className = "form__msgText"; msgText.setAttribute("role", "tooltip"); msgText.title = errText; msgText.textContent = errText; msgBlock.replaceChildren(msgText); return Promise.resolve(); }) .catch(() => { msgBlock.innerHTML = ``; }) .finally(() => { msgBlock.classList.remove(constants.stateClasses.loading); }); } } /** * Получает блок с сообщениями для поля * @private * @param input{HTMLInputElement|HTMLSelectElement|HTMLTextAreaElement} поле ввода * @returns {Element|null} */ static #getInputMsgBlock(input: TValidationInput) { const defaultMsg = input.parentNode?.querySelector(selectors.formInputMsg) || input.closest(selectors.formBlock)?.querySelector(selectors.formInputMsg); const name = input.name; switch (true) { case !!defaultMsg: return defaultMsg; case !!name.length: return getDocument().querySelector(`[${getAttr(selectors.formInputMsg)}='${name}']`); default: return null; } } /** * Получает коды сообщений для поля * @private * @param input{HTMLInputElement|HTMLSelectElement|HTMLTextAreaElement} поле ввода * @param validationType{String} тип валидации * @returns {Object} */ static #getInputErrorCodes(input: TValidationInput, validationType: string): TValidationMsgCodes { validationType = validationType || FormValidation.#getInputValidationType(input) || ""; const errorAttr = getAttr(selectors.formInputMsg); const errors = FormValidation.#getValidationRuleForType(validationType).errorKeys; const getErrors = () => { let customErrors = errors; if (input.hasAttribute(errorAttr)) { const attrValue = input.getAttribute(errorAttr); if (!attrValue) { return customErrors; } const custom: TValidationMsgCodes = getJSONFromStr(attrValue); if ( !isObjEmpty(custom) && FormValidation.reasons.empty in custom && FormValidation.reasons.invalid in custom ) { customErrors = custom; } } return customErrors; }; return getErrors(); } /** * Очищает блок с сообщениями поля * @param input{HTMLInputElement|HTMLSelectElement|HTMLTextAreaElement} поле ввода */ static clearInputMsgBlock(input: TValidationInput) { const errorBlock = FormValidation.#getInputMsgBlock(input); if (errorBlock) { removeChildNodes(errorBlock); } } /** * Валидация поля ввода * @param input{HTMLInputElement|HTMLSelectElement|HTMLTextAreaElement} поле ввода, * @param params{Object=} параметры валидации * @param params.isBubble{Boolean=} всплытие в результате валидации * @param params.isSilent{Boolean=} "тихая" валидация без изменений в интерфейсе * @param params.isDetailResult{Boolean=} вывод детального результата о валидации * @param params.ns{String=} неймспейс для локали * @param params.getCustomValidityFn{Function=} собственная логика валидации * @returns {{reason: (string), isValid: *}|*} */ static isInputValid(input: TValidationInput, params: TValidateInputCfg = {}) { const { isBubble = true, isSilent = false, isDetailResult = false, getCustomValidityFn = undefined, ns = FormValidation.#ns, container = undefined, } = params; const validationType = FormValidation.#getInputValidationType(input) || ""; const msgCodes = FormValidation.#getInputErrorCodes(input, validationType); const result = typeof getCustomValidityFn === "function" ? getCustomValidityFn(input) : FormValidation.isFieldValid(input, validationType || "", true) as TValidationResult; const { isValid, reason } = result; const updateValidationUI = () => FormValidation.setInputValidState(input, isValid, msgCodes, reason, container || null); if (isBubble) { const msgBlock = FormValidation.#getInputMsgBlock(input); const msg = FormValidation.#getInputMsg(input, msgCodes, reason, ns); const { inputValid, inputInvalid } = constants.formBubbles; const detail: TValidationEvent["detail"] = { container: container || null, reason, isValid, msgCodes, msgBlock, validationType, input, msg, updateValidationUI, }; [ { el: input }, { el: msgBlock, params: { bubbles: false }, }, ] .forEach(({ el, params = {} }) => { if (el) { bubble(el, isValid ? inputValid : inputInvalid, detail, params); } }); } else { if (!isSilent) { updateValidationUI(); } } return isDetailResult ? { isValid, reason } : isValid; } /** * Добавление или удаление класса успешной или неуспешной валидации для элемента * @private * @param node{HTMLElement} элемент * @param isValid{Boolean=} валидность * @returns {boolean} */ static #setValidClassToEl(node: Element, isValid = true) { const { valid, invalid } = constants.stateClasses; node.classList.remove(isValid ? invalid : valid); node.classList.add(isValid ? valid : invalid); } /** * Управление состоянием ошибки или корректности поля ввода * @param input{HTMLInputElement|HTMLSelectElement|HTMLTextAreaElement} поле * @param isValid{Boolean=} валидность поля * @param msgCodes{Object=} объект с сообщениями об ошибке или успехе * @param reason{String=} причина успешной или неуспешной валидации * @param container{HTMLElement|null=} контейнер, для которого меняются классы * @returns {boolean} */ static setInputValidState(input: TValidationInput, isValid = true, msgCodes: TValidationMsgCodes = constants.defaultErrorsKeys, reason: TValidationReason = FormValidation.reasons.valid, container: HTMLElement | null = null) { FormValidation.clearInputMsgBlock(input); FormValidation.#setInputMsg(input, msgCodes, reason); if (input.type === "radio" && !!input.name) { // радио-баттоны с одинаковым именем const escapedName = input.name.replaceAll("\\", "\\\\").replaceAll("\"", "\\\""); const radioButtons: NodeListOf = getDocument().querySelectorAll(`[name="${escapedName}"]`); radioButtons.forEach((radioInput) => { FormValidation.#setValidClassToEl(radioInput, isValid); }); } else { FormValidation.#setValidClassToEl(container || input, isValid); // остальные поля } } }