"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 내 *)가 1개 이상 존재 * * 동작: * - Label 내부 안의 * 문자로 필수 필드 자동 탐지 * - 빈 필수 필드에 빨간 테두리 + 에러 메시지 주입 * - data-variant="default" 버튼 비활성화 (저장/등록/수정/확인) */ export function useDialogAutoValidation( contentRef: RefObject, ) { 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(); let isValidating = false; type InputEl = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; function findRequiredFields(): Map { const fields = new Map(); 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(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]); }