259 lines
8.3 KiB
TypeScript
259 lines
8.3 KiB
TypeScript
"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 내 <span>*</span>)가 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<HTMLElement>();
|
|
let validationLock = false;
|
|
const prevEmpty = new Map<Element, boolean>();
|
|
|
|
function findRequiredFields(): Map<TargetEl, string> {
|
|
const fields = new Map<TargetEl, 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 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<HTMLButtonElement>("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]);
|
|
}
|