229 lines
7.6 KiB
TypeScript
229 lines
7.6 KiB
TypeScript
"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<TargetEl>();
|
|
let activated = false; // 첫 저장 시도 이후 true
|
|
|
|
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 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]);
|
|
}
|