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:
syc0123 2026-03-03 18:30:56 +09:00
parent dfc495d32b
commit 35dfe5bd79
5 changed files with 226 additions and 273 deletions

View File

@ -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 지연) | 낮음 (클릭 이벤트 하나) |

View File

@ -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 ===== */

View File

@ -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 && (

View File

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

View File

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