diff --git a/docs/ycshin-node/필수입력항목_자동검증_설계.md b/docs/ycshin-node/필수입력항목_자동검증_설계.md new file mode 100644 index 00000000..3fc764eb --- /dev/null +++ b/docs/ycshin-node/필수입력항목_자동검증_설계.md @@ -0,0 +1,188 @@ +# 모달 자동 검증 설계 + +## 1. 목표 + +모든 모달에서 필수 입력값이 있는 경우: +- 빈 필수 필드 아래에 경고 문구 표시 +- 모든 필수 필드가 입력되기 전까지 저장/등록 버튼 비활성화 + +--- + +## 2. 전체 구조 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ DialogContent (모든 모달의 공통 래퍼) │ +│ │ +│ useDialogAutoValidation(contentRef) │ +│ │ │ +│ ├─ 0단계: 모드 확인 │ +│ │ └─ useTabStore.mode === "user" 일 때만 실행 │ +│ │ (관리자 모드에서는 return → 나중에 필요 시 확장) │ +│ │ │ +│ ├─ 1단계: 필수 필드 탐지 │ +│ │ └─ Label 내부 안에 * 문자 존재 여부 │ +│ │ (라벨 텍스트 직접 매칭 X → span 태그 안의 * 만 감지) │ +│ │ │ +│ ├─ 2단계: 시각적 피드백 │ +│ │ ├─ 빈 필수 필드 → 빨간 테두리 (border-destructive) │ +│ │ └─ 필드 아래 에러 메시지 주입 ("필수 입력 항목입니다") │ +│ │ │ +│ └─ 3단계: 버튼 비활성화 │ +│ │ │ +│ ├─ 대상: data-variant="default" 인 버튼 │ +│ │ (저장, 등록, 수정, 확인 등 — variant 미지정 = default) │ +│ │ │ +│ ├─ 제외: outline, ghost, destructive, secondary │ +│ │ (취소, 닫기, X, 삭제 등) │ +│ │ │ +│ ├─ 빈 필수 필드 있음 → 버튼 반투명 + 클릭 차단 │ +│ └─ 모든 필수 필드 입력됨 → 정상 활성화 │ +│ │ +│ 제외 조건: │ +│ └─ 필수 필드가 0개인 모달 (자동 비활성) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 3. 필수 필드 감지: span 기반 * 감지 + +### 원리 + +화면 관리에서 필드를 "필수"로 체크하면 `component.required = true`가 저장된다. +V2 컴포넌트가 렌더링할 때 `required = true`이면 Label 안에 `*`을 추가한다. +훅은 이 span 안의 `*`를 감지하여 필수 필드를 식별한다. + +### 오탐 방지 + +관리자가 라벨 텍스트에 직접 `*`를 입력해도 span 안에 들어가지 않으므로 오탐이 발생하지 않는다. + +``` +required = true → + → span 안에 * 있음 → 감지 O + +required = false → + → span 없음 → 감지 X + +라벨에 * 직접 입력 → + → span 없이 텍스트에 * → 감지 X (오탐 방지) +``` + +### 코드 + +```typescript +const hasRequiredMark = Array.from(label.querySelectorAll("span")) + .some(span => span.textContent?.trim() === "*"); +if (!hasRequiredMark) return; +``` + +--- + +## 4. 버튼 식별: data-variant 속성 기반 + +### 원리 + +shadcn Button 컴포넌트의 `variant` 값을 `data-variant` 속성으로 DOM에 노출한다. +텍스트 매칭 없이 버튼의 역할을 식별할 수 있다. + +### 비활성화 대상 + +| data-variant | 용도 | 훅 동작 | +|:---:|------|:---:| +| `default` | 저장, 등록, 수정, 확인 | 비활성화 대상 | + +### 제외 (건드리지 않음) + +| data-variant | 용도 | +|:---:|------| +| `outline` | 취소 | +| `ghost` | 닫기, X 버튼 | +| `destructive` | 삭제 | +| `secondary` | 보조 액션 | + +--- + +## 5. 동작 흐름 + +``` +모달 열림 + │ + ▼ +DialogContent 마운트 + │ + ▼ +useDialogAutoValidation 실행 + │ + ▼ +모드 확인 (useTabStore.mode) + │ + ├─ mode !== "user"? → return (관리자 모드에서는 비활성) + │ + ▼ +필수 필드 탐지 (Label 내 span에서 * 감지) + │ + ├─ 필수 필드 0개? → return (비활성) + │ + ▼ +초기 검증 실행 (50ms 후) + │ + ├─ 빈 필수 필드 발견 + │ ├─ 해당 input에 border-destructive 클래스 추가 + │ ├─ input 아래에 "필수 입력 항목입니다" 에러 메시지 주입 + │ └─ data-variant="default" 버튼 비활성화 + │ + ├─ 모든 필수 필드 입력됨 + │ ├─ 에러 메시지 제거 + │ ├─ border-destructive 제거 + │ └─ 버튼 활성화 + │ + ▼ +이벤트 리스너 등록 + ├─ input 이벤트 → 재검증 + ├─ change 이벤트 → 재검증 + ├─ click 캡처링 → 비활성 버튼 클릭 차단 + └─ MutationObserver → DOM 변경 시 재검증 + +모달 닫힘 + │ + ▼ +클린업 + ├─ 이벤트 리스너 제거 + ├─ MutationObserver 해제 + ├─ 주입된 에러 메시지 제거 + ├─ 버튼 비활성화 상태 복원 + └─ border-destructive 클래스 제거 +``` + +--- + +## 6. 관련 파일 + +| 파일 | 역할 | +|------|------| +| `frontend/lib/hooks/useDialogAutoValidation.ts` | 자동 검증 훅 본체 | +| `frontend/components/ui/button.tsx` | data-variant 속성 노출 | +| `frontend/components/ui/dialog.tsx` | DialogContent에서 훅 호출 | + +--- + +## 7. 적용 범위 + +### 현재 (1단계): 사용자 모드만 + +| 모달 유형 | 동작 여부 | 이유 | +|---------------------------------------|:---:|-------------------------------| +| 사용자 모드 모달 (SaveModal 등) | O | mode === "user" + span * 있음 | +| 관리자 모드 모달 (CodeFormModal 등) | X | mode !== "user" → return | +| 확인/삭제 다이얼로그 (필수 필드 없음) | X | 필수 필드 0개 → 자동 제외 | + +### 나중에 (2단계): 관리자 모드 확장 시 + +```typescript +// 1단계 (현재) +if (mode !== "user") return; + +// 2단계 (확장) +if (!["user", "admin"].includes(mode)) return; +``` diff --git a/frontend/components/ui/button.tsx b/frontend/components/ui/button.tsx index 9a7847f7..42f71f26 100644 --- a/frontend/components/ui/button.tsx +++ b/frontend/components/ui/button.tsx @@ -44,7 +44,14 @@ function Button({ }) { const Comp = asChild ? Slot : "button"; - return ; + return ( + + ); } export { Button, buttonVariants }; diff --git a/frontend/components/ui/dialog.tsx b/frontend/components/ui/dialog.tsx index 83299b92..df383f25 100644 --- a/frontend/components/ui/dialog.tsx +++ b/frontend/components/ui/dialog.tsx @@ -8,6 +8,7 @@ import { cn } from "@/lib/utils"; import { useModalPortal } from "@/lib/modalPortalRef"; import { useTabId } from "@/contexts/TabIdContext"; import { useTabStore } from "@/stores/tabStore"; +import { useDialogAutoValidation } from "@/lib/hooks/useDialogAutoValidation"; // Dialog: 탭 시스템 내에서 자동으로 modal={false} + 비활성 탭이면 open={false} 처리 const Dialog: React.FC> = ({ @@ -82,6 +83,18 @@ const DialogContent = React.forwardRef< const container = explicitContainer !== undefined ? explicitContainer : autoContainer; const scoped = !!container; + // 모달 자동 검증용 내부 ref + const internalRef = React.useRef(null); + const mergedRef = React.useCallback( + (node: HTMLDivElement | null) => { + internalRef.current = node; + if (typeof ref === "function") ref(node); + else if (ref) (ref as React.MutableRefObject).current = node; + }, + [ref], + ); + useDialogAutoValidation(internalRef); + const handleInteractOutside = React.useCallback( (e: any) => { if (scoped && container) { @@ -125,7 +138,7 @@ const DialogContent = React.forwardRef< )} ) => ( -
+
); DialogFooter.displayName = "DialogFooter"; diff --git a/frontend/lib/hooks/useDialogAutoValidation.ts b/frontend/lib/hooks/useDialogAutoValidation.ts new file mode 100644 index 00000000..50010f4d --- /dev/null +++ b/frontend/lib/hooks/useDialogAutoValidation.ts @@ -0,0 +1,191 @@ +"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 내 *)가 1개 이상 존재 + * + * 동작: + * - Label 내부 안의 * 문자로 필수 필드 자동 탐지 + * - 빈 필수 필드에 빨간 테두리 + 에러 메시지 주입 + * - data-variant="default" 버튼 비활성화 (저장/등록/수정/확인) + */ +export function useDialogAutoValidation( + contentRef: RefObject, +) { + 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(); + let isValidating = false; + + type InputEl = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; + + 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 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(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]); +}