2026-03-03 12:07:12 +09:00
|
|
|
# 모달 자동 검증 설계
|
|
|
|
|
|
|
|
|
|
## 1. 목표
|
|
|
|
|
|
|
|
|
|
모든 모달에서 필수 입력값이 있는 경우:
|
|
|
|
|
- 빈 필수 필드 아래에 경고 문구 표시
|
|
|
|
|
- 모든 필수 필드가 입력되기 전까지 저장/등록 버튼 비활성화
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 2. 전체 구조
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
|
|
|
│ DialogContent (모든 모달의 공통 래퍼) │
|
|
|
|
|
│ │
|
|
|
|
|
│ useDialogAutoValidation(contentRef) │
|
|
|
|
|
│ │ │
|
|
|
|
|
│ ├─ 0단계: 모드 확인 │
|
|
|
|
|
│ │ └─ useTabStore.mode === "user" 일 때만 실행 │
|
|
|
|
|
│ │ (관리자 모드에서는 return → 나중에 필요 시 확장) │
|
|
|
|
|
│ │ │
|
|
|
|
|
│ ├─ 1단계: 필수 필드 탐지 │
|
|
|
|
|
│ │ └─ Label 내부 <span> 안에 * 문자 존재 여부 │
|
|
|
|
|
│ │ (라벨 텍스트 직접 매칭 X → span 태그 안의 * 만 감지) │
|
|
|
|
|
│ │ │
|
|
|
|
|
│ ├─ 2단계: 시각적 피드백 │
|
|
|
|
|
│ │ ├─ 빈 필수 필드 → 빨간 테두리 (border-destructive) │
|
|
|
|
|
│ │ └─ 필드 아래 에러 메시지 주입 ("필수 입력 항목입니다") │
|
|
|
|
|
│ │ │
|
|
|
|
|
│ └─ 3단계: 버튼 비활성화 │
|
|
|
|
|
│ │ │
|
2026-03-03 14:54:41 +09:00
|
|
|
│ ├─ 기본: 모달 내 모든 <button> 비활성화 │
|
2026-03-03 12:07:12 +09:00
|
|
|
│ │ │
|
2026-03-03 14:54:41 +09:00
|
|
|
│ ├─ 제외 (항상 활성): 아래 조건에 해당하는 버튼 │
|
|
|
|
|
│ │ ├─ data-variant: outline, ghost, destructive, secondary│
|
|
|
|
|
│ │ │ (취소, 닫기, 삭제 등 shadcn Button) │
|
|
|
|
|
│ │ ├─ role: combobox, tab, switch, radio, checkbox │
|
|
|
|
|
│ │ │ (폼 컨트롤, 탭 등 UI 요소) │
|
|
|
|
|
│ │ ├─ data-slot="select-trigger" (Radix Select 트리거) │
|
|
|
|
|
│ │ ├─ data-dialog-close (모달 닫기 X 버튼) │
|
|
|
|
|
│ │ └─ data-action-type != save/submit │
|
|
|
|
|
│ │ (ButtonPrimary 비저장 액션) │
|
2026-03-03 12:07:12 +09:00
|
|
|
│ │ │
|
2026-03-03 14:54:41 +09:00
|
|
|
│ ├─ 빈 필수 필드 있음 → 나머지 버튼 반투명 + 클릭 차단 │
|
|
|
|
|
│ └─ 모든 필수 필드 입력됨 → 전체 버튼 활성화 │
|
2026-03-03 12:07:12 +09:00
|
|
|
│ │
|
|
|
|
|
│ 제외 조건: │
|
|
|
|
|
│ └─ 필수 필드가 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;
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
2026-03-03 14:54:41 +09:00
|
|
|
## 4. 버튼 비활성화: 기본 비활성 + 허용 목록 방식
|
2026-03-03 12:07:12 +09:00
|
|
|
|
|
|
|
|
### 원리
|
|
|
|
|
|
2026-03-03 14:54:41 +09:00
|
|
|
모달 내 모든 `<button>` 요소를 기본 비활성화하고,
|
|
|
|
|
안전한 버튼(취소, 닫기, 삭제, 폼 컨트롤 등)만 허용 목록으로 제외한다.
|
|
|
|
|
저장 버튼의 구현 방식(shadcn Button / 네이티브 button)에 관계없이 동작한다.
|
2026-03-03 12:07:12 +09:00
|
|
|
|
2026-03-03 14:54:41 +09:00
|
|
|
### 허용 목록 (항상 활성 유지)
|
2026-03-03 12:07:12 +09:00
|
|
|
|
2026-03-03 14:54:41 +09:00
|
|
|
| 셀렉터 | 용도 |
|
|
|
|
|
|------|------|
|
|
|
|
|
| `data-variant="outline"` | 취소 버튼 |
|
|
|
|
|
| `data-variant="ghost"` | 닫기 버튼 |
|
|
|
|
|
| `data-variant="destructive"` | 삭제 버튼 |
|
|
|
|
|
| `data-variant="secondary"` | 보조 액션 |
|
|
|
|
|
| `role="combobox"` | V2Select 드롭다운 트리거 |
|
|
|
|
|
| `role="tab/switch/radio/checkbox"` | 폼 컨트롤, 탭 등 UI 요소 |
|
|
|
|
|
| `data-slot="select-trigger"` | Radix Select 트리거 |
|
|
|
|
|
| `data-dialog-close` | 모달 닫기 X 버튼 |
|
|
|
|
|
| `data-action-type` != save/submit | ButtonPrimary 비저장 액션 |
|
|
|
|
|
|
|
|
|
|
### 비활성화 대상 (허용 목록에 해당하지 않는 모든 버튼)
|
|
|
|
|
|
|
|
|
|
| 예시 | 이유 |
|
|
|
|
|
|------|------|
|
|
|
|
|
| `<Button>저장</Button>` (data-variant="default") | shadcn 기본 버튼 |
|
|
|
|
|
| `<button data-action-type="save">` | ButtonPrimary 저장 액션 |
|
|
|
|
|
| `<button data-action-type="submit">` | ButtonPrimary 제출 액션 |
|
|
|
|
|
| 기타 variant/role 없는 `<button>` | 알 수 없는 버튼은 안전하게 차단 |
|
2026-03-03 12:07:12 +09:00
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 5. 동작 흐름
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
모달 열림
|
|
|
|
|
│
|
|
|
|
|
▼
|
|
|
|
|
DialogContent 마운트
|
|
|
|
|
│
|
|
|
|
|
▼
|
|
|
|
|
useDialogAutoValidation 실행
|
|
|
|
|
│
|
|
|
|
|
▼
|
|
|
|
|
모드 확인 (useTabStore.mode)
|
|
|
|
|
│
|
|
|
|
|
├─ mode !== "user"? → return (관리자 모드에서는 비활성)
|
|
|
|
|
│
|
|
|
|
|
▼
|
|
|
|
|
필수 필드 탐지 (Label 내 span에서 * 감지)
|
|
|
|
|
│
|
|
|
|
|
├─ 필수 필드 0개? → return (비활성)
|
|
|
|
|
│
|
|
|
|
|
▼
|
|
|
|
|
초기 검증 실행 (50ms 후)
|
|
|
|
|
│
|
|
|
|
|
├─ 빈 필수 필드 발견
|
2026-03-03 14:54:41 +09:00
|
|
|
│ ├─ 해당 input에 data-validation-empty 속성 추가 (CSS 지연 테두리)
|
2026-03-03 12:07:12 +09:00
|
|
|
│ ├─ input 아래에 "필수 입력 항목입니다" 에러 메시지 주입
|
2026-03-03 14:54:41 +09:00
|
|
|
│ └─ 허용 목록 외 모든 버튼 비활성화
|
2026-03-03 12:07:12 +09:00
|
|
|
│
|
|
|
|
|
├─ 모든 필수 필드 입력됨
|
|
|
|
|
│ ├─ 에러 메시지 제거
|
2026-03-03 14:54:41 +09:00
|
|
|
│ ├─ data-validation-empty 속성 제거
|
|
|
|
|
│ └─ 전체 버튼 활성화
|
2026-03-03 12:07:12 +09:00
|
|
|
│
|
|
|
|
|
▼
|
|
|
|
|
이벤트 리스너 등록
|
|
|
|
|
├─ input 이벤트 → 재검증
|
|
|
|
|
├─ change 이벤트 → 재검증
|
|
|
|
|
├─ click 캡처링 → 비활성 버튼 클릭 차단
|
|
|
|
|
└─ MutationObserver → DOM 변경 시 재검증
|
|
|
|
|
|
|
|
|
|
모달 닫힘
|
|
|
|
|
│
|
|
|
|
|
▼
|
|
|
|
|
클린업
|
|
|
|
|
├─ 이벤트 리스너 제거
|
|
|
|
|
├─ MutationObserver 해제
|
2026-03-03 14:54:41 +09:00
|
|
|
├─ 폴링 타이머 해제
|
2026-03-03 12:07:12 +09:00
|
|
|
├─ 주입된 에러 메시지 제거
|
|
|
|
|
├─ 버튼 비활성화 상태 복원
|
2026-03-03 14:54:41 +09:00
|
|
|
└─ data-validation-empty 속성 제거
|
2026-03-03 12:07:12 +09:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 6. 관련 파일
|
|
|
|
|
|
|
|
|
|
| 파일 | 역할 |
|
|
|
|
|
|------|------|
|
|
|
|
|
| `frontend/lib/hooks/useDialogAutoValidation.ts` | 자동 검증 훅 본체 |
|
|
|
|
|
| `frontend/components/ui/button.tsx` | data-variant 속성 노출 |
|
2026-03-03 14:54:41 +09:00
|
|
|
| `frontend/components/ui/dialog.tsx` | DialogContent에서 훅 호출, data-dialog-close 속성 |
|
|
|
|
|
| `frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx` | data-action-type 속성 노출 |
|
|
|
|
|
| `frontend/app/globals.css` | CSS 애니메이션 지연 (깜빡임 방지) |
|
2026-03-03 12:07:12 +09:00
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 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;
|
|
|
|
|
```
|