"use client"; import { useEffect } from "react"; import { useTabStore } from "@/stores/tabStore"; const ERROR_ATTR = "data-auto-validation-error"; const DISABLED_ATTR = "data-validation-disabled"; const INTERACTED_ATTR = "data-validation-interacted"; /** 비활성화에서 제외할 버튼 셀렉터 (취소, 닫기, 삭제, 폼 컨트롤 등) */ const EXEMPT_BTN_SELECTOR = [ '[data-variant="outline"]', '[data-variant="ghost"]', '[data-variant="destructive"]', '[data-variant="secondary"]', '[role="combobox"]', '[role="tab"]', '[role="switch"]', '[role="radio"]', '[role="checkbox"]', '[data-slot="select-trigger"]', "[data-dialog-close]", ].join(", "); const POLL_INTERVAL = 300; type TargetEl = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | HTMLButtonElement; /** * 모달 자동 폼 검증 훅 * * 활성화 조건: * - useTabStore.mode === "user" (사용자 모드) * - 필수 필드(label 내 *)가 1개 이상 존재 * * 지원 요소: * - input, textarea, select (네이티브 폼 요소) * - button[role="combobox"] (Radix Select / V2Select 드롭다운) * * 설계: docs/ycshin-node/필수입력항목_자동검증_설계.md */ export function useDialogAutoValidation(contentEl: HTMLElement | null) { const mode = useTabStore((s) => s.mode); useEffect(() => { if (mode !== "user") return; const el = contentEl; if (!el) return; const injected = new Set(); let validationLock = false; const prevEmpty = new Map(); 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 target: TargetEl | null = null; if (forId) { try { const found = el!.querySelector(`#${CSS.escape(forId)}`); if (isFormElement(found)) { target = found; } else if (found) { const inner = found.querySelector("input, textarea, select"); if (isFormElement(inner) && !isHiddenRadixSelect(inner)) { target = inner; } // 숨겨진 Radix select이거나 폼 요소가 없으면 → 트리거 버튼 탐색 if (!target) { const btn = found.querySelector('button[role="combobox"], button[data-slot="select-trigger"]'); if (btn instanceof HTMLButtonElement) target = btn; } } } catch { /* invalid id */ } } if (!target) { const parent = label.closest('[class*="space-y"]') || label.parentElement; if (parent) { const inner = parent.querySelector("input, textarea, select"); if (isFormElement(inner) && !isHiddenRadixSelect(inner)) { target = inner; } if (!target) { const btn = parent.querySelector('button[role="combobox"], button[data-slot="select-trigger"]'); if (btn instanceof HTMLButtonElement) target = btn; } } } if (target) { const labelText = label.textContent?.replace(/\*/g, "").trim() || ""; fields.set(target, labelText); } }); return fields; } function isFormElement(el: Element | null): el is HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement { return el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement; } function isHiddenRadixSelect(el: Element): boolean { return el instanceof HTMLSelectElement && el.hasAttribute("aria-hidden"); } function isEmpty(input: TargetEl): boolean { if (input instanceof HTMLButtonElement) { // Radix Select: data-placeholder 속성이 자식 span에 있으면 미선택 상태 return !!input.querySelector("[data-placeholder]"); } return input.value.trim() === ""; } function validate() { if (validationLock) return; validationLock = true; try { const fields = findRequiredFields(); if (fields.size === 0) return; let hasEmpty = false; fields.forEach((_label, input) => { const isEmptyNow = isEmpty(input); const wasPrevEmpty = prevEmpty.get(input); if (isEmptyNow) hasEmpty = true; if (wasPrevEmpty === isEmptyNow) return; prevEmpty.set(input, isEmptyNow); if (isEmptyNow) { input.setAttribute("data-validation-empty", "true"); const parent = input.parentElement; if (parent && !parent.querySelector(`[${ERROR_ATTR}]`)) { const computed = getComputedStyle(parent); if (computed.position === "static") { parent.style.position = "relative"; } 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.removeAttribute("data-validation-empty"); const errorEl = input.parentElement?.querySelector(`[${ERROR_ATTR}]`); if (errorEl) { injected.delete(errorEl as HTMLElement); errorEl.remove(); } } }); updateButtons(hasEmpty); } finally { setTimeout(() => { validationLock = false; }, 0); } } function isExemptButton(btn: HTMLButtonElement): boolean { if (btn.matches(EXEMPT_BTN_SELECTOR)) return true; const actionType = btn.getAttribute("data-action-type"); if (actionType && actionType !== "save" && actionType !== "submit") return true; return false; } function updateButtons(hasErrors: boolean) { el!.querySelectorAll("button").forEach((btn) => { if (isExemptButton(btn)) return; 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(); } } function handleInteraction() { if (!el!.hasAttribute(INTERACTED_ATTR)) { el!.setAttribute(INTERACTED_ATTR, "true"); } validate(); } el.addEventListener("input", handleInteraction); el.addEventListener("change", handleInteraction); el.addEventListener("click", blockClick, true); validate(); const pollId = setInterval(validate, POLL_INTERVAL); const observer = new MutationObserver(() => { if (!validationLock) validate(); }); observer.observe(el, { childList: true, subtree: true, characterData: true, }); return () => { el.removeEventListener("input", handleInteraction); el.removeEventListener("change", handleInteraction); el.removeEventListener("click", blockClick, true); clearInterval(pollId); observer.disconnect(); el.removeAttribute(INTERACTED_ATTR); 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("[data-validation-empty]").forEach((input) => { input.removeAttribute("data-validation-empty"); }); }; }, [mode, contentEl]); }