192 lines
5.6 KiB
TypeScript
192 lines
5.6 KiB
TypeScript
|
|
"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]);
|
||
|
|
}
|