ERP-node/frontend/lib/hooks/useDialogAutoValidation.ts

192 lines
5.6 KiB
TypeScript
Raw Normal View History

"use client";
import { useEffect, useRef, type RefObject } from "react";
import { useTabStore } from "@/stores/tabStore";
const ERROR_ATTR = "data-auto-validation-error";
const DISABLED_ATTR = "data-validation-disabled";
const ACTION_BTN_SELECTOR = '[data-variant="default"]';
/**
*
*
* :
* - useTabStore.mode === "user" ( )
* - (label <span>*</span>) 1
*
* :
* - Label <span> *
* - +
* - data-variant="default" (///)
*/
export function useDialogAutoValidation(
contentRef: RefObject<HTMLElement | null>,
) {
const mode = useTabStore((s) => s.mode);
const activeRef = useRef(false);
useEffect(() => {
if (mode !== "user") return;
const el = contentRef.current;
if (!el) return;
activeRef.current = true;
const injected = new Set<HTMLElement>();
let isValidating = false;
type InputEl = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
function findRequiredFields(): Map<InputEl, string> {
const fields = new Map<InputEl, string>();
if (!el) return fields;
el.querySelectorAll("label").forEach((label) => {
const hasRequiredMark = Array.from(label.querySelectorAll("span")).some(
(span) => span.textContent?.trim() === "*",
);
if (!hasRequiredMark) return;
const forId =
label.getAttribute("for") || (label as HTMLLabelElement).htmlFor;
let input: Element | null = null;
if (forId) {
try {
input = el!.querySelector(`#${CSS.escape(forId)}`);
} catch {
/* invalid id */
}
}
if (!input) {
const parent =
label.closest('[class*="space-y"]') || label.parentElement;
input = parent?.querySelector("input, textarea, select") || null;
}
if (
input instanceof HTMLInputElement ||
input instanceof HTMLTextAreaElement ||
input instanceof HTMLSelectElement
) {
const labelText =
label.textContent?.replace(/\*/g, "").trim() || "";
fields.set(input, labelText);
}
});
return fields;
}
function isEmpty(input: InputEl): boolean {
return input.value.trim() === "";
}
function validate() {
if (isValidating) return;
isValidating = true;
try {
const fields = findRequiredFields();
if (fields.size === 0) return;
let hasEmpty = false;
fields.forEach((_label, input) => {
if (isEmpty(input)) {
hasEmpty = true;
input.classList.add("border-destructive");
const parent = input.parentElement;
if (parent && !parent.querySelector(`[${ERROR_ATTR}]`)) {
const p = document.createElement("p");
p.className = "text-xs text-destructive mt-1";
p.textContent = "필수 입력 항목입니다";
p.setAttribute(ERROR_ATTR, "true");
input.insertAdjacentElement("afterend", p);
injected.add(p);
}
} else {
input.classList.remove("border-destructive");
const errorEl = input.parentElement?.querySelector(
`[${ERROR_ATTR}]`,
);
if (errorEl) {
injected.delete(errorEl as HTMLElement);
errorEl.remove();
}
}
});
updateButtons(hasEmpty);
} finally {
requestAnimationFrame(() => {
isValidating = false;
});
}
}
function updateButtons(hasErrors: boolean) {
el!.querySelectorAll<HTMLButtonElement>(ACTION_BTN_SELECTOR).forEach(
(btn) => {
if (hasErrors) {
btn.setAttribute(DISABLED_ATTR, "true");
btn.style.opacity = "0.5";
btn.style.cursor = "not-allowed";
btn.title = "필수 입력 항목을 모두 채워주세요";
} else if (btn.hasAttribute(DISABLED_ATTR)) {
btn.removeAttribute(DISABLED_ATTR);
btn.style.opacity = "";
btn.style.cursor = "";
btn.title = "";
}
},
);
}
function blockClick(e: Event) {
const btn = (e.target as HTMLElement).closest(`[${DISABLED_ATTR}]`);
if (btn) {
e.stopPropagation();
e.preventDefault();
}
}
el.addEventListener("input", validate);
el.addEventListener("change", validate);
el.addEventListener("click", blockClick, true);
const initTimer = setTimeout(validate, 50);
const observer = new MutationObserver(() => {
if (!isValidating) validate();
});
observer.observe(el, { childList: true, subtree: true });
return () => {
activeRef.current = false;
el.removeEventListener("input", validate);
el.removeEventListener("change", validate);
el.removeEventListener("click", blockClick, true);
clearTimeout(initTimer);
observer.disconnect();
injected.forEach((p) => p.remove());
injected.clear();
el.querySelectorAll(`[${DISABLED_ATTR}]`).forEach((btn) => {
btn.removeAttribute(DISABLED_ATTR);
(btn as HTMLElement).style.opacity = "";
(btn as HTMLElement).style.cursor = "";
(btn as HTMLElement).title = "";
});
el.querySelectorAll(".border-destructive").forEach((input) => {
input.classList.remove("border-destructive");
});
};
}, [mode]);
}