feat: Update modal validation design and behavior
- Changed the modal validation mechanism to focus on the first empty required field and display a toast notification prompting the user to fill it. - Removed the disabling of the save button, ensuring it remains active regardless of validation state. - Enhanced visual feedback with a shake animation for empty fields and a red border to indicate errors. - Updated the documentation to reflect the new validation flow and requirements. Made-with: Cursor
This commit is contained in:
parent
dfc495d32b
commit
35dfe5bd79
|
|
@ -1,10 +1,11 @@
|
||||||
# 모달 자동 검증 설계
|
# 모달 필수 입력 검증 설계
|
||||||
|
|
||||||
## 1. 목표
|
## 1. 목표
|
||||||
|
|
||||||
모든 모달에서 필수 입력값이 있는 경우:
|
모든 모달에서 필수 입력값이 빈 상태로 저장 버튼을 클릭하면:
|
||||||
- 빈 필수 필드 아래에 경고 문구 표시
|
- 첫 번째 빈 필수 필드로 포커스 이동 + 하이라이트
|
||||||
- 모든 필수 필드가 입력되기 전까지 저장/등록 버튼 비활성화
|
- 우측 상단에 토스트 알림 ("○○ 항목을 입력해주세요")
|
||||||
|
- 버튼은 항상 활성 상태 (비활성화하지 않음)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -18,35 +19,28 @@
|
||||||
│ │ │
|
│ │ │
|
||||||
│ ├─ 0단계: 모드 확인 │
|
│ ├─ 0단계: 모드 확인 │
|
||||||
│ │ └─ useTabStore.mode === "user" 일 때만 실행 │
|
│ │ └─ useTabStore.mode === "user" 일 때만 실행 │
|
||||||
│ │ (관리자 모드에서는 return → 나중에 필요 시 확장) │
|
|
||||||
│ │ │
|
│ │ │
|
||||||
│ ├─ 1단계: 필수 필드 탐지 │
|
│ ├─ 1단계: 필수 필드 탐지 │
|
||||||
│ │ └─ Label 내부 <span> 안에 * 문자 존재 여부 │
|
│ │ └─ Label 내부 <span> 안에 * 문자 존재 여부 │
|
||||||
│ │ (라벨 텍스트 직접 매칭 X → span 태그 안의 * 만 감지) │
|
│ │ (라벨 텍스트 직접 매칭 X → span 태그 안의 * 만 감지) │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ ├─ 2단계: 시각적 피드백 │
|
│ └─ 2단계: 저장 버튼 클릭 인터셉트 │
|
||||||
│ │ ├─ 빈 필수 필드 → 빨간 테두리 (border-destructive) │
|
|
||||||
│ │ └─ 필드 아래 에러 메시지 주입 ("필수 입력 항목입니다") │
|
|
||||||
│ │ │
|
│ │ │
|
||||||
│ └─ 3단계: 버튼 비활성화 │
|
│ ├─ 저장/수정/확인 버튼 클릭 감지 │
|
||||||
|
│ │ (data-action-type="save"/"submit" │
|
||||||
|
│ │ 또는 data-variant="default") │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ ├─ 기본: 모달 내 모든 <button> 비활성화 │
|
│ ├─ 빈 필수 필드 있음: │
|
||||||
|
│ │ ├─ 클릭 이벤트 차단 (stopPropagation + preventDefault) │
|
||||||
|
│ │ ├─ 첫 번째 빈 필드로 포커스 이동 │
|
||||||
|
│ │ ├─ 해당 필드 빨간 테두리 + 하이라이트 애니메이션 │
|
||||||
|
│ │ └─ 토스트 알림: "{필드명} 항목을 입력해주세요" │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ ├─ 제외 (항상 활성): 아래 조건에 해당하는 버튼 │
|
│ └─ 모든 필수 필드 입력됨: │
|
||||||
│ │ ├─ 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 비저장 액션) │
|
|
||||||
│ │ │
|
|
||||||
│ ├─ 빈 필수 필드 있음 → 나머지 버튼 반투명 + 클릭 차단 │
|
|
||||||
│ └─ 모든 필수 필드 입력됨 → 전체 버튼 활성화 │
|
|
||||||
│ │
|
│ │
|
||||||
│ 제외 조건: │
|
│ 제외 조건: │
|
||||||
│ └─ 필수 필드가 0개인 모달 (자동 비활성) │
|
│ └─ 필수 필드가 0개인 모달 → 인터셉트 없음 │
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -75,50 +69,80 @@ required = false → <label>품목코드</label>
|
||||||
→ span 없이 텍스트에 * → 감지 X (오탐 방지)
|
→ span 없이 텍스트에 * → 감지 X (오탐 방지)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 코드
|
### 지원 필드 타입
|
||||||
|
|
||||||
|
| V2 컴포넌트 | 렌더링 요소 | 빈값 판정 |
|
||||||
|
|---|---|---|
|
||||||
|
| V2Input | `<input>`, `<textarea>` | `value.trim() === ""` |
|
||||||
|
| V2Select | `<button role="combobox">` | `querySelector("[data-placeholder]")` 존재 |
|
||||||
|
| V2Date | `<input>` (날짜/시간) | `value.trim() === ""` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 저장 버튼 클릭 인터셉트
|
||||||
|
|
||||||
|
### 원리
|
||||||
|
|
||||||
|
버튼을 비활성화하지 않고, 클릭 이벤트를 캡처링 단계에서 가로챈다.
|
||||||
|
빈 필수 필드가 있으면 이벤트를 차단하고, 없으면 통과시킨다.
|
||||||
|
|
||||||
|
### 인터셉트 대상 버튼
|
||||||
|
|
||||||
|
| 조건 | 예시 |
|
||||||
|
|------|------|
|
||||||
|
| `data-action-type="save"` | ButtonPrimary 저장 버튼 |
|
||||||
|
| `data-action-type="submit"` | ButtonPrimary 제출 버튼 |
|
||||||
|
| `data-variant="default"` | shadcn Button 기본 (저장/확인/등록) |
|
||||||
|
|
||||||
|
### 인터셉트하지 않는 버튼
|
||||||
|
|
||||||
|
| 조건 | 예시 |
|
||||||
|
|------|------|
|
||||||
|
| `data-variant` = outline/ghost/destructive/secondary | 취소, 닫기, 삭제 |
|
||||||
|
| `role` = combobox/tab/switch 등 | 폼 컨트롤 |
|
||||||
|
| `data-action-type` != save/submit | 기타 액션 버튼 |
|
||||||
|
| `data-dialog-close` | 모달 닫기 X 버튼 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 시각적 피드백
|
||||||
|
|
||||||
|
### 포커스 이동
|
||||||
|
|
||||||
|
첫 번째 빈 필수 필드로 커서를 이동한다.
|
||||||
|
- `<input>`, `<textarea>`: `input.focus()`
|
||||||
|
- `<button role="combobox">` (V2Select): `button.click()` → 드롭다운 열기
|
||||||
|
|
||||||
|
### 하이라이트 애니메이션
|
||||||
|
|
||||||
|
빈 필수 필드에 빨간 테두리 + 흔들림 효과를 준다.
|
||||||
|
|
||||||
|
```css
|
||||||
|
@keyframes validationShake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
20%, 60% { transform: translateX(-4px); }
|
||||||
|
40%, 80% { transform: translateX(4px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-validation-highlight] {
|
||||||
|
border-color: hsl(var(--destructive)) !important;
|
||||||
|
animation: validationShake 400ms ease-in-out;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
애니메이션 종료 후 `data-validation-highlight` 속성 제거 (일회성).
|
||||||
|
|
||||||
|
### 토스트 알림
|
||||||
|
|
||||||
|
우측 상단에 토스트 메시지를 표시한다.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const hasRequiredMark = Array.from(label.querySelectorAll("span"))
|
toast.error(`${fieldLabel} 항목을 입력해주세요`);
|
||||||
.some(span => span.textContent?.trim() === "*");
|
|
||||||
if (!hasRequiredMark) return;
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 버튼 비활성화: 기본 비활성 + 허용 목록 방식
|
## 6. 동작 흐름
|
||||||
|
|
||||||
### 원리
|
|
||||||
|
|
||||||
모달 내 모든 `<button>` 요소를 기본 비활성화하고,
|
|
||||||
안전한 버튼(취소, 닫기, 삭제, 폼 컨트롤 등)만 허용 목록으로 제외한다.
|
|
||||||
저장 버튼의 구현 방식(shadcn Button / 네이티브 button)에 관계없이 동작한다.
|
|
||||||
|
|
||||||
### 허용 목록 (항상 활성 유지)
|
|
||||||
|
|
||||||
| 셀렉터 | 용도 |
|
|
||||||
|------|------|
|
|
||||||
| `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>` | 알 수 없는 버튼은 안전하게 차단 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 동작 흐름
|
|
||||||
|
|
||||||
```
|
```
|
||||||
모달 열림
|
모달 열림
|
||||||
|
|
@ -132,60 +156,60 @@ useDialogAutoValidation 실행
|
||||||
▼
|
▼
|
||||||
모드 확인 (useTabStore.mode)
|
모드 확인 (useTabStore.mode)
|
||||||
│
|
│
|
||||||
├─ mode !== "user"? → return (관리자 모드에서는 비활성)
|
├─ mode !== "user"? → return
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
필수 필드 탐지 (Label 내 span에서 * 감지)
|
필수 필드 탐지 (Label 내 span에서 * 감지)
|
||||||
│
|
│
|
||||||
├─ 필수 필드 0개? → return (비활성)
|
├─ 필수 필드 0개? → return
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
초기 검증 실행 (50ms 후)
|
클릭 이벤트 리스너 등록 (캡처링 단계)
|
||||||
│
|
│
|
||||||
├─ 빈 필수 필드 발견
|
▼
|
||||||
│ ├─ 해당 input에 data-validation-empty 속성 추가 (CSS 지연 테두리)
|
사용자가 저장 버튼 클릭
|
||||||
│ ├─ input 아래에 "필수 입력 항목입니다" 에러 메시지 주입
|
│
|
||||||
│ └─ 허용 목록 외 모든 버튼 비활성화
|
▼
|
||||||
│
|
인터셉트 대상 버튼인가?
|
||||||
├─ 모든 필수 필드 입력됨
|
│
|
||||||
│ ├─ 에러 메시지 제거
|
├─ 아니오 → 클릭 통과
|
||||||
│ ├─ data-validation-empty 속성 제거
|
│
|
||||||
│ └─ 전체 버튼 활성화
|
▼
|
||||||
|
빈 필수 필드 검사
|
||||||
|
│
|
||||||
|
├─ 모두 입력됨 → 클릭 통과 (정상 저장)
|
||||||
|
│
|
||||||
|
├─ 빈 필드 있음:
|
||||||
|
│ ├─ e.stopPropagation() + e.preventDefault()
|
||||||
|
│ ├─ 첫 번째 빈 필드에 포커스 이동
|
||||||
|
│ ├─ 해당 필드에 data-validation-highlight 속성 추가
|
||||||
|
│ ├─ 애니메이션 종료 후 속성 제거
|
||||||
|
│ └─ toast.error("{필드명} 항목을 입력해주세요")
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
이벤트 리스너 등록
|
|
||||||
├─ input 이벤트 → 재검증
|
|
||||||
├─ change 이벤트 → 재검증
|
|
||||||
├─ click 캡처링 → 비활성 버튼 클릭 차단
|
|
||||||
└─ MutationObserver → DOM 변경 시 재검증
|
|
||||||
|
|
||||||
모달 닫힘
|
모달 닫힘
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
클린업
|
클린업
|
||||||
├─ 이벤트 리스너 제거
|
├─ 이벤트 리스너 제거
|
||||||
├─ MutationObserver 해제
|
└─ 하이라이트 속성 제거
|
||||||
├─ 폴링 타이머 해제
|
|
||||||
├─ 주입된 에러 메시지 제거
|
|
||||||
├─ 버튼 비활성화 상태 복원
|
|
||||||
└─ data-validation-empty 속성 제거
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 관련 파일
|
## 7. 관련 파일
|
||||||
|
|
||||||
| 파일 | 역할 |
|
| 파일 | 역할 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `frontend/lib/hooks/useDialogAutoValidation.ts` | 자동 검증 훅 본체 |
|
| `frontend/lib/hooks/useDialogAutoValidation.ts` | 검증 훅 본체 |
|
||||||
|
| `frontend/components/ui/dialog.tsx` | DialogContent에서 훅 호출 |
|
||||||
| `frontend/components/ui/button.tsx` | data-variant 속성 노출 |
|
| `frontend/components/ui/button.tsx` | data-variant 속성 노출 |
|
||||||
| `frontend/components/ui/dialog.tsx` | DialogContent에서 훅 호출, data-dialog-close 속성 |
|
|
||||||
| `frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx` | data-action-type 속성 노출 |
|
| `frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx` | data-action-type 속성 노출 |
|
||||||
| `frontend/app/globals.css` | CSS 애니메이션 지연 (깜빡임 방지) |
|
| `frontend/app/globals.css` | 하이라이트 애니메이션 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. 적용 범위
|
## 8. 적용 범위
|
||||||
|
|
||||||
### 현재 (1단계): 사용자 모드만
|
### 현재 (1단계): 사용자 모드만
|
||||||
|
|
||||||
|
|
@ -195,12 +219,13 @@ useDialogAutoValidation 실행
|
||||||
| 관리자 모드 모달 (CodeFormModal 등) | X | mode !== "user" → return |
|
| 관리자 모드 모달 (CodeFormModal 등) | X | mode !== "user" → return |
|
||||||
| 확인/삭제 다이얼로그 (필수 필드 없음) | X | 필수 필드 0개 → 자동 제외 |
|
| 확인/삭제 다이얼로그 (필수 필드 없음) | X | 필수 필드 0개 → 자동 제외 |
|
||||||
|
|
||||||
### 나중에 (2단계): 관리자 모드 확장 시
|
---
|
||||||
|
|
||||||
```typescript
|
## 9. 이전 방식과 비교
|
||||||
// 1단계 (현재)
|
|
||||||
if (mode !== "user") return;
|
|
||||||
|
|
||||||
// 2단계 (확장)
|
| 항목 | 이전 (버튼 비활성화) | 현재 (클릭 인터셉트) |
|
||||||
if (!["user", "admin"].includes(mode)) return;
|
|------|---|---|
|
||||||
```
|
| 버튼 상태 | 빈 필드 있으면 비활성화 | 항상 활성 |
|
||||||
|
| 피드백 시점 | 모달 열릴 때부터 | 저장 버튼 클릭 시 |
|
||||||
|
| 피드백 방식 | 빨간 테두리 + 에러 문구 | 포커스 이동 + 하이라이트 + 토스트 |
|
||||||
|
| 복잡도 | 높음 (MutationObserver, 폴링, CSS 지연) | 낮음 (클릭 이벤트 하나) |
|
||||||
|
|
|
||||||
|
|
@ -424,43 +424,21 @@ select {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== 모달 필수 입력 검증 - CSS 딜레이로 깜빡임 방지 ===== */
|
/* ===== 모달 필수 입력 검증 ===== */
|
||||||
@keyframes validationFadeIn {
|
@keyframes validationShake {
|
||||||
from { opacity: 0; }
|
0%, 100% { transform: translateX(0); }
|
||||||
to { opacity: 1; }
|
20%, 60% { transform: translateX(-4px); }
|
||||||
|
40%, 80% { transform: translateX(4px); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes validationBorderIn {
|
/* 흔들림 애니메이션 (일회성) */
|
||||||
from { border-color: inherit; }
|
[data-validation-highlight] {
|
||||||
to { border-color: hsl(var(--destructive)); }
|
animation: validationShake 400ms ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 빈 필수 필드 테두리: 500ms 딜레이 후 빨간색 */
|
/* 빨간 테두리 (값 입력 전까지 유지) */
|
||||||
[data-validation-empty] {
|
[data-validation-error] {
|
||||||
animation: validationBorderIn 0ms forwards;
|
|
||||||
animation-delay: 500ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 에러 메시지: 500ms 딜레이 후 fade-in */
|
|
||||||
[data-auto-validation-error] {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 100%;
|
|
||||||
opacity: 0;
|
|
||||||
animation: validationFadeIn 150ms ease-in forwards;
|
|
||||||
animation-delay: 500ms;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 사용자 상호작용 후에는 딜레이 없이 즉시 표시 */
|
|
||||||
[data-validation-interacted] [data-validation-empty] {
|
|
||||||
border-color: hsl(var(--destructive)) !important;
|
border-color: hsl(var(--destructive)) !important;
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-validation-interacted] [data-auto-validation-error] {
|
|
||||||
opacity: 1;
|
|
||||||
animation: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== End of Global Styles ===== */
|
/* ===== End of Global Styles ===== */
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ const DRAG_THRESHOLD = 5;
|
||||||
const SETTLE_MS = 70;
|
const SETTLE_MS = 70;
|
||||||
const DROP_SETTLE_MS = 180;
|
const DROP_SETTLE_MS = 180;
|
||||||
const BAR_PAD_X = 8;
|
const BAR_PAD_X = 8;
|
||||||
|
const ACTIVE_TAB_BORDER_OPACITY = 0.3;
|
||||||
|
|
||||||
interface DragState {
|
interface DragState {
|
||||||
tabId: string;
|
tabId: string;
|
||||||
|
|
@ -493,7 +494,7 @@ export function TabBar() {
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative flex h-7 shrink-0 cursor-pointer items-center gap-0.5 rounded-t-md border border-b-0 px-3 select-none",
|
"group relative flex h-7 shrink-0 cursor-pointer items-center gap-0.5 rounded-t-md border border-b-0 px-3 select-none",
|
||||||
isActive
|
isActive
|
||||||
? "border-border text-foreground z-10 -mb-px h-[30px] bg-white"
|
? "text-foreground z-10 -mb-px h-[30px] bg-white"
|
||||||
: "bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground border-transparent",
|
: "bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground border-transparent",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -501,6 +502,7 @@ export function TabBar() {
|
||||||
touchAction: "none",
|
touchAction: "none",
|
||||||
...animStyle,
|
...animStyle,
|
||||||
...(hiddenByGhost ? { opacity: 0 } : {}),
|
...(hiddenByGhost ? { opacity: 0 } : {}),
|
||||||
|
...(isActive ? { borderColor: `rgba(0,0,0,${ACTIVE_TAB_BORDER_OPACITY})` } : {}),
|
||||||
}}
|
}}
|
||||||
title={tab.title}
|
title={tab.title}
|
||||||
>
|
>
|
||||||
|
|
@ -547,6 +549,7 @@ export function TabBar() {
|
||||||
onDrop={handleBarDrop}
|
onDrop={handleBarDrop}
|
||||||
>
|
>
|
||||||
<div className="border-border pointer-events-none absolute inset-x-0 bottom-0 z-0 border-b" />
|
<div className="border-border pointer-events-none absolute inset-x-0 bottom-0 z-0 border-b" />
|
||||||
|
<div className="pointer-events-none absolute inset-0 z-5 bg-black/15" />
|
||||||
{displayVisible.map((tab, i) => renderTab(tab, i))}
|
{displayVisible.map((tab, i) => renderTab(tab, i))}
|
||||||
|
|
||||||
{hasOverflow && (
|
{hasOverflow && (
|
||||||
|
|
|
||||||
|
|
@ -196,7 +196,7 @@ const TextInput = forwardRef<
|
||||||
const hasError = hasBlurred && !!validationError;
|
const hasError = hasBlurred && !!validationError;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col">
|
<div className="relative h-full w-full">
|
||||||
<Input
|
<Input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -214,7 +214,7 @@ const TextInput = forwardRef<
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
/>
|
/>
|
||||||
{hasError && (
|
{hasError && (
|
||||||
<p className="text-destructive mt-1 text-[11px]">{validationError}</p>
|
<p className="text-destructive absolute left-0 top-full mt-0.5 text-[11px]">{validationError}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,38 +2,21 @@
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useTabStore } from "@/stores/tabStore";
|
import { useTabStore } from "@/stores/tabStore";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
const ERROR_ATTR = "data-auto-validation-error";
|
const HIGHLIGHT_ATTR = "data-validation-highlight";
|
||||||
const DISABLED_ATTR = "data-validation-disabled";
|
const ERROR_ATTR = "data-validation-error";
|
||||||
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;
|
type TargetEl = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | HTMLButtonElement;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 모달 자동 폼 검증 훅
|
* 모달 필수 입력 검증 훅 (클릭 인터셉트 방식)
|
||||||
*
|
*
|
||||||
* 활성화 조건:
|
* 저장/수정/확인 버튼 클릭 시 빈 필수 필드가 있으면:
|
||||||
* - useTabStore.mode === "user" (사용자 모드)
|
* 1. 클릭 이벤트 차단
|
||||||
* - 필수 필드(label 내 <span>*</span>)가 1개 이상 존재
|
* 2. 첫 번째 빈 필드로 포커스 이동 + 하이라이트
|
||||||
*
|
* 3. 빈 필드에 빨간 테두리 유지 (값 입력 시 해제)
|
||||||
* 지원 요소:
|
* 4. 토스트 알림 표시
|
||||||
* - input, textarea, select (네이티브 폼 요소)
|
|
||||||
* - button[role="combobox"] (Radix Select / V2Select 드롭다운)
|
|
||||||
*
|
*
|
||||||
* 설계: docs/ycshin-node/필수입력항목_자동검증_설계.md
|
* 설계: docs/ycshin-node/필수입력항목_자동검증_설계.md
|
||||||
*/
|
*/
|
||||||
|
|
@ -46,9 +29,8 @@ export function useDialogAutoValidation(contentEl: HTMLElement | null) {
|
||||||
const el = contentEl;
|
const el = contentEl;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
const injected = new Set<HTMLElement>();
|
const errorFields = new Set<TargetEl>();
|
||||||
let validationLock = false;
|
let activated = false; // 첫 저장 시도 이후 true
|
||||||
const prevEmpty = new Map<Element, boolean>();
|
|
||||||
|
|
||||||
function findRequiredFields(): Map<TargetEl, string> {
|
function findRequiredFields(): Map<TargetEl, string> {
|
||||||
const fields = new Map<TargetEl, string>();
|
const fields = new Map<TargetEl, string>();
|
||||||
|
|
@ -123,136 +105,101 @@ export function useDialogAutoValidation(contentEl: HTMLElement | null) {
|
||||||
return input.value.trim() === "";
|
return input.value.trim() === "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function validate() {
|
function isSaveButton(target: HTMLElement): boolean {
|
||||||
if (validationLock) return;
|
const btn = target.closest("button");
|
||||||
validationLock = true;
|
if (!btn) return false;
|
||||||
|
|
||||||
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");
|
const actionType = btn.getAttribute("data-action-type");
|
||||||
if (actionType && actionType !== "save" && actionType !== "submit") return true;
|
if (actionType === "save" || actionType === "submit") return true;
|
||||||
|
|
||||||
|
const variant = btn.getAttribute("data-variant");
|
||||||
|
if (variant === "default") return true;
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateButtons(hasErrors: boolean) {
|
function markError(input: TargetEl) {
|
||||||
el!.querySelectorAll<HTMLButtonElement>("button").forEach((btn) => {
|
input.setAttribute(ERROR_ATTR, "true");
|
||||||
if (isExemptButton(btn)) return;
|
errorFields.add(input);
|
||||||
|
|
||||||
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) {
|
function clearError(input: TargetEl) {
|
||||||
const btn = (e.target as HTMLElement).closest(`[${DISABLED_ATTR}]`);
|
input.removeAttribute(ERROR_ATTR);
|
||||||
if (btn) {
|
errorFields.delete(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
|
||||||
|
highlightField(firstEmpty);
|
||||||
|
toast.error(`${firstEmptyLabel} 항목을 입력해주세요`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleInteraction() {
|
// V2Select는 input/change 이벤트가 없으므로 DOM 변경 감지로 에러 동기화
|
||||||
if (!el!.hasAttribute(INTERACTED_ATTR)) {
|
const observer = new MutationObserver(syncErrors);
|
||||||
el!.setAttribute(INTERACTED_ATTR, "true");
|
observer.observe(el, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-placeholder"] });
|
||||||
}
|
|
||||||
validate();
|
|
||||||
}
|
|
||||||
|
|
||||||
el.addEventListener("input", handleInteraction);
|
el.addEventListener("click", handleClick, true);
|
||||||
el.addEventListener("change", handleInteraction);
|
el.addEventListener("input", syncErrors);
|
||||||
el.addEventListener("click", blockClick, true);
|
el.addEventListener("change", syncErrors);
|
||||||
|
|
||||||
validate();
|
|
||||||
|
|
||||||
const pollId = setInterval(validate, POLL_INTERVAL);
|
|
||||||
|
|
||||||
const observer = new MutationObserver(() => {
|
|
||||||
if (!validationLock) validate();
|
|
||||||
});
|
|
||||||
observer.observe(el, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
characterData: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
el.removeEventListener("input", handleInteraction);
|
el.removeEventListener("click", handleClick, true);
|
||||||
el.removeEventListener("change", handleInteraction);
|
el.removeEventListener("input", syncErrors);
|
||||||
el.removeEventListener("click", blockClick, true);
|
el.removeEventListener("change", syncErrors);
|
||||||
clearInterval(pollId);
|
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
el.removeAttribute(INTERACTED_ATTR);
|
|
||||||
|
|
||||||
injected.forEach((p) => p.remove());
|
el.querySelectorAll(`[${HIGHLIGHT_ATTR}]`).forEach((node) => node.removeAttribute(HIGHLIGHT_ATTR));
|
||||||
injected.clear();
|
el.querySelectorAll(`[${ERROR_ATTR}]`).forEach((node) => node.removeAttribute(ERROR_ATTR));
|
||||||
|
|
||||||
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]);
|
}, [mode, contentEl]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue