"use client"; import { useEffect } from "react"; import { useTabStore } from "@/stores/tabStore"; import { toast } from "sonner"; const HIGHLIGHT_ATTR = "data-validation-highlight"; const ERROR_ATTR = "data-validation-error"; const MSG_WRAPPER_CLASS = "validation-error-msg-wrapper"; type TargetEl = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | HTMLButtonElement; /** * 모달 필수 입력 검증 훅 (클릭 인터셉트 방식) * * 저장/수정/확인 버튼 클릭 시 빈 필수 필드가 있으면: * 1. 클릭 이벤트 차단 * 2. 첫 번째 빈 필드로 포커스 이동 + 하이라이트 * 3. 빈 필드에 빨간 테두리 유지 (값 입력 시 해제) * 4. 토스트 알림 표시 * * 설계: 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 errorFields = new Set(); let activated = false; // 첫 저장 시도 이후 true 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 isSaveButton(target: HTMLElement): boolean { const btn = target.closest("button"); if (!btn) return false; const actionType = btn.getAttribute("data-action-type"); if (actionType === "save" || actionType === "submit") return true; const variant = btn.getAttribute("data-variant"); if (variant === "default") return true; return false; } function markError(input: TargetEl) { input.setAttribute(ERROR_ATTR, "true"); errorFields.add(input); showErrorMsg(input); } function clearError(input: TargetEl) { input.removeAttribute(ERROR_ATTR); errorFields.delete(input); removeErrorMsg(input); } // 빈 필수 필드 아래에 경고 문구 삽입 (레이아웃 영향 없는 zero-height wrapper) function showErrorMsg(input: TargetEl) { if (input.parentElement?.querySelector(`.${MSG_WRAPPER_CLASS}`)) return; const wrapper = document.createElement("div"); wrapper.className = MSG_WRAPPER_CLASS; const msg = document.createElement("p"); msg.textContent = "필수 입력 항목입니다"; wrapper.appendChild(msg); input.insertAdjacentElement("afterend", wrapper); } function removeErrorMsg(input: TargetEl) { const wrapper = input.parentElement?.querySelector(`.${MSG_WRAPPER_CLASS}`); if (wrapper) wrapper.remove(); } function highlightField(input: TargetEl) { input.setAttribute(HIGHLIGHT_ATTR, "true"); input.addEventListener("animationend", () => input.removeAttribute(HIGHLIGHT_ATTR), { once: true }); if (input instanceof HTMLButtonElement) { input.click(); } else { input.focus(); } } // 첫 저장 시도 이후: 빈 필드 → 에러 유지/재적용, 값 있으면 해제 function syncErrors() { if (!activated) return; const fields = findRequiredFields(); for (const [input] of fields) { if (isEmpty(input)) { markError(input); } else { clearError(input); } } } function handleClick(e: Event) { const target = e.target as HTMLElement; if (!isSaveButton(target)) return; const fields = findRequiredFields(); if (fields.size === 0) return; let firstEmpty: TargetEl | null = null; let firstEmptyLabel = ""; for (const [input, label] of fields) { if (isEmpty(input)) { markError(input); if (!firstEmpty) { firstEmpty = input; firstEmptyLabel = label; } } else { clearError(input); } } if (!firstEmpty) return; activated = true; e.stopPropagation(); e.preventDefault(); highlightField(firstEmpty); toast.error(`${firstEmptyLabel} 항목을 입력해주세요`); } // V2Select는 input/change 이벤트가 없으므로 DOM 변경 감지로 에러 동기화 const observer = new MutationObserver(syncErrors); observer.observe(el, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-placeholder"] }); el.addEventListener("click", handleClick, true); el.addEventListener("input", syncErrors); el.addEventListener("change", syncErrors); return () => { el.removeEventListener("click", handleClick, true); el.removeEventListener("input", syncErrors); el.removeEventListener("change", syncErrors); observer.disconnect(); el.querySelectorAll(`[${HIGHLIGHT_ATTR}]`).forEach((node) => node.removeAttribute(HIGHLIGHT_ATTR)); el.querySelectorAll(`[${ERROR_ATTR}]`).forEach((node) => node.removeAttribute(ERROR_ATTR)); el.querySelectorAll(`.${MSG_WRAPPER_CLASS}`).forEach((node) => node.remove()); }; }, [mode, contentEl]); }