feat: Implement automatic validation for modal forms

- Introduced a new hook `useDialogAutoValidation` to handle automatic validation of required fields in modals.
- Added visual feedback for empty required fields, including red borders and error messages.
- Disabled action buttons when required fields are not filled, enhancing user experience.
- Updated `DialogContent` to integrate the new validation logic, ensuring that only user mode modals are validated.

Made-with: Cursor
This commit is contained in:
syc0123 2026-03-03 12:07:12 +09:00
parent eb471d087f
commit aa020bfdd8
4 changed files with 402 additions and 3 deletions

View File

@ -0,0 +1,188 @@
# 모달 자동 검증 설계
## 1. 목표
모든 모달에서 필수 입력값이 있는 경우:
- 빈 필수 필드 아래에 경고 문구 표시
- 모든 필수 필드가 입력되기 전까지 저장/등록 버튼 비활성화
---
## 2. 전체 구조
```
┌─────────────────────────────────────────────────────────────────┐
│ DialogContent (모든 모달의 공통 래퍼) │
│ │
│ useDialogAutoValidation(contentRef) │
│ │ │
│ ├─ 0단계: 모드 확인 │
│ │ └─ useTabStore.mode === "user" 일 때만 실행 │
│ │ (관리자 모드에서는 return → 나중에 필요 시 확장) │
│ │ │
│ ├─ 1단계: 필수 필드 탐지 │
│ │ └─ Label 내부 <span> 안에 * 문자 존재 여부 │
│ │ (라벨 텍스트 직접 매칭 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>`을 추가한다.
훅은 이 span 안의 `*`를 감지하여 필수 필드를 식별한다.
### 오탐 방지
관리자가 라벨 텍스트에 직접 `*`를 입력해도 span 안에 들어가지 않으므로 오탐이 발생하지 않는다.
```
required = true → <label>품목코드<span class="text-orange-500">*</span></label>
→ span 안에 * 있음 → 감지 O
required = false → <label>품목코드</label>
→ span 없음 → 감지 X
라벨에 * 직접 입력 → <label>품목코드*</label>
→ 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;
```

View File

@ -44,7 +44,14 @@ function Button({
}) { }) {
const Comp = asChild ? Slot : "button"; const Comp = asChild ? Slot : "button";
return <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />; return (
<Comp
data-slot="button"
data-variant={variant || "default"}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
} }
export { Button, buttonVariants }; export { Button, buttonVariants };

View File

@ -8,6 +8,7 @@ import { cn } from "@/lib/utils";
import { useModalPortal } from "@/lib/modalPortalRef"; import { useModalPortal } from "@/lib/modalPortalRef";
import { useTabId } from "@/contexts/TabIdContext"; import { useTabId } from "@/contexts/TabIdContext";
import { useTabStore } from "@/stores/tabStore"; import { useTabStore } from "@/stores/tabStore";
import { useDialogAutoValidation } from "@/lib/hooks/useDialogAutoValidation";
// Dialog: 탭 시스템 내에서 자동으로 modal={false} + 비활성 탭이면 open={false} 처리 // Dialog: 탭 시스템 내에서 자동으로 modal={false} + 비활성 탭이면 open={false} 처리
const Dialog: React.FC<React.ComponentProps<typeof DialogPrimitive.Root>> = ({ const Dialog: React.FC<React.ComponentProps<typeof DialogPrimitive.Root>> = ({
@ -82,6 +83,18 @@ const DialogContent = React.forwardRef<
const container = explicitContainer !== undefined ? explicitContainer : autoContainer; const container = explicitContainer !== undefined ? explicitContainer : autoContainer;
const scoped = !!container; const scoped = !!container;
// 모달 자동 검증용 내부 ref
const internalRef = React.useRef<HTMLDivElement>(null);
const mergedRef = React.useCallback(
(node: HTMLDivElement | null) => {
internalRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
},
[ref],
);
useDialogAutoValidation(internalRef);
const handleInteractOutside = React.useCallback( const handleInteractOutside = React.useCallback(
(e: any) => { (e: any) => {
if (scoped && container) { if (scoped && container) {
@ -125,7 +138,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Overlay className="fixed inset-0 z-999 bg-black/60" /> <DialogPrimitive.Overlay className="fixed inset-0 z-999 bg-black/60" />
)} )}
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={mergedRef}
onInteractOutside={handleInteractOutside} onInteractOutside={handleInteractOutside}
onFocusOutside={handleFocusOutside} onFocusOutside={handleFocusOutside}
className={cn( className={cn(
@ -156,7 +169,7 @@ const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivEleme
DialogHeader.displayName = "DialogHeader"; DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 shrink-0", className)} {...props} /> <div data-slot="dialog-footer" className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 shrink-0", className)} {...props} />
); );
DialogFooter.displayName = "DialogFooter"; DialogFooter.displayName = "DialogFooter";

View File

@ -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 <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]);
}